Skip to content

Commit 8e8240d

Browse files
dheeru0198claudeakhil-vamshi-konam
authored
feat: rich filters + pql on work-item list endpoints, add list_workspace [WEB-7393][PAI-1461] (#37)
* feat: rich `filters` + `pql` on work-item list endpoints, add `list_workspace` Expose the new external API's structured filtering capability on every work-item list method in the SDK. - WorkItemQueryParams now accepts `filters: dict[str, Any] | None` (the existing `pql: str | None` field is unchanged). A small `_prepare_work_item_params` helper JSON-encodes `filters` into the `filters=` query string the API expects. - New `WorkItems.list_workspace(workspace_slug, params)` calling `GET /workspaces/<slug>/work-items/` — paginated, total_results, spans every project the caller can view. - `WorkItems.list` and `WorkItems.list_archived` now route through `_prepare_work_item_params`, so `filters` and `pql` work uniformly. - `Cycles.list_work_items` and `Modules.list_work_items` accept `WorkItemQueryParams` (typed path) with the same serialization, and still accept a plain `Mapping[str, Any]` for backwards compatibility. - Real-API unit tests for `filters` on `list`, plus `list_workspace` with and without `filters`. - Bump version to 0.2.13. Backend: makeplane/plane-ee#7376 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: tighten `test_list_workspace_work_items_with_filters` assertion Drop the `or filtered.total_results > 0` fallback (which could pass even when filtering was a no-op) and assert directly that every returned item has `priority == "urgent"` — that's the property the test is meant to prove. Addresses CodeRabbit review. * fix: JSON-encode `filters` for the Mapping input path too Copilot caught a real bug: `Cycles.list_work_items` / `Modules.list_work_items` accept a plain `Mapping[str, Any]` for BC, but when callers passed `filters` as a dict through that path the SDK forwarded it to `requests` unchanged. The API expects `filters` as a JSON-encoded string, so the request would produce an invalid query parameter. - Extend `prepare_work_item_params` (renamed from `_prepare_work_item_params` since it's now used across resources, no longer private to the work_items module) to accept either a `WorkItemQueryParams` DTO or a plain mapping, and normalise `filters` to a JSON string in both paths. - Simplify `Cycles.list_work_items` and `Modules.list_work_items` to use the unified helper — drops the `isinstance` branch. - Fix README: `PaginatedQueryParams` is cursor-based (cursor, per_page), not (per_page, page) — that combination has never existed in the model. Addresses Copilot review on PR #37. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: akhil-vamshi-konam <konamakhilvamshi@gmail.com>
1 parent 553e9d4 commit 8e8240d

7 files changed

Lines changed: 237 additions & 43 deletions

File tree

README.md

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -747,10 +747,56 @@ The SDK provides comprehensive Pydantic v2 models for all API operations.
747747
### Query Parameters
748748

749749
- `BaseQueryParams` - Base query parameters
750-
- `PaginatedQueryParams` - Pagination support (per_page, page)
751-
- `WorkItemQueryParams` - Work item specific queries (expand, order_by, etc.)
750+
- `PaginatedQueryParams` - Cursor-based pagination support (cursor, per_page)
751+
- `WorkItemQueryParams` - Work item specific queries (expand, order_by, `filters`, `pql`, etc.)
752752
- `RetrieveQueryParams` - Retrieve operations (expand, fields, etc.)
753753

754+
#### Filtering work items
755+
756+
`WorkItemQueryParams` accepts two filter inputs that map to the same backend filter engine:
757+
758+
- **`filters`** — a structured filter expression (dict). Supports nested
759+
`and` / `or` / `not` groups and field operators (`__in`, `__gte`,
760+
`__range`, `__icontains`, etc.). The SDK JSON-encodes this into the
761+
`filters=` query parameter.
762+
- **`pql`** — a Plane Query Language string. Human-readable alternative
763+
with the same expressive power.
764+
765+
```python
766+
from plane.models.query_params import WorkItemQueryParams
767+
768+
# Project-scoped, structured filters
769+
client.work_items.list(
770+
"my-workspace",
771+
"project-id",
772+
params=WorkItemQueryParams(
773+
filters={"and": [
774+
{"priority": "urgent"},
775+
{"state_group__in": ["unstarted", "started"]},
776+
]},
777+
order_by="-created_at",
778+
per_page=50,
779+
),
780+
)
781+
782+
# Project-scoped, PQL
783+
client.work_items.list(
784+
"my-workspace",
785+
"project-id",
786+
params=WorkItemQueryParams(pql='priority = "urgent" AND assignee = currentUser()'),
787+
)
788+
789+
# Workspace-scoped — spans every project the caller can view, with
790+
# per-project authorization honored server-side
791+
client.work_items.list_workspace(
792+
"my-workspace",
793+
params=WorkItemQueryParams(filters={"priority": "urgent"}),
794+
)
795+
```
796+
797+
The same `filters` and `pql` query parameters also work on `list_archived`,
798+
`cycles.list_work_items`, and `modules.list_work_items`.
799+
754800
### Response Models
755801

756802
Paginated responses follow the pattern `Paginated<Resource>Response` and include:

plane/api/cycles.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
TransferCycleWorkItemsRequest,
1111
UpdateCycle,
1212
)
13+
from ..models.query_params import WorkItemQueryParams
1314
from .base_resource import BaseResource
15+
from .work_items.base import prepare_work_item_params
1416

1517

1618
class Cycles(BaseResource):
@@ -137,18 +139,25 @@ def list_work_items(
137139
workspace_slug: str,
138140
project_id: str,
139141
cycle_id: str,
140-
params: Mapping[str, Any] | None = None,
142+
params: WorkItemQueryParams | Mapping[str, Any] | None = None,
141143
) -> PaginatedCycleWorkItemResponse:
142144
"""List work items in a cycle.
143145
146+
Supports the same ``filters`` and ``pql`` query parameters as
147+
:meth:`WorkItems.list`. ``filters`` is JSON-encoded into the query
148+
string for both the DTO and the mapping path, so callers can pass
149+
a dict either way.
150+
144151
Args:
145152
workspace_slug: The workspace slug identifier
146153
project_id: UUID of the project
147154
cycle_id: UUID of the cycle
148-
params: Optional query parameters
155+
params: Optional query parameters. Prefer ``WorkItemQueryParams``;
156+
a plain mapping is also accepted for backwards compatibility.
149157
"""
150158
response = self._get(
151-
f"{workspace_slug}/projects/{project_id}/cycles/{cycle_id}/cycle-issues", params=params
159+
f"{workspace_slug}/projects/{project_id}/cycles/{cycle_id}/cycle-issues",
160+
params=prepare_work_item_params(params),
152161
)
153162
return PaginatedCycleWorkItemResponse.model_validate(response)
154163

@@ -180,9 +189,7 @@ def archive(self, workspace_slug: str, project_id: str, cycle_id: str) -> bool:
180189
project_id: UUID of the project
181190
cycle_id: UUID of the cycle
182191
"""
183-
self._post(
184-
f"{workspace_slug}/projects/{project_id}/cycles/{cycle_id}/archive", {}
185-
)
192+
self._post(f"{workspace_slug}/projects/{project_id}/cycles/{cycle_id}/archive", {})
186193
return True
187194

188195
def unarchive(self, workspace_slug: str, project_id: str, cycle_id: str) -> bool:
@@ -193,7 +200,5 @@ def unarchive(self, workspace_slug: str, project_id: str, cycle_id: str) -> bool
193200
project_id: UUID of the project
194201
cycle_id: UUID of the cycle
195202
"""
196-
self._delete(
197-
f"{workspace_slug}/projects/{project_id}/archived-cycles/{cycle_id}/unarchive"
198-
)
203+
self._delete(f"{workspace_slug}/projects/{project_id}/archived-cycles/{cycle_id}/unarchive")
199204
return True

plane/api/modules.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
PaginatedModuleWorkItemResponse,
1010
UpdateModule,
1111
)
12+
from ..models.query_params import WorkItemQueryParams
1213
from .base_resource import BaseResource
14+
from .work_items.base import prepare_work_item_params
1315

