eb370c5173537bdffaeff3e3368531a6dff3f9a0 braney Fri Apr 17 10:05:46 2026 -0700 redmineCli: try version name lookup before treating numeric input as ID Redmine version names are often bare numbers (e.g. "497" for the v497 release), and the version's internal ID (e.g. 261) differs. resolve_version was short-circuiting on isdigit() before any name lookup, so passing a name like "497" sent fixed_version_id=497 — which Redmine rejected as "not in the list". Flip the order: try name lookup first, only fall through to treating the input as a literal ID if it's digits and nothing matched by name. Also drop the parallel isdigit() short-circuit in cmd_update so it always goes through the resolver. refs #37339 diff --git src/utils/redmineCli src/utils/redmineCli index bcc3f98a030..590f0ac50b0 100755 --- src/utils/redmineCli +++ src/utils/redmineCli @@ -1,1044 +1,1045 @@ #!/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 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 PRIORITY_UNPRIORITIZED = 12 STATUS_NEW = 1 # 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 # 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 # --------------------------------------------------------------------------- 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'(?\s*', '\n```\n', text) text = re.sub(r'\s*', '\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 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 resolve_version(name_or_id, project_id, base_url, api_key): """Resolve a version name or numeric ID to a Redmine fixed_version_id. - Numeric input passes through. Names are matched case-insensitively against - the given project's versions (exact match preferred, else unique substring). + 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. """ - if str(name_or_id).isdigit(): - return int(name_or_id) 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.""" 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"", 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": args.tracker, "priority_id": args.priority, "status_id": 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 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: 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)) data = {"issue": {"notes": message}} api_put(args.base_url, f"/issues/{args.ticket_id}.json", args.api_key, data) print(f"Commented 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) if args.target_version is not None: if args.target_version == "": issue_data["fixed_version_id"] = "" - elif str(args.target_version).isdigit(): - issue_data["fixed_version_id"] = int(args.target_version) 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: custom_fields.append({"id": CF_RELEASED_TO_RR, "value": args.released_to_rr}) if args.file_list is not None: custom_fields.append({"id": CF_FILE_LIST, "value": args.file_list}) 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}") custom_fields.append({"id": int(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 not issue_data: sys.exit("Error: no fields to update. Provide at least one of: " "--status, --assigned-to, --priority, --subject, " "--target-version, --category, --mlm, " "--release-log-text, --release-log-url, " "--released-to-rr, --file-list, --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)) 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", type=int, default=TRACKER_MLQ, help="Tracker ID (default: %(default)s)") p_create.add_argument("--priority", type=int, default=PRIORITY_UNPRIORITIZED, help="Priority ID (default: %(default)s)") p_create.add_argument("--status", type=int, default=STATUS_NEW, help="Status ID (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)") # 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)") # 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("--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 (custom field, 0 or 1)") p_update.add_argument("--file-list", dest="file_list", help="File List (custom field)") 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)") # 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") # 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, "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()