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