c5057c7b3735ac3688a7703f97ead937b3c7c0a6
braney
Thu Apr 23 14:24:06 2026 -0700
redmineCli: let 'create' accept tracker and status by name
The existing resolve_tracker() / resolve_status() helpers already accepted
either a name ("To Do", "QA Ready") or a numeric ID, and the list / update
subcommands wired them up. The create subcommand declared --tracker and
--status as type=int, which rejected the name forms that the redmine skill
documents as working.
Drop the type=int, route args.tracker / args.status through the resolvers
in cmd_create, and update the help text. Numeric IDs continue to work
because the resolvers pass digit strings through.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
diff --git src/utils/redmineCli src/utils/redmineCli
index 961ac747fa9..82cec7b9e51 100755
--- src/utils/redmineCli
+++ src/utils/redmineCli
@@ -1,1071 +1,1071 @@
#!/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'(?<!\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'', 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.
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""
else:
return f""
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,
+ "tracker_id": resolve_tracker(args.tracker),
"priority_id": args.priority,
- "status_id": args.status,
+ "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 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"] = ""
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.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}")
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, --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))
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("--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", type=int, default=STATUS_NEW,
- help="Status 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)")
# 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, 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)")
# 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()