agent-tool-openproject-cli 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,90 @@
1
+ """Work-package comments (activities): list, add, edit.
2
+
3
+ OpenProject stores comments as *activities*. The collection is fetched by work
4
+ package id; edits target the activity id (from each entry's self link). There is
5
+ no delete-comment endpoint in API v3.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import typer
11
+
12
+ from .. import hal, serialize
13
+ from ._shared import ctx_obj
14
+
15
+ app = typer.Typer(no_args_is_help=True)
16
+
17
+ _COLUMNS = [
18
+ ("ActID", "id"),
19
+ ("User", lambda r: (r.get("user") or {}).get("name")),
20
+ ("When", "createdAt"),
21
+ ("Comment", "comment"),
22
+ ]
23
+
24
+
25
+ @app.command("list")
26
+ def list_comments(
27
+ ctx: typer.Context,
28
+ wp_id: int = typer.Argument(..., help="Work package id."),
29
+ comments_only: bool = typer.Option(True, "--comments-only/--all-activity", help="Only entries with a comment."),
30
+ ) -> None:
31
+ """List activities/comments for a work package (oldest first)."""
32
+ obj = ctx_obj(ctx)
33
+ client = obj.client()
34
+ activities = client.collect(f"work_packages/{wp_id}/activities")
35
+ rows = []
36
+ name_cache: dict[int, str] = {}
37
+ for a in activities:
38
+ s = serialize.comment(a)
39
+ if comments_only and not (s.get("comment") or "").strip():
40
+ continue
41
+ # activity user links carry no title on some versions — resolve once.
42
+ user = s.get("user")
43
+ if user and user.get("id") and not user.get("name"):
44
+ uid = user["id"]
45
+ if uid not in name_cache:
46
+ try:
47
+ name_cache[uid] = client.get(f"users/{uid}").get("name")
48
+ except Exception:
49
+ name_cache[uid] = None
50
+ user["name"] = name_cache[uid]
51
+ rows.append(s)
52
+ obj.emitter.emit(rows, columns=_COLUMNS, empty="(no comments)")
53
+
54
+
55
+ @app.command()
56
+ def add(
57
+ ctx: typer.Context,
58
+ wp_id: int = typer.Argument(..., help="Work package id."),
59
+ text: str = typer.Argument(..., help="Comment body (markdown)."),
60
+ notify: bool = typer.Option(False, "--notify/--no-notify", help="Send notifications (default off)."),
61
+ ) -> None:
62
+ """Add a comment (markdown) to a work package.
63
+
64
+ Example: openproject comment add 42 "Deployed to **staging** — please retest." --notify
65
+ """
66
+ obj = ctx_obj(ctx)
67
+ doc = obj.client().post(
68
+ f"work_packages/{wp_id}/activities",
69
+ json={"comment": {"raw": text}},
70
+ params={"notify": str(notify).lower()},
71
+ )
72
+ obj.emitter.emit(serialize.comment(doc))
73
+
74
+
75
+ @app.command()
76
+ def edit(
77
+ ctx: typer.Context,
78
+ activity_id: int = typer.Argument(..., help="Activity id (from `comment list`)."),
79
+ text: str = typer.Argument(..., help="New comment body."),
80
+ ) -> None:
81
+ """Edit a comment. The id is the ACTIVITY id (from `comment list`), not the work-package id.
82
+
83
+ Example: openproject comment list 42 # find the activity id, then:
84
+ openproject comment edit 137 "Corrected note."
85
+ """
86
+ obj = ctx_obj(ctx)
87
+ # The activity PATCH endpoint expects `comment` as a plain string (unlike the
88
+ # POST add endpoint, which takes a {"raw": ...} Formattable).
89
+ doc = obj.client().patch(f"activities/{activity_id}", json={"comment": text})
90
+ obj.emitter.emit(serialize.comment(doc))
@@ -0,0 +1,205 @@
1
+ """Per-person time & cost reporting for monthly invoicing.
2
+
3
+ Reality check (from the API research): OpenProject's API v3 exposes **no**
4
+ hourly rates and **no** cost-report endpoints. The only reliable, per-person
5
+ figures come from summing ``time_entries`` client-side. So this report pulls the
6
+ time bookings for a period, groups them by person (and project), sums the hours,
7
+ and — if you supply a rate table — multiplies to get billable amounts.
8
+
9
+ Rate table (JSON) format::
10
+
11
+ {
12
+ "currency": "EUR",
13
+ "default": 90,
14
+ "users": { "admin": 120, "jane.doe": 100 },
15
+ "projects": { "demo-project": { "default": 100, "admin": 110 } }
16
+ }
17
+
18
+ User keys may be a login, a full name, or a numeric id. Project keys may be an
19
+ identifier or name. Most specific wins: project+user → project default → user → default.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from pathlib import Path
26
+
27
+ import typer
28
+
29
+ from .. import hal
30
+ from ..duration import iso_to_hours
31
+ from ..errors import OpError
32
+ from ._shared import ctx_obj
33
+ from .time_entries import query_time_entries
34
+
35
+ app = typer.Typer(no_args_is_help=True)
36
+
37
+
38
+ def _load_rates(path: Path | None) -> dict | None:
39
+ if path is None:
40
+ return None
41
+ if not path.exists():
42
+ raise OpError(f"rates file not found: {path}")
43
+ try:
44
+ return json.loads(path.read_text())
45
+ except json.JSONDecodeError as exc:
46
+ raise OpError(f"invalid rates JSON: {exc}") from exc
47
+
48
+
49
+ def _rate_for(rates: dict, user_keys: list[str], project_keys: list[str]) -> float | None:
50
+ # Keys are ordered by priority (most specific first) so the result is
51
+ # deterministic when a rate table has overlapping keys.
52
+ if not rates:
53
+ return None
54
+ projects = rates.get("projects", {})
55
+ for pk in project_keys:
56
+ if pk in projects:
57
+ pconf = projects[pk]
58
+ if isinstance(pconf, dict):
59
+ for uk in user_keys:
60
+ if uk in pconf:
61
+ return float(pconf[uk])
62
+ if "default" in pconf:
63
+ return float(pconf["default"])
64
+ else:
65
+ return float(pconf)
66
+ users = rates.get("users", {})
67
+ for uk in user_keys:
68
+ if uk in users:
69
+ return float(users[uk])
70
+ if "default" in rates:
71
+ return float(rates["default"])
72
+ return None
73
+
74
+
75
+ @app.command()
76
+ def report(
77
+ ctx: typer.Context,
78
+ month: str = typer.Option(None, "--month", help="Month YYYY-MM (sets the date range)."),
79
+ frm: str = typer.Option(None, "--from", help="Start date YYYY-MM-DD."),
80
+ to: str = typer.Option(None, "--to", help="End date YYYY-MM-DD."),
81
+ user: str = typer.Option(None, "--user", "-u", help="Restrict to one user (login/name/id or 'me')."),
82
+ project: str = typer.Option(None, "--project", "-P", help="Restrict to one project."),
83
+ rates: Path = typer.Option(None, "--rates", help="JSON rate table for billable amounts."),
84
+ by_project: bool = typer.Option(True, "--by-project/--no-by-project", help="Break each person down by project."),
85
+ ) -> None:
86
+ """Summarise hours (and billable cost) per person for a period — for invoicing."""
87
+ obj = ctx_obj(ctx)
88
+ client = obj.client()
89
+ rate_table = _load_rates(rates)
90
+
91
+ entries = query_time_entries(client, user=user, project=project, frm=frm, to=to, month=month, desc=False, limit=None)
92
+
93
+ # cache user + project details so rate keys can match login/name/id and
94
+ # project identifier/name/id (the rate table may key by any of them).
95
+ user_cache: dict[int, dict] = {}
96
+ project_cache: dict[int, dict] = {}
97
+
98
+ def user_info(entry) -> dict:
99
+ uid = hal.link_id(entry, "user")
100
+ if uid not in user_cache:
101
+ name = hal.link_title(entry, "user")
102
+ info = {"id": uid, "name": name, "login": None}
103
+ try:
104
+ u = client.get(f"users/{uid}")
105
+ info["login"] = u.get("login")
106
+ info["name"] = u.get("name") or name
107
+ except OpError:
108
+ pass
109
+ user_cache[uid] = info
110
+ return user_cache[uid]
111
+
112
+ def project_info(entry) -> dict:
113
+ pid = hal.link_id(entry, "project")
114
+ name = hal.link_title(entry, "project") or "(none)"
115
+ if pid is None:
116
+ return {"id": None, "name": name, "identifier": None}
117
+ if pid not in project_cache:
118
+ info = {"id": pid, "name": name, "identifier": None}
119
+ try:
120
+ p = client.get(f"projects/{pid}")
121
+ info["identifier"] = p.get("identifier")
122
+ info["name"] = p.get("name") or name
123
+ except OpError:
124
+ pass
125
+ project_cache[pid] = info
126
+ return project_cache[pid]
127
+
128
+ people: dict[int, dict] = {}
129
+ grand_hours = 0.0
130
+ grand_amount = 0.0
131
+ for e in entries:
132
+ info = user_info(e)
133
+ uid = info["id"]
134
+ hours = iso_to_hours(e.get("hours")) or 0.0
135
+ pinfo = project_info(e)
136
+ proj_name = pinfo["name"]
137
+ proj_id = pinfo["id"]
138
+
139
+ # ordered by specificity -> deterministic matching
140
+ user_keys = [str(k) for k in (info.get("login"), info.get("name"), uid) if k is not None]
141
+ project_keys = [str(k) for k in (pinfo.get("identifier"), proj_name, proj_id) if k is not None]
142
+ rate = _rate_for(rate_table, user_keys, project_keys) if rate_table else None
143
+ # accumulate unrounded; round only at output to avoid cent drift
144
+ amount = hours * rate if rate is not None else None
145
+
146
+ person = people.setdefault(
147
+ uid,
148
+ {"user": info, "hours": 0.0, "entries": 0, "amount": 0.0 if rate_table else None, "projects": {}},
149
+ )
150
+ person["hours"] += hours
151
+ person["entries"] += 1
152
+ if amount is not None:
153
+ person["amount"] = (person["amount"] or 0.0) + amount
154
+ if by_project:
155
+ pj = person["projects"].setdefault(proj_name, {"project": proj_name, "hours": 0.0, "amount": 0.0 if rate_table else None, "rate": rate})
156
+ pj["hours"] += hours
157
+ if amount is not None:
158
+ pj["amount"] = (pj["amount"] or 0.0) + amount
159
+
160
+ grand_hours += hours
161
+ if amount is not None:
162
+ grand_amount += amount
163
+
164
+ by_user = []
165
+ for p in people.values():
166
+ row = {
167
+ "user": p["user"],
168
+ "hours": round(p["hours"], 2),
169
+ "entries": p["entries"],
170
+ "amount": round(p["amount"], 2) if p["amount"] is not None else None,
171
+ }
172
+ if by_project:
173
+ row["projects"] = [
174
+ {"project": pj["project"], "hours": round(pj["hours"], 2),
175
+ "rate": pj["rate"], "amount": round(pj["amount"], 2) if pj["amount"] is not None else None}
176
+ for pj in p["projects"].values()
177
+ ]
178
+ by_user.append(row)
179
+ by_user.sort(key=lambda r: r["hours"], reverse=True)
180
+
181
+ result = {
182
+ "period": {"month": month, "from": frm, "to": to},
183
+ "currency": (rate_table or {}).get("currency"),
184
+ "billable": rate_table is not None,
185
+ "byUser": by_user,
186
+ "totals": {
187
+ "hours": round(grand_hours, 2),
188
+ "amount": round(grand_amount, 2) if rate_table is not None else None,
189
+ "entries": sum(p["entries"] for p in people.values()),
190
+ "people": len(people),
191
+ },
192
+ }
193
+
194
+ if obj.output.value == "table":
195
+ cols = [
196
+ ("User", lambda r: r["user"].get("name")),
197
+ ("Login", lambda r: r["user"].get("login")),
198
+ ("Hours", "hours"),
199
+ ("Entries", "entries"),
200
+ ("Amount", "amount"),
201
+ ]
202
+ obj.emitter.emit(by_user, columns=cols, title=f"Time report {month or (frm or '') + '..' + (to or '')}")
203
+ obj.emitter.message(f"TOTAL: {result['totals']['hours']}h" + (f", {result['currency'] or ''} {result['totals']['amount']}" if rate_table else ""))
204
+ else:
205
+ obj.emitter.emit(result)
@@ -0,0 +1,82 @@
1
+ """Custom-field discovery.
2
+
3
+ Custom fields cannot be *created* through the API v3 (that's admin-UI/Rails
4
+ only). What the API *does* let you do is discover which custom fields exist on a
5
+ resource and their allowed values — which is exactly what you need to then set
6
+ them via `wp create/update --custom-fields`. These commands read the relevant
7
+ schema/form and surface the ``customFieldN`` entries.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import typer
13
+
14
+ from .. import resolve, serialize
15
+ from ._shared import ctx_obj
16
+
17
+ app = typer.Typer(no_args_is_help=True)
18
+
19
+ _COLUMNS = [
20
+ ("Key", "key"),
21
+ ("Name", "name"),
22
+ ("Type", "type"),
23
+ ("Required", "required"),
24
+ ("Writable", "writable"),
25
+ ("AllowedValues", lambda r: len(r.get("allowedValues", [])) if r.get("allowedValues") else ""),
26
+ ]
27
+
28
+
29
+ def _schema_fields(schema: dict, *, only_custom: bool) -> list[dict]:
30
+ out = []
31
+ for name, spec in schema.items():
32
+ if not isinstance(spec, dict) or not spec.get("type"):
33
+ continue
34
+ if only_custom and not name.startswith("customField"):
35
+ continue
36
+ out.append(serialize.custom_field_schema(name, spec))
37
+ return out
38
+
39
+
40
+ @app.command()
41
+ def wp(
42
+ ctx: typer.Context,
43
+ project: str = typer.Option(..., "--project", "-P", help="Project id/identifier."),
44
+ type_: str = typer.Option("Task", "--type", "-t", help="Work-package type name."),
45
+ all_fields: bool = typer.Option(False, "--all", help="Include built-in fields, not just custom."),
46
+ ) -> None:
47
+ """List custom fields available on work packages of a project/type."""
48
+ obj = ctx_obj(ctx)
49
+ client = obj.client()
50
+ pid = resolve.project_id(client, project)
51
+ form = client.post(
52
+ f"projects/{pid}/work_packages/form",
53
+ json={"_links": {"project": {"href": f"/api/v3/projects/{pid}"}, "type": {"href": resolve.wp_type(client, type_)}}},
54
+ )
55
+ schema = (form.get("_embedded") or {}).get("schema") or {}
56
+ obj.emitter.emit(_schema_fields(schema, only_custom=not all_fields), columns=_COLUMNS, empty="(no custom fields)")
57
+
58
+
59
+ @app.command()
60
+ def project(
61
+ ctx: typer.Context,
62
+ all_fields: bool = typer.Option(False, "--all", help="Include built-in fields, not just custom."),
63
+ ) -> None:
64
+ """List custom fields (project attributes) available on projects."""
65
+ obj = ctx_obj(ctx)
66
+ client = obj.client()
67
+ form = client.post("projects/form", json={})
68
+ schema = (form.get("_embedded") or {}).get("schema") or {}
69
+ obj.emitter.emit(_schema_fields(schema, only_custom=not all_fields), columns=_COLUMNS, empty="(no custom fields)")
70
+
71
+
72
+ @app.command("time")
73
+ def time_entry(
74
+ ctx: typer.Context,
75
+ all_fields: bool = typer.Option(False, "--all", help="Include built-in fields, not just custom."),
76
+ ) -> None:
77
+ """List custom fields available on time entries."""
78
+ obj = ctx_obj(ctx)
79
+ client = obj.client()
80
+ form = client.post("time_entries/form", json={})
81
+ schema = (form.get("_embedded") or {}).get("schema") or {}
82
+ obj.emitter.emit(_schema_fields(schema, only_custom=not all_fields), columns=_COLUMNS, empty="(no custom fields)")
@@ -0,0 +1,100 @@
1
+ """File-storage (Nextcloud) links on work packages.
2
+
3
+ Links an existing file in a configured storage (e.g. your Nextcloud) to a work
4
+ package via ``/api/v3/work_packages/{id}/file_links``. Requires the storage to
5
+ be set up in OpenProject admin and connected to the project. Creating a link is
6
+ idempotent on (originData.id, work package, storage).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import typer
12
+
13
+ from .. import serialize
14
+ from ..errors import OpError
15
+ from ._shared import ctx_obj
16
+
17
+ app = typer.Typer(no_args_is_help=True)
18
+
19
+ _COLUMNS = [
20
+ ("ID", "id"),
21
+ ("Name", "originName"),
22
+ ("OriginID", "originId"),
23
+ ("Storage", "storage"),
24
+ ("Status", "status"),
25
+ ]
26
+
27
+
28
+ @app.command()
29
+ def storages(ctx: typer.Context) -> None:
30
+ """List configured file storages (Nextcloud etc.)."""
31
+ obj = ctx_obj(ctx)
32
+ rows = [
33
+ {
34
+ "id": s.get("id"),
35
+ "name": s.get("name"),
36
+ "type": s.get("_type"),
37
+ "host": s.get("host"),
38
+ }
39
+ for s in obj.client().collect("storages")
40
+ ]
41
+ obj.emitter.emit(rows, columns=[("ID", "id"), ("Name", "name"), ("Type", "type"), ("Host", "host")], empty="(no storages configured)")
42
+
43
+
44
+ @app.command("list")
45
+ def list_links(
46
+ ctx: typer.Context,
47
+ wp_id: int = typer.Argument(..., help="Work package id."),
48
+ ) -> None:
49
+ """List file links on a work package."""
50
+ obj = ctx_obj(ctx)
51
+ rows = [serialize.file_link(f) for f in obj.client().collect(f"work_packages/{wp_id}/file_links")]
52
+ obj.emitter.emit(rows, columns=_COLUMNS, empty="(no file links)")
53
+
54
+
55
+ @app.command()
56
+ def add(
57
+ ctx: typer.Context,
58
+ wp_id: int = typer.Argument(..., help="Work package id."),
59
+ storage: int = typer.Option(..., "--storage", "-s", help="Storage id (see `filelink storages`)."),
60
+ file_id: str = typer.Option(..., "--file-id", help="File id within the storage (Nextcloud fileid)."),
61
+ file_name: str = typer.Option(..., "--file-name", help="File name to display."),
62
+ mime: str = typer.Option("application/octet-stream", "--mime", help="MIME type (folder: application/x-op-directory)."),
63
+ ) -> None:
64
+ """Link a Nextcloud/storage file to a work package."""
65
+ obj = ctx_obj(ctx)
66
+ body = {
67
+ "_type": "Collection",
68
+ "_embedded": {
69
+ "elements": [
70
+ {
71
+ "originData": {"id": file_id, "name": file_name, "mimeType": mime},
72
+ "_links": {"storage": {"href": f"/api/v3/storages/{storage}"}},
73
+ }
74
+ ]
75
+ },
76
+ }
77
+ resp = obj.client().post(f"work_packages/{wp_id}/file_links", json=body)
78
+ elements = (resp.get("_embedded") or {}).get("elements") or [resp]
79
+ obj.emitter.emit([serialize.file_link(e) for e in elements])
80
+
81
+
82
+ @app.command()
83
+ def get(ctx: typer.Context, file_link_id: int = typer.Argument(..., help="File link id.")) -> None:
84
+ """Show a single file link."""
85
+ obj = ctx_obj(ctx)
86
+ obj.emitter.emit(serialize.file_link(obj.client().get(f"file_links/{file_link_id}")))
87
+
88
+
89
+ @app.command()
90
+ def delete(
91
+ ctx: typer.Context,
92
+ file_link_id: int = typer.Argument(..., help="File link id."),
93
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
94
+ ) -> None:
95
+ """Remove a file link."""
96
+ obj = ctx_obj(ctx)
97
+ if not yes:
98
+ typer.confirm(f"Delete file link {file_link_id}?", abort=True)
99
+ obj.client().delete(f"file_links/{file_link_id}")
100
+ obj.emitter.emit({"status": "deleted", "fileLink": file_link_id})
@@ -0,0 +1,198 @@
1
+ """`openproject guide` — a built-in operating manual.
2
+
3
+ The point of this command is self-sufficiency: an agent (or human) with only the
4
+ installed CLI and no other context can run `openproject guide` and learn the
5
+ output contract, how to authenticate, how to discover filters, and the gotchas —
6
+ without needing the README or any external docs.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import typer
12
+
13
+ OVERVIEW = """\
14
+ openproject — operating guide (run `openproject guide <topic>` for details)
15
+
16
+ WHAT IT IS
17
+ A CLI for OpenProject: work packages, projects, assignees, custom fields,
18
+ comments, time entries, search, notifications, wiki, attachments, and
19
+ per-person time/cost reporting for invoicing.
20
+
21
+ OUTPUT CONTRACT (important for scripting/agents)
22
+ - stdout is JSON by default — parse it.
23
+ - Errors go to stderr as JSON with a non-zero exit code: {"error": "...", "status": 404}.
24
+ - Exit codes: 0 ok · 3 config · 4 auth · 5 not-found · 6 conflict(lockVersion) · 7 validation · 1 other.
25
+ - Change format anywhere on the line: `-o table`, `-o markdown` (or `-f markdown`).
26
+ - Trim output to what you need: `--fields id,subject,status,assignee.name` (dotted paths ok).
27
+ - Get just a count: add `--count` to `search wp`.
28
+
29
+ AUTHENTICATE
30
+ Non-interactive (best for agents/CI — no keyring, never prompts):
31
+ export OPCLI_BASE_URL=https://openproject.example.com
32
+ export OPCLI_TOKEN=opapi-xxxxxxxx
33
+ Or store in the OS keyring: openproject auth login --url <URL> --token <TOKEN>
34
+ Verify: openproject auth whoami (or `auth status`)
35
+
36
+ USE NAMES, NOT IDS
37
+ Names are resolved for you: --status "In progress", --assignee jane.doe,
38
+ --type Bug, --priority High, --project my-proj, --version "Sprint 3".
39
+ `me` always means the authenticated user.
40
+
41
+ FIND THINGS — don't hand-write filter JSON
42
+ Plain flags: openproject search wp --mine --overdue --updated-since 7d
43
+ Expressions: openproject search wp --where "status = open" --where "updated > 7d"
44
+ Presets: openproject search mine | overdue | recent | unassigned | reported | watching
45
+ Discover: openproject search fields # what you can filter on (live)
46
+ openproject search operators # what =, o, !*, <>d, ~ mean
47
+ openproject search values status # allowed values for a field
48
+
49
+ DISCOVER COMMANDS & OPTIONS
50
+ openproject --help
51
+ openproject <group> --help e.g. `openproject wp --help`
52
+ openproject <group> <command> --help e.g. `openproject wp create --help`
53
+
54
+ KEY GOTCHAS (save yourself a round-trip)
55
+ - `wp update` handles lockVersion automatically; `raw patch` does NOT (send it yourself).
56
+ - Time `hours` accept decimals (2.5) or ISO-8601 (PT2H30M).
57
+ - Assignees must be project members — a new project has none; add with `member add`.
58
+ - `comment add` takes markdown; `comment edit` takes a plain string.
59
+ - Wiki is read-only metadata over the API; costs have no rates, so `cost report`
60
+ multiplies hours by a rate table you pass with --rates.
61
+ - Anything not wrapped: `openproject raw {get,post,patch,delete} <path> [-d JSON]`.
62
+
63
+ TOPICS: search · wp · time · comments · projects · costs · customfields · output · auth · notifications
64
+ """
65
+
66
+ TOPICS: dict[str, str] = {
67
+ "search": """\
68
+ SEARCH — three ways, easiest first
69
+
70
+ 1) Flags: openproject search wp --mine --overdue
71
+ openproject search wp --project P --type Bug --priority High --updated-since 7d
72
+ openproject search wp --unassigned --status open
73
+ openproject search wp --id 42,57 --all
74
+ Dates accept ISO (2026-08-01) or relative: 7d, 2w, 1m, +30d, today, yesterday.
75
+ 2) Presets: openproject search mine | overdue | recent --days 3 | unassigned | reported | watching
76
+ 3) --where: openproject search wp --where "status = open" --where "assignee = me" --where "updated > 14d"
77
+ Operators in --where: =, !=, ~ (contains), >=, <=, >, <, and :open/:closed/:none/:any.
78
+ Field aliases: updated->updatedAt, created->createdAt, due->dueDate.
79
+
80
+ DISCOVER (so you never guess):
81
+ openproject search fields [--project P] # every filterable field (incl. custom fields)
82
+ openproject search operators # operator cheat-sheet
83
+ openproject search values <field> # allowed values (status/type/priority/version/...)
84
+
85
+ Handy: --sort <field> --asc/--desc, --group-by status, --limit N (0=all), --count, --raw.
86
+ Default is OPEN work packages only; add --all for closed too.
87
+ """,
88
+ "wp": """\
89
+ WORK PACKAGES
90
+ create: openproject wp create "Title" --project P --type Bug --assignee me --priority High \\
91
+ --due-date 2026-08-01 --estimated 3 --description "..."
92
+ read: openproject wp get <id> # add --raw for the full HAL document
93
+ update: openproject wp update <id> --status "In progress" --done-ratio 50 (lockVersion auto)
94
+ assign: openproject wp assign <id> jane.doe (or `... none` / `wp unassign <id>`)
95
+ move: openproject wp move <id> other-project
96
+ watch: openproject wp watch <id> me (list: `wp watchers <id>`)
97
+ delete: openproject wp delete <id> -y
98
+ fields: openproject wp schema --project P --type Bug # discover writable + custom fields
99
+ set custom fields: --custom-fields '{"customField1":"INV-1","customField2":{"href":"/api/v3/custom_options/3"}}'
100
+ """,
101
+ "time": """\
102
+ TIME ENTRIES
103
+ log: openproject time add 2.5 --work-package <id> --activity Development --comment "..."
104
+ openproject time add 1 --project P --date 2026-07-10 (hours: 2.5 or PT2H30M)
105
+ list: openproject time list --user me --month 2026-07
106
+ (filters: --from/--to dates, --work-package, --project)
107
+ edit: openproject time edit <id> --hours 3 --comment "..."
108
+ delete: openproject time delete <id> -y
109
+ activities: openproject time activities --project P (Development, Management, ...)
110
+ """,
111
+ "comments": """\
112
+ COMMENTS (stored as work-package activities)
113
+ add: openproject comment add <wp_id> "markdown body" (add --notify to email watchers)
114
+ list: openproject comment list <wp_id>
115
+ edit: openproject comment edit <activity_id> "plain string" # NOTE: edit takes a plain string
116
+ The activity_id comes from `comment list` (not the work-package id). No delete via the API.
117
+ """,
118
+ "projects": """\
119
+ PROJECTS
120
+ create: openproject project create "Name" --identifier my-id --description "..." [--parent P] [--public]
121
+ list: openproject project list (add --archived for archived ones)
122
+ update: openproject project update my-id --name "..." --description "..."
123
+ archive: openproject project archive my-id (unarchive: `project unarchive my-id`)
124
+ delete: openproject project delete my-id -y
125
+ Members: openproject member add --project my-id --user jane.doe --role Member
126
+ (only members can be assignees; `user available my-id` lists assignable people)
127
+ """,
128
+ "costs": """\
129
+ TIME & COST REPORTING (for invoicing, per person)
130
+ openproject cost report --month 2026-07 --rates rates.json
131
+ openproject cost report --from 2026-07-01 --to 2026-07-31 --user jane.doe --rates rates.json
132
+
133
+ The OpenProject API exposes no hourly rates, so you supply them. rates.json:
134
+ {"currency":"EUR","default":90,"users":{"jane.doe":100},
135
+ "projects":{"my-proj":{"default":100,"admin":110}}}
136
+ Most specific wins: project+user > project default > user > global default.
137
+ Without --rates you get hours only. Output groups by user, then by project.
138
+ """,
139
+ "customfields": """\
140
+ CUSTOM FIELDS
141
+ Custom fields are created in the OpenProject admin UI (not via the API). You can
142
+ DISCOVER and SET them:
143
+ openproject cf wp --project P --type Bug # lists customFieldN, type, allowed values
144
+ openproject cf project # project attributes
145
+ openproject cf time # time-entry custom fields
146
+ Set on create/update with --custom-fields (JSON):
147
+ scalar (string/int/date/bool): {"customField1":"INV-1"}
148
+ reference (list/user/version): {"customField2":{"href":"/api/v3/custom_options/3"}}
149
+ Use the ids shown by `cf wp` / `search values customFieldN`.
150
+ """,
151
+ "output": """\
152
+ OUTPUT & FIELDS
153
+ Formats: json (default), table, markdown. Choose per command:
154
+ openproject wp list -o table
155
+ openproject wp get 42 -f markdown
156
+ Set a default: openproject settings set-format table (see `settings show`)
157
+ Select fields (dotted paths ok, works in every format):
158
+ openproject search mine --fields id,subject,status,assignee.name
159
+ -o/--format/-f and --fields work ANYWHERE on the line (before or after the command).
160
+ Precedence: --format > -o > $OPCLI_FORMAT > saved default > json.
161
+ """,
162
+ "auth": """\
163
+ AUTH & PROFILES
164
+ login: openproject auth login --url https://op.example.com --token opapi-xxxx [--name prod]
165
+ check: openproject auth whoami | openproject auth status
166
+ logout: openproject auth logout [--name prod]
167
+ Env (non-interactive, no keyring): OPCLI_BASE_URL, OPCLI_TOKEN (+ OPCLI_PROFILE).
168
+ Multiple instances: `openproject -p prod wp list` targets a saved profile.
169
+ Get a token in OpenProject: My account -> Access tokens -> API.
170
+ """,
171
+ "notifications": """\
172
+ NOTIFICATIONS (in-app)
173
+ list: openproject notify list (unread by default; --state read|all)
174
+ openproject notify list --today
175
+ count: openproject notify count [--today] -> {"total","unread","today","todayUnread"}
176
+ mark: openproject notify read <id> | unread <id> | read-all
177
+ """,
178
+ }
179
+
180
+
181
+ def guide(
182
+ topic: str = typer.Argument(None, help="Optional topic: search, wp, time, comments, projects, costs, customfields, output, auth, notifications."),
183
+ ) -> None:
184
+ """Print a built-in operating guide so the CLI is usable without external docs.
185
+
186
+ Run `openproject guide` for the overview, or `openproject guide <topic>` for a
187
+ focused cheat-sheet (e.g. `openproject guide search`).
188
+ """
189
+ if not topic:
190
+ typer.echo(OVERVIEW)
191
+ return
192
+ key = topic.strip().lower()
193
+ text = TOPICS.get(key)
194
+ if text is None:
195
+ typer.echo(f"unknown topic '{topic}'. Available: " + ", ".join(TOPICS) + "\n")
196
+ typer.echo(OVERVIEW)
197
+ raise typer.Exit(code=2)
198
+ typer.echo(text)