07c109ea4fe627879544dddf222650ab8c29eaf8
max
  Tue Apr 21 02:55:53 2026 -0700
redmineCli: add --file-list-add PATH option to append paths idempotently, refs #35059

Existing --file-list overwrites the custom field. The new --file-list-add
reads the current value, appends each path that isn't already listed, and
writes the combined value back with CRLF separators (matching Redmine's
internal format).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

diff --git src/utils/redmineCli src/utils/redmineCli
index 590f0ac50b0..5b6b24c76c2 100755
--- src/utils/redmineCli
+++ src/utils/redmineCli
@@ -648,55 +648,70 @@
                 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.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})
     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, --table-list, --assemblies, "
-                 "--custom-field, --note")
+                 "--released-to-rr, --file-list, --file-list-add, "
+                 "--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
     if not os.path.isfile(filepath):
         sys.exit(f"Error: file not found: {filepath}")
@@ -957,31 +972,35 @@
     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)")
+                          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",
                           help="Read note from file (- for stdin)")
 
     # attach
     p_attach = sub.add_parser("attach", help="Upload an attachment")
     p_attach.add_argument("ticket_id", help="Ticket ID number")
     p_attach.add_argument("file", help="File path to upload")