7aab0f04919c3e598426fb33d914051d57cbfa29
braney
  Sat Jul 25 09:53:29 2026 -0700
redmineCli: let update edit the ticket description refs #37281

Adds --description and --description-file to the update subcommand, which
previously could not touch the description at all: that needed a raw API PUT.

Mirrors how create handles the same two flags, with one difference. An empty
--description clears the field rather than being an error, so the check is
"is not None" instead of a truthiness test. The From Claude attribution is
idempotent, so rewriting a description Claude already wrote does not stack a
second header on it.

diff --git src/utils/redmineCli src/utils/redmineCli
index 8130aa030f7..fc83f38e107 100755
--- src/utils/redmineCli
+++ src/utils/redmineCli
@@ -1,1273 +1,1290 @@
 #!/usr/bin/env python3
 """Comprehensive Redmine CLI for the UCSC Genome Browser team.
 
 Reads the Redmine API key from ~/.hg.conf (redmine.apiKey=...).
 
 Usage:
     redmineCli show 33571
     redmineCli list --project maillists --status open --limit 10
     redmineCli create --subject "Bug report" --description "Details here"
     redmineCli create --project genomebrowser --tracker 11 --subject "New track" \
         --description "Details here" --assemblies hg38   # Track tracker requires Assemblies
     redmineCli build-patch --subject "v500 build patch: ..." --description "..." \
         --developer braney --commit-id abc123 --files src/hg/js/hgTracks.js \
         --cgis hgTracks --test-case "..." --reviewer hiram \
         --target-version 500 --relates 27113
     redmineCli comment 33571 --message "Adding a note"
     redmineCli update 33571 --status 1 --assigned-to lou --note "Reopening"
+    redmineCli update 33571 --description-file newDescription.txt
     redmineCli update 33571 --release-log-url "../cgi-bin/hgTrackUi?db=hg38&g=myTrack"
     redmineCli update 33571 --custom-field 46="some value"
     redmineCli attach 33571 screenshot.png --note "See attached"
     redmineCli note 20460 85                   # show note-85 from ticket 20460
     redmineCli relate 10316 15336 30368       # relate tickets to each other
     redmineCli watch 37339 lou braney         # add watchers to a ticket
     redmineCli users                          # list user names and IDs
 """
 
 import argparse
 import json
 import mimetypes
 import os
 import re
 import sys
 import tempfile
 import urllib.error
 import urllib.parse
 import urllib.request
 from datetime import datetime
 
 # ---------------------------------------------------------------------------
 # Constants
 # ---------------------------------------------------------------------------
 
 DEFAULT_REDMINE = "https://redmine.gi.ucsc.edu"
 DEFAULT_PROJECT = "maillists"
 
 TRACKER_MLQ = 7
 TRACKER_BUILD_PATCH = 36
 PRIORITY_UNPRIORITIZED = 12
 PRIORITY_URGENT = 6
 STATUS_NEW = 1
 PROJECT_GB = "genomebrowser"
 
 # Name -> Redmine tracker ID mapping (case-insensitive lookup in resolve_tracker)
 TRACKER_IDS = {
     "bug": 21,
     "feature": 23,
     "track": 11,
     "hub": 45,
     "data sets": 46, "datasets": 46,
     "to do": 10, "todo": 10,
     "docs": 25,
     "assembly": 24,
     "process": 12,
     "meeting": 28,
     "cr": 26,
     "cgi build": 33,
     "mlq": 7,
     "mlq off list": 15,
     "suggestion box": 44,
     "github": 48,
     "information": 35, "info": 35,
     "build patch": 36,
     "release": 47,
     "housekeeping": 49,
 }
 
 # Name -> Redmine status ID mapping (case-insensitive lookup in resolve_status)
 STATUS_IDS = {
     "new": 1,
     "looking for dev": 37,
     "snoozed": 34,
     "limbo": 36,
     "researching/exploratory": 35, "researching": 35, "exploratory": 35,
     "masked 2bit file": 29,
     "initial sequence": 25,
     "minimal browser": 26,
     "docs in progress": 27,
     "on deck": 33,
     "in progress": 2,
     "stalled": 13,
     "qa ready": 10, "qa": 10,
     "available": 30,
     "loaded": 8,
     "resolved": 3,
     "written": 15,
     "reviewing": 11,
     "approved": 16,
     "bounced": 24,
     "feedback": 4,
     "patched": 22,
     "cgi-ready": 20,
     "cgi-ready-open-issues": 21,
     "hibernating": 32,
     "preview1": 17,
     "preview2": 18,
     "final build": 19,
     "rejected": 6,
     "verified": 23,
     "released": 12,
     "closed": 5,
     "reopened": 31,
 }
 
 CF_CATEGORY = 28
 CF_EMAIL = 40
 CF_MLM = 9
 
 # Track ticket custom fields
 CF_RELEASE_LOG_TEXT = 48
 CF_RELEASE_LOG_URL = 46
 CF_RELEASED_TO_RR = 47
 CF_FILE_LIST = 43
 CF_TABLE_LIST = 44
 CF_ASSEMBLIES = 2
 
 # Build Patch ticket custom fields (all but Post-mortem are required by the tracker)
 CF_DEVELOPER = 22
 CF_COMMIT_ID = 23
 CF_FILES_CHANGED = 24
 CF_CGIS_RETEST = 25
 CF_TEST_CASE = 27
 CF_CODE_REVIEWER = 31
 CF_POST_MORTEM = 61
 
 
 # Name -> Redmine user ID mapping (short names and full names)
 USER_IDS = {
     "ana": 174, "ana benet": 174,
     "angie": 34, "angie hinrichs": 34,
     "ann": 3, "ann zweig": 3,
     "bob": 45, "bob kuhn": 45,
     "blee": 122, "brian lee": 122,
     "braney": 31, "brian raney": 31,
     "build": 197, "build meister": 197,
     "cath": 155, "cath tyner": 155,
     "charlie": 186, "charlie vaske": 186,
     "chin": 25, "chin li": 25,
     "chris": 152, "chris eisenhart": 152,
     "christopher": 156, "christopher lee": 156,
     "clay": 161, "clay fischer": 161,
     "cricket": 29, "cricket sloan": 29,
     "daniel": 172, "daniel schmelter": 172,
     "dev": 157, "dev team": 157,
     "donna": 4, "donna karolchik": 4,
     "erich": 52, "erich weiler": 52,
     "galt": 28, "galt barber": 28,
     "gautomation": 195, "genome automation": 195,
     "gadmin": 71, "genome browser admin": 71,
     "gerardo": 179, "gerardo perez": 179, "gera": 179,
     "haifang": 165, "haifang telc": 165,
     "hiram": 24, "hiram clawson": 24,
     "jairo": 163, "jairo navarro": 163,
     "jason": 180, "jason fernandes": 180,
     "jeltje": 184, "jeltje van baren": 184,
     "jim": 44, "jim kent": 44,
     "johannes": 190, "johannes birgmeier": 190,
     "jonathan": 142, "jonathan casper": 142,
     "jorge": 5, "jorge garcia": 5,
     "kate": 33, "kate rosenbloom": 33,
     "lou": 171, "lou nassar": 171,
     "marc": 183, "marc perry": 183,
     "mark": 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"
 
 # ---------------------------------------------------------------------------
 # 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)
     req.add_header("X-Redmine-API-Key", api_key)
     req.add_header("Accept", "application/json")
     try:
         with urllib.request.urlopen(req, timeout=30) as resp:
             return json.loads(resp.read())
     except urllib.error.HTTPError as e:
         body = e.read().decode("utf-8", errors="replace")[:500]
         sys.exit(f"Error: HTTP {e.code} GET {url}: {body}")
     except urllib.error.URLError as e:
         sys.exit(f"Error: {e.reason} connecting to {url}")
 
 
 def api_post(base_url, path, api_key, data):
     """POST JSON to Redmine. Returns parsed JSON response (or None if empty)."""
     url = base_url.rstrip("/") + path
     body = json.dumps(data).encode("utf-8")
     req = urllib.request.Request(url, data=body, method="POST")
     req.add_header("X-Redmine-API-Key", api_key)
     req.add_header("Content-Type", "application/json")
     try:
         with urllib.request.urlopen(req, timeout=30) as resp:
             raw = resp.read()
             return json.loads(raw) if raw else None
     except urllib.error.HTTPError as e:
         resp_body = e.read().decode("utf-8", errors="replace")[:500]
         sys.exit(f"Error: HTTP {e.code} POST {url}: {resp_body}")
     except urllib.error.URLError as e:
         sys.exit(f"Error: {e.reason} connecting to {url}")
 
 
 def api_put(base_url, path, api_key, data):
     """PUT JSON to Redmine. Returns True on success."""
     url = base_url.rstrip("/") + path
     body = json.dumps(data).encode("utf-8")
     req = urllib.request.Request(url, data=body, method="PUT")
     req.add_header("X-Redmine-API-Key", api_key)
     req.add_header("Content-Type", "application/json")
     try:
         with urllib.request.urlopen(req, timeout=30) as resp:
             return True
     except urllib.error.HTTPError as e:
         resp_body = e.read().decode("utf-8", errors="replace")[:500]
         sys.exit(f"Error: HTTP {e.code} PUT {url}: {resp_body}")
     except urllib.error.URLError as e:
         sys.exit(f"Error: {e.reason} connecting to {url}")
 
 
 def api_upload(base_url, api_key, filename, file_data):
     """Upload binary file to Redmine, returns upload token."""
     encoded_name = urllib.parse.quote(filename)
     url = f"{base_url.rstrip('/')}/uploads.json?filename={encoded_name}"
     req = urllib.request.Request(url, data=file_data, method="POST")
     req.add_header("X-Redmine-API-Key", api_key)
     req.add_header("Content-Type", "application/octet-stream")
     try:
         with urllib.request.urlopen(req, timeout=60) as resp:
             return json.loads(resp.read())["upload"]["token"]
     except urllib.error.HTTPError as e:
         resp_body = e.read().decode("utf-8", errors="replace")[:500]
         sys.exit(f"Error: HTTP {e.code} uploading {filename}: {resp_body}")
     except urllib.error.URLError as e:
         sys.exit(f"Error: {e.reason} uploading {filename}")
 
 
 def format_date(iso_str):
     """Format an ISO date string nicely."""
     dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
     return dt.strftime("%Y-%m-%d %H:%M UTC")
 
 
 def redmine_textile_to_md(text):
     """Convert common Redmine textile/wiki markup to Markdown."""
     if not text:
         return ""
     text = re.sub(r'(?<!\w)\*(\S.*?\S)\*(?!\w)', r'**\1**', text)
     text = re.sub(r'(?<!\w)_(\S.*?\S)_(?!\w)', r'*\1*', text)
     text = re.sub(r'@([^@\n]+)@', r'`\1`', text)
     text = re.sub(r'<pre>\s*', '\n```\n', text)
     text = re.sub(r'\s*</pre>', '\n```\n', text)
     for i in range(1, 7):
         text = re.sub(rf'^h{i}\.\s*', '#' * i + ' ', text, flags=re.MULTILINE)
     text = re.sub(r'!([^!\n]+\.(png|jpg|jpeg|gif))!', r'![image](\1)', text, flags=re.IGNORECASE)
     text = re.sub(r'"([^"]+)":(\S+)', r'[\1](\2)', text)
     return text
 
 
 def resolve_user(name_or_id):
     """Resolve a user name or numeric ID to a Redmine user ID."""
     if name_or_id.isdigit():
         return int(name_or_id)
     key = name_or_id.lower().strip()
     if key in USER_IDS:
         return USER_IDS[key]
     sys.exit(f"Error: unknown user '{name_or_id}'. "
              "Run 'redmineCli users' to see available names and IDs.")
 
 
 def resolve_status(name_or_id):
     """Resolve a status name to a Redmine status 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 STATUS_IDS:
         return STATUS_IDS[key]
     sys.exit(f"Error: unknown status '{name_or_id}'. Known statuses: "
              + ", ".join(sorted(k for k in STATUS_IDS if " " in k or not any(
                  k2 != k and STATUS_IDS[k2] == STATUS_IDS[k] for k2 in STATUS_IDS))))
 
 
 def resolve_version(name_or_id, project_id, base_url, api_key):
     """Resolve a version name or numeric ID to a Redmine fixed_version_id.
 
     Name lookup is tried first (case-insensitive, exact match preferred, else
     unique substring). Only falls through to treating the input as a literal
     ID if it is purely digits and no name matched. This matters because
     Redmine version names are often numeric (e.g. "497") and differ from
     their internal IDs.
     """
     key = str(name_or_id).lower().strip()
     data = api_get(base_url, f"/projects/{project_id}/versions.json", api_key)
     versions = data.get("versions", [])
     for v in versions:
         if v["name"].lower() == key:
             return v["id"]
     matches = [v for v in versions if key in v["name"].lower()]
     if len(matches) == 1:
         return matches[0]["id"]
     if len(matches) > 1:
         names = ", ".join(v["name"] for v in matches)
         sys.exit(f"Error: ambiguous version '{name_or_id}': matches {names}")
     if str(name_or_id).isdigit():
         return int(name_or_id)
     all_names = ", ".join(v["name"] for v in versions) or "(none)"
     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.
 
     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.
     """
     if text and re.match(r'^\s*\*+\s*From Claude:?\s*\*+', text, re.IGNORECASE):
         return text
     return ATTRIBUTION + text
 
 
 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
 
 
 def make_url(base_url, ticket_id):
     """Build the web URL for a ticket."""
     return f"{base_url.rstrip('/')}/issues/{ticket_id}"
 
 
 def format_details(details):
     """Format journal detail changes (status changes, assignments, etc.)."""
     lines = []
     for d in details:
         prop = d.get("property", "")
         name = d.get("name", "")
         old = d.get("old_value", "")
         new = d.get("new_value", "")
         if prop == "attr":
             if name == "status_id":
                 lines.append(f"  - Status changed: {old} -> {new}")
             elif name == "assigned_to_id":
                 lines.append(f"  - Assignee changed: {old} -> {new}")
             elif name == "done_ratio":
                 lines.append(f"  - Progress: {old}% -> {new}%")
             else:
                 lines.append(f"  - {name}: {old} -> {new}")
         elif prop == "attachment":
             lines.append(f"  - Attached: {new}")
     return "\n".join(lines)
 
 
 # ---------------------------------------------------------------------------
 # Subcommand: show
 # ---------------------------------------------------------------------------
 
 def cmd_show(args):
     """Display a single ticket in Markdown."""
     data = api_get(args.base_url,
                    f"/issues/{args.ticket_id}.json?include=journals,attachments",
                    args.api_key)
     issue = data["issue"]
 
     attachments = {a["id"]: a for a in issue.get("attachments", [])}
     attach_by_name = {a["filename"]: a for a in issue.get("attachments", [])}
 
     dl_dir = None
     if args.images or args.download_all:
         dl_dir = tempfile.mkdtemp(prefix=f"redmine_{args.ticket_id}_")
         print(f"<!-- Attachments downloaded to: {dl_dir} -->", file=sys.stderr)
 
     def resolve_images(text):
         if not text:
             return text
         def replace_img(m):
             fname = m.group(1)
             if fname in attach_by_name:
                 a = attach_by_name[fname]
                 if dl_dir:
                     local = os.path.join(dl_dir, fname)
                     if not os.path.exists(local):
                         download_file(a["content_url"], local, args.api_key)
                         print(f"  Downloaded: {local}", file=sys.stderr)
                     return f"![image]({local})"
                 else:
                     return f"![image]({a['content_url']})"
             return m.group(0)
         return re.sub(r'!\[image\]\(([^)]+\.(png|jpg|jpeg|gif))\)',
                        replace_img, text, flags=re.IGNORECASE)
 
     out = []
     out.append(f"# #{issue['id']}: {issue['subject']}")
     out.append("")
     out.append(f"- **Project:** {issue['project']['name']}")
     out.append(f"- **Tracker:** {issue['tracker']['name']}")
     out.append(f"- **Status:** {issue['status']['name']}")
     out.append(f"- **Priority:** {issue['priority']['name']}")
     out.append(f"- **Author:** {issue['author']['name']}")
     if issue.get("assigned_to"):
         out.append(f"- **Assigned to:** {issue['assigned_to']['name']}")
     out.append(f"- **Created:** {format_date(issue['created_on'])}")
     out.append(f"- **Updated:** {format_date(issue['updated_on'])}")
     if issue.get("closed_on"):
         out.append(f"- **Closed:** {format_date(issue['closed_on'])}")
     out.append(f"- **URL:** {make_url(args.base_url, issue['id'])}")
 
     # Custom fields
     for cf in issue.get("custom_fields", []):
         if cf.get("value"):
             out.append(f"- **{cf['name']}:** {cf['value']}")
     out.append("")
 
     if attachments:
         out.append("## Attachments")
         out.append("")
         for a in issue["attachments"]:
             out.append(f"- [{a['filename']}]({a['content_url']}) "
                        f"({a['filesize']} bytes, {a['author']['name']}, "
                        f"{format_date(a['created_on'])})")
         out.append("")
 
     out.append("## Description")
     out.append("")
     desc = redmine_textile_to_md(issue.get("description", ""))
     desc = resolve_images(desc)
     out.append(desc)
     out.append("")
 
     journals = issue.get("journals", [])
     if journals:
         out.append("---")
         out.append("## Discussion")
         out.append("")
 
     for j in journals:
         notes = j.get("notes", "")
         details = j.get("details", [])
         if not notes and not details:
             continue
         user = j["user"]["name"]
         date = format_date(j["created_on"])
         out.append(f"### {user} — {date}")
         out.append("")
         if details:
             detail_text = format_details(details)
             if detail_text:
                 out.append(detail_text)
                 out.append("")
         if notes:
             md_notes = redmine_textile_to_md(notes)
             md_notes = resolve_images(md_notes)
             out.append(md_notes)
             out.append("")
         out.append("---")
         out.append("")
 
     if dl_dir:
         for a in issue["attachments"]:
             is_image = a.get("content_type", "").startswith("image/")
             if args.download_all or is_image:
                 local = os.path.join(dl_dir, a["filename"])
                 if not os.path.exists(local):
                     download_file(a["content_url"], local, args.api_key)
                     print(f"  Downloaded: {local}", file=sys.stderr)
 
     print("\n".join(out))
 
 
 def download_file(url, dest_path, api_key):
     """Download a file with API key auth."""
     req = urllib.request.Request(url)
     req.add_header("X-Redmine-API-Key", api_key)
     with urllib.request.urlopen(req, timeout=30) as resp:
         with open(dest_path, "wb") as f:
             f.write(resp.read())
 
 
 # ---------------------------------------------------------------------------
 # Subcommand: list
 # ---------------------------------------------------------------------------
 
 def cmd_list(args):
     """List/search tickets with filters."""
     params = {
         "project_id": args.project,
         "limit": str(args.limit),
         "offset": str(args.offset),
         "sort": args.sort,
     }
 
     if args.status:
         params["status_id"] = args.status
 
     if args.assigned_to:
         if args.assigned_to.lower() == "me":
             params["assigned_to_id"] = "me"
         else:
             params["assigned_to_id"] = str(resolve_user(args.assigned_to))
 
     if args.tracker:
         params["tracker_id"] = resolve_tracker(args.tracker)
 
     if args.search:
         params["subject"] = f"~{args.search}"
 
     query = urllib.parse.urlencode(params)
     data = api_get(args.base_url, f"/issues.json?{query}", args.api_key)
 
     issues = data.get("issues", [])
     total = data.get("total_count", 0)
 
     if not issues:
         print("No issues found.")
         return
 
     out = []
     out.append("| # | Status | Assignee | Subject |")
     out.append("|---|--------|----------|---------|")
     for iss in issues:
         tid = iss["id"]
         status = iss["status"]["name"]
         assignee = iss.get("assigned_to", {}).get("name", "—")
         subject = iss["subject"][:60].replace("|", "\\|")
         out.append(f"| {tid} | {status} | {assignee} | {subject} |")
     out.append("")
     start = args.offset + 1
     end = args.offset + len(issues)
     out.append(f"{total} issues total (showing {start}-{end})")
 
     print("\n".join(out))
 
 
 # ---------------------------------------------------------------------------
 # Subcommand: create
 # ---------------------------------------------------------------------------
 
 def cmd_create(args):
     """Create a new Redmine ticket."""
     description = read_text_input(args.description, args.description_file)
     if not description:
         sys.exit("Error: --description or --description-file is required")
 
     description = strip_emoji(prepend_attribution(description))
     subject = strip_emoji(args.subject)
 
     issue_data = {
         "issue": {
             "project_id": args.project,
             "subject": subject,
             "description": description,
             "tracker_id": resolve_tracker(args.tracker),
             "priority_id": args.priority,
             "status_id": resolve_status(args.status),
         }
     }
 
     custom_fields = []
     if args.category:
         custom_fields.append({"id": CF_CATEGORY, "value": args.category})
     if args.email:
         custom_fields.append({"id": CF_EMAIL, "value": args.email})
     if args.mlm:
         custom_fields.append({"id": CF_MLM, "value": args.mlm})
     if getattr(args, "assemblies", None) is not None:
         custom_fields.append({"id": CF_ASSEMBLIES, "value": args.assemblies})
     for cf_spec in (getattr(args, "custom_field", None) 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}")
         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["issue"]["custom_fields"] = custom_fields
 
     if args.assigned_to:
         issue_data["issue"]["assigned_to_id"] = resolve_user(args.assigned_to)
 
     result = api_post(args.base_url, "/issues.json", args.api_key, issue_data)
     ticket_id = result["issue"]["id"]
     print(f"Created #{ticket_id}: {make_url(args.base_url, ticket_id)}")
 
 
 # ---------------------------------------------------------------------------
 # Subcommand: build-patch
 # ---------------------------------------------------------------------------
 
 def cmd_build_patch(args):
     """Create a Build Patch ticket with every field the process requires.
 
     Encodes the team's Build Patch process so the required fields cannot be
     forgotten: GB project, Build Patch tracker, Urgent priority, New status,
     no assignee (QA claims it), and the five required custom fields (Commit ID,
     Files Changed, CGIs to retest, Test Case, Suggested Code Reviewer). Relates
     the ticket to the bug it fixes and, unless --no-default-watchers is given,
     adds the QA Team and build meister as watchers.
     """
     description = read_text_input(args.description, args.description_file)
     if not description:
         sys.exit("Error: --description or --description-file is required")
     description = strip_emoji(prepend_attribution(description))
 
     custom_fields = [
         {"id": CF_DEVELOPER, "value": str(resolve_user(args.developer))},
         {"id": CF_COMMIT_ID, "value": args.commit_id},
         {"id": CF_FILES_CHANGED, "value": args.files},
         {"id": CF_CGIS_RETEST, "value": args.cgis},
         {"id": CF_TEST_CASE, "value": args.test_case},
         {"id": CF_CODE_REVIEWER, "value": str(resolve_user(args.reviewer))},
     ]
     if args.post_mortem:
         custom_fields.append({"id": CF_POST_MORTEM, "value": args.post_mortem})
 
     issue_data = {
         "issue": {
             "project_id": args.project,
             "subject": strip_emoji(args.subject),
             "description": description,
             "tracker_id": TRACKER_BUILD_PATCH,
             "priority_id": PRIORITY_URGENT,
             "status_id": STATUS_NEW,
             "fixed_version_id": resolve_version(
                 args.target_version, args.project, args.base_url, args.api_key),
             "custom_fields": custom_fields,
         }
     }
 
     result = api_post(args.base_url, "/issues.json", args.api_key, issue_data)
     ticket_id = result["issue"]["id"]
     print(f"Created Build Patch #{ticket_id}: {make_url(args.base_url, ticket_id)}")
 
     # Relate to the bug(s) this patch fixes.
     for bug_id in (args.relates or []):
         data = {"relation": {"issue_to_id": int(bug_id), "relation_type": "relates"}}
         try:
             api_post(args.base_url, f"/issues/{ticket_id}/relations.json",
                      args.api_key, data)
             print(f"  Related #{ticket_id} <-> #{bug_id}")
         except SystemExit as e:
             if "422" in str(e):
                 print(f"  Already related: #{ticket_id} <-> #{bug_id}")
             else:
                 raise
 
     # Watchers: QA Team + build meister by default, plus any extras. The
     # process requires QA Team on every Build Patch from the start.
     watchers = [] if args.no_default_watchers else ["qa", "build"]
     watchers += (args.watch or [])
     seen = set()
     for name in watchers:
         uid = resolve_user(name)
         if uid in seen:
             continue
         seen.add(uid)
         try:
             api_post(args.base_url, f"/issues/{ticket_id}/watchers.json",
                      args.api_key, {"user_id": uid})
             print(f"  Added watcher {name} (user {uid}) to #{ticket_id}")
         except SystemExit as e:
             if "422" in str(e):
                 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))
     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):
     """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"] = ""
         else:
             issue_data["assigned_to_id"] = resolve_user(args.assigned_to)
     if args.priority is not None:
         issue_data["priority_id"] = args.priority
     if args.subject is not None:
         issue_data["subject"] = strip_emoji(args.subject)
+    description = read_text_input(args.description, args.description_file)
+    if description is not None:
+        # An explicit empty string clears the description; anything else gets
+        # the same attribution treatment as create, which is idempotent so
+        # rewriting a description Claude already wrote will not double it up.
+        if description == "":
+            issue_data["description"] = ""
+        else:
+            issue_data["description"] = strip_emoji(
+                prepend_attribution(description))
     if args.target_version is not None:
         if args.target_version == "":
             issue_data["fixed_version_id"] = ""
         else:
             issue = api_get(args.base_url,
                             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:
         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}")
         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, "
+                 "--description, --description-file, "
                  "--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)}")
 
 
 # ---------------------------------------------------------------------------
 # 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}")
 
     filename = args.filename or os.path.basename(filepath)
     with open(filepath, "rb") as f:
         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
     limit = 100
     while True:
         data = api_get(args.base_url,
                        f"/projects/{args.project}/memberships.json?limit={limit}&offset={offset}",
                        args.api_key)
         for m in data.get("memberships", []):
             if "user" not in m:
                 continue
             users.append((m["user"]["name"], m["user"]["id"]))
         total = data.get("total_count", 0)
         offset += limit
         if offset >= total:
             break
 
     users.sort(key=lambda x: x[0].lower())
 
     # Count first-name usage to detect collisions
     first_counts = {}
     for name, uid in users:
         first = name.split()[0].lower()
         first_counts[first] = first_counts.get(first, 0) + 1
 
     out = []
     out.append("| Short | Name | ID |")
     out.append("|-------|------|----|")
     for name, uid in users:
         parts = name.split()
         first = parts[0].lower()
         if first_counts[first] == 1:
             short = first
         elif len(parts) >= 2 and parts[0].isalpha() and parts[-1].isalpha():
             short = (parts[0][0] + parts[-1]).lower()
         else:
             short = first
         out.append(f"| {short} | {name} | {uid} |")
     print("\n".join(out))
 
 
 # ---------------------------------------------------------------------------
 # Subcommand: relate
 # ---------------------------------------------------------------------------
 
 def cmd_relate(args):
     """Create 'relates' relations between tickets."""
     ticket_ids = args.ticket_ids
     if len(ticket_ids) < 2:
         sys.exit("Error: need at least two ticket IDs to relate")
 
     relation_type = args.type
     created = 0
     skipped = 0
 
     # Relate each pair: for N tickets, relate ticket[0] to all others,
     # then ticket[1] to all after it, etc. Redmine relations are bidirectional
     # so we only need to create them in one direction.
     for i in range(len(ticket_ids)):
         for j in range(i + 1, len(ticket_ids)):
             data = {
                 "relation": {
                     "issue_to_id": int(ticket_ids[j]),
                     "relation_type": relation_type,
                 }
             }
             try:
                 api_post(args.base_url,
                          f"/issues/{ticket_ids[i]}/relations.json",
                          args.api_key, data)
                 created += 1
                 print(f"  Related #{ticket_ids[i]} <-> #{ticket_ids[j]}")
             except SystemExit as e:
                 # Duplicate relation returns 422; treat as skip
                 if "422" in str(e):
                     skipped += 1
                     print(f"  Already related: #{ticket_ids[i]} <-> #{ticket_ids[j]}")
                 else:
                     raise
 
     print(f"Done: {created} created, {skipped} already existed")
 
 
 # ---------------------------------------------------------------------------
 # Subcommand: watch
 # ---------------------------------------------------------------------------
 
 def cmd_watch(args):
     """Add watchers to a ticket."""
     ticket_id = args.ticket_id
     for name in args.users:
         user_id = resolve_user(name)
         data = {"user_id": user_id}
         try:
             api_post(args.base_url,
                      f"/issues/{ticket_id}/watchers.json",
                      args.api_key, data)
             print(f"  Added watcher {name} (user {user_id}) to #{ticket_id}")
         except SystemExit as e:
             if "422" in str(e):
                 print(f"  {name} is already watching #{ticket_id}")
             else:
                 raise
 
     print(f"Done: {make_url(args.base_url, ticket_id)}")
 
 
 # ---------------------------------------------------------------------------
 # Subcommand: note
 # ---------------------------------------------------------------------------
 
 def cmd_note(args):
     """Display a specific note from a ticket."""
     data = api_get(args.base_url,
                    f"/issues/{args.ticket_id}.json?include=journals",
                    args.api_key)
     issue = data["issue"]
     journals = issue.get("journals", [])
 
     # Redmine API may return journals newest-first; sort by created_on
     # to match the web UI's #note-N numbering (note-1 = oldest).
     journals.sort(key=lambda j: j["created_on"])
 
     note_num = args.note_number
     if note_num < 1 or note_num > len(journals):
         sys.exit(f"Error: note-{note_num} does not exist. "
                  f"Ticket #{args.ticket_id} has {len(journals)} journal entries.")
 
     j = journals[note_num - 1]
     user = j["user"]["name"]
     date = format_date(j["created_on"])
     notes = j.get("notes", "")
     details = j.get("details", [])
 
     out = []
     out.append(f"# #{issue['id']} note-{note_num}: {user} — {date}")
     out.append(f"URL: {make_url(args.base_url, issue['id'])}#note-{note_num}")
     out.append("")
     if details:
         detail_text = format_details(details)
         if detail_text:
             out.append(detail_text)
             out.append("")
     if notes:
         out.append(redmine_textile_to_md(notes))
     if not notes and not details:
         out.append("(empty journal entry)")
 
     print("\n".join(out))
 
 
 # ---------------------------------------------------------------------------
 # Argument parsing
 # ---------------------------------------------------------------------------
 
 def build_parser():
     parser = argparse.ArgumentParser(
         prog="redmineCli",
         description="Redmine CLI for the UCSC Genome Browser team")
     parser.add_argument("--redmine", default=DEFAULT_REDMINE,
                         help="Redmine base URL (default: %(default)s)")
     parser.add_argument("--conf", default="~/.hg.conf",
                         help="Config file with redmine.apiKey (default: %(default)s)")
 
     sub = parser.add_subparsers(dest="command", required=True)
 
     # show
     p_show = sub.add_parser("show", help="Display a ticket in Markdown, optionally download attachments")
     p_show.add_argument("ticket_id", help="Ticket ID number")
     p_show.add_argument("--images", action="store_true",
                         help="Download images to a temp directory")
     p_show.add_argument("--download-all", dest="download_all", action="store_true",
                         help="Download all attachments to a temp directory")
 
     # list
     p_list = sub.add_parser("list", help="List/search tickets")
     p_list.add_argument("--project", default=DEFAULT_PROJECT,
                         help="Project identifier (default: %(default)s)")
     p_list.add_argument("--status", default="open",
                         help="Status filter: open, closed, * (default: %(default)s)")
     p_list.add_argument("--assigned-to", dest="assigned_to",
                         help="Assignee name or 'me'")
     p_list.add_argument("--tracker",
                         help="Tracker name or ID")
     p_list.add_argument("--search",
                         help="Search in subject")
     p_list.add_argument("--limit", type=int, default=25,
                         help="Max results (default: %(default)s)")
     p_list.add_argument("--offset", type=int, default=0,
                         help="Pagination offset (default: %(default)s)")
     p_list.add_argument("--sort", default="updated_on:desc",
                         help="Sort field:direction (default: %(default)s)")
 
     # create
     p_create = sub.add_parser("create", help="Create a new ticket")
     p_create.add_argument("--subject", required=True, help="Ticket subject")
     p_create.add_argument("--description", help="Ticket description")
     p_create.add_argument("--description-file", dest="description_file",
                           help="Read description from file (- for stdin)")
     p_create.add_argument("--project", default=DEFAULT_PROJECT,
                           help="Project (default: %(default)s)")
     p_create.add_argument("--tracker", default=TRACKER_MLQ,
                           help="Tracker name or ID (e.g. 'To Do' or 10; default: %(default)s)")
     p_create.add_argument("--priority", type=int, default=PRIORITY_UNPRIORITIZED,
                           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)")
     p_create.add_argument("--assemblies",
                           help="Assemblies (custom field, e.g. 'hg38')")
     p_create.add_argument("--custom-field", dest="custom_field",
                           action="append", metavar="ID=VALUE",
                           help="Set an arbitrary custom field by numeric ID "
                                "(repeatable). Use for required fields on trackers "
                                "like Track that would otherwise 422.")
 
     # build-patch
     p_bp = sub.add_parser("build-patch",
                           help="Create a Build Patch ticket (GB, Urgent, all required fields set)")
     p_bp.add_argument("--subject", required=True, help="Ticket subject")
     p_bp.add_argument("--description", help="What is broken and why it needs a patch")
     p_bp.add_argument("--description-file", dest="description_file",
                       help="Read description from file (- for stdin)")
     p_bp.add_argument("--developer", required=True,
                       help="Developer who caused the bug (name or user ID)")
     p_bp.add_argument("--commit-id", dest="commit_id", required=True,
                       help="Fix commit hash; list all hashes if several commits fix it")
     p_bp.add_argument("--files", required=True,
                       help="Full paths of the source files changed")
     p_bp.add_argument("--cgis", required=True,
                       help="CGIs that could be affected and need retesting")
     p_bp.add_argument("--test-case", dest="test_case", required=True,
                       help="Step-by-step to reproduce both the failure and the fix")
     p_bp.add_argument("--reviewer", required=True,
                       help="Suggested code reviewer (name or user ID)")
     p_bp.add_argument("--target-version", dest="target_version", required=True,
                       help="Release number being built (e.g. 500)")
     p_bp.add_argument("--post-mortem", dest="post_mortem",
                       help="Optional plain-language what-happened / how-to-prevent note")
     p_bp.add_argument("--relates", nargs="+", metavar="BUG_ID",
                       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 defaults")
     p_bp.add_argument("--no-default-watchers", dest="no_default_watchers",
                       action="store_true",
                       help="Do not auto-add the QA Team and build meister 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("--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)")
+    p_update.add_argument("--description-file", dest="description_file",
+                          help="Read new description from file (- for stdin)")
     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 (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",
                           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
     p_watch = sub.add_parser("watch", help="Add watchers to a ticket")
     p_watch.add_argument("ticket_id", help="Ticket ID number")
     p_watch.add_argument("users", nargs="+", help="User names or IDs to add as watchers")
 
     # note
     p_note = sub.add_parser("note", help="Display a specific note from a ticket")
     p_note.add_argument("ticket_id", help="Ticket ID number")
     p_note.add_argument("note_number", type=int, help="Note number (as shown in Redmine URL #note-N)")
 
     return parser
 
 
 # ---------------------------------------------------------------------------
 # Main
 # ---------------------------------------------------------------------------
 
 def main():
     parser = build_parser()
     args = parser.parse_args()
 
     args.api_key = read_api_key(args.conf)
     args.base_url = args.redmine
 
     commands = {
         "show": cmd_show,
         "list": cmd_list,
         "create": cmd_create,
         "build-patch": cmd_build_patch,
         "comment": cmd_comment,
         "update": cmd_update,
         "attach": cmd_attach,
         "users": cmd_users,
         "relate": cmd_relate,
         "watch": cmd_watch,
         "note": cmd_note,
     }
     commands[args.command](args)
 
 
 if __name__ == "__main__":
     main()