33daea3fc1defaeddf990265711fb9905b4917b1 braney Thu Apr 16 11:27:53 2026 -0700 redmineCli: add --target-version to update, resolves name against ticket's project versions Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> diff --git src/utils/redmineCli src/utils/redmineCli index 3ff4803237b..bcc3f98a030 100755 --- src/utils/redmineCli +++ src/utils/redmineCli @@ -279,30 +279,55 @@ "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_version(name_or_id, project_id, base_url, api_key): + """Resolve a version name or numeric ID to a Redmine fixed_version_id. + + Numeric input passes through. Names are matched case-insensitively against + the given project's versions (exact match preferred, else unique substring). + """ + if str(name_or_id).isdigit(): + return int(name_or_id) + key = str(name_or_id).lower().strip() + data = api_get(base_url, f"/projects/{project_id}/versions.json", api_key) + versions = data.get("versions", []) + for v in versions: + if v["name"].lower() == key: + return v["id"] + matches = [v for v in versions if key in v["name"].lower()] + if len(matches) == 1: + return matches[0]["id"] + if len(matches) > 1: + names = ", ".join(v["name"] for v in matches) + sys.exit(f"Error: ambiguous version '{name_or_id}': matches {names}") + all_names = ", ".join(v["name"] for v in versions) or "(none)" + sys.exit(f"Error: unknown version '{name_or_id}'. " + f"Available for project {project_id}: {all_names}") + + 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 @@ -597,30 +622,41 @@ 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) + if args.target_version is not None: + if args.target_version == "": + issue_data["fixed_version_id"] = "" + elif str(args.target_version).isdigit(): + issue_data["fixed_version_id"] = int(args.target_version) + 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}) 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: @@ -632,31 +668,32 @@ 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, --release-log-text, --release-log-url, " + "--target-version, --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 @@ -908,30 +945,32 @@ # 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("--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 (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",