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