7aab0f04919c3e598426fb33d914051d57cbfa29
braney
  Sat Jul 25 09:53:29 2026 -0700
redmineCli: let update edit the ticket description refs #37281

Adds --description and --description-file to the update subcommand, which
previously could not touch the description at all: that needed a raw API PUT.

Mirrors how create handles the same two flags, with one difference. An empty
--description clears the field rather than being an error, so the check is
"is not None" instead of a truthiness test. The From Claude attribution is
idempotent, so rewriting a description Claude already wrote does not stack a
second header on it.

diff --git src/utils/redmineCli src/utils/redmineCli
index 8130aa030f7..fc83f38e107 100755
--- src/utils/redmineCli
+++ src/utils/redmineCli
@@ -3,30 +3,31 @@
 
 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 create --project genomebrowser --tracker 11 --subject "New track" \
         --description "Details here" --assemblies hg38   # Track tracker requires Assemblies
     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 --description-file newDescription.txt
     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
@@ -771,30 +772,40 @@
 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)
+    description = read_text_input(args.description, args.description_file)
+    if description is not None:
+        # An explicit empty string clears the description; anything else gets
+        # the same attribution treatment as create, which is idempotent so
+        # rewriting a description Claude already wrote will not double it up.
+        if description == "":
+            issue_data["description"] = ""
+        else:
+            issue_data["description"] = strip_emoji(
+                prepend_attribution(description))
     if args.target_version is not None:
         if args.target_version == "":
             issue_data["fixed_version_id"] = ""
         else:
             issue = api_get(args.base_url,
                             f"/issues/{args.ticket_id}.json", args.api_key)
             project_id = issue["issue"]["project"]["id"]
             issue_data["fixed_version_id"] = resolve_version(
                 args.target_version, project_id, args.base_url, args.api_key)
 
     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})
@@ -839,30 +850,31 @@
         custom_fields.append({"id": 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 args.private:
             issue_data["private_notes"] = True
     elif args.private:
         sys.exit("Error: --private requires --note or --note-file")
 
     if not issue_data:
         sys.exit("Error: no fields to update. Provide at least one of: "
                  "--status, --assigned-to, --priority, --subject, "
+                 "--description, --description-file, "
                  "--target-version, --category, --mlm, "
                  "--release-log-text, --release-log-url, "
                  "--released-to-rr, --file-list, --file-list-add, "
                  "--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):
@@ -1163,30 +1175,35 @@
     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",
                           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("--description",
+                          help="Replace the ticket description (empty string "
+                               "clears it)")
+    p_update.add_argument("--description-file", dest="description_file",
+                          help="Read new description from file (- for stdin)")
     p_update.add_argument("--target-version", dest="target_version",
                           help="Target version name or ID (empty string to clear)")
     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 (checkbox: 0/1/true/false/yes/no/on/off)")
     p_update.add_argument("--file-list", dest="file_list",
                           help="File List (custom field, overwrites existing value)")
     p_update.add_argument("--file-list-add", dest="file_list_add",
                           action="append", metavar="PATH",
                           help="Append PATH to the File List custom field "