eb370c5173537bdffaeff3e3368531a6dff3f9a0
braney
  Fri Apr 17 10:05:46 2026 -0700
redmineCli: try version name lookup before treating numeric input as ID

Redmine version names are often bare numbers (e.g. "497" for the v497
release), and the version's internal ID (e.g. 261) differs. resolve_version
was short-circuiting on isdigit() before any name lookup, so passing a name
like "497" sent fixed_version_id=497 — which Redmine rejected as "not in
the list". Flip the order: try name lookup first, only fall through to
treating the input as a literal ID if it's digits and nothing matched by
name. Also drop the parallel isdigit() short-circuit in cmd_update so it
always goes through the resolver. refs #37339

diff --git src/utils/redmineCli src/utils/redmineCli
index bcc3f98a030..590f0ac50b0 100755
--- src/utils/redmineCli
+++ src/utils/redmineCli
@@ -282,47 +282,50 @@
 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).
+    Name lookup is tried first (case-insensitive, exact match preferred, else
+    unique substring). Only falls through to treating the input as a literal
+    ID if it is purely digits and no name matched. This matters because
+    Redmine version names are often numeric (e.g. "497") and differ from
+    their internal IDs.
     """
-    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}")
+    if str(name_or_id).isdigit():
+        return int(name_or_id)
     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()))))
 
@@ -625,32 +628,30 @@
 
     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: