67cf0a3977040d7f607149d36479886e3e9d12a3
braney
  Mon Sep 21 17:24:39 2026 -0700
redmineCli: let a comment say who wrote it. No RM.

A comment Claude posts carries a "From Claude:" line, which is wrong when the
words are the developer's own and Claude only sent them.  The comment
subcommand takes --written-by NAME now, and writes "Written by NAME, posted by
Claude:" instead.  With no NAME it reads the name off the API key.

prepend_attribution recognises both lines, so text that already begins with
either one is posted unchanged, and a draft file can carry its own header.

diff --git src/utils/redmineCli src/utils/redmineCli
index a7623b44b01..9e7df7f5d1a 100755
--- src/utils/redmineCli
+++ src/utils/redmineCli
@@ -215,30 +215,31 @@
     "jorge": 5, "jorge garcia": 5,
     "kate": 33, "kate rosenbloom": 33,
     "lou": 171, "lou nassar": 171,
     "marc": 183, "marc perry": 183,
     "markd": 7, "mark diekhans": 7,
     "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"
+WRITTEN_ATTRIBUTION = "**Written by %s, posted by 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:
@@ -416,42 +417,64 @@
     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()))))
 
 
-def prepend_attribution(text):
-    """Prepend 'From Claude:' attribution to text for write operations.
+def prepend_attribution(text, written_by=None):
+    """Prepend an attribution line to text for write operations.
 
-    Idempotent: if the text already begins with a 'From Claude:' attribution
-    line (e.g. '**From Claude:**', '***From Claude:***'), return it unchanged
-    so the header is not duplicated when Claude models include it in the body.
+    With written_by, the line says the person wrote the words and Claude only
+    posted them.  Without it, the line is the plain 'From Claude:'.
+
+    Idempotent: if the text already begins with either attribution line
+    (e.g. '**From Claude:**', '***From Claude:***', '**Written by X, posted by
+    Claude:**'), return it unchanged so the header is not duplicated when
+    Claude models include it in the body.
     """
-    if text and re.match(r'^\s*\*+\s*From Claude:?\s*\*+', text, re.IGNORECASE):
+    if text and re.match(r'^\s*\*+\s*(From Claude|Written by .{1,100}?, posted by Claude)'
+                         r'\s*:?\s*\*+', text, re.IGNORECASE):
         return text
+    if written_by:
+        return (WRITTEN_ATTRIBUTION % written_by) + text
     return ATTRIBUTION + text
 
 
+def resolve_written_by(value, base_url, api_key):
+    """Turn the --written-by argument into a name to put in the attribution line.
+
+    A value is used as given.  An empty value (the flag with no argument) means
+    the owner of the API key, whose name comes from the Redmine API.
+    """
+    if value:
+        return value
+    user = api_get(base_url, "/users/current.json", api_key).get("user", {})
+    name = " ".join(x for x in (user.get("firstname"), user.get("lastname")) if x)
+    if not name:
+        sys.exit("Error: could not work out who you are, pass a name to --written-by")
+    return name
+
+
 def strip_emoji(text):
     """Strip 4-byte Unicode (emoji) that Redmine's MySQL may reject."""
     if not text:
         return text
     return re.sub(r'[\U00010000-\U0010FFFF]', '', text)
 
 
 def read_text_input(direct, from_file):
     """Read text from --message/--description or --message-file/--description-file."""
     if from_file:
         if from_file == "-":
             return sys.stdin.read()
         with open(from_file) as f:
             return f.read()
     return direct
@@ -926,31 +949,34 @@
                 print(f"  {name} is already watching #{ticket_id}")
             else:
                 raise
 
 
 # ---------------------------------------------------------------------------
 # 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))
+    written_by = None
+    if args.written_by is not None:
+        written_by = resolve_written_by(args.written_by, args.base_url, args.api_key)
+    message = strip_emoji(prepend_attribution(message, written_by))
     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)
     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):
@@ -1655,30 +1681,34 @@
                       help="Bug ticket(s) this patch fixes; related on creation")
     p_bp.add_argument("--watch", nargs="+", metavar="NAME",
                       help="Extra watchers beyond the QA Team + build meister + reviewer defaults")
     p_bp.add_argument("--no-default-watchers", dest="no_default_watchers",
                       action="store_true",
                       help="Do not auto-add the QA Team, build meister, and reviewer as watchers")
     p_bp.add_argument("--project", default=PROJECT_GB,
                       help="Project (default: %(default)s)")
 
     # 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("--written-by", dest="written_by", nargs="?", const="",
+                           metavar="NAME",
+                           help="Say the comment was written by NAME and only posted by "
+                                "Claude.  With no NAME, use the name on the API key.")
     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("--description",
                           help="Replace the ticket description (empty string "
                                "clears it)")