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.
- agent_tool_openproject_cli-0.2.1.dist-info/METADATA +457 -0
- agent_tool_openproject_cli-0.2.1.dist-info/RECORD +40 -0
- agent_tool_openproject_cli-0.2.1.dist-info/WHEEL +5 -0
- agent_tool_openproject_cli-0.2.1.dist-info/entry_points.txt +2 -0
- agent_tool_openproject_cli-0.2.1.dist-info/licenses/LICENSE +21 -0
- agent_tool_openproject_cli-0.2.1.dist-info/top_level.txt +1 -0
- opcli/__init__.py +3 -0
- opcli/__main__.py +6 -0
- opcli/cli.py +166 -0
- opcli/client.py +252 -0
- opcli/commands/__init__.py +1 -0
- opcli/commands/_shared.py +50 -0
- opcli/commands/attachments.py +104 -0
- opcli/commands/auth.py +113 -0
- opcli/commands/comments.py +90 -0
- opcli/commands/costs.py +205 -0
- opcli/commands/custom_fields.py +82 -0
- opcli/commands/filelinks.py +100 -0
- opcli/commands/guide.py +198 -0
- opcli/commands/memberships.py +110 -0
- opcli/commands/notifications.py +130 -0
- opcli/commands/projects.py +149 -0
- opcli/commands/raw.py +83 -0
- opcli/commands/search.py +256 -0
- opcli/commands/settings.py +59 -0
- opcli/commands/time_entries.py +214 -0
- opcli/commands/users.py +109 -0
- opcli/commands/wiki.py +49 -0
- opcli/commands/workpackages.py +338 -0
- opcli/config.py +134 -0
- opcli/context.py +117 -0
- opcli/credentials.py +139 -0
- opcli/duration.py +56 -0
- opcli/errors.py +81 -0
- opcli/hal.py +75 -0
- opcli/output.py +225 -0
- opcli/resolve.py +183 -0
- opcli/searchspec.py +269 -0
- opcli/serialize.py +225 -0
- opcli/wpfilters.py +159 -0
|
@@ -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))
|
opcli/commands/costs.py
ADDED
|
@@ -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})
|
opcli/commands/guide.py
ADDED
|
@@ -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)
|