427905a00dc6799d3c8d750de5d9376bccc3c972 jnavarr5 Thu Apr 9 15:49:33 2026 -0700 Add tracker name resolution to redmineCli list command, refs #37339 Co-Authored-By: Claude Opus 4.6 (1M context) diff --git src/utils/redmineCli src/utils/redmineCli index a23e921b16e..909628e2c0b 100755 --- src/utils/redmineCli +++ src/utils/redmineCli @@ -1,873 +1,908 @@ #!/usr/bin/env python3 """Comprehensive Redmine CLI for the UCSC Genome Browser team. Reads the Redmine API key from ~/.hg.conf (redmine.apiKey=...). Usage: redmineCli show 33571 redmineCli list --project maillists --status open --limit 10 redmineCli create --subject "Bug report" --description "Details here" redmineCli comment 33571 --message "Adding a note" redmineCli update 33571 --status 1 --assigned-to lou --note "Reopening" redmineCli attach 33571 screenshot.png --note "See attached" redmineCli relate 10316 15336 30368 # relate tickets to each other redmineCli watch 37339 lou braney # add watchers to a ticket redmineCli users # list user names and IDs """ import argparse import json import mimetypes import os import re import sys import tempfile import urllib.error import urllib.parse import urllib.request from datetime import datetime # --------------------------------------------------------------------------- # 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 # 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_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"] = 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) 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 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, " "--category, --mlm, --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)}") # --------------------------------------------------------------------------- # 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("--category", help="MLQ Category") p_update.add_argument("--mlm", help="MLM name") p_update.add_argument("--note", help="Comment to include with update") p_update.add_argument("--note-file", dest="note_file", help="Read note from file (- for stdin)") # attach p_attach = sub.add_parser("attach", help="Upload an attachment") p_attach.add_argument("ticket_id", help="Ticket ID number") p_attach.add_argument("file", help="File path to upload") p_attach.add_argument("--filename", help="Override filename") p_attach.add_argument("--description", help="Attachment description") p_attach.add_argument("--note", help="Comment to add with attachment") # users p_users = sub.add_parser("users", help="List project members and their user IDs") p_users.add_argument("--project", default=DEFAULT_PROJECT, help="Project identifier (default: %(default)s)") # relate p_relate = sub.add_parser("relate", help="Create relations between tickets") p_relate.add_argument("ticket_ids", nargs="+", help="Two or more ticket IDs to relate") p_relate.add_argument("--type", default="relates", help="Relation type: relates, duplicates, duplicated, blocks, " "blocked, precedes, follows, copied_to, copied_from " "(default: %(default)s)") # watch p_watch = sub.add_parser("watch", help="Add watchers to a ticket") p_watch.add_argument("ticket_id", help="Ticket ID number") p_watch.add_argument("users", nargs="+", help="User names or IDs to add as watchers") return parser # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main(): parser = build_parser() args = parser.parse_args() args.api_key = read_api_key(args.conf) args.base_url = args.redmine commands = { "show": cmd_show, "list": cmd_list, "create": cmd_create, "comment": cmd_comment, "update": cmd_update, "attach": cmd_attach, "users": cmd_users, "relate": cmd_relate, "watch": cmd_watch, } commands[args.command](args) if __name__ == "__main__": main()