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) 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")