776 lines
29 KiB
Python
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())
|