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
opcli/commands/wiki.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Wiki commands (read-only metadata).
|
|
2
|
+
|
|
3
|
+
OpenProject API v3 exposes only ``GET /api/v3/wiki_pages/{id}`` and the page's
|
|
4
|
+
attachments. The WikiPage model is a stub (id + title only) — there is NO
|
|
5
|
+
list/create/update/delete and NO way to read or write the page body over the
|
|
6
|
+
API. These commands surface what's available and are explicit about the limits.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from .. import serialize
|
|
14
|
+
from ._shared import ctx_obj
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(no_args_is_help=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command()
|
|
20
|
+
def get(
|
|
21
|
+
ctx: typer.Context,
|
|
22
|
+
page_id: int = typer.Argument(..., help="Wiki page id."),
|
|
23
|
+
raw: bool = typer.Option(False, "--raw", "-r", help="Return the full HAL document."),
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Show wiki page metadata (id + title only; body is not exposed by the API)."""
|
|
26
|
+
obj = ctx_obj(ctx)
|
|
27
|
+
doc = obj.client().get(f"wiki_pages/{page_id}")
|
|
28
|
+
if raw:
|
|
29
|
+
obj.emitter.emit(doc)
|
|
30
|
+
return
|
|
31
|
+
obj.emitter.emit(
|
|
32
|
+
{
|
|
33
|
+
"id": doc.get("id"),
|
|
34
|
+
"title": doc.get("title"),
|
|
35
|
+
"project": (doc.get("_embedded") or {}).get("project", {}).get("identifier"),
|
|
36
|
+
"note": "OpenProject API v3 does not expose wiki page body text.",
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.command()
|
|
42
|
+
def attachments(
|
|
43
|
+
ctx: typer.Context,
|
|
44
|
+
page_id: int = typer.Argument(..., help="Wiki page id."),
|
|
45
|
+
) -> None:
|
|
46
|
+
"""List attachments on a wiki page."""
|
|
47
|
+
obj = ctx_obj(ctx)
|
|
48
|
+
rows = [serialize.attachment(a) for a in obj.client().collect(f"wiki_pages/{page_id}/attachments")]
|
|
49
|
+
obj.emitter.emit(rows, columns=[("ID", "id"), ("File", "fileName"), ("Size", "fileSize"), ("Type", "contentType")])
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Work-package commands: CRUD, move, assign, watchers, schema."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from .. import hal, resolve, serialize, wpfilters
|
|
10
|
+
from ..duration import parse_hours_input
|
|
11
|
+
from ..errors import OpError
|
|
12
|
+
from ._shared import apply_custom_fields, ctx_obj, parse_json_option, set_link
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(no_args_is_help=True)
|
|
15
|
+
|
|
16
|
+
_COLUMNS = [
|
|
17
|
+
("ID", "id"),
|
|
18
|
+
("Type", "type"),
|
|
19
|
+
("Subject", "subject"),
|
|
20
|
+
("Status", "status"),
|
|
21
|
+
("Assignee", lambda r: (r.get("assignee") or {}).get("name")),
|
|
22
|
+
("Project", lambda r: (r.get("project") or {}).get("name")),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# --------------------------------------------------------------------- list
|
|
27
|
+
@app.command("list")
|
|
28
|
+
def list_wps(
|
|
29
|
+
ctx: typer.Context,
|
|
30
|
+
project: str = typer.Option(None, "--project", "-P", help="Project id/identifier."),
|
|
31
|
+
status: str = typer.Option(None, "--status", "-s", help="'open', 'closed', or a status name."),
|
|
32
|
+
type_: str = typer.Option(None, "--type", "-t", help="Work-package type name."),
|
|
33
|
+
assignee: str = typer.Option(None, "--assignee", "-a", help="Assignee (login/name/id or 'me')."),
|
|
34
|
+
mine: bool = typer.Option(False, "--mine", help="Assigned to me."),
|
|
35
|
+
unassigned: bool = typer.Option(False, "--unassigned", help="No assignee."),
|
|
36
|
+
author: str = typer.Option(None, "--author", "--created-by", help="Author (login/name/id or 'me')."),
|
|
37
|
+
priority: str = typer.Option(None, "--priority", help="Priority name."),
|
|
38
|
+
version: str = typer.Option(None, "--version", help="Target version (name/id)."),
|
|
39
|
+
overdue: bool = typer.Option(False, "--overdue", help="Past due and still open."),
|
|
40
|
+
updated_since: str = typer.Option(None, "--updated-since", help="Updated since (date or 7d/today)."),
|
|
41
|
+
due_before: str = typer.Option(None, "--due-before", help="Due on/before (date or spec)."),
|
|
42
|
+
query: str = typer.Option(None, "--query", "-q", help="Full-text search (subject/description/comments)."),
|
|
43
|
+
where: list[str] = typer.Option(None, "--where", "-w", help='Expression filter, e.g. "status = open" (repeatable).'),
|
|
44
|
+
all_statuses: bool = typer.Option(False, "--all", help="Include closed (disable default open filter)."),
|
|
45
|
+
sort: str = typer.Option("id", "--sort", help="Sort field, e.g. id, updatedAt, dueDate."),
|
|
46
|
+
desc: bool = typer.Option(False, "--desc", help="Sort descending."),
|
|
47
|
+
limit: int = typer.Option(50, "--limit", "-n", help="Maximum rows (0 = all)."),
|
|
48
|
+
) -> None:
|
|
49
|
+
"""List / filter work packages (global, scoped by filters). See `search wp` for the full set."""
|
|
50
|
+
obj = ctx_obj(ctx)
|
|
51
|
+
client = obj.client()
|
|
52
|
+
filters = wpfilters.build(
|
|
53
|
+
client, project=project, status=status, type_=type_, assignee=assignee,
|
|
54
|
+
mine=mine, unassigned=unassigned, author=author, priority=priority, version=version,
|
|
55
|
+
overdue=overdue, updated_since=updated_since, due_before=due_before,
|
|
56
|
+
query=query, where=where, all_statuses=all_statuses,
|
|
57
|
+
)
|
|
58
|
+
params = {
|
|
59
|
+
"filters": wpfilters.encode(filters),
|
|
60
|
+
"sortBy": json.dumps([[sort, "desc" if desc else "asc"]]),
|
|
61
|
+
}
|
|
62
|
+
rows = [
|
|
63
|
+
serialize.work_package(w, include_description=False)
|
|
64
|
+
for w in client.collect("work_packages", params=params, limit=limit or None)
|
|
65
|
+
]
|
|
66
|
+
obj.emitter.emit(rows, columns=_COLUMNS, empty="(no work packages)")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------- get
|
|
70
|
+
@app.command()
|
|
71
|
+
def get(
|
|
72
|
+
ctx: typer.Context,
|
|
73
|
+
wp_id: int = typer.Argument(..., help="Work package id."),
|
|
74
|
+
raw: bool = typer.Option(False, "--raw", "-r", help="Return the full HAL document."),
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Show one work package."""
|
|
77
|
+
obj = ctx_obj(ctx)
|
|
78
|
+
doc = obj.client().get(f"work_packages/{wp_id}")
|
|
79
|
+
obj.emitter.emit(doc if raw else serialize.work_package(doc))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------- create
|
|
83
|
+
@app.command()
|
|
84
|
+
def create(
|
|
85
|
+
ctx: typer.Context,
|
|
86
|
+
subject: str = typer.Argument(..., help="Work package subject/title."),
|
|
87
|
+
project: str = typer.Option(..., "--project", "-P", help="Project id/identifier."),
|
|
88
|
+
type_: str = typer.Option("Task", "--type", "-t", help="Type name (default Task)."),
|
|
89
|
+
description: str = typer.Option(None, "--description", "-d", help="Description (markdown)."),
|
|
90
|
+
status: str = typer.Option(None, "--status", "-s", help="Initial status name."),
|
|
91
|
+
priority: str = typer.Option(None, "--priority", help="Priority name."),
|
|
92
|
+
assignee: str = typer.Option(None, "--assignee", "-a", help="Assignee (login/name/id or 'me')."),
|
|
93
|
+
responsible: str = typer.Option(None, "--responsible", help="Accountable user."),
|
|
94
|
+
parent: int = typer.Option(None, "--parent", help="Parent work package id."),
|
|
95
|
+
start_date: str = typer.Option(None, "--start-date", help="YYYY-MM-DD."),
|
|
96
|
+
due_date: str = typer.Option(None, "--due-date", help="YYYY-MM-DD."),
|
|
97
|
+
estimated: str = typer.Option(None, "--estimated", help="Estimated time (hours or ISO8601)."),
|
|
98
|
+
custom_fields: str = typer.Option(None, "--custom-fields", help="JSON of customFieldN values."),
|
|
99
|
+
set_: str = typer.Option(None, "--set", help="Raw JSON merged into the create body."),
|
|
100
|
+
notify: bool = typer.Option(False, "--notify", help="Send notifications for this create."),
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Create a work package. Pass names (type/status/priority/assignee) — they're resolved.
|
|
103
|
+
|
|
104
|
+
Example: openproject wp create "Fix login" --project webshop --type Bug --assignee me --priority High
|
|
105
|
+
Custom fields: --custom-fields '{"customField1":"INV-1"}' (discover them with `cf wp`).
|
|
106
|
+
Note: the assignee must be a member of the project (see `member add`).
|
|
107
|
+
"""
|
|
108
|
+
obj = ctx_obj(ctx)
|
|
109
|
+
client = obj.client()
|
|
110
|
+
pid = resolve.project_id(client, project)
|
|
111
|
+
|
|
112
|
+
body: dict = {"subject": subject, "_links": {}}
|
|
113
|
+
set_link(body, "project", f"/api/v3/projects/{pid}")
|
|
114
|
+
set_link(body, "type", resolve.wp_type(client, type_))
|
|
115
|
+
if description is not None:
|
|
116
|
+
body["description"] = {"raw": description}
|
|
117
|
+
if status:
|
|
118
|
+
set_link(body, "status", resolve.status(client, status))
|
|
119
|
+
if priority:
|
|
120
|
+
set_link(body, "priority", resolve.priority(client, priority))
|
|
121
|
+
if assignee:
|
|
122
|
+
set_link(body, "assignee", resolve.user(client, assignee, project_ref=project))
|
|
123
|
+
if responsible:
|
|
124
|
+
set_link(body, "responsible", resolve.user(client, responsible, project_ref=project))
|
|
125
|
+
if parent:
|
|
126
|
+
set_link(body, "parent", f"/api/v3/work_packages/{parent}")
|
|
127
|
+
if start_date:
|
|
128
|
+
body["startDate"] = start_date
|
|
129
|
+
if due_date:
|
|
130
|
+
body["dueDate"] = due_date
|
|
131
|
+
if estimated:
|
|
132
|
+
body["estimatedTime"] = parse_hours_input(estimated)
|
|
133
|
+
apply_custom_fields(body, custom_fields)
|
|
134
|
+
if set_:
|
|
135
|
+
extra = parse_json_option(set_, what="--set")
|
|
136
|
+
if isinstance(extra, dict):
|
|
137
|
+
body.update({k: v for k, v in extra.items() if k != "_links"})
|
|
138
|
+
body["_links"].update(extra.get("_links", {}))
|
|
139
|
+
|
|
140
|
+
doc = client.post("work_packages", json=body, params={"notify": str(notify).lower()})
|
|
141
|
+
obj.emitter.emit(serialize.work_package(doc))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ------------------------------------------------------------------- update
|
|
145
|
+
@app.command()
|
|
146
|
+
def update(
|
|
147
|
+
ctx: typer.Context,
|
|
148
|
+
wp_id: int = typer.Argument(..., help="Work package id."),
|
|
149
|
+
subject: str = typer.Option(None, "--subject", help="New subject."),
|
|
150
|
+
description: str = typer.Option(None, "--description", "-d", help="New description."),
|
|
151
|
+
status: str = typer.Option(None, "--status", "-s", help="New status name."),
|
|
152
|
+
type_: str = typer.Option(None, "--type", "-t", help="New type name."),
|
|
153
|
+
priority: str = typer.Option(None, "--priority", help="New priority."),
|
|
154
|
+
assignee: str = typer.Option(None, "--assignee", "-a", help="Assignee ('none' to clear)."),
|
|
155
|
+
responsible: str = typer.Option(None, "--responsible", help="Accountable ('none' to clear)."),
|
|
156
|
+
parent: str = typer.Option(None, "--parent", help="Parent id ('none' to detach)."),
|
|
157
|
+
start_date: str = typer.Option(None, "--start-date", help="YYYY-MM-DD."),
|
|
158
|
+
due_date: str = typer.Option(None, "--due-date", help="YYYY-MM-DD."),
|
|
159
|
+
estimated: str = typer.Option(None, "--estimated", help="Estimated time (hours or ISO8601)."),
|
|
160
|
+
done_ratio: int = typer.Option(None, "--done-ratio", help="Percentage done 0-100."),
|
|
161
|
+
custom_fields: str = typer.Option(None, "--custom-fields", help="JSON of customFieldN values."),
|
|
162
|
+
set_: str = typer.Option(None, "--set", help="Raw JSON merged into the patch body."),
|
|
163
|
+
notify: bool = typer.Option(False, "--notify", help="Send notifications for this change."),
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Update a work package. lockVersion is fetched and retried automatically.
|
|
166
|
+
|
|
167
|
+
Example: openproject wp update 42 --status "In progress" --assignee jane.doe --done-ratio 50
|
|
168
|
+
Clear a link with the value 'none' (e.g. --assignee none). Use --set '{...}' for arbitrary fields.
|
|
169
|
+
"""
|
|
170
|
+
obj = ctx_obj(ctx)
|
|
171
|
+
client = obj.client()
|
|
172
|
+
|
|
173
|
+
body: dict = {"_links": {}}
|
|
174
|
+
if subject is not None:
|
|
175
|
+
body["subject"] = subject
|
|
176
|
+
if description is not None:
|
|
177
|
+
body["description"] = {"raw": description}
|
|
178
|
+
if start_date is not None:
|
|
179
|
+
body["startDate"] = start_date
|
|
180
|
+
if due_date is not None:
|
|
181
|
+
body["dueDate"] = due_date
|
|
182
|
+
if estimated is not None:
|
|
183
|
+
body["estimatedTime"] = parse_hours_input(estimated)
|
|
184
|
+
if done_ratio is not None:
|
|
185
|
+
body["percentageDone"] = done_ratio
|
|
186
|
+
if status:
|
|
187
|
+
set_link(body, "status", resolve.status(client, status))
|
|
188
|
+
if type_:
|
|
189
|
+
set_link(body, "type", resolve.wp_type(client, type_))
|
|
190
|
+
if priority:
|
|
191
|
+
set_link(body, "priority", resolve.priority(client, priority))
|
|
192
|
+
if assignee is not None:
|
|
193
|
+
set_link(body, "assignee", None if assignee.lower() == "none" else resolve.user(client, assignee))
|
|
194
|
+
if responsible is not None:
|
|
195
|
+
set_link(body, "responsible", None if responsible.lower() == "none" else resolve.user(client, responsible))
|
|
196
|
+
if parent is not None:
|
|
197
|
+
set_link(body, "parent", None if parent.lower() == "none" else f"/api/v3/work_packages/{parent}")
|
|
198
|
+
apply_custom_fields(body, custom_fields)
|
|
199
|
+
if set_:
|
|
200
|
+
extra = parse_json_option(set_, what="--set")
|
|
201
|
+
if isinstance(extra, dict):
|
|
202
|
+
body.update({k: v for k, v in extra.items() if k != "_links"})
|
|
203
|
+
body["_links"].update(extra.get("_links", {}))
|
|
204
|
+
if not body.get("_links"):
|
|
205
|
+
body.pop("_links")
|
|
206
|
+
if not body:
|
|
207
|
+
raise OpError("nothing to update — pass at least one field")
|
|
208
|
+
|
|
209
|
+
doc = client.update_locked(f"work_packages/{wp_id}", body, params={"notify": str(notify).lower()})
|
|
210
|
+
obj.emitter.emit(serialize.work_package(doc))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# --------------------------------------------------------------------- move
|
|
214
|
+
@app.command()
|
|
215
|
+
def move(
|
|
216
|
+
ctx: typer.Context,
|
|
217
|
+
wp_id: int = typer.Argument(..., help="Work package id."),
|
|
218
|
+
project: str = typer.Argument(..., help="Destination project id/identifier."),
|
|
219
|
+
type_: str = typer.Option(None, "--type", "-t", help="New type (if not valid in target project)."),
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Move a work package to another project."""
|
|
222
|
+
obj = ctx_obj(ctx)
|
|
223
|
+
client = obj.client()
|
|
224
|
+
pid = resolve.project_id(client, project)
|
|
225
|
+
body: dict = {"_links": {"project": {"href": f"/api/v3/projects/{pid}"}}}
|
|
226
|
+
if type_:
|
|
227
|
+
body["_links"]["type"] = {"href": resolve.wp_type(client, type_)}
|
|
228
|
+
doc = client.update_locked(f"work_packages/{wp_id}", body)
|
|
229
|
+
obj.emitter.emit(serialize.work_package(doc))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ------------------------------------------------------------------- delete
|
|
233
|
+
@app.command()
|
|
234
|
+
def delete(
|
|
235
|
+
ctx: typer.Context,
|
|
236
|
+
wp_id: int = typer.Argument(..., help="Work package id."),
|
|
237
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
|
|
238
|
+
) -> None:
|
|
239
|
+
"""Delete a work package."""
|
|
240
|
+
obj = ctx_obj(ctx)
|
|
241
|
+
if not yes:
|
|
242
|
+
typer.confirm(f"Delete work package {wp_id}?", abort=True)
|
|
243
|
+
obj.client().delete(f"work_packages/{wp_id}")
|
|
244
|
+
obj.emitter.emit({"status": "deleted", "workPackage": wp_id})
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ------------------------------------------------------- assignee shortcuts
|
|
248
|
+
@app.command()
|
|
249
|
+
def assign(
|
|
250
|
+
ctx: typer.Context,
|
|
251
|
+
wp_id: int = typer.Argument(..., help="Work package id."),
|
|
252
|
+
user: str = typer.Argument(..., help="Assignee (login/name/id or 'me'; 'none' to unassign)."),
|
|
253
|
+
) -> None:
|
|
254
|
+
"""Set (or clear with 'none') the assignee."""
|
|
255
|
+
obj = ctx_obj(ctx)
|
|
256
|
+
client = obj.client()
|
|
257
|
+
href = None if user.lower() == "none" else resolve.user(client, user)
|
|
258
|
+
doc = client.update_locked(f"work_packages/{wp_id}", {"_links": {"assignee": {"href": href}}})
|
|
259
|
+
obj.emitter.emit(serialize.work_package(doc))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@app.command()
|
|
263
|
+
def unassign(ctx: typer.Context, wp_id: int = typer.Argument(..., help="Work package id.")) -> None:
|
|
264
|
+
"""Remove the assignee."""
|
|
265
|
+
obj = ctx_obj(ctx)
|
|
266
|
+
doc = obj.client().update_locked(f"work_packages/{wp_id}", {"_links": {"assignee": {"href": None}}})
|
|
267
|
+
obj.emitter.emit(serialize.work_package(doc))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@app.command()
|
|
271
|
+
def assignees(
|
|
272
|
+
ctx: typer.Context,
|
|
273
|
+
wp_id: int = typer.Argument(..., help="Work package id."),
|
|
274
|
+
) -> None:
|
|
275
|
+
"""List users assignable to this work package."""
|
|
276
|
+
obj = ctx_obj(ctx)
|
|
277
|
+
rows = [serialize.principal(p) for p in obj.client().collect(f"work_packages/{wp_id}/available_assignees")]
|
|
278
|
+
obj.emitter.emit(rows, columns=[("ID", "id"), ("Name", "name"), ("Login", "login"), ("Type", "type")])
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ----------------------------------------------------------------- watchers
|
|
282
|
+
@app.command()
|
|
283
|
+
def watchers(ctx: typer.Context, wp_id: int = typer.Argument(..., help="Work package id.")) -> None:
|
|
284
|
+
"""List watchers of a work package."""
|
|
285
|
+
obj = ctx_obj(ctx)
|
|
286
|
+
rows = [serialize.principal(p) for p in obj.client().collect(f"work_packages/{wp_id}/watchers")]
|
|
287
|
+
obj.emitter.emit(rows, columns=[("ID", "id"), ("Name", "name"), ("Login", "login")])
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@app.command()
|
|
291
|
+
def watch(
|
|
292
|
+
ctx: typer.Context,
|
|
293
|
+
wp_id: int = typer.Argument(..., help="Work package id."),
|
|
294
|
+
user: str = typer.Argument("me", help="User to add as watcher (default: me)."),
|
|
295
|
+
) -> None:
|
|
296
|
+
"""Add a watcher (note: body key is 'user', not under _links)."""
|
|
297
|
+
obj = ctx_obj(ctx)
|
|
298
|
+
client = obj.client()
|
|
299
|
+
href = resolve.user(client, user)
|
|
300
|
+
client.post(f"work_packages/{wp_id}/watchers", json={"user": {"href": href}})
|
|
301
|
+
obj.emitter.emit({"status": "watching", "workPackage": wp_id, "user": href})
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@app.command()
|
|
305
|
+
def unwatch(
|
|
306
|
+
ctx: typer.Context,
|
|
307
|
+
wp_id: int = typer.Argument(..., help="Work package id."),
|
|
308
|
+
user: str = typer.Argument("me", help="User to remove (default: me)."),
|
|
309
|
+
) -> None:
|
|
310
|
+
"""Remove a watcher."""
|
|
311
|
+
obj = ctx_obj(ctx)
|
|
312
|
+
client = obj.client()
|
|
313
|
+
href = resolve.user(client, user)
|
|
314
|
+
uid = hal.id_from_href(href)
|
|
315
|
+
client.delete(f"work_packages/{wp_id}/watchers/{uid}")
|
|
316
|
+
obj.emitter.emit({"status": "unwatched", "workPackage": wp_id, "user": uid})
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# ------------------------------------------------------------------- schema
|
|
320
|
+
@app.command()
|
|
321
|
+
def schema(
|
|
322
|
+
ctx: typer.Context,
|
|
323
|
+
project: str = typer.Option(..., "--project", "-P", help="Project id/identifier."),
|
|
324
|
+
type_: str = typer.Option("Task", "--type", "-t", help="Type name."),
|
|
325
|
+
) -> None:
|
|
326
|
+
"""Discover the create schema (fields, required flags, custom fields) via the form."""
|
|
327
|
+
obj = ctx_obj(ctx)
|
|
328
|
+
client = obj.client()
|
|
329
|
+
pid = resolve.project_id(client, project)
|
|
330
|
+
body = {"_links": {"project": {"href": f"/api/v3/projects/{pid}"}, "type": {"href": resolve.wp_type(client, type_)}}}
|
|
331
|
+
form = client.post(f"projects/{pid}/work_packages/form", json=body)
|
|
332
|
+
sch = (form.get("_embedded") or {}).get("schema") or {}
|
|
333
|
+
fields = [
|
|
334
|
+
serialize.custom_field_schema(name, spec)
|
|
335
|
+
for name, spec in sch.items()
|
|
336
|
+
if isinstance(spec, dict) and spec.get("type")
|
|
337
|
+
]
|
|
338
|
+
obj.emitter.emit(fields, columns=[("Key", "key"), ("Name", "name"), ("Type", "type"), ("Required", "required"), ("Writable", "writable")])
|
opcli/config.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Non-secret configuration: connection profiles.
|
|
2
|
+
|
|
3
|
+
Config lives in a plain JSON file (``~/.config/op-cli/config.json`` by
|
|
4
|
+
default). It never contains the API token — that is kept in the OS keyring by
|
|
5
|
+
:mod:`opcli.credentials`. Multiple named *profiles* let one operator point the
|
|
6
|
+
CLI at several OpenProject instances (e.g. ``prod`` and ``staging``).
|
|
7
|
+
|
|
8
|
+
Environment overrides (useful for CI and the test-suite):
|
|
9
|
+
|
|
10
|
+
* ``OPCLI_BASE_URL`` — overrides the active profile's base URL.
|
|
11
|
+
* ``OPCLI_PROFILE`` — selects the active profile.
|
|
12
|
+
* ``OPCLI_TOKEN`` — supplies the token directly (see :mod:`credentials`).
|
|
13
|
+
* ``OPCLI_CONFIG_DIR`` / ``XDG_CONFIG_HOME`` — relocate the config directory.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from .errors import ConfigError
|
|
25
|
+
|
|
26
|
+
DEFAULT_PROFILE = "default"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def config_dir() -> Path:
|
|
30
|
+
base = os.environ.get("OPCLI_CONFIG_DIR")
|
|
31
|
+
if base:
|
|
32
|
+
return Path(base)
|
|
33
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
34
|
+
root = Path(xdg) if xdg else Path.home() / ".config"
|
|
35
|
+
return root / "op-cli"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def config_path() -> Path:
|
|
39
|
+
return config_dir() / "config.json"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Profile:
|
|
44
|
+
name: str
|
|
45
|
+
base_url: str
|
|
46
|
+
username: str | None = None # informational; the login of the API user
|
|
47
|
+
verify_ssl: bool = True
|
|
48
|
+
|
|
49
|
+
def api_root(self) -> str:
|
|
50
|
+
return self.base_url.rstrip("/") + "/api/v3"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Config:
|
|
55
|
+
current_profile: str = DEFAULT_PROFILE
|
|
56
|
+
profiles: dict[str, Profile] = field(default_factory=dict)
|
|
57
|
+
default_format: str | None = None # json | table | markdown; None = not yet chosen
|
|
58
|
+
|
|
59
|
+
# ---- persistence -------------------------------------------------
|
|
60
|
+
@classmethod
|
|
61
|
+
def load(cls) -> "Config":
|
|
62
|
+
path = config_path()
|
|
63
|
+
if not path.exists():
|
|
64
|
+
return cls()
|
|
65
|
+
try:
|
|
66
|
+
raw = json.loads(path.read_text())
|
|
67
|
+
profiles = {
|
|
68
|
+
name: Profile(
|
|
69
|
+
name=name,
|
|
70
|
+
base_url=p["base_url"],
|
|
71
|
+
username=p.get("username"),
|
|
72
|
+
verify_ssl=p.get("verify_ssl", True),
|
|
73
|
+
)
|
|
74
|
+
for name, p in raw.get("profiles", {}).items()
|
|
75
|
+
}
|
|
76
|
+
return cls(
|
|
77
|
+
current_profile=raw.get("current_profile", DEFAULT_PROFILE),
|
|
78
|
+
profiles=profiles,
|
|
79
|
+
default_format=raw.get("default_format"),
|
|
80
|
+
)
|
|
81
|
+
except (ValueError, KeyError, TypeError, AttributeError) as exc:
|
|
82
|
+
raise ConfigError(f"malformed config at {path}: {exc}") from exc
|
|
83
|
+
|
|
84
|
+
def save(self) -> None:
|
|
85
|
+
path = config_path()
|
|
86
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
data: dict[str, Any] = {
|
|
88
|
+
"current_profile": self.current_profile,
|
|
89
|
+
"default_format": self.default_format,
|
|
90
|
+
"profiles": {
|
|
91
|
+
name: {
|
|
92
|
+
"base_url": p.base_url,
|
|
93
|
+
"username": p.username,
|
|
94
|
+
"verify_ssl": p.verify_ssl,
|
|
95
|
+
}
|
|
96
|
+
for name, p in self.profiles.items()
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
100
|
+
|
|
101
|
+
# ---- resolution --------------------------------------------------
|
|
102
|
+
def active_profile_name(self) -> str:
|
|
103
|
+
return os.environ.get("OPCLI_PROFILE") or self.current_profile
|
|
104
|
+
|
|
105
|
+
def resolve(self) -> Profile:
|
|
106
|
+
"""Return the effective profile, applying env overrides.
|
|
107
|
+
|
|
108
|
+
A profile can be fully synthesised from the environment even with no
|
|
109
|
+
config file on disk, so ``OPCLI_BASE_URL`` + ``OPCLI_TOKEN`` are enough
|
|
110
|
+
to run the CLI headless.
|
|
111
|
+
"""
|
|
112
|
+
name = self.active_profile_name()
|
|
113
|
+
env_url = os.environ.get("OPCLI_BASE_URL")
|
|
114
|
+
|
|
115
|
+
prof = self.profiles.get(name)
|
|
116
|
+
if prof is None:
|
|
117
|
+
if env_url:
|
|
118
|
+
return Profile(name=name, base_url=env_url)
|
|
119
|
+
raise ConfigError(
|
|
120
|
+
f"no profile '{name}' configured. Run `openproject auth login` or set OPCLI_BASE_URL."
|
|
121
|
+
)
|
|
122
|
+
if env_url:
|
|
123
|
+
return Profile(
|
|
124
|
+
name=prof.name,
|
|
125
|
+
base_url=env_url,
|
|
126
|
+
username=prof.username,
|
|
127
|
+
verify_ssl=prof.verify_ssl,
|
|
128
|
+
)
|
|
129
|
+
return prof
|
|
130
|
+
|
|
131
|
+
def upsert_profile(self, prof: Profile, make_current: bool = True) -> None:
|
|
132
|
+
self.profiles[prof.name] = prof
|
|
133
|
+
if make_current:
|
|
134
|
+
self.current_profile = prof.name
|
opcli/context.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""The shared application context stored on Typer's ``ctx.obj``.
|
|
2
|
+
|
|
3
|
+
Wires together configuration, stored credentials, the HTTP client, and the
|
|
4
|
+
output emitter so every command can reach them without re-plumbing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from . import credentials
|
|
14
|
+
from .client import Client
|
|
15
|
+
from .config import Config, Profile
|
|
16
|
+
from .errors import AuthError
|
|
17
|
+
from .output import Emitter, OutputFormat
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AppContext:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
output: Optional[OutputFormat] = None,
|
|
24
|
+
color: bool = True,
|
|
25
|
+
*,
|
|
26
|
+
interactive: Optional[bool] = None,
|
|
27
|
+
):
|
|
28
|
+
self.config = Config.load()
|
|
29
|
+
fmt = self._resolve_format(output, interactive)
|
|
30
|
+
env_fields = os.environ.get("OPCLI_CLI_FIELDS")
|
|
31
|
+
fields = env_fields.split(",") if env_fields else None
|
|
32
|
+
self.emitter = Emitter(fmt, color=color, fields=fields)
|
|
33
|
+
self._client: Optional[Client] = None
|
|
34
|
+
|
|
35
|
+
# ---- output-format resolution ------------------------------------
|
|
36
|
+
def _resolve_format(self, output: Optional[OutputFormat], interactive: Optional[bool]) -> OutputFormat:
|
|
37
|
+
"""Precedence: --format anywhere > -o/--output > $OPCLI_FORMAT >
|
|
38
|
+
saved default > first-run prompt (interactive) > json."""
|
|
39
|
+
cli = os.environ.get("OPCLI_CLI_FORMAT") # set by `--format/-f` (main pre-parse)
|
|
40
|
+
if cli:
|
|
41
|
+
try:
|
|
42
|
+
return OutputFormat.coerce(cli)
|
|
43
|
+
except ValueError:
|
|
44
|
+
pass
|
|
45
|
+
if output is not None:
|
|
46
|
+
return output
|
|
47
|
+
env = os.environ.get("OPCLI_FORMAT")
|
|
48
|
+
if env:
|
|
49
|
+
try:
|
|
50
|
+
return OutputFormat.coerce(env)
|
|
51
|
+
except ValueError:
|
|
52
|
+
pass
|
|
53
|
+
if self.config.default_format:
|
|
54
|
+
try:
|
|
55
|
+
return OutputFormat.coerce(self.config.default_format)
|
|
56
|
+
except ValueError:
|
|
57
|
+
pass
|
|
58
|
+
if interactive is None:
|
|
59
|
+
interactive = sys.stdin.isatty() and sys.stdout.isatty()
|
|
60
|
+
if interactive:
|
|
61
|
+
chosen = self._prompt_default_format()
|
|
62
|
+
if chosen is not None:
|
|
63
|
+
self.config.default_format = chosen.value
|
|
64
|
+
try:
|
|
65
|
+
self.config.save()
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
return chosen
|
|
69
|
+
return OutputFormat.json
|
|
70
|
+
|
|
71
|
+
def _prompt_default_format(self) -> Optional[OutputFormat]:
|
|
72
|
+
# Prompt on stderr so stdout (the real command output) stays clean.
|
|
73
|
+
try:
|
|
74
|
+
sys.stderr.write(
|
|
75
|
+
"\nFirst run — choose a default output format (saved for next time):\n"
|
|
76
|
+
" 1) json structured, best for scripts & agents (default)\n"
|
|
77
|
+
" 2) table human-readable terminal tables\n"
|
|
78
|
+
" 3) markdown paste-into-docs tables\n"
|
|
79
|
+
"Enter 1/2/3 [1]: "
|
|
80
|
+
)
|
|
81
|
+
sys.stderr.flush()
|
|
82
|
+
ans = sys.stdin.readline().strip().lower()
|
|
83
|
+
except Exception:
|
|
84
|
+
return None
|
|
85
|
+
return {
|
|
86
|
+
"": OutputFormat.json, "1": OutputFormat.json, "json": OutputFormat.json,
|
|
87
|
+
"2": OutputFormat.table, "table": OutputFormat.table,
|
|
88
|
+
"3": OutputFormat.markdown, "markdown": OutputFormat.markdown, "md": OutputFormat.markdown,
|
|
89
|
+
}.get(ans, OutputFormat.json)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def output(self) -> OutputFormat:
|
|
93
|
+
return self.emitter.fmt
|
|
94
|
+
|
|
95
|
+
def profile(self) -> Profile:
|
|
96
|
+
return self.config.resolve()
|
|
97
|
+
|
|
98
|
+
def token(self) -> Optional[str]:
|
|
99
|
+
return credentials.get_token(self.config.active_profile_name())
|
|
100
|
+
|
|
101
|
+
def client(self) -> Client:
|
|
102
|
+
if self._client is not None:
|
|
103
|
+
return self._client
|
|
104
|
+
prof = self.profile()
|
|
105
|
+
token = self.token()
|
|
106
|
+
if not token:
|
|
107
|
+
raise AuthError(
|
|
108
|
+
f"no API token for profile '{prof.name}'. Run `openproject auth login` "
|
|
109
|
+
f"(or set OPCLI_TOKEN)."
|
|
110
|
+
)
|
|
111
|
+
self._client = Client(prof.base_url, token, verify_ssl=prof.verify_ssl)
|
|
112
|
+
return self._client
|
|
113
|
+
|
|
114
|
+
def close(self) -> None:
|
|
115
|
+
if self._client is not None:
|
|
116
|
+
self._client.close()
|
|
117
|
+
self._client = None
|