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