Files
open-project/skills/openproject/scripts/op.py
T
2026-06-27 17:25:55 -05:00

776 lines
29 KiB
Python

#!/usr/bin/env python3
"""
op — command-line interface to an OpenProject instance.
A thin, friendly CLI over the OpenProject API v3, designed for Claude (and you)
to read and manage projects, work packages, time entries, and more.
Run `op.py --help` or `op.py <command> --help` for usage.
Output: human-readable tables by default; add `--json` to any command for the
raw HAL+JSON (useful for chaining / precise field access).
Examples:
op.py ping
op.py projects --search infra
op.py wp list --project 3 --assignee me --status open
op.py wp show 1234
op.py wp create --project 3 --type Task --subject "Fix the thing" \
--description "details..." --assignee me --priority High
op.py wp update 1234 --status "In progress" --done-ratio 50
op.py wp comment 1234 --text "Working on it"
op.py wp close 1234
op.py wp delete 1234 --yes
op.py time log --wp 1234 --hours 1.5 --comment "investigation"
op.py raw GET /api/v3/statuses
"""
from __future__ import annotations
import argparse
import datetime
import json
import sys
from opclient import OpenProjectClient, OpenProjectError
def _today():
return datetime.date.today().isoformat()
# --------------------------------------------------------------------------- #
# Output helpers
# --------------------------------------------------------------------------- #
def emit_json(obj):
print(json.dumps(obj, indent=2, ensure_ascii=False))
def table(rows, headers):
"""Render a simple aligned text table."""
if not rows:
print("(no results)")
return
cols = list(zip(*([headers] + rows)))
widths = [max(len(str(c)) for c in col) for col in cols]
line = " ".join(str(h).ljust(w) for h, w in zip(headers, widths))
print(line)
print(" ".join("-" * w for w in widths))
for row in rows:
print(" ".join(str(c).ljust(w) for c, w in zip(row, widths)))
def wp_row(wp, client):
return [
wp.get("id"),
(wp.get("subject") or "")[:50],
client.link_title(wp, "type") or "",
client.link_title(wp, "status") or "",
client.link_title(wp, "assignee") or "—",
client.link_title(wp, "project") or "",
]
# --------------------------------------------------------------------------- #
# Filter building
# --------------------------------------------------------------------------- #
def build_wp_filters(client, args):
filters = []
if getattr(args, "project", None):
proj = client.find_project(args.project)
filters.append({"project": {"operator": "=", "values": [str(proj["id"])]}})
if getattr(args, "assignee", None):
if args.assignee.lower() == "me":
filters.append({"assignee": {"operator": "=", "values": ["me"]}})
else:
user = client.find_user(args.assignee)
filters.append({"assignee": {"operator": "=", "values": [str(user["id"])]}})
if getattr(args, "status", None):
s = args.status.lower()
if s == "open":
filters.append({"status": {"operator": "o", "values": None}})
elif s in ("closed", "close"):
filters.append({"status": {"operator": "c", "values": None}})
else:
st = client.find_status(args.status)
filters.append({"status": {"operator": "=", "values": [str(st["id"])]}})
if getattr(args, "type", None):
t = client.find_type(args.type)
filters.append({"type": {"operator": "=", "values": [str(t["id"])]}})
if getattr(args, "query", None):
filters.append({"search": {"operator": "**", "values": [args.query]}})
return filters
# --------------------------------------------------------------------------- #
# Commands
# --------------------------------------------------------------------------- #
def cmd_ping(client, args):
me = client.me()
name = me.get("name") or me.get("login")
print(f"OK — authenticated as {name} (id {me.get('id')}) at {client.base_url}")
def cmd_me(client, args):
me = client.me()
if args.json:
emit_json(me)
return
for k in ("id", "name", "login", "email", "admin", "status"):
if k in me:
print(f"{k:>8}: {me[k]}")
def cmd_projects(client, args):
filters = None
if args.search:
filters = [{"name_and_identifier": {"operator": "~", "values": [args.search]}}]
items = client.get_all("/api/v3/projects", params={"filters": filters} if filters else None,
max_items=args.limit)
if args.json:
emit_json(items)
return
rows = [[p.get("id"), p.get("identifier"), (p.get("name") or "")[:50],
"archived" if not p.get("active", True) else "active"] for p in items]
table(rows, ["ID", "IDENTIFIER", "NAME", "STATE"])
print(f"\n{len(items)} project(s)")
def cmd_wp_list(client, args):
filters = build_wp_filters(client, args)
params = {"pageSize": args.limit}
if filters:
params["filters"] = filters
if args.sort:
params["sortBy"] = [args.sort.split(":")] if ":" in args.sort else [[args.sort, "asc"]]
items = client.get_all("/api/v3/work_packages", params=params, max_items=args.limit)
if args.json:
emit_json(items)
return
rows = [wp_row(wp, client) for wp in items]
table(rows, ["ID", "SUBJECT", "TYPE", "STATUS", "ASSIGNEE", "PROJECT"])
print(f"\n{len(items)} work package(s)")
def cmd_wp_show(client, args):
wp = client.get(f"/api/v3/work_packages/{args.id}")
if args.json:
emit_json(wp)
return
L = client.link_title
print(f"#{wp.get('id')} {wp.get('subject')}")
print(f" project : {L(wp,'project')}")
print(f" type : {L(wp,'type')}")
print(f" status : {L(wp,'status')} ({wp.get('percentageDone', 0)}% done)")
print(f" priority: {L(wp,'priority')}")
print(f" assignee: {L(wp,'assignee') or '—'}")
print(f" author : {L(wp,'author')}")
if wp.get("startDate") or wp.get("dueDate"):
print(f" dates : {wp.get('startDate') or '?'}{wp.get('dueDate') or '?'}")
print(f" version : lockVersion={wp.get('lockVersion')}")
desc = (wp.get("description") or {}).get("raw") or ""
if desc.strip():
print(" description:")
for line in desc.splitlines():
print(f" {line}")
def _wp_link_body(client, args):
"""Build the _links + scalar body for create/update from common args."""
body = {}
links = {}
if getattr(args, "subject", None) is not None:
body["subject"] = args.subject
if getattr(args, "description", None) is not None:
body["description"] = {"format": "markdown", "raw": args.description}
if getattr(args, "start", None) is not None:
body["startDate"] = args.start
if getattr(args, "due", None) is not None:
body["dueDate"] = args.due
if getattr(args, "done_ratio", None) is not None:
body["percentageDone"] = int(args.done_ratio)
if getattr(args, "project", None):
links["project"] = {"href": f"/api/v3/projects/{client.find_project(args.project)['id']}"}
if getattr(args, "type", None):
links["type"] = {"href": f"/api/v3/types/{client.find_type(args.type)['id']}"}
if getattr(args, "status", None):
links["status"] = {"href": f"/api/v3/statuses/{client.find_status(args.status)['id']}"}
if getattr(args, "priority", None):
links["priority"] = {"href": f"/api/v3/priorities/{client.find_priority(args.priority)['id']}"}
if getattr(args, "assignee", None):
if args.assignee.lower() in ("none", "unassign"):
links["assignee"] = {"href": None}
else:
links["assignee"] = {"href": f"/api/v3/users/{client.find_user(args.assignee)['id']}"}
if getattr(args, "parent", None):
links["parent"] = {"href": f"/api/v3/work_packages/{args.parent}"}
if links:
body["_links"] = links
return body
def cmd_wp_create(client, args):
body = _wp_link_body(client, args)
if "subject" not in body:
raise OpenProjectError("--subject is required to create a work package.")
if "_links" not in body or "project" not in body["_links"]:
raise OpenProjectError("--project is required to create a work package.")
if "type" not in body.get("_links", {}):
# Default to the project's first available type.
proj_id = client.id_from_href(body["_links"]["project"]["href"])
types = client.get_all(f"/api/v3/projects/{proj_id}/types", max_items=1)
if types:
body["_links"]["type"] = {"href": types[0]["_links"]["self"]["href"]}
created = client.create_work_package(body)
if args.json:
emit_json(created)
return
print(f"Created #{created.get('id')}: {created.get('subject')}")
print(f" {client.base_url}/work_packages/{created.get('id')}")
def cmd_wp_update(client, args):
current = client.get(f"/api/v3/work_packages/{args.id}")
body = _wp_link_body(client, args)
if not body:
raise OpenProjectError("Nothing to update — pass at least one field.")
body["lockVersion"] = current.get("lockVersion")
updated = client.patch(f"/api/v3/work_packages/{args.id}", body=body)
if args.json:
emit_json(updated)
return
print(f"Updated #{updated.get('id')}: {updated.get('subject')} "
f"[{client.link_title(updated,'status')}]")
def cmd_wp_comment(client, args):
body = {"comment": {"format": "markdown", "raw": args.text}}
res = client.post(f"/api/v3/work_packages/{args.id}/activities", body=body)
if args.json:
emit_json(res)
return
print(f"Comment added to #{args.id}.")
def cmd_wp_close(client, args):
current = client.get(f"/api/v3/work_packages/{args.id}")
status = client.find_status(args.status_name)
body = {
"lockVersion": current.get("lockVersion"),
"_links": {"status": {"href": f"/api/v3/statuses/{status['id']}"}},
}
updated = client.patch(f"/api/v3/work_packages/{args.id}", body=body)
print(f"#{args.id}{client.link_title(updated,'status')}")
def cmd_wp_delete(client, args):
if not args.yes:
raise OpenProjectError(
f"Refusing to delete #{args.id} without --yes. "
f"This permanently removes the work package."
)
client.delete(f"/api/v3/work_packages/{args.id}")
print(f"Deleted work package #{args.id}.")
def cmd_time_log(client, args):
wp = client.get(f"/api/v3/work_packages/{args.wp}")
proj_href = wp["_links"]["project"]["href"]
body = {
"hours": f"PT{args.hours}H" if not str(args.hours).startswith("PT") else args.hours,
"_links": {
"workPackage": {"href": f"/api/v3/work_packages/{args.wp}"},
"project": {"href": proj_href},
},
}
if args.comment:
body["comment"] = {"format": "markdown", "raw": args.comment}
# spentOn is required by the API; default to today when not given.
body["spentOn"] = args.date or _today()
if args.activity:
act = client._resolve_by_name("/api/v3/time_entries/activities", args.activity)
body["_links"]["activity"] = {"href": act["_links"]["self"]["href"]}
res = client.post("/api/v3/time_entries", body=body)
if args.json:
emit_json(res)
return
print(f"Logged {args.hours}h on #{args.wp}.")
def cmd_users(client, args):
filters = None
if args.search:
filters = [{"name": {"operator": "~", "values": [args.search]}}]
items = client.get_all("/api/v3/users", params={"filters": filters} if filters else None,
max_items=args.limit)
if args.json:
emit_json(items)
return
rows = [[u.get("id"), u.get("login"), u.get("name"), u.get("email", "")] for u in items]
table(rows, ["ID", "LOGIN", "NAME", "EMAIL"])
def _simple_list(client, path, args, name_field="name"):
items = client.get_all(path, max_items=200)
if args.json:
emit_json(items)
return
rows = [[i.get("id"), i.get(name_field)] for i in items]
table(rows, ["ID", "NAME"])
def cmd_types(client, args):
if args.project:
proj = client.find_project(args.project)
_simple_list(client, f"/api/v3/projects/{proj['id']}/types", args)
else:
_simple_list(client, "/api/v3/types", args)
def cmd_statuses(client, args):
_simple_list(client, "/api/v3/statuses", args)
def cmd_priorities(client, args):
_simple_list(client, "/api/v3/priorities", args)
def cmd_roles(client, args):
_simple_list(client, "/api/v3/roles", args)
def cmd_project_create(client, args):
created = client.create_project(
args.name, identifier=args.identifier,
description=args.description, parent=args.parent)
if args.json:
emit_json(created)
return
print(f"Created project '{created.get('name')}' "
f"(id {created.get('id')}, identifier {created.get('identifier')})")
print(f" {client.base_url}/projects/{created.get('identifier')}")
def cmd_members_add(client, args):
proj = client.find_project(args.project)
user = client.find_user(args.user)
client.add_membership(proj["id"], user["id"], args.role)
print(f"Added {user.get('name')} to '{proj.get('name')}' as {args.role}.")
def cmd_wp_relate(client, args):
res = client.create_relation(args.id, args.to, rel_type=args.type,
description=args.description)
print(f"#{args.id} {args.type} #{args.to} (relation {res.get('id')})")
# --------------------------------------------------------------------------- #
# Import: spreadsheet -> project + work packages
# --------------------------------------------------------------------------- #
# Default value mappings (tuned for the APTA planning register; override-able
# would be a future flag, but these are sensible defaults for H/M/L + statuses).
PRIORITY_MAP = {"H": "High", "M": "Normal", "L": "Low",
"HIGH": "High", "MEDIUM": "Normal", "LOW": "Low"}
STATUS_MAP = {
"OPEN": "New", "NEW": "New",
"IN PROGRESS": "In progress", "INPROGRESS": "In progress",
"COMPLETE": "Closed", "COMPLETED": "Closed", "DONE": "Closed",
"CLOSED": "Closed",
}
DEFAULT_PRIORITY = "Normal"
DEFAULT_STATUS = "New"
# Column header -> internal field. Header lookup is case-insensitive and matches
# on a prefix so "Priority (H/M/L)" maps to priority.
COLUMN_ALIASES = {
"task": "subject", "title": "subject", "name": "subject",
"category": "category", "group": "category",
"owner": "owner", "assignee": "owner", "assigned": "owner",
"priority": "priority",
"status": "status",
"due": "due", "due date": "due",
"notes": "notes", "notes / dependencies": "notes", "dependencies": "notes",
"#": "ref", "id": "ref",
}
def _map_columns(headers):
"""Build {internal_field: header_string} from the sheet headers."""
mapping = {}
for h in headers:
if not h:
continue
key = h.strip().lower()
field = COLUMN_ALIASES.get(key)
if not field:
for alias, fld in COLUMN_ALIASES.items():
if key.startswith(alias):
field = fld
break
if field and field not in mapping:
mapping[field] = h
return mapping
def _build_description(rec, cols, owner_noted):
notes = (rec.get(cols.get("notes", "")) or "").strip()
meta = []
if cols.get("ref") and rec.get(cols["ref"]):
ref = str(rec[cols["ref"]]).rstrip("0").rstrip(".")
meta.append(f"- Source row #: {ref}")
if cols.get("category") and rec.get(cols["category"]):
meta.append(f"- Category: {rec[cols['category']].strip()}")
if owner_noted:
meta.append(f"- Owner: {owner_noted}")
if cols.get("due") and (rec.get(cols["due"]) or "").strip():
meta.append(f"- Due (from sheet): {rec[cols['due']].strip()}")
parts = []
if notes:
parts.append(notes)
if meta:
parts.append("---\n" + "\n".join(meta))
return "\n\n".join(parts) if parts else None
def cmd_import_xlsx(client, args):
import xlsx_read # local import so the CLI still loads without the file present
headers, records = xlsx_read.read_table(args.path, args.sheet)
cols = _map_columns(headers)
if "subject" not in cols:
raise OpenProjectError(
f"Could not find a task/title column in sheet '{args.sheet}'. "
f"Headers seen: {[h for h in headers if h]}")
# Keep only rows with a real subject.
rows = [r for r in records if (r.get(cols["subject"]) or "").strip()]
if args.limit:
rows = rows[: args.limit]
# Category normalization (merge near-duplicates), e.g.
# --merge-category "Display & Technology=Display & Tech"
merge_map = {}
for pair in getattr(args, "merge_category", None) or []:
if "=" in pair:
old, new = pair.split("=", 1)
merge_map[old.strip().lower()] = new.strip()
cat_col = cols.get("category")
def norm_cat(r):
c = (r.get(cat_col) or "Uncategorized").strip() if cat_col else "Tasks"
return merge_map.get(c.lower(), c)
# Preserve first-seen category order.
categories = []
for r in rows:
c = norm_cat(r)
if c not in categories:
categories.append(c)
# Resolve owners once (match existing users, else note in description).
owner_col = cols.get("owner")
owner_cache = {}
if owner_col:
for r in rows:
o = (r.get(owner_col) or "").strip()
if not o or o.upper() == "TBD" or o in owner_cache:
continue
try:
owner_cache[o] = client.find_user(o)
except OpenProjectError:
owner_cache[o] = None
def map_priority(r):
raw = (r.get(cols["priority"]) or "").strip().upper() if "priority" in cols else ""
return PRIORITY_MAP.get(raw, DEFAULT_PRIORITY)
def map_status(r):
raw = (r.get(cols["status"]) or "").strip().upper() if "status" in cols else ""
return STATUS_MAP.get(raw, DEFAULT_STATUS)
# ----- Dry run: report the plan, write nothing ----- #
if not args.execute:
print(f"DRY RUN — would import {len(rows)} task(s) from sheet "
f"'{args.sheet}' into a new project '{args.project_name}'.\n")
print(f"Detected columns: {cols}\n")
print(f"Hierarchy: {len(categories)} parent Summary task(s), each with its rows as children:")
for c in categories:
n = sum(1 for r in rows if norm_cat(r) == c)
print(f" • {c} ({n})")
if owner_col:
matched = {o: u for o, u in owner_cache.items() if u}
unmatched = sorted(o for o, u in owner_cache.items() if not u)
print(f"\nOwners: {len(matched)} matched to users "
f"{[u.get('name') for u in matched.values()]}; "
f"{len(unmatched)} recorded in description: {unmatched}")
print("\nSample of first 5 mapped tasks:")
for r in rows[:5]:
print(f" [{map_priority(r)}/{map_status(r)}] "
f"{(r.get(cols['subject']) or '').strip()[:60]}")
print("\nNothing was written. Re-run with --execute to create everything.")
return
# ----- Execute: create project -> parents -> children ----- #
print(f"Creating project '{args.project_name}'...")
project = client.create_project(args.project_name, identifier=args.identifier,
description=args.project_description)
pid = project["id"]
proj_href = f"/api/v3/projects/{pid}"
summary_type = client.find_type("Summary task")
task_type = client.find_type(args.task_type)
print(f" project id {pid}{client.base_url}/projects/{project['identifier']}")
# Parent per category.
parent_id = {}
for c in categories:
body = {"subject": c, "_links": {
"project": {"href": proj_href},
"type": {"href": f"/api/v3/types/{summary_type['id']}"}}}
wp = client.create_work_package(body)
parent_id[c] = wp["id"]
print(f" parent: {c} -> #{wp['id']}")
created, errors = 0, []
for r in rows:
subject = (r.get(cols["subject"]) or "").strip()
cat = norm_cat(r)
owner_raw = (r.get(owner_col) or "").strip() if owner_col else ""
owner_user = owner_cache.get(owner_raw)
owner_noted = owner_raw if (owner_raw and owner_raw.upper() != "TBD" and not owner_user) else None
links = {
"project": {"href": proj_href},
"type": {"href": f"/api/v3/types/{task_type['id']}"},
"parent": {"href": f"/api/v3/work_packages/{parent_id[cat]}"},
"priority": {"href": f"/api/v3/priorities/{client.find_priority(map_priority(r))['id']}"},
"status": {"href": f"/api/v3/statuses/{client.find_status(map_status(r))['id']}"},
}
if owner_user:
links["assignee"] = {"href": f"/api/v3/users/{owner_user['id']}"}
body = {"subject": subject[:255], "_links": links}
desc = _build_description(r, cols, owner_noted)
if desc:
body["description"] = {"format": "markdown", "raw": desc}
try:
wp = client.create_work_package(body)
created += 1
if created % 20 == 0:
print(f" ... {created}/{len(rows)} tasks created")
except OpenProjectError as exc:
errors.append((subject[:50], str(exc)))
print(f"\nDone. Project '{project['name']}': "
f"{len(categories)} parents + {created} tasks created.")
print(f" {client.base_url}/projects/{project['identifier']}/work_packages")
if errors:
print(f"\n{len(errors)} row(s) failed:")
for subj, err in errors[:15]:
print(f" ✗ {subj}: {err}")
def cmd_raw(client, args):
body = json.loads(args.data) if args.data else None
method = args.method.upper()
if method == "GET":
res = client.get(args.path)
elif method == "POST":
res = client.post(args.path, body=body)
elif method == "PATCH":
res = client.patch(args.path, body=body)
elif method == "DELETE":
res = client.delete(args.path)
else:
raise OpenProjectError(f"Unsupported method {method}")
emit_json(res)
# --------------------------------------------------------------------------- #
# Argument parser
# --------------------------------------------------------------------------- #
def build_parser():
p = argparse.ArgumentParser(prog="op", description="OpenProject CLI")
p.add_argument("--json", action="store_true", help="raw JSON output")
sub = p.add_subparsers(dest="command", required=True)
sub.add_parser("ping", help="verify connectivity + auth")
sub.add_parser("me", help="show the authenticated user")
sp = sub.add_parser("projects", help="list/search projects")
sp.add_argument("--search", help="name/identifier fragment")
sp.add_argument("--limit", type=int, default=100)
us = sub.add_parser("users", help="list/search users")
us.add_argument("--search")
us.add_argument("--limit", type=int, default=100)
ty = sub.add_parser("types", help="list work package types")
ty.add_argument("--project", help="restrict to types enabled in a project")
sub.add_parser("statuses", help="list statuses")
sub.add_parser("priorities", help="list priorities")
sub.add_parser("roles", help="list membership roles")
pj = sub.add_parser("project", help="project admin")
pjsub = pj.add_subparsers(dest="project_command", required=True)
pc = pjsub.add_parser("create", help="create a project")
pc.add_argument("--name", required=True)
pc.add_argument("--identifier", help="url slug (default: derived from name)")
pc.add_argument("--description")
pc.add_argument("--parent", help="parent project ref (for a subproject)")
mb = sub.add_parser("members", help="project memberships")
mbsub = mb.add_subparsers(dest="members_command", required=True)
ma = mbsub.add_parser("add", help="add a user to a project with a role")
ma.add_argument("--project", required=True)
ma.add_argument("--user", required=True)
ma.add_argument("--role", required=True, help="role name or id (e.g. Member)")
wp = sub.add_parser("wp", help="work packages")
wpsub = wp.add_subparsers(dest="wp_command", required=True)
wl = wpsub.add_parser("list", help="list/search work packages")
wl.add_argument("--project")
wl.add_argument("--assignee", help="user ref or 'me'")
wl.add_argument("--status", help="'open', 'closed', or a status name/id")
wl.add_argument("--type")
wl.add_argument("--query", help="free-text search")
wl.add_argument("--sort", help="field or field:asc|desc")
wl.add_argument("--limit", type=int, default=50)
ws = wpsub.add_parser("show", help="show one work package")
ws.add_argument("id")
wc = wpsub.add_parser("create", help="create a work package")
wc.add_argument("--project", required=True)
wc.add_argument("--subject", required=True)
wc.add_argument("--type")
wc.add_argument("--description")
wc.add_argument("--assignee")
wc.add_argument("--status")
wc.add_argument("--priority")
wc.add_argument("--parent")
wc.add_argument("--start")
wc.add_argument("--due")
wc.add_argument("--done-ratio", dest="done_ratio")
wu = wpsub.add_parser("update", help="update a work package (auto lockVersion)")
wu.add_argument("id")
wu.add_argument("--subject")
wu.add_argument("--description")
wu.add_argument("--assignee", help="user ref, 'me', or 'none' to unassign")
wu.add_argument("--status")
wu.add_argument("--priority")
wu.add_argument("--type")
wu.add_argument("--parent")
wu.add_argument("--start")
wu.add_argument("--due")
wu.add_argument("--done-ratio", dest="done_ratio")
wcm = wpsub.add_parser("comment", help="add a comment")
wcm.add_argument("id")
wcm.add_argument("--text", required=True)
wcl = wpsub.add_parser("close", help="set status (default 'Closed')")
wcl.add_argument("id")
wcl.add_argument("--status-name", dest="status_name", default="Closed")
wd = wpsub.add_parser("delete", help="delete a work package (needs --yes)")
wd.add_argument("id")
wd.add_argument("--yes", action="store_true")
wr = wpsub.add_parser("relate", help="link two work packages")
wr.add_argument("id")
wr.add_argument("--to", required=True, help="target work package id")
wr.add_argument("--type", default="relates",
help="relates|blocks|blocked|precedes|follows|requires|...")
wr.add_argument("--description")
tm = sub.add_parser("time", help="time entries")
tmsub = tm.add_subparsers(dest="time_command", required=True)
tl = tmsub.add_parser("log", help="log time on a work package")
tl.add_argument("--wp", required=True)
tl.add_argument("--hours", required=True)
tl.add_argument("--comment")
tl.add_argument("--date", help="YYYY-MM-DD (default today)")
tl.add_argument("--activity")
im = sub.add_parser("import", help="import a spreadsheet into a project")
imsub = im.add_subparsers(dest="import_command", required=True)
ix = imsub.add_parser("xlsx", help="import an .xlsx sheet as a project of tasks")
ix.add_argument("path", help="path to the .xlsx file")
ix.add_argument("--sheet", required=True, help="worksheet name to import")
ix.add_argument("--project-name", dest="project_name", required=True)
ix.add_argument("--identifier", help="project url slug")
ix.add_argument("--project-description", dest="project_description")
ix.add_argument("--task-type", dest="task_type", default="Task")
ix.add_argument("--merge-category", dest="merge_category", action="append",
default=[], help="merge a category: 'OLD=NEW' (repeatable)")
ix.add_argument("--limit", type=int, help="only import the first N rows")
ix.add_argument("--execute", action="store_true",
help="actually write (default is a dry run)")
rw = sub.add_parser("raw", help="raw API call (escape hatch)")
rw.add_argument("method")
rw.add_argument("path")
rw.add_argument("--data", help="JSON request body")
return p
DISPATCH = {
("ping",): cmd_ping,
("me",): cmd_me,
("projects",): cmd_projects,
("users",): cmd_users,
("types",): cmd_types,
("statuses",): cmd_statuses,
("priorities",): cmd_priorities,
("roles",): cmd_roles,
("project", "create"): cmd_project_create,
("members", "add"): cmd_members_add,
("wp", "list"): cmd_wp_list,
("wp", "show"): cmd_wp_show,
("wp", "create"): cmd_wp_create,
("wp", "update"): cmd_wp_update,
("wp", "comment"): cmd_wp_comment,
("wp", "close"): cmd_wp_close,
("wp", "delete"): cmd_wp_delete,
("wp", "relate"): cmd_wp_relate,
("time", "log"): cmd_time_log,
("import", "xlsx"): cmd_import_xlsx,
("raw",): cmd_raw,
}
def main(argv=None):
args = build_parser().parse_args(argv)
key = (args.command,)
if args.command == "wp":
key = ("wp", args.wp_command)
elif args.command == "time":
key = ("time", args.time_command)
elif args.command == "project":
key = ("project", args.project_command)
elif args.command == "members":
key = ("members", args.members_command)
elif args.command == "import":
key = ("import", args.import_command)
handler = DISPATCH.get(key)
if not handler:
print(f"Unknown command: {key}", file=sys.stderr)
return 2
try:
client = OpenProjectClient.from_config()
handler(client, args)
return 0
except OpenProjectError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())