1416

1517
class Modules(BaseResource):
@@ -136,19 +138,25 @@ def list_work_items(
136138
workspace_slug: str,
137139
project_id: str,
138140
module_id: str,
139-
params: Mapping[str, Any] | None = None,
141+
params: WorkItemQueryParams | Mapping[str, Any] | None = None,
140142
) -> PaginatedModuleWorkItemResponse:
141143
"""List work items in a module.
142144
145+
Supports the same ``filters`` and ``pql`` query parameters as
146+
:meth:`WorkItems.list`. ``filters`` is JSON-encoded into the query
147+
string for both the DTO and the mapping path, so callers can pass
148+
a dict either way.
149+
143150
Args:
144151
workspace_slug: The workspace slug identifier
145152
project_id: UUID of the project
146153
module_id: UUID of the module
147-
params: Optional query parameters
154+
params: Optional query parameters. Prefer ``WorkItemQueryParams``;
155+
a plain mapping is also accepted for backwards compatibility.
148156
"""
149157
response = self._get(
150158
f"{workspace_slug}/projects/{project_id}/modules/{module_id}/module-issues",
151-
params=params,
159+
params=prepare_work_item_params(params),
152160
)
153161
return PaginatedModuleWorkItemResponse.model_validate(response)
154162

plane/api/work_items/base.py

