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