ver 1.0.0 initial commit
This commit is contained in:
@@ -0,0 +1,775 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user