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,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)