From 2a7efe9c5d5aecc816f4a20f1e40d1aa8df1081f Mon Sep 17 00:00:00 2001 From: Jason Stedwell Date: Sat, 27 Jun 2026 17:25:55 -0500 Subject: [PATCH] ver 1.0.0 initial commit --- .claude-plugin/plugin.json | 16 + .gitignore | 6 + README.md | 93 +++ build.py | 77 +++ commands/op-comment.md | 17 + commands/op-create.md | 14 + commands/op-ping.md | 16 + commands/op-projects.md | 12 + commands/op-show.md | 12 + commands/op-time.md | 10 + commands/op-update.md | 11 + commands/op-work.md | 17 + openproject-1.0.0-baked.plugin | Bin 0 -> 27902 bytes placeholder.md | 0 skills/openproject/SKILL.md | 158 +++++ skills/openproject/reference/api-map.md | 60 ++ skills/openproject/reference/filters.md | 66 ++ skills/openproject/scripts/op.py | 775 ++++++++++++++++++++++++ skills/openproject/scripts/opclient.py | 355 +++++++++++ skills/openproject/scripts/xlsx_read.py | 135 +++++ 20 files changed, 1850 insertions(+) create mode 100644 .claude-plugin/plugin.json create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build.py create mode 100644 commands/op-comment.md create mode 100644 commands/op-create.md create mode 100644 commands/op-ping.md create mode 100644 commands/op-projects.md create mode 100644 commands/op-show.md create mode 100644 commands/op-time.md create mode 100644 commands/op-update.md create mode 100644 commands/op-work.md create mode 100644 openproject-1.0.0-baked.plugin delete mode 100644 placeholder.md create mode 100644 skills/openproject/SKILL.md create mode 100644 skills/openproject/reference/api-map.md create mode 100644 skills/openproject/reference/filters.md create mode 100644 skills/openproject/scripts/op.py create mode 100644 skills/openproject/scripts/opclient.py create mode 100644 skills/openproject/scripts/xlsx_read.py diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..172aa6a --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,16 @@ +{ + "name": "openproject", + "version": "1.0.0", + "description": "Interact with a self-hosted OpenProject instance over its API v3. A stdlib-only Python client (no pip installs) for projects, work packages, time entries, comments, users, types, statuses, and priorities — with name->id resolution, HAL+JSON parsing, pagination, optimistic-locking-safe updates, and delete guards. Ships a comprehensive skill plus /op-ping, /op-projects, /op-work, /op-show, /op-create, /op-update, /op-comment, /op-time commands.", + "author": { + "name": "Jason Stedwell" + }, + "license": "UNLICENSED", + "keywords": [ + "openproject", + "project-management", + "work-packages", + "rest-api", + "tickets" + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..654ddfb --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# The API token lives in config.json — never commit the real key. +config.json + +__pycache__/ +*.pyc +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb736a2 --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# openproject (Claude Code plugin) + +Lets Claude interact with a self-hosted **OpenProject** instance over its +[API v3](https://www.openproject.org/docs/api/introduction/) — projects, work +packages, time entries, comments, users, and metadata — through one stdlib-only +Python CLI. No `pip install`, no external dependencies: just a Python 3 interpreter. + +Built for a **single-user, local-network** install (server at `http://10.2.0.2:5683`). + +## Setup + +1. Get an API token: OpenProject → **My Account → Access tokens → API**. +2. Put it in `config.json` at the plugin root: + + ```json + { + "url": "http://10.2.0.2:5683", + "api_key": "opapi-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "default_project": null + } + ``` + + `config.json` is gitignored so the token never lands in version control. + Environment variables `OPENPROJECT_URL` / `OPENPROJECT_API_KEY` override the file. + +3. Verify: + + ```bash + python3 skills/openproject/scripts/op.py ping + ``` + +## What's inside + +``` +.claude-plugin/plugin.json plugin manifest +config.json server URL + API token (gitignored) +commands/ /op-ping /op-work /op-show /op-projects + /op-create /op-update /op-comment /op-time +skills/openproject/ + SKILL.md when/how Claude uses this; full command table + reference/filters.md filter & query JSON syntax + reference/api-map.md endpoint catalog (wrapped vs. raw) + scripts/op.py the CLI (argparse dispatch) + scripts/opclient.py HTTP client library (HAL+JSON, paging, resolvers) + scripts/xlsx_read.py stdlib .xlsx reader (no openpyxl) for imports +``` + +## CLI quick reference + +```bash +OP=skills/openproject/scripts/op.py +python3 $OP ping +python3 $OP projects --search infra +python3 $OP wp list --assignee me --status open +python3 $OP wp show 1234 +python3 $OP wp create --project "Infrastructure" --type Task --subject "..." --assignee me +python3 $OP wp update 1234 --status "In progress" --done-ratio 50 +python3 $OP wp comment 1234 --text "..." +python3 $OP wp relate 1234 --to 1240 --type blocks +python3 $OP time log --wp 1234 --hours 1.5 --comment "..." +python3 $OP project create --name "New Project" +python3 $OP members add --project new-project --user jdoe --role Member +python3 $OP wp delete 1234 --yes # destructive — guarded +python3 $OP raw GET /api/v3/statuses # escape hatch +``` + +### Import a spreadsheet + +Build a whole project from an `.xlsx` — one parent Summary task per Category, each +row a child Task. **Dry run by default**; add `--execute` to write. + +```bash +# Preview (writes nothing) +python3 $OP import xlsx "Plan.xlsx" --sheet "To-Do List" --project-name "My Project" +# Commit +python3 $OP import xlsx "Plan.xlsx" --sheet "To-Do List" --project-name "My Project" \ + --identifier my-project --merge-category "Display & Technology=Display & Tech" --execute +``` + +Columns are auto-detected (Task/Category/Owner/Priority/Status/Due/Notes/#). Priority +H/M/L → High/Normal/Low; Status Open/In Progress/Complete → New/In progress/Closed. +Owners are matched to existing users or noted in the description. Free-text +dependencies are preserved in the description (link them with `wp relate`). + +Add `--json` to any command for raw HAL+JSON. Names (project/type/status/priority/ +assignee) resolve to ids automatically; `--assignee me` and `--assignee none` are +special. Work-package updates fetch `lockVersion` automatically (optimistic locking). + +## Safety + +Full CRUD is enabled, with guards: deletes require `--yes`, and Claude is instructed +to show an item before changing it and to confirm destructive actions with you first. +The token is never printed or written into OpenProject content. diff --git a/build.py b/build.py new file mode 100644 index 0000000..c73ddff --- /dev/null +++ b/build.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +""" +Package the openproject plugin into a distributable `.plugin` file. + +A `.plugin` is just a zip archive of the plugin tree with +`.claude-plugin/plugin.json` at the root. Timestamps are normalized and entries +are sorted so builds are deterministic (identical input -> identical bytes). + +Usage: + python3 build.py # credential-free build (no config.json) + python3 build.py --bake # personal build WITH config.json (your API token!) + +Output: -.plugin (or --baked.plugin with --bake) +""" + +from __future__ import annotations + +import argparse +import json +import os +import zipfile + +ROOT = os.path.dirname(os.path.abspath(__file__)) +FIXED_DATE = (2026, 1, 1, 0, 0, 0) # deterministic mtime for every entry + +# Directories/files never included in any build. +EXCLUDE_DIRS = {".git", "__pycache__", ".claude"} +EXCLUDE_FILES = {".DS_Store", ".gitignore", "build.py"} +# Excluded unless --bake (carries the API token). +SECRET_FILES = {"config.json"} + + +def iter_files(bake): + for dirpath, dirnames, filenames in os.walk(ROOT): + dirnames[:] = sorted(d for d in dirnames if d not in EXCLUDE_DIRS) + for fn in sorted(filenames): + if fn in EXCLUDE_FILES: + continue + if fn in SECRET_FILES and not bake: + continue + full = os.path.join(dirpath, fn) + rel = os.path.relpath(full, ROOT) + if rel.endswith(".plugin"): + continue # never nest a previous artifact + yield full, rel + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--bake", action="store_true", + help="include config.json (your API token) in the artifact") + args = ap.parse_args() + + with open(os.path.join(ROOT, ".claude-plugin", "plugin.json")) as fh: + meta = json.load(fh) + name, version = meta["name"], meta["version"] + suffix = "-baked" if args.bake else "" + out = os.path.join(ROOT, f"{name}-{version}{suffix}.plugin") + + files = sorted(iter_files(args.bake), key=lambda x: x[1]) + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf: + for full, rel in files: + with open(full, "rb") as fh: + data = fh.read() + info = zipfile.ZipInfo(rel.replace(os.sep, "/"), date_time=FIXED_DATE) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o644 << 16 + zf.writestr(info, data) + + size = os.path.getsize(out) + print(f"Built {os.path.basename(out)} ({size:,} bytes, {len(files)} entries)") + if args.bake: + print(" ⚠ This artifact contains your API token — do not share it.") + + +if __name__ == "__main__": + main() diff --git a/commands/op-comment.md b/commands/op-comment.md new file mode 100644 index 0000000..33e4693 --- /dev/null +++ b/commands/op-comment.md @@ -0,0 +1,17 @@ +--- +description: Add a comment to a work package, e.g. /op-comment 1234 looks good to me. +allowed-tools: Bash(python3 *op.py*), Bash(python *op.py*), Bash(ls *) +--- + +Add a comment to an OpenProject work package. Input: `$ARGUMENTS` + +The first token is the work package id; the rest is the comment text. Use the +openproject skill: + +```bash +OP="${CLAUDE_PLUGIN_ROOT}/skills/openproject/scripts/op.py" +[ -f "$OP" ] || OP=$(ls "$PWD"/skills/openproject/scripts/op.py 2>/dev/null | head -1) +ID=$(echo "$ARGUMENTS" | awk '{print $1}') +TEXT=$(echo "$ARGUMENTS" | cut -d' ' -f2-) +python3 "$OP" wp comment "$ID" --text "$TEXT" +``` diff --git a/commands/op-create.md b/commands/op-create.md new file mode 100644 index 0000000..10b473a --- /dev/null +++ b/commands/op-create.md @@ -0,0 +1,14 @@ +--- +description: Create a work package from a natural-language description. +allowed-tools: Bash(python3 *op.py*), Bash(python *op.py*), Bash(ls *) +--- + +The user wants to create an OpenProject work package: `$ARGUMENTS` + +Using the **openproject** skill: +1. Determine the target `--project` (ask if ambiguous; list with `op.py projects` if needed). +2. Infer `--type` (Task/Bug/Feature/Milestone), `--subject`, and a `--description`. + Pull out any `--assignee`, `--priority`, `--due`, or `--start` the user mentioned. +3. Run `op.py wp create ...` and report the new id + web URL. + +Do not invent a project — if you can't tell which one, ask first. diff --git a/commands/op-ping.md b/commands/op-ping.md new file mode 100644 index 0000000..f489935 --- /dev/null +++ b/commands/op-ping.md @@ -0,0 +1,16 @@ +--- +description: Verify the OpenProject connection and show the authenticated user. +allowed-tools: Bash(python3 *op.py*), Bash(python *op.py*), Bash(ls *) +--- + +Confirm connectivity and auth to the OpenProject server. + +```bash +OP="${CLAUDE_PLUGIN_ROOT}/skills/openproject/scripts/op.py" +[ -f "$OP" ] || OP=$(ls "$PWD"/skills/openproject/scripts/op.py 2>/dev/null | head -1) +python3 "$OP" ping +# Windows: use `python` or `py -3` if `python3` is not on PATH. +``` + +If this prints an auth or connection error, see the Troubleshooting section of the +openproject SKILL.md (token in `config.json`, or `OPENPROJECT_API_KEY` / `OPENPROJECT_URL`). diff --git a/commands/op-projects.md b/commands/op-projects.md new file mode 100644 index 0000000..2d88abd --- /dev/null +++ b/commands/op-projects.md @@ -0,0 +1,12 @@ +--- +description: List or search OpenProject projects. Optional search term as args. +allowed-tools: Bash(python3 *op.py*), Bash(python *op.py*), Bash(ls *) +--- + +List OpenProject projects (optionally filtered by `$ARGUMENTS`) using the openproject skill. + +```bash +OP="${CLAUDE_PLUGIN_ROOT}/skills/openproject/scripts/op.py" +[ -f "$OP" ] || OP=$(ls "$PWD"/skills/openproject/scripts/op.py 2>/dev/null | head -1) +if [ -n "$ARGUMENTS" ]; then python3 "$OP" projects --search "$ARGUMENTS"; else python3 "$OP" projects; fi +``` diff --git a/commands/op-show.md b/commands/op-show.md new file mode 100644 index 0000000..b954a11 --- /dev/null +++ b/commands/op-show.md @@ -0,0 +1,12 @@ +--- +description: Show a work package by id, e.g. /op-show 1234. +allowed-tools: Bash(python3 *op.py*), Bash(python *op.py*), Bash(ls *) +--- + +Show full detail for OpenProject work package `$ARGUMENTS` using the openproject skill. + +```bash +OP="${CLAUDE_PLUGIN_ROOT}/skills/openproject/scripts/op.py" +[ -f "$OP" ] || OP=$(ls "$PWD"/skills/openproject/scripts/op.py 2>/dev/null | head -1) +python3 "$OP" wp show $ARGUMENTS +``` diff --git a/commands/op-time.md b/commands/op-time.md new file mode 100644 index 0000000..8a1effa --- /dev/null +++ b/commands/op-time.md @@ -0,0 +1,10 @@ +--- +description: Log time on a work package, e.g. /op-time 1234 1.5h debugging the cron. +allowed-tools: Bash(python3 *op.py*), Bash(python *op.py*), Bash(ls *) +--- + +Log time against an OpenProject work package. Input: `$ARGUMENTS` + +Using the **openproject** skill, parse the work package id, the hours (e.g. `1.5h`, +`45m`), and an optional comment, then run `op.py time log --wp ID --hours N +[--comment "..."] [--date YYYY-MM-DD]`. Confirm the logged amount back to the user. diff --git a/commands/op-update.md b/commands/op-update.md new file mode 100644 index 0000000..0389faa --- /dev/null +++ b/commands/op-update.md @@ -0,0 +1,11 @@ +--- +description: Update / progress a work package, e.g. "1234 to In Progress, 50% done". +allowed-tools: Bash(python3 *op.py*), Bash(python *op.py*), Bash(ls *) +--- + +The user wants to update an OpenProject work package: `$ARGUMENTS` + +Using the **openproject** skill: parse the work package id and the changes +(status, assignee, priority, done-ratio, dates, subject, description), run +`op.py wp show ID` first to confirm current state, then `op.py wp update ID ...`. +Report the resulting status. Updates are lockVersion-safe automatically. diff --git a/commands/op-work.md b/commands/op-work.md new file mode 100644 index 0000000..fdba8cc --- /dev/null +++ b/commands/op-work.md @@ -0,0 +1,17 @@ +--- +description: List/search work packages. Args become filters, e.g. "my open bugs in Infra". +allowed-tools: Bash(python3 *op.py*), Bash(python *op.py*), Bash(ls *) +--- + +Use the **openproject** skill to list work packages matching the user's request: `$ARGUMENTS` + +Translate the request into `op.py wp list` flags (`--assignee me`, `--status open`, +`--project NAME`, `--type Bug`, `--query TEXT`, `--sort id:desc`, `--limit N`), run it, +and summarize the results. If no arguments were given, default to the current user's +open items: + +```bash +OP="${CLAUDE_PLUGIN_ROOT}/skills/openproject/scripts/op.py" +[ -f "$OP" ] || OP=$(ls "$PWD"/skills/openproject/scripts/op.py 2>/dev/null | head -1) +python3 "$OP" wp list --assignee me --status open +``` diff --git a/openproject-1.0.0-baked.plugin b/openproject-1.0.0-baked.plugin new file mode 100644 index 0000000000000000000000000000000000000000..175df29416633f8567a6933c9e4a6a5b396793db GIT binary patch literal 27902 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 [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 + (`/work_packages/`) 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 ` → 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`. diff --git a/skills/openproject/reference/api-map.md b/skills/openproject/reference/api-map.md new file mode 100644 index 0000000..cf70736 --- /dev/null +++ b/skills/openproject/reference/api-map.md @@ -0,0 +1,60 @@ +# OpenProject API v3 endpoint map + +Base: `/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 [--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. diff --git a/skills/openproject/reference/filters.md b/skills/openproject/reference/filters.md new file mode 100644 index 0000000..f338c26 --- /dev/null +++ b/skills/openproject/reference/filters.md @@ -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: `{ "": { "operator": "", "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` | 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":"=","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.) diff --git a/skills/openproject/scripts/op.py b/skills/openproject/scripts/op.py new file mode 100644 index 0000000..4dd4984 --- /dev/null +++ b/skills/openproject/scripts/op.py @@ -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 --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()) diff --git a/skills/openproject/scripts/opclient.py b/skills/openproject/scripts/opclient.py new file mode 100644 index 0000000..164fc47 --- /dev/null +++ b/skills/openproject/scripts/opclient.py @@ -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 /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 -> -> ... + 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 diff --git a/skills/openproject/scripts/xlsx_read.py b/skills/openproject/scripts/xlsx_read.py new file mode 100644 index 0000000..4f1706f --- /dev/null +++ b/skills/openproject/scripts/xlsx_read.py @@ -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")