8634e6bb43da3e4118c9d66a425fb8ed2e112537
max
  Wed Mar 25 07:55:41 2026 -0700
redmineCli: accept status names in --status, not just numeric IDs

Add STATUS_IDS mapping and resolve_status() so update --status accepts
names like "QA Ready" or "In Progress" in addition to numeric IDs,
matching the existing name-based --assigned-to behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

diff --git src/utils/redmineCli src/utils/redmineCli
index fd724fc43ab..9689b1d5f5a 100755
--- src/utils/redmineCli
+++ src/utils/redmineCli
@@ -23,30 +23,67 @@
 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 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
 
 # Name -> Redmine user ID mapping (case-insensitive lookup in resolve_user)
 USER_IDS = {
     "jairo navarro": 163, "jairo": 163,
     "lou nassar": 171, "lou": 171,
     "gerardo perez": 179, "gerardo": 179, "gera": 179,
     "clay fischer": 161, "clay": 161,
     "matt speir": 150, "matt": 150,
 }
 
 ATTRIBUTION = "**From Claude:**\n\n"
 
@@ -154,30 +191,42 @@
     return text
 
 
 def resolve_user(name_or_id):
     """Resolve a user name to a Redmine user 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 USER_IDS:
         return USER_IDS[key]
     sys.exit(f"Error: unknown user '{name_or_id}'. Known users: "
              + ", ".join(sorted(set(f"{v} ({k})" for k, v in USER_IDS.items()
                                     if " " in k))))
 
 
+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 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:
@@ -451,31 +500,31 @@
     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"] = args.status
+        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})
@@ -590,31 +639,31 @@
                           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", type=int, help="New status ID")
+    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("--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")