7cb2c8998e6c5bae70d4caac61d8fd6ffa1e064d
max
  Mon Aug 24 02:19:00 2026 -0700
redmineCli: show related tickets by default; add "related" subcommand to list/add relations. No RM.

diff --git src/utils/redmineCli src/utils/redmineCli
index bf42134b093..93e265427c2 100755
--- src/utils/redmineCli
+++ src/utils/redmineCli
@@ -9,30 +9,32 @@
     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 related 34446                   # list a ticket's related tickets
+    redmineCli related 34446 --add 12345 6789  # add relations to a ticket
     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
@@ -490,30 +492,39 @@
     # 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("")
 
+    # Related tickets (shown by default; --no-related suppresses the extra API calls).
+    if not getattr(args, "no_related", False):
+        rel_lines = related_lines(args.base_url, args.ticket_id, args.api_key)
+        if rel_lines:
+            out.append("## Related tickets")
+            out.append("")
+            out.extend(rel_lines)
+            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", "")
@@ -1105,30 +1116,115 @@
                          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")
 
 
+# ---------------------------------------------------------------------------
+# Related-ticket helpers (shared by `show` and `related`)
+# ---------------------------------------------------------------------------
+
+def fetch_relations(base_url, ticket_id, api_key):
+    """Return a list of (other_ticket_id, relation_type) for a ticket's relations.
+    The relation JSON names both ends (issue_id, issue_to_id); the "other" end is
+    whichever one is not this ticket."""
+    data = api_get(base_url, f"/issues/{ticket_id}.json?include=relations", api_key)
+    tid = int(ticket_id)
+    rels = []
+    for r in data["issue"].get("relations", []):
+        other = r["issue_to_id"] if r["issue_id"] == tid else r["issue_id"]
+        rels.append((other, r.get("relation_type", "relates")))
+    # Stable order: by relation type, then ticket id.
+    rels.sort(key=lambda t: (t[1], t[0]))
+    return rels
+
+
+def summarize_issue(base_url, ticket_id, api_key):
+    """Return {id, subject, status, tracker} for a ticket, or None if it can't be read
+    (e.g. deleted or no permission)."""
+    try:
+        d = api_get(base_url, f"/issues/{ticket_id}.json", api_key)["issue"]
+    except SystemExit:
+        return None
+    return {"id": d["id"], "subject": d.get("subject", ""),
+            "status": d.get("status", {}).get("name", ""),
+            "tracker": d.get("tracker", {}).get("name", "")}
+
+
+def related_lines(base_url, ticket_id, api_key):
+    """Build markdown bullet lines describing a ticket's related tickets, one per line
+    with tracker/status and subject.  Returns [] if there are none."""
+    rels = fetch_relations(base_url, ticket_id, api_key)
+    if not rels:
+        return []
+    lines = []
+    for other_id, rtype in rels:
+        info = summarize_issue(base_url, other_id, api_key)
+        if info:
+            lines.append(f"- {rtype} #{other_id} [{info['tracker']}/{info['status']}] "
+                         f"{info['subject']}")
+        else:
+            lines.append(f"- {rtype} #{other_id} (not readable)")
+    return lines
+
+
+# ---------------------------------------------------------------------------
+# Subcommand: related
+# ---------------------------------------------------------------------------
+
+def cmd_related(args):
+    """List a ticket's related tickets, or add new relations to it."""
+    if args.add:
+        # Add each --add ticket as a relation of the main ticket (idempotent, like `relate`).
+        created = skipped = 0
+        for other in args.add:
+            data = {"relation": {"issue_to_id": int(other),
+                                 "relation_type": args.type}}
+            try:
+                api_post(args.base_url,
+                         f"/issues/{args.ticket_id}/relations.json",
+                         args.api_key, data)
+                created += 1
+                print(f"  Related #{args.ticket_id} <-> #{other} ({args.type})")
+            except SystemExit as e:
+                if "422" in str(e):
+                    skipped += 1
+                    print(f"  Already related: #{args.ticket_id} <-> #{other}")
+                else:
+                    raise
+        print(f"Done: {created} created, {skipped} already existed")
+        return
+
+    # No --add: list (pull) the related tickets.
+    lines = related_lines(args.base_url, args.ticket_id, args.api_key)
+    if not lines:
+        print(f"#{args.ticket_id} has no related tickets.")
+        return
+    print(f"# Related tickets for #{args.ticket_id}")
+    print("")
+    print("\n".join(lines))
+
+
 # ---------------------------------------------------------------------------
 # 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}")
@@ -1195,30 +1291,32 @@
         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")
+    p_show.add_argument("--no-related", dest="no_related", action="store_true",
+                        help="Don't fetch and show related tickets (faster)")
 
     # 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)")
@@ -1366,30 +1464,41 @@
                                "(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)")
 
+    # related
+    p_related = sub.add_parser("related",
+                               help="List a ticket's related tickets, or add relations to it")
+    p_related.add_argument("ticket_id", help="Ticket ID number")
+    p_related.add_argument("--add", nargs="+", metavar="ID",
+                           help="Ticket IDs to relate to this ticket (omit to just list)")
+    p_related.add_argument("--type", default="relates",
+                           help="Relation type used with --add: 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
@@ -1401,23 +1510,24 @@
 
     args.api_key = read_api_key(args.conf)
     args.base_url = args.redmine
 
     commands = {
         "show": cmd_show,
         "list": cmd_list,
         "patch-queue": cmd_patch_queue,
         "create": cmd_create,
         "build-patch": cmd_build_patch,
         "comment": cmd_comment,
         "update": cmd_update,
         "attach": cmd_attach,
         "users": cmd_users,
         "relate": cmd_relate,
+        "related": cmd_related,
         "watch": cmd_watch,
         "note": cmd_note,
     }
     commands[args.command](args)
 
 
 if __name__ == "__main__":
     main()