Lines changed: 83 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import json
4+
from collections.abc import Mapping
35
from typing import Any
46

57
from ...models.query_params import RetrieveQueryParams, WorkItemQueryParams
@@ -23,6 +25,28 @@
2325
from .work_logs import WorkLogs
2426

2527

28+
def prepare_work_item_params(
29+
params: WorkItemQueryParams | Mapping[str, Any] | None,
30+
) -> dict[str, Any] | None:
31+
"""Serialize work-item query params for use as HTTP query params.
32+
33+
Accepts either a :class:`WorkItemQueryParams` DTO or a plain mapping,
34+
and normalises the ``filters`` field: the API expects it as a JSON
35+
string in a single ``filters=`` query parameter, but callers are free
36+
to pass it as a dict for ergonomics. Everything else is passed through
37+
as-is by ``requests``' query-string encoder.
38+
"""
39+
if params is None:
40+
return None
41+
if isinstance(params, WorkItemQueryParams):
42+
payload: dict[str, Any] = params.model_dump(exclude_none=True)
43+
else:
44+
payload = {k: v for k, v in params.items() if v is not None}
45+
if "filters" in payload and isinstance(payload["filters"], dict):
46+
payload["filters"] = json.dumps(payload["filters"], separators=(",", ":"))
47+
return payload
48+
49+
2650
class WorkItems(BaseResource):
2751
def __init__(self, config: Any) -> None:
2852
super().__init__(config, "/workspaces/")
@@ -157,23 +181,67 @@ def list(
157181
project_id: UUID of the project
158182
params: Optional query parameters for filtering, ordering, and pagination
159183
160-
Example:
161-
from plane.models.schemas import WorkItemQueryParams
184+
Example::
185+
186+
from plane.models.query_params import WorkItemQueryParams
187+
188+
# PQL filter (human-readable)
189+
work_items = client.work_items.list(
190+
"my-workspace",
191+
"project-id",
192+
params=WorkItemQueryParams(pql='priority = "urgent"'),
193+
)
162194
163-
# List work items with filters
195+
# Structured `filters` (JSON-encoded into the query string)
164196
work_items = client.work_items.list(
165197
"my-workspace",
166198
"project-id",
167199
params=WorkItemQueryParams(
168-
priority="high",
169-
state="state-id",
170-
expand="assignees,labels"
171-
)
200+
filters={"and": [
201+
{"priority": "urgent"},
202+
{"state_group__in": ["unstarted", "started"]},
203+
]},
204+
),
205+
)
206+
"""
207+
response = self._get(
208+
f"{workspace_slug}/projects/{project_id}/work-items",
209+
params=prepare_work_item_params(params),
210+
)
211+
return PaginatedWorkItemResponse.model_validate(response)
212+
213+
def list_workspace(
214+
self,
215+
workspace_slug: str,
216+
params: WorkItemQueryParams | None = None,
217+
) -> PaginatedWorkItemResponse:
218+
"""List work items across an entire workspace.
219+
220+
Returns a paginated envelope of work items the caller can view,
221+
spanning every project in the workspace (per-project authorization
222+
and conditional grants are honored server-side).
223+
224+
Args:
225+
workspace_slug: The workspace slug identifier
226+
params: Optional query parameters — supports ``filters``, ``pql``,
227+
``order_by``, ``cursor``, ``per_page``, ``fields``, ``expand``.
228+
229+
Example::
230+
231+
from plane.models.query_params import WorkItemQueryParams
232+
233+
results = client.work_items.list_workspace(
234+
"my-workspace",
235+
params=WorkItemQueryParams(
236+
filters={"priority": "urgent"},
237+
order_by="-created_at",
238+
per_page=50,
239+
),
172240
)
173241
"""
174-
query_params = params.model_dump(exclude_none=True) if params else None
175242
response = self._get(
176-
f"{workspace_slug}/projects/{project_id}/work-items", params=query_params
243+
f"{workspace_slug}/work-items",
244+
params=prepare_work_item_params(params),
177245
)
178246
return PaginatedWorkItemResponse.model_validate(response)
179247

@@ -264,14 +332,17 @@ def list_archived(
264332
) -> PaginatedWorkItemResponse:
265333
"""List archived work items in a project.
266334
335+
Supports the same ``filters`` and ``pql`` query parameters as
336+
:meth:`list`.
337+
267338
Args:
268339
workspace_slug: The workspace slug identifier
269340
project_id: UUID of the project
270341
params: Optional query parameters for filtering, ordering, and pagination
271342
"""
272-
query_params = params.model_dump(exclude_none=True) if params else None
273343
response = self._get(
274-
f"{workspace_slug}/projects/{project_id}/archived-work-items", params=query_params
344+
f"{workspace_slug}/projects/{project_id}/archived-work-items",
345+
params=prepare_work_item_params(params),
275346
)
276347
return PaginatedWorkItemResponse.model_validate(response)
277348

@@ -303,6 +374,4 @@ def unarchive(self, workspace_slug: str, project_id: str, work_item_id: str) ->
303374
Returns:
304375
None (HTTP 204 No Content)
305376
"""
306-
self._delete(
307-
f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/unarchive"
308-
)
377+
self._delete(f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/unarchive")

plane/models/query_params.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Query parameter DTOs for list/retrieve endpoints."""
22

3+
from typing import Any
4+
35
from pydantic import BaseModel, ConfigDict, Field
46

57

@@ -58,12 +60,28 @@ class WorkItemQueryParams(PaginatedQueryParams):
5860
- fields: Comma-separated fields to include
5961
- order_by: Field to order by (prefix with '-' for descending)
6062
- per_page: Number of results per page (1-100)
61-
- pql: PQL filters
63+
- pql: Plane Query Language expression for structured filtering
64+
- filters: JSON-serializable filter expression for structured filtering
6265
"""
6366

6467
model_config = ConfigDict(extra="ignore", populate_by_name=True)
6568

66-
pql: str | None = Field(None, description="PQL filters")
69+
pql: str | None = Field(
70+
None,
71+
description=(
72+
"Plane Query Language expression. Human-readable alternative to "
73+
'`filters`. Example: `priority = "urgent" AND assignee = currentUser()`.'
74+
),
75+
)
76+
filters: dict[str, Any] | None = Field(
77+
None,
78+
description=(
79+
"Structured filter expression. Supports nested `and`/`or`/`not` groups "
80+
"and field comparisons with operators like `__in`, `__gte`, `__range`, "
81+
"`__isnull`, `__icontains`, etc. JSON-encoded into the `filters=` "
82+
"query param by the client."
83+
),
84+
)
6785

6886

6987
class RetrieveQueryParams(BaseQueryParams):

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "plane-sdk"
7-
version = "0.2.12"
7+
version = "0.2.13"
88
description = "Python SDK for Plane API"
99
readme = "README.md"
1010
requires-python = ">=3.10"

0 commit comments

Comments
 (0)