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,110 @@
|
|
|
1
|
+
"""Project membership commands: list, add, update roles, remove.
|
|
2
|
+
|
|
3
|
+
A membership links a principal (user/group) to a project with a set of roles.
|
|
4
|
+
The ``roles`` link is an ARRAY that *replaces* the whole role set on write.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from .. import hal, resolve, 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
|
+
("Principal", lambda r: (r.get("principal") or {}).get("name")),
|
|
22
|
+
("Project", lambda r: (r.get("project") or {}).get("name")),
|
|
23
|
+
("Roles", "roles"),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command("list")
|
|
28
|
+
def list_members(
|
|
29
|
+
ctx: typer.Context,
|
|
30
|
+
project: str = typer.Option(None, "--project", "-P", help="Filter by project."),
|
|
31
|
+
user: str = typer.Option(None, "--user", "-u", help="Filter by principal (login/name/id)."),
|
|
32
|
+
limit: int = typer.Option(100, "--limit", "-n", help="Maximum rows (0 = all)."),
|
|
33
|
+
) -> None:
|
|
34
|
+
"""List memberships."""
|
|
35
|
+
obj = ctx_obj(ctx)
|
|
36
|
+
client = obj.client()
|
|
37
|
+
filters = []
|
|
38
|
+
if project:
|
|
39
|
+
filters.append({"project": {"operator": "=", "values": [str(resolve.project_id(client, project))]}})
|
|
40
|
+
if user:
|
|
41
|
+
href = resolve.user(client, user, project_ref=project)
|
|
42
|
+
filters.append({"principal": {"operator": "=", "values": [str(hal.id_from_href(href))]}})
|
|
43
|
+
params = {"filters": json.dumps(filters)} if filters else {}
|
|
44
|
+
rows = [serialize.membership(m) for m in client.collect("memberships", params=params, limit=limit or None)]
|
|
45
|
+
obj.emitter.emit(rows, columns=_COLUMNS, empty="(no memberships)")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _role_hrefs(client, roles: list[str]) -> list[dict]:
|
|
49
|
+
return [{"href": resolve.role(client, r)} for r in roles]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command()
|
|
53
|
+
def add(
|
|
54
|
+
ctx: typer.Context,
|
|
55
|
+
project: str = typer.Option(..., "--project", "-P", help="Project id/identifier."),
|
|
56
|
+
user: str = typer.Option(..., "--user", "-u", help="Principal (login/name/id or 'me')."),
|
|
57
|
+
role: list[str] = typer.Option(..., "--role", "-r", help="Role name (repeatable)."),
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Add a member to a project with one or more roles.
|
|
60
|
+
|
|
61
|
+
Do this before assigning work packages — only project members can be assignees.
|
|
62
|
+
Example: openproject member add --project webshop --user jane.doe --role Member
|
|
63
|
+
"""
|
|
64
|
+
obj = ctx_obj(ctx)
|
|
65
|
+
client = obj.client()
|
|
66
|
+
pid = resolve.project_id(client, project)
|
|
67
|
+
principal = resolve.user(client, user, project_ref=project)
|
|
68
|
+
body = {
|
|
69
|
+
"_links": {
|
|
70
|
+
"project": {"href": f"/api/v3/projects/{pid}"},
|
|
71
|
+
"principal": {"href": principal},
|
|
72
|
+
"roles": _role_hrefs(client, role),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
obj.emitter.emit(serialize.membership(client.post("memberships", json=body)))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command()
|
|
79
|
+
def update(
|
|
80
|
+
ctx: typer.Context,
|
|
81
|
+
membership_id: int = typer.Argument(..., help="Membership id."),
|
|
82
|
+
role: list[str] = typer.Option(..., "--role", "-r", help="New role set (repeatable; replaces all)."),
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Replace a membership's roles."""
|
|
85
|
+
obj = ctx_obj(ctx)
|
|
86
|
+
client = obj.client()
|
|
87
|
+
body = {"_links": {"roles": _role_hrefs(client, role)}}
|
|
88
|
+
obj.emitter.emit(serialize.membership(client.patch(f"memberships/{membership_id}", json=body)))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.command()
|
|
92
|
+
def remove(
|
|
93
|
+
ctx: typer.Context,
|
|
94
|
+
membership_id: int = typer.Argument(..., help="Membership id."),
|
|
95
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Remove a membership."""
|
|
98
|
+
obj = ctx_obj(ctx)
|
|
99
|
+
if not yes:
|
|
100
|
+
typer.confirm(f"Remove membership {membership_id}?", abort=True)
|
|
101
|
+
obj.client().delete(f"memberships/{membership_id}")
|
|
102
|
+
obj.emitter.emit({"status": "removed", "membership": membership_id})
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command()
|
|
106
|
+
def roles(ctx: typer.Context) -> None:
|
|
107
|
+
"""List available roles."""
|
|
108
|
+
obj = ctx_obj(ctx)
|
|
109
|
+
rows = [{"id": r.get("id"), "name": r.get("name")} for r in obj.client().collect("roles")]
|
|
110
|
+
obj.emitter.emit(rows, columns=[("ID", "id"), ("Name", "name")])
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""In-app notification commands: list, get, mark read/unread, mark all read.
|
|
2
|
+
|
|
3
|
+
``readIAN`` is boolean-encoded as the strings ``"t"``/``"f"``. Mark endpoints
|
|
4
|
+
return 204 with an empty body.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime as _dt
|
|
10
|
+
import json
|
|
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
|
+
("ID", "id"),
|
|
21
|
+
("Reason", "reason"),
|
|
22
|
+
("Read", "readIAN"),
|
|
23
|
+
("Subject", "subject"),
|
|
24
|
+
("Resource", lambda r: (r.get("resource") or {}).get("name")),
|
|
25
|
+
("When", "createdAt"),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command("list")
|
|
30
|
+
def list_notifications(
|
|
31
|
+
ctx: typer.Context,
|
|
32
|
+
state: str = typer.Option("unread", "--state", help="unread | read | all."),
|
|
33
|
+
reason: str = typer.Option(None, "--reason", help="mentioned, assigned, watched, ..."),
|
|
34
|
+
project: str = typer.Option(None, "--project", "-P", help="Filter by project."),
|
|
35
|
+
today: bool = typer.Option(False, "--today", help="Only notifications created today."),
|
|
36
|
+
limit: int = typer.Option(50, "--limit", "-n", help="Maximum rows (0 = all)."),
|
|
37
|
+
) -> None:
|
|
38
|
+
"""List your in-app notifications (unread by default)."""
|
|
39
|
+
obj = ctx_obj(ctx)
|
|
40
|
+
client = obj.client()
|
|
41
|
+
filters = _filters(client, state=state, reason=reason, project=project)
|
|
42
|
+
params = {"sortBy": json.dumps([["id", "desc"]])}
|
|
43
|
+
if filters:
|
|
44
|
+
params["filters"] = json.dumps(filters)
|
|
45
|
+
# `today` isn't a server-side filter for notifications, so scope client-side.
|
|
46
|
+
fetch_limit = None if today else (limit or None)
|
|
47
|
+
rows = [serialize.notification(n) for n in client.collect("notifications", params=params, limit=fetch_limit)]
|
|
48
|
+
if today:
|
|
49
|
+
prefix = _dt.date.today().isoformat()
|
|
50
|
+
rows = [r for r in rows if (r.get("createdAt") or "").startswith(prefix)]
|
|
51
|
+
if limit:
|
|
52
|
+
rows = rows[:limit]
|
|
53
|
+
obj.emitter.emit(rows, columns=_COLUMNS, empty="(no notifications)")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _filters(client, *, state=None, reason=None, project=None) -> list[dict]:
|
|
57
|
+
filters = []
|
|
58
|
+
if state == "unread":
|
|
59
|
+
filters.append({"readIAN": {"operator": "=", "values": ["f"]}})
|
|
60
|
+
elif state == "read":
|
|
61
|
+
filters.append({"readIAN": {"operator": "=", "values": ["t"]}})
|
|
62
|
+
if reason:
|
|
63
|
+
filters.append({"reason": {"operator": "=", "values": [reason]}})
|
|
64
|
+
if project:
|
|
65
|
+
filters.append({"project": {"operator": "=", "values": [str(resolve.project_id(client, project))]}})
|
|
66
|
+
return filters
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command()
|
|
70
|
+
def count(
|
|
71
|
+
ctx: typer.Context,
|
|
72
|
+
today: bool = typer.Option(False, "--today", help="Also count today's notifications."),
|
|
73
|
+
project: str = typer.Option(None, "--project", "-P", help="Scope counts to a project."),
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Count notifications (total + unread, and optionally today's)."""
|
|
76
|
+
obj = ctx_obj(ctx)
|
|
77
|
+
client = obj.client()
|
|
78
|
+
|
|
79
|
+
def total(filters: list[dict]) -> int:
|
|
80
|
+
params = {"pageSize": 1}
|
|
81
|
+
if filters:
|
|
82
|
+
params["filters"] = json.dumps(filters)
|
|
83
|
+
return int(client.get("notifications", params=params).get("total", 0))
|
|
84
|
+
|
|
85
|
+
proj_filter = _filters(client, project=project)
|
|
86
|
+
out = {
|
|
87
|
+
"total": total(proj_filter),
|
|
88
|
+
"unread": total(_filters(client, state="unread", project=project)),
|
|
89
|
+
}
|
|
90
|
+
if today:
|
|
91
|
+
prefix = _dt.date.today().isoformat()
|
|
92
|
+
params = {"sortBy": json.dumps([["id", "desc"]])}
|
|
93
|
+
if proj_filter:
|
|
94
|
+
params["filters"] = json.dumps(proj_filter)
|
|
95
|
+
todays = [n for n in client.collect("notifications", params=params, limit=None)
|
|
96
|
+
if (n.get("createdAt") or "").startswith(prefix)]
|
|
97
|
+
out["today"] = len(todays)
|
|
98
|
+
out["todayUnread"] = sum(1 for n in todays if not n.get("readIAN"))
|
|
99
|
+
obj.emitter.emit(out)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.command()
|
|
103
|
+
def get(ctx: typer.Context, notification_id: int = typer.Argument(..., help="Notification id.")) -> None:
|
|
104
|
+
"""Show one notification."""
|
|
105
|
+
obj = ctx_obj(ctx)
|
|
106
|
+
obj.emitter.emit(serialize.notification(obj.client().get(f"notifications/{notification_id}")))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@app.command()
|
|
110
|
+
def read(ctx: typer.Context, notification_id: int = typer.Argument(..., help="Notification id.")) -> None:
|
|
111
|
+
"""Mark a notification as read."""
|
|
112
|
+
obj = ctx_obj(ctx)
|
|
113
|
+
obj.client().post(f"notifications/{notification_id}/read_ian")
|
|
114
|
+
obj.emitter.emit({"status": "read", "notification": notification_id})
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@app.command()
|
|
118
|
+
def unread(ctx: typer.Context, notification_id: int = typer.Argument(..., help="Notification id.")) -> None:
|
|
119
|
+
"""Mark a notification as unread."""
|
|
120
|
+
obj = ctx_obj(ctx)
|
|
121
|
+
obj.client().post(f"notifications/{notification_id}/unread_ian")
|
|
122
|
+
obj.emitter.emit({"status": "unread", "notification": notification_id})
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.command("read-all")
|
|
126
|
+
def read_all(ctx: typer.Context) -> None:
|
|
127
|
+
"""Mark all notifications as read."""
|
|
128
|
+
obj = ctx_obj(ctx)
|
|
129
|
+
obj.client().post("notifications/read_ian")
|
|
130
|
+
obj.emitter.emit({"status": "all read"})
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Project commands: list, get, create, update, archive, unarchive, delete."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from .. import hal, resolve, serialize
|
|
10
|
+
from ..errors import OpError
|
|
11
|
+
from ._shared import apply_custom_fields, ctx_obj, set_link
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(no_args_is_help=True)
|
|
14
|
+
|
|
15
|
+
_COLUMNS = [
|
|
16
|
+
("ID", "id"),
|
|
17
|
+
("Identifier", "identifier"),
|
|
18
|
+
("Name", "name"),
|
|
19
|
+
("Active", "active"),
|
|
20
|
+
("Public", "public"),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command("list")
|
|
25
|
+
def list_projects(
|
|
26
|
+
ctx: typer.Context,
|
|
27
|
+
active: bool = typer.Option(None, "--active/--archived", help="Filter by active state."),
|
|
28
|
+
filters: str = typer.Option(None, "--filters", help="Raw OpenProject filters JSON (overrides --active)."),
|
|
29
|
+
sort: str = typer.Option("name", "--sort", help="Sort column (e.g. name, id, created_at)."),
|
|
30
|
+
limit: int = typer.Option(None, "--limit", "-n", help="Maximum rows."),
|
|
31
|
+
) -> None:
|
|
32
|
+
"""List projects (active by default include all unless filtered)."""
|
|
33
|
+
obj = ctx_obj(ctx)
|
|
34
|
+
params: dict = {"sortBy": json.dumps([[sort, "asc"]])}
|
|
35
|
+
if filters:
|
|
36
|
+
params["filters"] = filters
|
|
37
|
+
elif active is not None:
|
|
38
|
+
params["filters"] = json.dumps([{"active": {"operator": "=", "values": ["t" if active else "f"]}}])
|
|
39
|
+
rows = [serialize.project(p) for p in obj.client().collect("projects", params=params, limit=limit)]
|
|
40
|
+
obj.emitter.emit(rows, columns=_COLUMNS, empty="(no projects)")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command()
|
|
44
|
+
def get(
|
|
45
|
+
ctx: typer.Context,
|
|
46
|
+
project: str = typer.Argument(..., help="Project id or identifier."),
|
|
47
|
+
raw: bool = typer.Option(False, "--raw", "-r", help="Return the full HAL document."),
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Show a single project."""
|
|
50
|
+
obj = ctx_obj(ctx)
|
|
51
|
+
doc = resolve.project(obj.client(), project)
|
|
52
|
+
obj.emitter.emit(doc if raw else serialize.project(doc))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command()
|
|
56
|
+
def create(
|
|
57
|
+
ctx: typer.Context,
|
|
58
|
+
name: str = typer.Argument(..., help="Project name."),
|
|
59
|
+
identifier: str = typer.Option(None, "--identifier", help="URL identifier (auto from name if omitted)."),
|
|
60
|
+
description: str = typer.Option(None, "--description", "-d", help="Description (markdown)."),
|
|
61
|
+
parent: str = typer.Option(None, "--parent", help="Parent project id/identifier."),
|
|
62
|
+
public: bool = typer.Option(False, "--public", help="Make the project public."),
|
|
63
|
+
custom_fields: str = typer.Option(None, "--custom-fields", help='JSON of customFieldN values.'),
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Create a project."""
|
|
66
|
+
obj = ctx_obj(ctx)
|
|
67
|
+
client = obj.client()
|
|
68
|
+
body: dict = {"name": name, "public": public}
|
|
69
|
+
if identifier:
|
|
70
|
+
body["identifier"] = identifier
|
|
71
|
+
if description is not None:
|
|
72
|
+
body["description"] = {"raw": description}
|
|
73
|
+
if parent:
|
|
74
|
+
set_link(body, "parent", f"/api/v3/projects/{resolve.project_id(client, parent)}")
|
|
75
|
+
apply_custom_fields(body, custom_fields)
|
|
76
|
+
doc = client.post("projects", json=body)
|
|
77
|
+
obj.emitter.emit(serialize.project(doc))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.command()
|
|
81
|
+
def update(
|
|
82
|
+
ctx: typer.Context,
|
|
83
|
+
project: str = typer.Argument(..., help="Project id or identifier."),
|
|
84
|
+
name: str = typer.Option(None, "--name", help="New name."),
|
|
85
|
+
identifier: str = typer.Option(None, "--identifier", help="New identifier."),
|
|
86
|
+
description: str = typer.Option(None, "--description", "-d", help="New description."),
|
|
87
|
+
parent: str = typer.Option(None, "--parent", help="New parent id/identifier ('none' to detach)."),
|
|
88
|
+
public: bool = typer.Option(None, "--public/--private", help="Public flag."),
|
|
89
|
+
custom_fields: str = typer.Option(None, "--custom-fields", help="JSON of customFieldN values."),
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Update project attributes (no lockVersion needed for projects)."""
|
|
92
|
+
obj = ctx_obj(ctx)
|
|
93
|
+
client = obj.client()
|
|
94
|
+
pid = resolve.project_id(client, project)
|
|
95
|
+
body: dict = {}
|
|
96
|
+
if name is not None:
|
|
97
|
+
body["name"] = name
|
|
98
|
+
if identifier is not None:
|
|
99
|
+
body["identifier"] = identifier
|
|
100
|
+
if description is not None:
|
|
101
|
+
body["description"] = {"raw": description}
|
|
102
|
+
if public is not None:
|
|
103
|
+
body["public"] = public
|
|
104
|
+
if parent is not None:
|
|
105
|
+
if parent.lower() == "none":
|
|
106
|
+
set_link(body, "parent", None)
|
|
107
|
+
else:
|
|
108
|
+
set_link(body, "parent", f"/api/v3/projects/{resolve.project_id(client, parent)}")
|
|
109
|
+
apply_custom_fields(body, custom_fields)
|
|
110
|
+
if not body:
|
|
111
|
+
raise OpError("nothing to update — pass at least one field")
|
|
112
|
+
doc = client.patch(f"projects/{pid}", json=body)
|
|
113
|
+
obj.emitter.emit(serialize.project(doc))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _set_active(ctx: typer.Context, project: str, active: bool) -> None:
|
|
117
|
+
obj = ctx_obj(ctx)
|
|
118
|
+
client = obj.client()
|
|
119
|
+
pid = resolve.project_id(client, project)
|
|
120
|
+
doc = client.patch(f"projects/{pid}", json={"active": active})
|
|
121
|
+
obj.emitter.emit(serialize.project(doc))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@app.command()
|
|
125
|
+
def archive(ctx: typer.Context, project: str = typer.Argument(..., help="Project id/identifier.")) -> None:
|
|
126
|
+
"""Archive a project (PATCH active=false)."""
|
|
127
|
+
_set_active(ctx, project, False)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@app.command()
|
|
131
|
+
def unarchive(ctx: typer.Context, project: str = typer.Argument(..., help="Project id/identifier.")) -> None:
|
|
132
|
+
"""Restore an archived project (PATCH active=true)."""
|
|
133
|
+
_set_active(ctx, project, True)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@app.command()
|
|
137
|
+
def delete(
|
|
138
|
+
ctx: typer.Context,
|
|
139
|
+
project: str = typer.Argument(..., help="Project id or identifier."),
|
|
140
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Permanently delete a project (asynchronous on the server)."""
|
|
143
|
+
obj = ctx_obj(ctx)
|
|
144
|
+
client = obj.client()
|
|
145
|
+
pid = resolve.project_id(client, project)
|
|
146
|
+
if not yes:
|
|
147
|
+
typer.confirm(f"Delete project {pid}? This cannot be undone.", abort=True)
|
|
148
|
+
client.delete(f"projects/{pid}")
|
|
149
|
+
obj.emitter.emit({"status": "delete requested", "project": pid})
|
opcli/commands/raw.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Escape hatch: call any OpenProject API v3 endpoint directly.
|
|
2
|
+
|
|
3
|
+
Useful for the agent to reach endpoints the typed commands don't wrap yet, and
|
|
4
|
+
for debugging. Paths may be given as ``work_packages/1`` or
|
|
5
|
+
``/api/v3/work_packages/1``. Bodies and query params are passed as JSON.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from ._shared import ctx_obj, parse_json_option
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(no_args_is_help=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _params(param: list[str]) -> dict:
|
|
20
|
+
out: dict = {}
|
|
21
|
+
for item in param or []:
|
|
22
|
+
if "=" not in item:
|
|
23
|
+
raise typer.BadParameter(f"--param must be key=value, got '{item}'")
|
|
24
|
+
k, v = item.split("=", 1)
|
|
25
|
+
out[k] = v
|
|
26
|
+
return out
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _body(data: str | None, data_file: Path | None):
|
|
30
|
+
if data_file is not None:
|
|
31
|
+
return parse_json_option(data_file.read_text(), what="--data-file")
|
|
32
|
+
return parse_json_option(data, what="--data")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _run(ctx: typer.Context, method: str, path: str, param, data, data_file):
|
|
36
|
+
obj = ctx_obj(ctx)
|
|
37
|
+
result = obj.client().request(method, path, params=_params(param), json=_body(data, data_file))
|
|
38
|
+
obj.emitter.emit(result)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.command()
|
|
42
|
+
def get(
|
|
43
|
+
ctx: typer.Context,
|
|
44
|
+
path: str = typer.Argument(..., help="API path, e.g. work_packages/1 or statuses."),
|
|
45
|
+
param: list[str] = typer.Option(None, "--param", "-p", help="Query param key=value (repeatable)."),
|
|
46
|
+
) -> None:
|
|
47
|
+
"""GET an endpoint."""
|
|
48
|
+
_run(ctx, "GET", path, param, None, None)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command()
|
|
52
|
+
def post(
|
|
53
|
+
ctx: typer.Context,
|
|
54
|
+
path: str = typer.Argument(..., help="API path."),
|
|
55
|
+
data: str = typer.Option(None, "--data", "-d", help="JSON request body."),
|
|
56
|
+
data_file: Path = typer.Option(None, "--data-file", help="File containing the JSON body."),
|
|
57
|
+
param: list[str] = typer.Option(None, "--param", "-p", help="Query param key=value (repeatable)."),
|
|
58
|
+
) -> None:
|
|
59
|
+
"""POST to an endpoint."""
|
|
60
|
+
_run(ctx, "POST", path, param, data, data_file)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command()
|
|
64
|
+
def patch(
|
|
65
|
+
ctx: typer.Context,
|
|
66
|
+
path: str = typer.Argument(..., help="API path."),
|
|
67
|
+
data: str = typer.Option(None, "--data", "-d", help="JSON request body."),
|
|
68
|
+
data_file: Path = typer.Option(None, "--data-file", help="File containing the JSON body."),
|
|
69
|
+
param: list[str] = typer.Option(None, "--param", "-p", help="Query param key=value (repeatable)."),
|
|
70
|
+
) -> None:
|
|
71
|
+
"""PATCH an endpoint. For work packages you MUST include the current lockVersion in the body
|
|
72
|
+
(fetch it with `raw get work_packages/<id>` first) — unlike `wp update`, which handles it."""
|
|
73
|
+
_run(ctx, "PATCH", path, param, data, data_file)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command()
|
|
77
|
+
def delete(
|
|
78
|
+
ctx: typer.Context,
|
|
79
|
+
path: str = typer.Argument(..., help="API path."),
|
|
80
|
+
param: list[str] = typer.Option(None, "--param", "-p", help="Query param key=value (repeatable)."),
|
|
81
|
+
) -> None:
|
|
82
|
+
"""DELETE an endpoint."""
|
|
83
|
+
_run(ctx, "DELETE", path, param, None, None)
|