ver 1.0.0 initial commit

This commit is contained in:
Jason Stedwell
2026-06-27 17:25:55 -05:00
parent 550111295a
commit 2a7efe9c5d
20 changed files with 1850 additions and 0 deletions
+158
View File
@@ -0,0 +1,158 @@
---
name: openproject
description: Interact with the user's self-hosted OpenProject instance over its REST API v3. Use whenever the user wants to view, search, create, update, comment on, close, delete, or report on OpenProject projects, work packages, tasks, bugs, features, milestones, tickets, or time entries — e.g. "what's assigned to me in OpenProject", "create a task in the Infra project", "move #1234 to In Progress", "log 2 hours on that ticket", "show open bugs", "comment on work package 88", "list my projects". Also triggers on bare references to a work package number (#1234) or an OpenProject URL when the intent is to inspect or change it. Do NOT use for unrelated project-management tools (Jira, Asana, Trello, GitHub issues, Gitea) or for generic local task tracking.
---
# OpenProject
Read and manage the user's self-hosted **OpenProject** instance through its API v3.
The server URL and API token are configured in `config.json` at the plugin root
(baked for this single-user, local-network install). Everything runs through one
stdlib-only Python CLI — **no pip installs, no external dependencies**, works on
macOS/Linux/Windows with just a Python 3 interpreter.
## The tool
All logic lives in `${CLAUDE_PLUGIN_ROOT}/skills/openproject/scripts/op.py`.
Invoke it with `python3` (use `python` or `py -3` on Windows if `python3` isn't on PATH):
```bash
OP="${CLAUDE_PLUGIN_ROOT}/skills/openproject/scripts/op.py"
python3 "$OP" <command> [options]
```
If `${CLAUDE_PLUGIN_ROOT}` is not set in your shell, use the absolute path to this
skill's `scripts/op.py`. Every command accepts `--json` for raw HAL+JSON output
(prefer this when you need exact field values to chain into another call; otherwise
the default human-readable tables are easier to read back to the user).
**Always run `ping` first in a session** if you're unsure the token is valid — it
fails fast with a clear message instead of a confusing error mid-task.
## Command reference
| Goal | Command |
|------|---------|
| Verify connection + auth | `op.py ping` |
| Who am I | `op.py me` |
| List / search projects | `op.py projects [--search TEXT]` |
| List / search work packages | `op.py wp list [--project P] [--assignee me] [--status open\|closed\|NAME] [--type T] [--query TEXT] [--sort field:asc] [--limit N]` |
| Show one work package | `op.py wp show ID` |
| Create a work package | `op.py wp create --project P --subject "..." [--type T] [--description "..."] [--assignee U] [--status S] [--priority P] [--parent ID] [--start YYYY-MM-DD] [--due YYYY-MM-DD]` |
| Update a work package | `op.py wp update ID [--subject] [--status] [--assignee] [--description] [--priority] [--done-ratio N] [--start] [--due] [--parent]` |
| Add a comment | `op.py wp comment ID --text "..."` |
| Change status / close | `op.py wp close ID [--status-name "Closed"]` |
| Delete (guarded) | `op.py wp delete ID --yes` |
| Log time | `op.py time log --wp ID --hours 1.5 [--comment "..."] [--date YYYY-MM-DD] [--activity NAME]` |
| Relate two work packages | `op.py wp relate ID --to ID --type blocks\|precedes\|relates\|requires\|...` |
| Create a project | `op.py project create --name "..." [--identifier slug] [--description] [--parent REF]` |
| Add a project member | `op.py members add --project P --user U --role "Member"` |
| Import a spreadsheet | `op.py import xlsx FILE.xlsx --sheet "Sheet" --project-name "..." [--execute]` |
| List users / roles | `op.py users [--search TEXT]` · `op.py roles` |
| List types / statuses / priorities | `op.py types [--project P]` · `op.py statuses` · `op.py priorities` |
| Anything else (escape hatch) | `op.py raw GET\|POST\|PATCH\|DELETE /api/v3/... [--data '{...}']` |
## How to work with this
- **Friendly references resolve automatically.** `--project`, `--assignee`, `--type`,
`--status`, `--priority` accept either a numeric id **or a name** (e.g.
`--project "Infrastructure"`, `--assignee me`, `--status "In progress"`,
`--priority High`). The CLI looks up the id for you. `--assignee me` and
`--assignee none` (unassign) are special.
- **Updates are concurrency-safe.** `wp update` and `wp close` fetch the current
`lockVersion` automatically before PATCHing, so you never need to manage it. If an
update fails with a lockVersion error, the record changed underneath you — just retry.
- **Status filtering shortcuts:** `--status open` and `--status closed` use the API's
open/closed operators; any other value is treated as a status name/id.
- **Prefer the typed subcommands** over `raw`. Reach for `raw` only for endpoints the
CLI doesn't wrap (versions, categories, queries, attachments, notifications, etc.) —
see `reference/api-map.md` for the endpoint catalog and `reference/filters.md` for
the filter JSON syntax.
## Importing a spreadsheet into a project
`op.py import xlsx` reads an `.xlsx` (via the bundled stdlib `xlsx_read.py` — no
openpyxl) and builds a project: one parent **Summary task** per Category, with each
row as a child Task. It is a **dry run by default** — it prints the plan (row count,
category hierarchy, owner matches, value mappings, a sample) and writes nothing until
you add `--execute`. Always run the dry run first and show the plan to the user.
```bash
OP="${CLAUDE_PLUGIN_ROOT}/skills/openproject/scripts/op.py"
# 1) Preview
python3 "$OP" import xlsx "Plan.xlsx" --sheet "To-Do List" --project-name "My Project"
# 2) Commit
python3 "$OP" import xlsx "Plan.xlsx" --sheet "To-Do List" --project-name "My Project" \
--identifier my-project --execute
```
- **Column detection** is automatic and case-insensitive by prefix: a Task/Title/Name
column → subject; Category/Group → parent; Owner/Assignee → assignee; plus
Priority, Status, Due, Notes/Dependencies, and `#`/Id. Notes, due-date text, owner,
and source row # are preserved in each task's description.
- **Value mapping:** priority H/M/L → High/Normal/Low; status Open/In Progress/
Complete → New/In progress/Closed (unknown → New). Setting a non-default status on
create is handled with an automatic create-then-patch fallback.
- **Owners:** matched to existing users when possible; otherwise the name is recorded
in the description (no failed rows). People aren't auto-created (no emails to do so).
- **Blockers/dependencies** are only linked as real relations if the sheet has a
column referencing other rows. Free-text dependencies stay in the description —
use `op.py wp relate` to wire them up explicitly afterward.
- `--merge-category "Old=New"` (repeatable) merges near-duplicate category names;
`--limit N` imports only the first N rows (good for a trial run); `--task-type`
overrides the child type (default `Task`).
## Guardrails (full CRUD, with confirmation on destructive actions)
- **Deletes require `--yes`** and the CLI refuses otherwise. Before deleting anything,
confirm with the user in plain language (show what `wp show ID` returns first), then
run with `--yes`. The same caution applies to bulk changes and any `raw DELETE`.
- **Show before you change.** For an update/close/delete on a specific item, run
`wp show ID` first so you (and the user) see the current state.
- **Never print the API token.** It lives only in `config.json`. Don't echo it, don't
paste it into a work package, comment, or any external surface.
- When a write succeeds, report the resulting id/status and the web URL
(`<server>/work_packages/<id>`) so the user can click through.
## Common recipes
```bash
OP="${CLAUDE_PLUGIN_ROOT}/skills/openproject/scripts/op.py"
# What's on my plate (open items assigned to me)
python3 "$OP" wp list --assignee me --status open
# Open bugs in a project, newest first
python3 "$OP" wp list --project "Infrastructure" --type Bug --status open --sort id:desc
# File a task
python3 "$OP" wp create --project "Infrastructure" --type Task \
--subject "Rotate TLS certs" --description "Expires next month." \
--assignee me --priority High --due 2026-07-15
# Progress an item and log time against it
python3 "$OP" wp update 1234 --status "In progress" --done-ratio 40
python3 "$OP" wp comment 1234 --text "Halfway — blocked on DNS."
python3 "$OP" time log --wp 1234 --hours 1.5 --comment "cert work"
# Inspect, then close
python3 "$OP" wp show 1234
python3 "$OP" wp close 1234
```
## Troubleshooting
- `error: No API key configured` / `HTTP 401 Unauthenticated` → the token in
`config.json` is missing/placeholder/expired. Ask the user for a fresh token from
OpenProject → **My Account → Access tokens → API**, and write it into the `api_key`
field of the plugin's `config.json` (or set `OPENPROJECT_API_KEY` in the env, which
overrides the file).
- `Could not reach <url>` → the server is down or off-network. Confirm the URL in
`config.json` (`OPENPROJECT_URL` overrides it) and that the machine can reach it.
- `HTTP 422` on create/update → a required field or invalid link. The error message
echoes the API's validation detail; re-check type/status/priority names exist for
that project (`op.py types --project P`, `op.py statuses`).
- `HTTP 422: The chosen user is not allowed to be 'Assignee'` → that user isn't a
member of the project (with an assignable role). Add them in OpenProject first, or
create the work package without `--assignee`.
+60
View File
@@ -0,0 +1,60 @@
# OpenProject API v3 endpoint map
Base: `<server>/api/v3`. Auth: HTTP Basic, username `apikey`, password = API token
(the CLI handles this). Responses are HAL+JSON: `_type`, `_links`, `_embedded`.
Collections carry `total`, `count`, `pageSize`, and `_embedded.elements`.
Endpoints the `op.py` CLI wraps directly are marked ✅. Everything else is reachable
via `op.py raw <METHOD> <path> [--data '{...}']`.
## Work packages
-`GET /work_packages` — list/filter (also `/projects/{id}/work_packages`)
-`GET /work_packages/{id}` — single
-`POST /work_packages` — create
-`PATCH /work_packages/{id}` — update (needs `lockVersion`; CLI auto-fetches)
-`DELETE /work_packages/{id}` — delete
-`POST /work_packages/{id}/activities` — add comment
- `GET /work_packages/{id}/activities` — comment/change history
- `GET /work_packages/{id}/relations` · `POST /relations` — links between WPs
- `GET /work_packages/{id}/watchers` · `POST .../watchers`
- `GET /work_packages/form` · `POST /work_packages/{id}/form` — field schema/validation
- `GET /work_packages/schemas` — allowed values per project+type
## Projects
-`GET /projects` — list/filter (`name_and_identifier` filter)
-`GET /projects/{id}` — single (id or identifier)
- `POST /projects` · `PATCH /projects/{id}` · `DELETE /projects/{id}`
-`GET /projects/{id}/types` — types enabled in a project
- `GET /projects/{id}/versions` — versions/milestones
- `GET /projects/{id}/categories`
- `GET /projects/{id}/memberships`
## People & metadata
-`GET /users/me` — current user (`ping`/`me`)
-`GET /users` — list/filter
- `GET /groups` · `GET /roles`
-`GET /types` · `GET /statuses` · `GET /priorities`
- `GET /time_entries/activities` — activity types for time logging
## Time tracking
-`POST /time_entries` — log time (`hours` is ISO-8601 duration, e.g. `PT1H30M`;
CLI accepts a plain number of hours and converts)
- `GET /time_entries` — list/filter
- `PATCH /time_entries/{id}` · `DELETE /time_entries/{id}`
## Other useful resources
- `GET /queries` · `GET /queries/{id}` — saved views (filters+columns+sort)
- `GET /versions/{id}` — milestone detail
- `GET /attachments/{id}` · `POST /work_packages/{id}/attachments` — files
- `GET /notifications` — in-app notifications
- `GET /memberships` — project membership management
## Notes
- **lockVersion / optimistic locking:** any `PATCH` to a work package must include the
`lockVersion` you got from the last `GET`. If it's stale, the API returns `409`/`422`
— re-fetch and retry. The CLI does this automatically for `wp update`/`wp close`.
- **Durations:** time values use ISO-8601 (`PT1H`, `PT30M`, `P1DT2H`). The CLI's
`time log --hours 1.5` emits `PT1.5H`.
- **Markdown fields:** `description` and `comment` take `{"format":"markdown","raw":"..."}`.
- **Form endpoints** (`POST .../form`) validate a payload and return allowed values
without persisting — handy when unsure which statuses/types are valid for a project.
+66
View File
@@ -0,0 +1,66 @@
# OpenProject filter & query syntax (API v3)
Filters are passed as a **JSON array** in the `filters` query parameter. Each element
is a single-key object: `{ "<attribute>": { "operator": "<op>", "values": [...] } }`.
The `op.py` CLI builds the common ones for you; this is for `raw` calls and edge cases.
## Operators
| Operator | Meaning | Values |
|----------|---------|--------|
| `=` | equals (one of) | array of ids/strings |
| `!` | not equal | array |
| `~` | contains (substring) | `["text"]` |
| `!~` | doesn't contain | `["text"]` |
| `o` | open (status) | `null` |
| `c` | closed (status) | `null` |
| `**` | full-text search | `["text"]` |
| `>=` / `<=` | date/number bounds | `["2026-01-01"]` / `["5"]` |
| `<t+` / `>t-` / `t` | relative dates (days from today) | `["7"]` |
| `*` | any (present) | `null` |
| `!*` | none (absent) | `null` |
## Common work-package filters
```jsonc
// Assigned to me, still open
[{"assignee":{"operator":"=","values":["me"]}},
{"status":{"operator":"o","values":null}}]
// In project 3, type Bug (id 7)
[{"project":{"operator":"=","values":["3"]}},
{"type":{"operator":"=","values":["7"]}}]
// Full-text search
[{"search":{"operator":"**","values":["certificate"]}}]
// Due within 7 days
[{"dueDate":{"operator":"<t+","values":["7"]}}]
// Updated since a date
[{"updatedAt":{"operator":">=","values":["2026-06-01T00:00:00Z"]}}]
// Created by a specific user (id 11)
[{"author":{"operator":"=","values":["11"]}}]
```
## Sorting & paging
- `sortBy` — JSON array of `[field, "asc"|"desc"]` pairs, e.g. `[["id","desc"]]`,
`[["status","asc"],["priority","desc"]]`.
- `offset` — 1-based page number (default 1).
- `pageSize` — items per page (the CLI's `get_all` follows pages automatically up to
its `max_items` cap).
- `groupBy`, `showSums=true` — for grouped/aggregated collection views.
## Passing filters via the CLI escape hatch
URL-encode the JSON in a `raw GET`:
```bash
OP="${CLAUDE_PLUGIN_ROOT}/skills/openproject/scripts/op.py"
python3 "$OP" raw GET '/api/v3/work_packages?filters=[{"status":{"operator":"o","values":null}}]&pageSize=5'
```
(The typed `wp list` command is easier for the common cases — use `raw` only for
attributes the CLI doesn't expose as flags.)
+775
View File
@@ -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())
+355
View File
@@ -0,0 +1,355 @@
#!/usr/bin/env python3
"""
OpenProject API v3 client — standard library only (no pip installs).
Talks to an OpenProject instance over its HAL+JSON REST API.
Auth: API token via HTTP Basic auth (username "apikey", password = token).
Config resolution order (first hit wins for each key):
1. Environment variables OPENPROJECT_URL / OPENPROJECT_API_KEY
2. config.json next to the plugin root (../config.json relative to this file)
3. ~/.config/openproject/config.json
This module is imported by op.py (the CLI) and can also be used directly:
from opclient import OpenProjectClient
op = OpenProjectClient.from_config()
me = op.get("/api/v3/users/me")
"""
from __future__ import annotations
import base64
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
__all__ = ["OpenProjectClient", "OpenProjectError", "load_config"]
DEFAULT_TIMEOUT = 30
class OpenProjectError(Exception):
"""Raised for API and configuration errors. Carries status + parsed body."""
def __init__(self, message, status=None, body=None):
super().__init__(message)
self.status = status
self.body = body
# --------------------------------------------------------------------------- #
# Configuration
# --------------------------------------------------------------------------- #
def _config_search_paths():
"""Look for config.json at the plugin root and common locations.
Scripts live at <root>/skills/openproject/scripts/, so we walk up a few
parent directories to find the plugin root regardless of install depth.
"""
paths = []
if os.environ.get("CLAUDE_PLUGIN_ROOT"):
paths.append(os.path.join(os.environ["CLAUDE_PLUGIN_ROOT"], "config.json"))
cur = os.path.dirname(os.path.abspath(__file__))
for _ in range(5): # scripts -> openproject -> skills -> <root> -> ...
paths.append(os.path.join(cur, "config.json"))
cur = os.path.dirname(cur)
paths.append(os.path.expanduser("~/.config/openproject/config.json"))
seen, ordered = set(), []
for p in paths:
if p not in seen:
seen.add(p)
ordered.append(p)
return ordered
def load_config():
"""Merge config sources. Env vars override file values."""
cfg = {"url": None, "api_key": None, "default_project": None}
for path in _config_search_paths():
if os.path.isfile(path):
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
for key in cfg:
if data.get(key) not in (None, "", "REPLACE_WITH_YOUR_API_TOKEN"):
cfg[key] = data[key]
except (OSError, ValueError) as exc:
print(f"warning: could not read {path}: {exc}", file=sys.stderr)
break # first existing config file wins
# Environment overrides
if os.environ.get("OPENPROJECT_URL"):
cfg["url"] = os.environ["OPENPROJECT_URL"]
if os.environ.get("OPENPROJECT_API_KEY"):
cfg["api_key"] = os.environ["OPENPROJECT_API_KEY"]
if os.environ.get("OPENPROJECT_DEFAULT_PROJECT"):
cfg["default_project"] = os.environ["OPENPROJECT_DEFAULT_PROJECT"]
return cfg
# --------------------------------------------------------------------------- #
# Client
# --------------------------------------------------------------------------- #
class OpenProjectClient:
def __init__(self, url, api_key, default_project=None, timeout=DEFAULT_TIMEOUT):
if not url:
raise OpenProjectError(
"No OpenProject URL configured. Set OPENPROJECT_URL or config.json 'url'."
)
if not api_key:
raise OpenProjectError(
"No API key configured. Set OPENPROJECT_API_KEY or config.json 'api_key'."
)
self.base_url = url.rstrip("/")
self.api_key = api_key
self.default_project = default_project
self.timeout = timeout
token = base64.b64encode(f"apikey:{api_key}".encode()).decode()
self._auth_header = f"Basic {token}"
@classmethod
def from_config(cls, timeout=DEFAULT_TIMEOUT):
cfg = load_config()
return cls(cfg["url"], cfg["api_key"], cfg.get("default_project"), timeout)
# -- low-level HTTP ----------------------------------------------------- #
def _request(self, method, path, params=None, body=None):
# Allow callers to pass either "/api/v3/..." or a full URL.
if path.startswith("http://") or path.startswith("https://"):
url = path
else:
url = self.base_url + path
if params:
# Drop None values; JSON-encode any non-string (filters/sortBy).
clean = {}
for k, v in params.items():
if v is None:
continue
clean[k] = v if isinstance(v, str) else json.dumps(v)
url += "?" + urllib.parse.urlencode(clean)
data = None
headers = {
"Authorization": self._auth_header,
"Accept": "application/json",
}
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
raw = resp.read()
if not raw:
return {}
return json.loads(raw.decode("utf-8"))
except urllib.error.HTTPError as exc:
raw = exc.read().decode("utf-8", "replace")
parsed = None
try:
parsed = json.loads(raw)
except ValueError:
pass
msg = self._format_api_error(exc.code, parsed, raw)
raise OpenProjectError(msg, status=exc.code, body=parsed or raw)
except urllib.error.URLError as exc:
raise OpenProjectError(
f"Could not reach {self.base_url} ({exc.reason}). "
f"Check the URL and network."
)
@staticmethod
def _format_api_error(code, parsed, raw):
if isinstance(parsed, dict):
message = parsed.get("message", "")
details = []
embedded = parsed.get("_embedded", {})
for err in embedded.get("errors", []) or []:
if isinstance(err, dict) and err.get("message"):
details.append(err["message"])
if details:
message = message + " | " + "; ".join(details)
if message:
return f"HTTP {code}: {message}"
return f"HTTP {code}: {raw[:400]}"
def get(self, path, params=None):
return self._request("GET", path, params=params)
def post(self, path, body=None, params=None):
return self._request("POST", path, params=params, body=body or {})
def patch(self, path, body=None, params=None):
return self._request("PATCH", path, params=params, body=body or {})
def delete(self, path, params=None):
return self._request("DELETE", path, params=params)
# -- pagination --------------------------------------------------------- #
def get_all(self, path, params=None, max_items=1000):
"""Follow nextByOffset links, accumulating collection elements."""
params = dict(params or {})
params.setdefault("pageSize", 100)
params.setdefault("offset", 1)
items = []
while True:
page = self.get(path, params=params)
embedded = page.get("_embedded", {})
elements = embedded.get("elements", [])
items.extend(elements)
if len(items) >= max_items:
return items[:max_items]
total = page.get("total", 0)
if len(items) >= total or not elements:
return items
params["offset"] = int(params["offset"]) + 1
# -- helpers / resolvers ------------------------------------------------ #
@staticmethod
def id_from_href(href):
if not href:
return None
return href.rstrip("/").split("/")[-1]
@staticmethod
def link_title(resource, link_name):
link = (resource.get("_links") or {}).get(link_name) or {}
return link.get("title")
def me(self):
return self.get("/api/v3/users/me")
def find_project(self, ref):
"""Resolve a project by numeric id, identifier, or (partial) name."""
if ref is None:
return None
ref = str(ref)
if ref.isdigit():
return self.get(f"/api/v3/projects/{ref}")
# Try identifier lookup directly.
try:
return self.get(f"/api/v3/projects/{urllib.parse.quote(ref)}")
except OpenProjectError:
pass
# Fall back to name search.
flt = [{"name_and_identifier": {"operator": "~", "values": [ref]}}]
results = self.get_all("/api/v3/projects", params={"filters": flt}, max_items=50)
if not results:
raise OpenProjectError(f"No project matching '{ref}'.")
return results[0]
def find_user(self, ref):
"""Resolve a user by 'me', numeric id, login, or name fragment."""
if ref is None:
return None
ref = str(ref)
if ref.lower() == "me":
return self.me()
if ref.isdigit():
return self.get(f"/api/v3/users/{ref}")
flt = [{"name": {"operator": "~", "values": [ref]}}]
results = self.get_all("/api/v3/users", params={"filters": flt}, max_items=25)
if not results:
raise OpenProjectError(f"No user matching '{ref}'.")
return results[0]
def _resolve_by_name(self, collection_path, ref, fields=("name",)):
"""Generic resolver for small collections (types, statuses, priorities)."""
ref = str(ref)
if ref.isdigit():
return self.get(f"{collection_path}/{ref}")
for item in self.get_all(collection_path, max_items=200):
for field in fields:
val = item.get(field)
if val and val.lower() == ref.lower():
return item
# second pass: substring match
for item in self.get_all(collection_path, max_items=200):
for field in fields:
val = item.get(field)
if val and ref.lower() in val.lower():
return item
raise OpenProjectError(f"No item in {collection_path} matching '{ref}'.")
def find_type(self, ref):
return self._resolve_by_name("/api/v3/types", ref)
def find_status(self, ref):
return self._resolve_by_name("/api/v3/statuses", ref)
def find_priority(self, ref):
return self._resolve_by_name("/api/v3/priorities", ref)
def find_role(self, ref):
return self._resolve_by_name("/api/v3/roles", ref)
# -- create / relate helpers ------------------------------------------- #
@staticmethod
def slugify(name):
"""Make a valid OpenProject project identifier from a name."""
slug = re.sub(r"[^a-z0-9]+", "-", str(name).lower()).strip("-")
return slug[:100] or "project"
def create_project(self, name, identifier=None, description=None, parent=None):
body = {"name": name, "identifier": identifier or self.slugify(name)}
if description:
body["description"] = {"format": "markdown", "raw": description}
if parent:
pid = self.find_project(parent)["id"]
body["_links"] = {"parent": {"href": f"/api/v3/projects/{pid}"}}
return self.post("/api/v3/projects", body=body)
def add_membership(self, project_id, user_id, role_ref):
role = self.find_role(role_ref)
body = {
"_links": {
"project": {"href": f"/api/v3/projects/{project_id}"},
"principal": {"href": f"/api/v3/users/{user_id}"},
"roles": [{"href": f"/api/v3/roles/{role['id']}"}],
}
}
return self.post("/api/v3/memberships", body=body)
# OpenProject relation types: relates, duplicates, blocks, blocked, precedes,
# follows, includes, partof, requires, required.
def create_relation(self, from_id, to_id, rel_type="relates", description=None):
body = {
"type": rel_type,
"_links": {"to": {"href": f"/api/v3/work_packages/{to_id}"}},
}
if description:
body["description"] = description
return self.post(f"/api/v3/work_packages/{from_id}/relations", body=body)
def create_work_package(self, body, notify=False):
"""Create a WP; falls back to create-without-status + PATCH when the
workflow rejects setting a non-default status on creation."""
params = {"notify": "false"} if not notify else None
try:
return self.post("/api/v3/work_packages", body=body, params=params)
except OpenProjectError as exc:
links = body.get("_links", {})
if exc.status in (422,) and "status" in links:
status_link = links.pop("status")
created = self.post("/api/v3/work_packages", body=body, params=params)
patch = {"lockVersion": created.get("lockVersion"),
"_links": {"status": status_link}}
try:
return self.patch(
f"/api/v3/work_packages/{created['id']}", body=patch)
except OpenProjectError:
return created # created at default status; status change rejected
raise
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""
Minimal .xlsx reader — standard library only (no openpyxl/pandas).
An .xlsx file is a zip of XML parts. This reads worksheet cell values, resolving
shared strings and inline strings, and returns plain Python rows. Enough for
tabular imports; it does NOT evaluate formulas (it returns the cached value if
present) and ignores styling/number formats.
API:
from xlsx_read import read_sheet, sheet_names
names = sheet_names("file.xlsx")
rows = read_sheet("file.xlsx", "To-Do List") # -> list[list[str|None]]
table = read_table("file.xlsx", "To-Do List") # -> (headers, list[dict])
"""
from __future__ import annotations
import re
import zipfile
from xml.etree import ElementTree as ET
_NS = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
_RID = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id"
_RNS = "{http://schemas.openxmlformats.org/package/2006/relationships}"
def _col_to_num(ref):
"""'B7' -> 2 (1-based column index)."""
letters = re.match(r"[A-Z]+", ref).group()
n = 0
for ch in letters:
n = n * 26 + (ord(ch) - 64)
return n
def _shared_strings(zf):
if "xl/sharedStrings.xml" not in zf.namelist():
return []
root = ET.fromstring(zf.read("xl/sharedStrings.xml"))
out = []
for si in root.findall(f"{_NS}si"):
out.append("".join(t.text or "" for t in si.iter(f"{_NS}t")))
return out
def _sheet_map(zf):
"""Return ordered list of (sheet_name, worksheet_part_path)."""
wb = ET.fromstring(zf.read("xl/workbook.xml"))
rels = ET.fromstring(zf.read("xl/_rels/workbook.xml.rels"))
rid2target = {r.get("Id"): r.get("Target")
for r in rels.iter(f"{_RNS}Relationship")}
sheets = []
for s in wb.iter(f"{_NS}sheet"):
target = rid2target.get(s.get(_RID))
if not target:
continue
if not target.startswith("xl/"):
target = "xl/" + target.lstrip("/")
sheets.append((s.get("name"), target))
return sheets
def sheet_names(path):
with zipfile.ZipFile(path) as zf:
return [name for name, _ in _sheet_map(zf)]
def read_sheet(path, sheet_name):
"""Return rows as lists of cell values (str or None), padded to max width."""
with zipfile.ZipFile(path) as zf:
shared = _shared_strings(zf)
target = None
for name, part in _sheet_map(zf):
if name == sheet_name:
target = part
break
if target is None:
raise KeyError(f"Sheet '{sheet_name}' not found. "
f"Available: {[n for n, _ in _sheet_map(zf)]}")
root = ET.fromstring(zf.read(target))
rows = []
max_col = 0
for row in root.iter(f"{_NS}row"):
cells = {}
for c in row.findall(f"{_NS}c"):
ref = c.get("r")
if not ref:
continue
ctype = c.get("t")
v = c.find(f"{_NS}v")
inline = c.find(f"{_NS}is")
val = None
if ctype == "s" and v is not None:
val = shared[int(v.text)]
elif ctype == "inlineStr" and inline is not None:
val = "".join(t.text or "" for t in inline.iter(f"{_NS}t"))
elif v is not None:
val = v.text
idx = _col_to_num(ref)
cells[idx] = val
max_col = max(max_col, idx)
rows.append(cells)
return [[r.get(i) for i in range(1, max_col + 1)] for r in rows]
def read_table(path, sheet_name, header_row=0):
"""Return (headers, list[dict]) using the given 0-based header row index.
Rows above the header are skipped; the first row at/after header_row whose
first cell is non-empty is treated as the header.
"""
rows = read_sheet(path, sheet_name)
if not rows:
return [], []
headers = [(h or "").strip() for h in rows[header_row]]
records = []
for r in rows[header_row + 1:]:
rec = {}
for i, h in enumerate(headers):
if not h:
continue
rec[h] = r[i] if i < len(r) else None
records.append(rec)
return headers, records
if __name__ == "__main__":
import sys
p = sys.argv[1]
print("Sheets:", sheet_names(p))
if len(sys.argv) > 2:
hdrs, recs = read_table(p, sys.argv[2])
print("Headers:", hdrs)
print(f"{len(recs)} data rows")