7fbdfb4c6e5566f9590c6e8ab9ddbd723d329a45
braney
  Mon May 18 13:17:51 2026 -0700
redmineCli: add --private flag to comment/update/attach for Redmine private notes. refs #37281

diff --git src/utils/redmineCli src/utils/redmineCli
index e895f87f040..d6e8d004b8d 100755
--- src/utils/redmineCli
+++ src/utils/redmineCli
@@ -629,34 +629,38 @@
     ticket_id = result["issue"]["id"]
     print(f"Created #{ticket_id}: {make_url(args.base_url, ticket_id)}")
 
 
 # ---------------------------------------------------------------------------
 # Subcommand: comment
 # ---------------------------------------------------------------------------
 
 def cmd_comment(args):
     """Add a comment to an existing ticket."""
     message = read_text_input(args.message, args.message_file)
     if not message:
         sys.exit("Error: --message or --message-file is required")
 
     message = strip_emoji(prepend_attribution(message))
-    data = {"issue": {"notes": message}}
+    issue = {"notes": message}
+    if args.private:
+        issue["private_notes"] = True
+    data = {"issue": issue}
 
     api_put(args.base_url, f"/issues/{args.ticket_id}.json", args.api_key, data)
-    print(f"Commented on #{args.ticket_id}: {make_url(args.base_url, args.ticket_id)}")
+    label = "private comment" if args.private else "Commented"
+    print(f"{label} on #{args.ticket_id}: {make_url(args.base_url, args.ticket_id)}")
 
 
 # ---------------------------------------------------------------------------
 # Subcommand: update
 # ---------------------------------------------------------------------------
 
 def cmd_update(args):
     """Update fields on an existing ticket."""
     issue_data = {}
 
     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"] = ""
@@ -714,30 +718,34 @@
             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}")
         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 args.private:
+            issue_data["private_notes"] = True
+    elif args.private:
+        sys.exit("Error: --private requires --note or --note-file")
 
     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")
 
     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)}")
 
 
 # ---------------------------------------------------------------------------
@@ -755,30 +763,34 @@
         file_data = f.read()
 
     token = api_upload(args.base_url, args.api_key, filename, file_data)
 
     content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
     issue_data = {
         "uploads": [{"token": token, "filename": filename,
                       "content_type": content_type}]
     }
 
     if args.description:
         issue_data["uploads"][0]["description"] = args.description
 
     if args.note:
         issue_data["notes"] = strip_emoji(prepend_attribution(args.note))
+        if args.private:
+            issue_data["private_notes"] = True
+    elif args.private:
+        sys.exit("Error: --private requires --note")
 
     data = {"issue": issue_data}
     api_put(args.base_url, f"/issues/{args.ticket_id}.json", args.api_key, data)
     print(f"Attached {filename} to #{args.ticket_id}: "
           f"{make_url(args.base_url, args.ticket_id)}")
 
 
 # ---------------------------------------------------------------------------
 # Subcommand: users
 # ---------------------------------------------------------------------------
 
 def cmd_users(args):
     """List project members and their Redmine user IDs."""
     users = []
     offset = 0
@@ -986,30 +998,33 @@
                           help="Priority ID (default: %(default)s)")
     p_create.add_argument("--status", default=STATUS_NEW,
                           help="Status name or ID (e.g. 'New' or 1; default: %(default)s)")
     p_create.add_argument("--assigned-to", dest="assigned_to",
                           help="Assignee name or user ID")
     p_create.add_argument("--category", help="MLQ Category (custom field)")
     p_create.add_argument("--email", help="Sender email (custom field)")
     p_create.add_argument("--mlm", help="MLM name (custom field)")
 
     # comment
     p_comment = sub.add_parser("comment", help="Add a comment to a ticket")
     p_comment.add_argument("ticket_id", help="Ticket ID number")
     p_comment.add_argument("--message", help="Comment text")
     p_comment.add_argument("--message-file", dest="message_file",
                            help="Read comment from file (- for stdin)")
+    p_comment.add_argument("--private", action="store_true",
+                           help="Mark this comment as a private note "
+                                "(only visible to project members with permission)")
 
     # update
     p_update = sub.add_parser("update", help="Update ticket fields")
     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)")
@@ -1021,38 +1036,44 @@
                           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)")
+    p_update.add_argument("--private", action="store_true",
+                          help="Mark the accompanying note as a private note "
+                               "(requires --note or --note-file)")
 
     # 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")
     p_attach.add_argument("--filename", help="Override filename")
     p_attach.add_argument("--description", help="Attachment description")
     p_attach.add_argument("--note", help="Comment to add with attachment")
+    p_attach.add_argument("--private", action="store_true",
+                          help="Mark the accompanying note as a private note "
+                               "(requires --note)")
 
     # users
     p_users = sub.add_parser("users", help="List project members and their user IDs")
     p_users.add_argument("--project", default=DEFAULT_PROJECT,
                          help="Project identifier (default: %(default)s)")
 
     # relate
     p_relate = sub.add_parser("relate", help="Create relations between tickets")
     p_relate.add_argument("ticket_ids", nargs="+", help="Two or more ticket IDs to relate")
     p_relate.add_argument("--type", default="relates",
                           help="Relation type: relates, duplicates, duplicated, blocks, "
                                "blocked, precedes, follows, copied_to, copied_from "
                                "(default: %(default)s)")
 
     # watch