ver 1.0.0 initial commit
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# The API token lives in config.json — never commit the real key.
|
||||
config.json
|
||||
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
@@ -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.
|
||||
@@ -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: <name>-<version>.plugin (or <name>-<version>-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()
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
Binary file not shown.
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.)
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user