3fb134a6ffe6d0b369926292362dd266b6da1247 lrnassar Tue Apr 28 19:18:35 2026 -0700 redmineCli: validate --released-to-rr checkbox values per CR. refs #37418 Add _validate_bool helper accepting 0/1/true/false/yes/no/on/off and normalizing to "0"/"1". Apply to --released-to-rr (CF 47, a Redmine checkbox) and to the --custom-field ID=VALUE escape hatch when ID matches a known typed field, so validation cannot be bypassed. diff --git src/utils/redmineCli src/utils/redmineCli index a147c7c427a..e895f87f040 100755 --- src/utils/redmineCli +++ src/utils/redmineCli @@ -155,30 +155,52 @@ "matt": 150, "matt speir": 150, "max": 100, "max haeussler": 100, "melissa": 27, "melissa cline": 27, "pauline": 16, "pauline fujita": 16, "qa": 99, "qa team": 99, "rachel": 41, "rachel harte": 41, "ward": 196, "ward en": 196, } ATTRIBUTION = "**From Claude:**\n\n" # --------------------------------------------------------------------------- # Shared helpers # --------------------------------------------------------------------------- +_BOOL_TRUE = {"1", "true", "yes", "on"} +_BOOL_FALSE = {"0", "false", "no", "off"} + + +def _validate_bool(value, field_name): + """Validate a checkbox custom-field value; return canonical "0" or "1".""" + norm = str(value).strip().lower() + if norm in _BOOL_TRUE: + return "1" + if norm in _BOOL_FALSE: + return "0" + sys.exit(f"Error: {field_name}: expected one of " + f"0/1/true/false/yes/no/on/off, got: {value!r}") + + +# CF ID -> (validator, friendly name). Used both for named flags and to gate +# the --custom-field ID=VALUE escape hatch when ID matches a known typed field. +FIELD_VALIDATORS = { + CF_RELEASED_TO_RR: (_validate_bool, "Released To RR"), +} + + def read_api_key(conf_path="~/.hg.conf"): """Read redmine.apiKey from ~/.hg.conf.""" conf_path = os.path.expanduser(conf_path) with open(conf_path) as f: for line in f: line = line.strip() if line.startswith("redmine.apiKey="): return line.split("=", 1)[1] sys.exit("Error: redmine.apiKey not found in " + conf_path) def api_get(base_url, path, api_key): """GET a JSON endpoint from Redmine.""" url = base_url.rstrip("/") + path req = urllib.request.Request(url) @@ -652,59 +674,64 @@ 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}) + val = _validate_bool(args.released_to_rr, "--released-to-rr") + custom_fields.append({"id": CF_RELEASED_TO_RR, "value": val}) if args.file_list is not None: custom_fields.append({"id": CF_FILE_LIST, "value": args.file_list}) if args.file_list_add: existing = api_get(args.base_url, f"/issues/{args.ticket_id}.json", args.api_key)["issue"] current = "" for cf in existing.get("custom_fields", []): if cf["id"] == CF_FILE_LIST: current = cf.get("value") or "" break lines = [l for l in current.splitlines() if l.strip()] for path in args.file_list_add: if path not in lines: lines.append(path) custom_fields.append({"id": CF_FILE_LIST, "value": "\r\n".join(lines)}) if args.table_list is not None: custom_fields.append({"id": CF_TABLE_LIST, "value": args.table_list}) if args.assemblies is not None: custom_fields.append({"id": CF_ASSEMBLIES, "value": args.assemblies}) for cf_spec in (args.custom_field or []): if "=" not in cf_spec: 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}) + cf_id = int(cf_id) + if cf_id in FIELD_VALIDATORS: + validator, name = FIELD_VALIDATORS[cf_id] + cf_val = validator(cf_val, f"--custom-field {cf_id} ({name})") + custom_fields.append({"id": 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, " "--target-version, --category, --mlm, " "--release-log-text, --release-log-url, " "--released-to-rr, --file-list, --file-list-add, " "--table-list, --assemblies, --custom-field, --note") @@ -977,31 +1004,31 @@ 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)") + help="Released to RR (checkbox: 0/1/true/false/yes/no/on/off)") p_update.add_argument("--file-list", dest="file_list", help="File List (custom field, overwrites existing value)") p_update.add_argument("--file-list-add", dest="file_list_add", action="append", metavar="PATH", help="Append PATH to the File List custom field " "(repeatable; idempotent, skips paths already present)") 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", action="append", metavar="ID=VALUE", help="Set arbitrary custom field by ID (repeatable)") p_update.add_argument("--note", help="Comment to include with update") p_update.add_argument("--note-file", dest="note_file",