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: