4ec511e6d18ea08b0b623cfc9100e193ea167a8a
braney
  Tue Jun 30 17:18:42 2026 -0700
redmineCli: add build-patch subcommand for Build Patch tickets, refs #37281

The Build Patch tracker requires five custom fields (Commit ID, Files Changed,
CGIs to retest, Test Case, Suggested Code Reviewer), so a plain create returns
HTTP 422 and the fields had to be set with a hand-built API POST. The new
build-patch subcommand does it in one call: sets the GB project, Build Patch
tracker, Urgent priority, New status, and blank assignee; fills the required
custom fields plus Developer and an optional Post-mortem; resolves developer,
reviewer, and target-version names to IDs; relates the ticket to the bug it
fixes; and adds the QA Team and build meister as watchers.

diff --git src/utils/redmineCli src/utils/redmineCli
index d6e8d004b8d..65b4f781575 100755
--- src/utils/redmineCli
+++ src/utils/redmineCli
@@ -1,24 +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 build-patch --subject "v500 build patch: ..." --description "..." \
+        --developer braney --commit-id abc123 --files src/hg/js/hgTracks.js \
+        --cgis hgTracks --test-case "..." --reviewer hiram \
+        --target-version 500 --relates 27113
     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
@@ -26,32 +30,35 @@
 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
+TRACKER_BUILD_PATCH = 36
 PRIORITY_UNPRIORITIZED = 12
+PRIORITY_URGENT = 6
 STATUS_NEW = 1
+PROJECT_GB = "genomebrowser"
 
 # 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,
@@ -102,30 +109,39 @@
     "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
 
+# Build Patch ticket custom fields (all but Post-mortem are required by the tracker)
+CF_DEVELOPER = 22
+CF_COMMIT_ID = 23
+CF_FILES_CHANGED = 24
+CF_CGIS_RETEST = 25
+CF_TEST_CASE = 27
+CF_CODE_REVIEWER = 31
+CF_POST_MORTEM = 61
+
 
 # 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,
@@ -618,30 +634,112 @@
     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: build-patch
+# ---------------------------------------------------------------------------
+
+def cmd_build_patch(args):
+    """Create a Build Patch ticket with every field the process requires.
+
+    Encodes the team's Build Patch process so the required fields cannot be
+    forgotten: GB project, Build Patch tracker, Urgent priority, New status,
+    no assignee (QA claims it), and the five required custom fields (Commit ID,
+    Files Changed, CGIs to retest, Test Case, Suggested Code Reviewer). Relates
+    the ticket to the bug it fixes and, unless --no-default-watchers is given,
+    adds the QA Team and build meister as watchers.
+    """
+    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))
+
+    custom_fields = [
+        {"id": CF_DEVELOPER, "value": str(resolve_user(args.developer))},
+        {"id": CF_COMMIT_ID, "value": args.commit_id},
+        {"id": CF_FILES_CHANGED, "value": args.files},
+        {"id": CF_CGIS_RETEST, "value": args.cgis},
+        {"id": CF_TEST_CASE, "value": args.test_case},
+        {"id": CF_CODE_REVIEWER, "value": str(resolve_user(args.reviewer))},
+    ]
+    if args.post_mortem:
+        custom_fields.append({"id": CF_POST_MORTEM, "value": args.post_mortem})
+
+    issue_data = {
+        "issue": {
+            "project_id": args.project,
+            "subject": strip_emoji(args.subject),
+            "description": description,
+            "tracker_id": TRACKER_BUILD_PATCH,
+            "priority_id": PRIORITY_URGENT,
+            "status_id": STATUS_NEW,
+            "fixed_version_id": resolve_version(
+                args.target_version, args.project, args.base_url, args.api_key),
+            "custom_fields": custom_fields,
+        }
+    }
+
+    result = api_post(args.base_url, "/issues.json", args.api_key, issue_data)
+    ticket_id = result["issue"]["id"]
+    print(f"Created Build Patch #{ticket_id}: {make_url(args.base_url, ticket_id)}")
+
+    # Relate to the bug(s) this patch fixes.
+    for bug_id in (args.relates or []):
+        data = {"relation": {"issue_to_id": int(bug_id), "relation_type": "relates"}}
+        try:
+            api_post(args.base_url, f"/issues/{ticket_id}/relations.json",
+                     args.api_key, data)
+            print(f"  Related #{ticket_id} <-> #{bug_id}")
+        except SystemExit as e:
+            if "422" in str(e):
+                print(f"  Already related: #{ticket_id} <-> #{bug_id}")
+            else:
+                raise
+
+    # Watchers: QA Team + build meister by default, plus any extras. The
+    # process requires QA Team on every Build Patch from the start.
+    watchers = [] if args.no_default_watchers else ["qa", "build"]
+    watchers += (args.watch or [])
+    seen = set()
+    for name in watchers:
+        uid = resolve_user(name)
+        if uid in seen:
+            continue
+        seen.add(uid)
+        try:
+            api_post(args.base_url, f"/issues/{ticket_id}/watchers.json",
+                     args.api_key, {"user_id": uid})
+            print(f"  Added watcher {name} (user {uid}) to #{ticket_id}")
+        except SystemExit as e:
+            if "422" in str(e):
+                print(f"  {name} is already watching #{ticket_id}")
+            else:
+                raise
+
+
 # ---------------------------------------------------------------------------
 # 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))
     issue = {"notes": message}
     if args.private:
         issue["private_notes"] = True
     data = {"issue": issue}
@@ -992,30 +1090,63 @@
                           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", default=TRACKER_MLQ,
                           help="Tracker name or ID (e.g. 'To Do' or 10; default: %(default)s)")
     p_create.add_argument("--priority", type=int, default=PRIORITY_UNPRIORITIZED,
                           help="Priority ID (default: %(default)s)")
     p_create.add_argument("--status", default=STATUS_NEW,
                           help="Status name or ID (e.g. 'New' or 1; 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)")
 
+    # build-patch
+    p_bp = sub.add_parser("build-patch",
+                          help="Create a Build Patch ticket (GB, Urgent, all required fields set)")
+    p_bp.add_argument("--subject", required=True, help="Ticket subject")
+    p_bp.add_argument("--description", help="What is broken and why it needs a patch")
+    p_bp.add_argument("--description-file", dest="description_file",
+                      help="Read description from file (- for stdin)")
+    p_bp.add_argument("--developer", required=True,
+                      help="Developer who caused the bug (name or user ID)")
+    p_bp.add_argument("--commit-id", dest="commit_id", required=True,
+                      help="Fix commit hash; list all hashes if several commits fix it")
+    p_bp.add_argument("--files", required=True,
+                      help="Full paths of the source files changed")
+    p_bp.add_argument("--cgis", required=True,
+                      help="CGIs that could be affected and need retesting")
+    p_bp.add_argument("--test-case", dest="test_case", required=True,
+                      help="Step-by-step to reproduce both the failure and the fix")
+    p_bp.add_argument("--reviewer", required=True,
+                      help="Suggested code reviewer (name or user ID)")
+    p_bp.add_argument("--target-version", dest="target_version", required=True,
+                      help="Release number being built (e.g. 500)")
+    p_bp.add_argument("--post-mortem", dest="post_mortem",
+                      help="Optional plain-language what-happened / how-to-prevent note")
+    p_bp.add_argument("--relates", nargs="+", metavar="BUG_ID",
+                      help="Bug ticket(s) this patch fixes; related on creation")
+    p_bp.add_argument("--watch", nargs="+", metavar="NAME",
+                      help="Extra watchers beyond the QA Team + build meister defaults")
+    p_bp.add_argument("--no-default-watchers", dest="no_default_watchers",
+                      action="store_true",
+                      help="Do not auto-add the QA Team and build meister as watchers")
+    p_bp.add_argument("--project", default=PROJECT_GB,
+                      help="Project (default: %(default)s)")
+
     # 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)")
     p_comment.add_argument("--private", action="store_true",
                            help="Mark this comment as a private note "
                                 "(only visible to project members with permission)")
 
     # 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",
@@ -1092,28 +1223,29 @@
 # ---------------------------------------------------------------------------
 # 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,
+        "build-patch": cmd_build_patch,
         "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()