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,908 +1,954 @@
 #!/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
 import urllib.request
 from datetime import datetime
 
 # ---------------------------------------------------------------------------
 # Constants
 # ---------------------------------------------------------------------------
 
 DEFAULT_REDMINE = "https://redmine.gi.ucsc.edu"
 DEFAULT_PROJECT = "maillists"
 
 TRACKER_MLQ = 7
 PRIORITY_UNPRIORITIZED = 12
 STATUS_NEW = 1
 
 # Name -> Redmine tracker ID mapping (case-insensitive lookup in resolve_tracker)
 TRACKER_IDS = {
     "bug": 21,
     "feature": 23,
     "track": 11,
     "hub": 45,
     "data sets": 46, "datasets": 46,
     "to do": 10, "todo": 10,
     "docs": 25,
     "assembly": 24,
     "process": 12,
     "meeting": 28,
     "cr": 26,
     "cgi build": 33,
     "mlq": 7,
     "mlq off list": 15,
     "suggestion box": 44,
     "github": 48,
     "information": 35, "info": 35,
     "build patch": 36,
     "release": 47,
     "housekeeping": 49,
 }
 
 # Name -> Redmine status ID mapping (case-insensitive lookup in resolve_status)
 STATUS_IDS = {
     "new": 1,
     "looking for dev": 37,
     "snoozed": 34,
     "limbo": 36,
     "researching/exploratory": 35, "researching": 35, "exploratory": 35,
     "masked 2bit file": 29,
     "initial sequence": 25,
     "minimal browser": 26,
     "docs in progress": 27,
     "on deck": 33,
     "in progress": 2,
     "stalled": 13,
     "qa ready": 10, "qa": 10,
     "available": 30,
     "loaded": 8,
     "resolved": 3,
     "written": 15,
     "reviewing": 11,
     "approved": 16,
     "bounced": 24,
     "feedback": 4,
     "patched": 22,
     "cgi-ready": 20,
     "cgi-ready-open-issues": 21,
     "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,
     "clay": 161, "clay fischer": 161,
     "cricket": 29, "cricket sloan": 29,
     "daniel": 172, "daniel schmelter": 172,
     "dev": 157, "dev team": 157,
     "donna": 4, "donna karolchik": 4,
     "erich": 52, "erich weiler": 52,
     "galt": 28, "galt barber": 28,
     "gautomation": 195, "genome automation": 195,
     "gadmin": 71, "genome browser admin": 71,
     "gerardo": 179, "gerardo perez": 179, "gera": 179,
     "haifang": 165, "haifang telc": 165,
     "hiram": 24, "hiram clawson": 24,
     "jairo": 163, "jairo navarro": 163,
     "jason": 180, "jason fernandes": 180,
     "jeltje": 184, "jeltje van baren": 184,
     "jim": 44, "jim kent": 44,
     "johannes": 190, "johannes birgmeier": 190,
     "jonathan": 142, "jonathan casper": 142,
     "jorge": 5, "jorge garcia": 5,
     "kate": 33, "kate rosenbloom": 33,
     "lou": 171, "lou nassar": 171,
     "marc": 183, "marc perry": 183,
     "mark": 7, "mark diekhans": 7,
     "matt": 150, "matt speir": 150,
     "max": 100, "max haeussler": 100,
     "melissa": 27, "melissa cline": 27,
     "pauline": 16, "pauline fujita": 16,
     "qa": 99, "qa team": 99,
     "rachel": 41, "rachel harte": 41,
     "ward": 196, "ward en": 196,
 }
 
 ATTRIBUTION = "**From Claude:**\n\n"
 
 # ---------------------------------------------------------------------------
 # Shared helpers
 # ---------------------------------------------------------------------------
 
 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")
     try:
         with urllib.request.urlopen(req, timeout=30) as resp:
             return json.loads(resp.read())
     except urllib.error.HTTPError as e:
         body = e.read().decode("utf-8", errors="replace")[:500]
         sys.exit(f"Error: HTTP {e.code} GET {url}: {body}")
     except urllib.error.URLError as e:
         sys.exit(f"Error: {e.reason} connecting to {url}")
 
 
 def api_post(base_url, path, api_key, data):
     """POST JSON to Redmine. Returns parsed JSON response (or None if empty)."""
     url = base_url.rstrip("/") + path
     body = json.dumps(data).encode("utf-8")
     req = urllib.request.Request(url, data=body, method="POST")
     req.add_header("X-Redmine-API-Key", api_key)
     req.add_header("Content-Type", "application/json")
     try:
         with urllib.request.urlopen(req, timeout=30) as resp:
             raw = resp.read()
             return json.loads(raw) if raw else None
     except urllib.error.HTTPError as e:
         resp_body = e.read().decode("utf-8", errors="replace")[:500]
         sys.exit(f"Error: HTTP {e.code} POST {url}: {resp_body}")
     except urllib.error.URLError as e:
         sys.exit(f"Error: {e.reason} connecting to {url}")
 
 
 def api_put(base_url, path, api_key, data):
     """PUT JSON to Redmine. Returns True on success."""
     url = base_url.rstrip("/") + path
     body = json.dumps(data).encode("utf-8")
     req = urllib.request.Request(url, data=body, method="PUT")
     req.add_header("X-Redmine-API-Key", api_key)
     req.add_header("Content-Type", "application/json")
     try:
         with urllib.request.urlopen(req, timeout=30) as resp:
             return True
     except urllib.error.HTTPError as e:
         resp_body = e.read().decode("utf-8", errors="replace")[:500]
         sys.exit(f"Error: HTTP {e.code} PUT {url}: {resp_body}")
     except urllib.error.URLError as e:
         sys.exit(f"Error: {e.reason} connecting to {url}")
 
 
 def api_upload(base_url, api_key, filename, file_data):
     """Upload binary file to Redmine, returns upload token."""
     encoded_name = urllib.parse.quote(filename)
     url = f"{base_url.rstrip('/')}/uploads.json?filename={encoded_name}"
     req = urllib.request.Request(url, data=file_data, method="POST")
     req.add_header("X-Redmine-API-Key", api_key)
     req.add_header("Content-Type", "application/octet-stream")
     try:
         with urllib.request.urlopen(req, timeout=60) as resp:
             return json.loads(resp.read())["upload"]["token"]
     except urllib.error.HTTPError as e:
         resp_body = e.read().decode("utf-8", errors="replace")[:500]
         sys.exit(f"Error: HTTP {e.code} uploading {filename}: {resp_body}")
     except urllib.error.URLError as e:
         sys.exit(f"Error: {e.reason} uploading {filename}")
 
 
 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 ""
     text = re.sub(r'(?<!\w)\*(\S.*?\S)\*(?!\w)', r'**\1**', text)
     text = re.sub(r'(?<!\w)_(\S.*?\S)_(?!\w)', r'*\1*', text)
     text = re.sub(r'@([^@\n]+)@', r'`\1`', text)
     text = re.sub(r'<pre>\s*', '\n```\n', text)
     text = re.sub(r'\s*</pre>', '\n```\n', text)
     for i in range(1, 7):
         text = re.sub(rf'^h{i}\.\s*', '#' * i + ' ', text, flags=re.MULTILINE)
     text = re.sub(r'!([^!\n]+\.(png|jpg|jpeg|gif))!', r'![image](\1)', text, flags=re.IGNORECASE)
     text = re.sub(r'"([^"]+)":(\S+)', r'[\1](\2)', text)
     return text
 
 
 def resolve_user(name_or_id):
     """Resolve a user name or numeric ID to a Redmine user ID."""
     if name_or_id.isdigit():
         return int(name_or_id)
     key = name_or_id.lower().strip()
     if key in USER_IDS:
         return USER_IDS[key]
     sys.exit(f"Error: unknown user '{name_or_id}'. "
              "Run 'redmineCli users' to see available names and IDs.")
 
 
 def resolve_status(name_or_id):
     """Resolve a status name to a Redmine status ID. Accepts name or numeric ID."""
     if name_or_id.isdigit():
         return int(name_or_id)
     key = name_or_id.lower().strip()
     if key in STATUS_IDS:
         return STATUS_IDS[key]
     sys.exit(f"Error: unknown status '{name_or_id}'. Known statuses: "
              + ", ".join(sorted(k for k in STATUS_IDS if " " in k or not any(
                  k2 != k and STATUS_IDS[k2] == STATUS_IDS[k] for k2 in STATUS_IDS))))
 
 
 def resolve_tracker(name_or_id):
     """Resolve a tracker name to a Redmine tracker ID. Accepts name or numeric ID."""
     if str(name_or_id).isdigit():
         return int(name_or_id)
     key = str(name_or_id).lower().strip()
     if key in TRACKER_IDS:
         return TRACKER_IDS[key]
     sys.exit(f"Error: unknown tracker '{name_or_id}'. Known trackers: "
              + ", ".join(sorted(set(TRACKER_IDS.keys()))))
 
 
 def prepend_attribution(text):
     """Prepend 'From Claude:' attribution to text for write operations."""
     return ATTRIBUTION + text
 
 
 def strip_emoji(text):
     """Strip 4-byte Unicode (emoji) that Redmine's MySQL may reject."""
     if not text:
         return text
     return re.sub(r'[\U00010000-\U0010FFFF]', '', text)
 
 
 def read_text_input(direct, from_file):
     """Read text from --message/--description or --message-file/--description-file."""
     if from_file:
         if from_file == "-":
             return sys.stdin.read()
         with open(from_file) as f:
             return f.read()
     return direct
 
 
 def make_url(base_url, ticket_id):
     """Build the web URL for a ticket."""
     return f"{base_url.rstrip('/')}/issues/{ticket_id}"
 
 
 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)
 
 
 # ---------------------------------------------------------------------------
 # Subcommand: show
 # ---------------------------------------------------------------------------
 
 def cmd_show(args):
     """Display a single ticket in Markdown."""
     data = api_get(args.base_url,
                    f"/issues/{args.ticket_id}.json?include=journals,attachments",
                    args.api_key)
     issue = data["issue"]
 
     attachments = {a["id"]: a for a in issue.get("attachments", [])}
     attach_by_name = {a["filename"]: a for a in issue.get("attachments", [])}
 
     dl_dir = None
     if args.images or args.download_all:
         dl_dir = tempfile.mkdtemp(prefix=f"redmine_{args.ticket_id}_")
         print(f"<!-- Attachments downloaded to: {dl_dir} -->", file=sys.stderr)
 
     def resolve_images(text):
         if not text:
             return text
         def replace_img(m):
             fname = m.group(1)
             if fname in attach_by_name:
                 a = attach_by_name[fname]
                 if dl_dir:
                     local = os.path.join(dl_dir, fname)
                     if not os.path.exists(local):
                         download_file(a["content_url"], local, args.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)
 
     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:** {make_url(args.base_url, issue['id'])}")
 
     # Custom fields
     for cf in issue.get("custom_fields", []):
         if cf.get("value"):
             out.append(f"- **{cf['name']}:** {cf['value']}")
     out.append("")
 
     if attachments:
         out.append("## Attachments")
         out.append("")
         for a in issue["attachments"]:
             out.append(f"- [{a['filename']}]({a['content_url']}) "
                        f"({a['filesize']} bytes, {a['author']['name']}, "
                        f"{format_date(a['created_on'])})")
         out.append("")
 
     out.append("## Description")
     out.append("")
     desc = redmine_textile_to_md(issue.get("description", ""))
     desc = resolve_images(desc)
     out.append(desc)
     out.append("")
 
     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", [])
         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("")
 
     if dl_dir:
         for a in issue["attachments"]:
             is_image = a.get("content_type", "").startswith("image/")
             if args.download_all or is_image:
                 local = os.path.join(dl_dir, a["filename"])
                 if not os.path.exists(local):
                     download_file(a["content_url"], local, args.api_key)
                     print(f"  Downloaded: {local}", file=sys.stderr)
 
     print("\n".join(out))
 
 
 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, timeout=30) as resp:
         with open(dest_path, "wb") as f:
             f.write(resp.read())
 
 
 # ---------------------------------------------------------------------------
 # Subcommand: list
 # ---------------------------------------------------------------------------
 
 def cmd_list(args):
     """List/search tickets with filters."""
     params = {
         "project_id": args.project,
         "limit": str(args.limit),
         "offset": str(args.offset),
         "sort": args.sort,
     }
 
     if args.status:
         params["status_id"] = args.status
 
     if args.assigned_to:
         if args.assigned_to.lower() == "me":
             params["assigned_to_id"] = "me"
         else:
             params["assigned_to_id"] = str(resolve_user(args.assigned_to))
 
     if args.tracker:
         params["tracker_id"] = resolve_tracker(args.tracker)
 
     if args.search:
         params["subject"] = f"~{args.search}"
 
     query = urllib.parse.urlencode(params)
     data = api_get(args.base_url, f"/issues.json?{query}", args.api_key)
 
     issues = data.get("issues", [])
     total = data.get("total_count", 0)
 
     if not issues:
         print("No issues found.")
         return
 
     out = []
     out.append("| # | Status | Assignee | Subject |")
     out.append("|---|--------|----------|---------|")
     for iss in issues:
         tid = iss["id"]
         status = iss["status"]["name"]
         assignee = iss.get("assigned_to", {}).get("name", "—")
         subject = iss["subject"][:60].replace("|", "\\|")
         out.append(f"| {tid} | {status} | {assignee} | {subject} |")
     out.append("")
     start = args.offset + 1
     end = args.offset + len(issues)
     out.append(f"{total} issues total (showing {start}-{end})")
 
     print("\n".join(out))
 
 
 # ---------------------------------------------------------------------------
 # Subcommand: create
 # ---------------------------------------------------------------------------
 
 def cmd_create(args):
     """Create a new Redmine ticket."""
     description = read_text_input(args.description, args.description_file)
     if not description:
         sys.exit("Error: --description or --description-file is required")
 
     description = strip_emoji(prepend_attribution(description))
     subject = strip_emoji(args.subject)
 
     issue_data = {
         "issue": {
             "project_id": args.project,
             "subject": subject,
             "description": description,
             "tracker_id": args.tracker,
             "priority_id": args.priority,
             "status_id": args.status,
         }
     }
 
     custom_fields = []
     if args.category:
         custom_fields.append({"id": CF_CATEGORY, "value": args.category})
     if args.email:
         custom_fields.append({"id": CF_EMAIL, "value": args.email})
     if args.mlm:
         custom_fields.append({"id": CF_MLM, "value": args.mlm})
     if custom_fields:
         issue_data["issue"]["custom_fields"] = custom_fields
 
     if args.assigned_to:
         issue_data["issue"]["assigned_to_id"] = resolve_user(args.assigned_to)
 
     result = api_post(args.base_url, "/issues.json", args.api_key, issue_data)
     ticket_id = result["issue"]["id"]
     print(f"Created #{ticket_id}: {make_url(args.base_url, ticket_id)}")
 
 
 # ---------------------------------------------------------------------------
 # Subcommand: comment
 # ---------------------------------------------------------------------------
 
 def cmd_comment(args):
     """Add a comment to an existing ticket."""
     message = read_text_input(args.message, args.message_file)
     if not message:
         sys.exit("Error: --message or --message-file is required")
 
     message = strip_emoji(prepend_attribution(message))
     data = {"issue": {"notes": message}}
 
     api_put(args.base_url, f"/issues/{args.ticket_id}.json", args.api_key, data)
     print(f"Commented on #{args.ticket_id}: {make_url(args.base_url, args.ticket_id)}")
 
 
 # ---------------------------------------------------------------------------
 # Subcommand: update
 # ---------------------------------------------------------------------------
 
 def cmd_update(args):
     """Update fields on an existing ticket."""
     issue_data = {}
 
     if args.status is not None:
         issue_data["status_id"] = resolve_status(args.status)
     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}")
 
     filename = args.filename or os.path.basename(filepath)
     with open(filepath, "rb") as f:
         file_data = f.read()
 
     token = api_upload(args.base_url, args.api_key, filename, file_data)
 
     content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
     issue_data = {
         "uploads": [{"token": token, "filename": filename,
                       "content_type": content_type}]
     }
 
     if args.description:
         issue_data["uploads"][0]["description"] = args.description
 
     if args.note:
         issue_data["notes"] = strip_emoji(prepend_attribution(args.note))
 
     data = {"issue": issue_data}
     api_put(args.base_url, f"/issues/{args.ticket_id}.json", args.api_key, data)
     print(f"Attached {filename} to #{args.ticket_id}: "
           f"{make_url(args.base_url, args.ticket_id)}")
 
 
 # ---------------------------------------------------------------------------
 # Subcommand: users
 # ---------------------------------------------------------------------------
 
 def cmd_users(args):
     """List project members and their Redmine user IDs."""
     users = []
     offset = 0
     limit = 100
     while True:
         data = api_get(args.base_url,
                        f"/projects/{args.project}/memberships.json?limit={limit}&offset={offset}",
                        args.api_key)
         for m in data.get("memberships", []):
             if "user" not in m:
                 continue
             users.append((m["user"]["name"], m["user"]["id"]))
         total = data.get("total_count", 0)
         offset += limit
         if offset >= total:
             break
 
     users.sort(key=lambda x: x[0].lower())
 
     # Count first-name usage to detect collisions
     first_counts = {}
     for name, uid in users:
         first = name.split()[0].lower()
         first_counts[first] = first_counts.get(first, 0) + 1
 
     out = []
     out.append("| Short | Name | ID |")
     out.append("|-------|------|----|")
     for name, uid in users:
         parts = name.split()
         first = parts[0].lower()
         if first_counts[first] == 1:
             short = first
         elif len(parts) >= 2 and parts[0].isalpha() and parts[-1].isalpha():
             short = (parts[0][0] + parts[-1]).lower()
         else:
             short = first
         out.append(f"| {short} | {name} | {uid} |")
     print("\n".join(out))
 
 
 # ---------------------------------------------------------------------------
 # Subcommand: relate
 # ---------------------------------------------------------------------------
 
 def cmd_relate(args):
     """Create 'relates' relations between tickets."""
     ticket_ids = args.ticket_ids
     if len(ticket_ids) < 2:
         sys.exit("Error: need at least two ticket IDs to relate")
 
     relation_type = args.type
     created = 0
     skipped = 0
 
     # Relate each pair: for N tickets, relate ticket[0] to all others,
     # then ticket[1] to all after it, etc. Redmine relations are bidirectional
     # so we only need to create them in one direction.
     for i in range(len(ticket_ids)):
         for j in range(i + 1, len(ticket_ids)):
             data = {
                 "relation": {
                     "issue_to_id": int(ticket_ids[j]),
                     "relation_type": relation_type,
                 }
             }
             try:
                 api_post(args.base_url,
                          f"/issues/{ticket_ids[i]}/relations.json",
                          args.api_key, data)
                 created += 1
                 print(f"  Related #{ticket_ids[i]} <-> #{ticket_ids[j]}")
             except SystemExit as e:
                 # Duplicate relation returns 422; treat as skip
                 if "422" in str(e):
                     skipped += 1
                     print(f"  Already related: #{ticket_ids[i]} <-> #{ticket_ids[j]}")
                 else:
                     raise
 
     print(f"Done: {created} created, {skipped} already existed")
 
 
 # ---------------------------------------------------------------------------
 # Subcommand: watch
 # ---------------------------------------------------------------------------
 
 def cmd_watch(args):
     """Add watchers to a ticket."""
     ticket_id = args.ticket_id
     for name in args.users:
         user_id = resolve_user(name)
         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)}")
 
 
 # ---------------------------------------------------------------------------
 # 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)
 
     # show
     p_show = sub.add_parser("show", help="Display a ticket in Markdown, optionally download attachments")
     p_show.add_argument("ticket_id", help="Ticket ID number")
     p_show.add_argument("--images", action="store_true",
                         help="Download images to a temp directory")
     p_show.add_argument("--download-all", dest="download_all", action="store_true",
                         help="Download all attachments to a temp directory")
 
     # list
     p_list = sub.add_parser("list", help="List/search tickets")
     p_list.add_argument("--project", default=DEFAULT_PROJECT,
                         help="Project identifier (default: %(default)s)")
     p_list.add_argument("--status", default="open",
                         help="Status filter: open, closed, * (default: %(default)s)")
     p_list.add_argument("--assigned-to", dest="assigned_to",
                         help="Assignee name or 'me'")
     p_list.add_argument("--tracker",
                         help="Tracker name or ID")
     p_list.add_argument("--search",
                         help="Search in subject")
     p_list.add_argument("--limit", type=int, default=25,
                         help="Max results (default: %(default)s)")
     p_list.add_argument("--offset", type=int, default=0,
                         help="Pagination offset (default: %(default)s)")
     p_list.add_argument("--sort", default="updated_on:desc",
                         help="Sort field:direction (default: %(default)s)")
 
     # create
     p_create = sub.add_parser("create", help="Create a new ticket")
     p_create.add_argument("--subject", required=True, help="Ticket subject")
     p_create.add_argument("--description", help="Ticket description")
     p_create.add_argument("--description-file", dest="description_file",
                           help="Read description from file (- for stdin)")
     p_create.add_argument("--project", default=DEFAULT_PROJECT,
                           help="Project (default: %(default)s)")
     p_create.add_argument("--tracker", type=int, default=TRACKER_MLQ,
                           help="Tracker ID (default: %(default)s)")
     p_create.add_argument("--priority", type=int, default=PRIORITY_UNPRIORITIZED,
                           help="Priority ID (default: %(default)s)")
     p_create.add_argument("--status", type=int, default=STATUS_NEW,
                           help="Status ID (default: %(default)s)")
     p_create.add_argument("--assigned-to", dest="assigned_to",
                           help="Assignee name or user ID")
     p_create.add_argument("--category", help="MLQ Category (custom field)")
     p_create.add_argument("--email", help="Sender email (custom field)")
     p_create.add_argument("--mlm", help="MLM name (custom field)")
 
     # comment
     p_comment = sub.add_parser("comment", help="Add a comment to a ticket")
     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,
                          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")
 
     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,
     }
     commands[args.command](args)
 
 
 if __name__ == "__main__":
     main()