9745b5b84a513b8fc527081b47f416884a0aad5f
braney
  Tue Apr 7 14:57:14 2026 -0700
Add relate, watch, and users subcommands to redmineCli, refs #37339

- `relate`: create relations between two or more tickets
- `watch`: add watchers to a ticket by name
- `users`: list project members with short names and IDs
- Expand user ID table from 5 hardcoded MLQ members to all 42 project members

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

diff --git src/utils/redmineCli src/utils/redmineCli
index 9689b1d5f5a..a23e921b16e 100755
--- src/utils/redmineCli
+++ src/utils/redmineCli
@@ -1,27 +1,30 @@
 #!/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 comment 33571 --message "Adding a note"
     redmineCli update 33571 --status 1 --assigned-to lou --note "Reopening"
     redmineCli attach 33571 screenshot.png --note "See attached"
+    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
 
 # ---------------------------------------------------------------------------
@@ -64,37 +67,75 @@
     "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
 
-# Name -> Redmine user ID mapping (case-insensitive lookup in resolve_user)
+
+# Name -> Redmine user ID mapping (short names and full names)
 USER_IDS = {
-    "jairo navarro": 163, "jairo": 163,
-    "lou nassar": 171, "lou": 171,
-    "gerardo perez": 179, "gerardo": 179, "gera": 179,
-    "clay fischer": 161, "clay": 161,
-    "matt speir": 150, "matt": 150,
+    "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
 # ---------------------------------------------------------------------------
 
 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="):
@@ -107,39 +148,40 @@
     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."""
+    """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:
-            return json.loads(resp.read())
+            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:
@@ -180,39 +222,38 @@
     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 to a Redmine user ID. Accepts name or numeric 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}'. Known users: "
-             + ", ".join(sorted(set(f"{v} ({k})" for k, v in USER_IDS.items()
-                                    if " " in k))))
+    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 name_or_id.isdigit():
         return int(name_or_id)
     key = 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 prepend_attribution(text):
@@ -567,30 +608,142 @@
                       "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))
 
     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)}")
+
+
 # ---------------------------------------------------------------------------
 # 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)
 
@@ -658,42 +811,63 @@
     p_update.add_argument("--subject", help="New subject")
     p_update.add_argument("--category", help="MLQ Category")
     p_update.add_argument("--mlm", help="MLM name")
     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")
     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")
 
+    # 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")
+
     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,
         "comment": cmd_comment,
         "update": cmd_update,
         "attach": cmd_attach,
+        "users": cmd_users,
+        "relate": cmd_relate,
+        "watch": cmd_watch,
     }
     commands[args.command](args)
 
 
 if __name__ == "__main__":
     main()