Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -748,9 +748,55 @@ The SDK provides comprehensive Pydantic v2 models for all API operations.

- `BaseQueryParams` - Base query parameters
- `PaginatedQueryParams` - Pagination support (per_page, page)
- `WorkItemQueryParams` - Work item specific queries (expand, order_by, etc.)
- `WorkItemQueryParams` - Work item specific queries (expand, order_by, `filters`, `pql`, etc.)
- `RetrieveQueryParams` - Retrieve operations (expand, fields, etc.)

#### Filtering work items

`WorkItemQueryParams` accepts two filter inputs that map to the same backend filter engine:

- **`filters`** — a structured filter expression (dict). Supports nested
`and` / `or` / `not` groups and field operators (`__in`, `__gte`,
`__range`, `__icontains`, etc.). The SDK JSON-encodes this into the
`filters=` query parameter.
- **`pql`** — a Plane Query Language string. Human-readable alternative
with the same expressive power.

```python
from plane.models.query_params import WorkItemQueryParams

# Project-scoped, structured filters
client.work_items.list(
"my-workspace",
"project-id",
params=WorkItemQueryParams(
filters={"and": [
{"priority": "urgent"},
{"state_group__in": ["unstarted", "started"]},
]},
order_by="-created_at",
per_page=50,
),
)

# Project-scoped, PQL
client.work_items.list(
"my-workspace",
"project-id",
params=WorkItemQueryParams(pql='priority = "urgent" AND assignee = currentUser()'),
)

# Workspace-scoped — spans every project the caller can view, with
# per-project authorization honored server-side
client.work_items.list_workspace(
"my-workspace",
params=WorkItemQueryParams(filters={"priority": "urgent"}),
)
```

The same `filters` and `pql` query parameters also work on `list_archived`,
`cycles.list_work_items`, and `modules.list_work_items`.

### Response Models

Paginated responses follow the pattern `Paginated<Resource>Response` and include:
Expand Down
26 changes: 17 additions & 9 deletions plane/api/cycles.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
TransferCycleWorkItemsRequest,
UpdateCycle,
)
from ..models.query_params import WorkItemQueryParams
from .base_resource import BaseResource
from .work_items.base import _prepare_work_item_params


class Cycles(BaseResource):
Expand Down Expand Up @@ -137,18 +139,28 @@ def list_work_items(
workspace_slug: str,
project_id: str,
cycle_id: str,
params: Mapping[str, Any] | None = None,
params: WorkItemQueryParams | Mapping[str, Any] | None = None,
) -> PaginatedCycleWorkItemResponse:
"""List work items in a cycle.

Supports the same ``filters`` and ``pql`` query parameters as
:meth:`WorkItems.list`.

Args:
workspace_slug: The workspace slug identifier
project_id: UUID of the project
cycle_id: UUID of the cycle
params: Optional query parameters
params: Optional query parameters. Prefer ``WorkItemQueryParams``;
a plain mapping is also accepted for backwards compatibility
and is passed through unchanged.
"""
if isinstance(params, WorkItemQueryParams):
query_params: Mapping[str, Any] | None = _prepare_work_item_params(params)
else:
query_params = params
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Enforce DTO-only params for resource method inputs

Cycles.list_work_items still accepts plain mappings (Line 142, Line 159-Line 160). This conflicts with the resource contract and weakens typed validation for filters/pql input.

Suggested direction
-    def list_work_items(
+    def list_work_items(
         self,
         workspace_slug: str,
         project_id: str,
         cycle_id: str,
-        params: WorkItemQueryParams | Mapping[str, Any] | None = None,
+        params: WorkItemQueryParams | None = None,
     ) -> PaginatedCycleWorkItemResponse:
@@
-        if isinstance(params, WorkItemQueryParams):
-            query_params: Mapping[str, Any] | None = _prepare_work_item_params(params)
-        else:
-            query_params = params
+        query_params: Mapping[str, Any] | None = (
+            _prepare_work_item_params(params) if params is not None else None
+        )

As per coding guidelines, "Resource methods must accept Pydantic DTOs, serialize with model_dump(exclude_none=True), and validate responses with Model.model_validate()."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plane/api/cycles.py` around lines 142 - 160, Update Cycles.list_work_items to
accept only the DTO type by changing the params handling to require
WorkItemQueryParams | None (remove the Mapping branch) and call
_prepare_work_item_params(params.model_dump(exclude_none=True)) when params is
provided; ensure the outgoing request uses that serialized mapping and validate
the response with PaginatedCycleWorkItemResponse.model_validate(...) before
returning. Target the Cycles.list_work_items function and use
WorkItemQueryParams, _prepare_work_item_params, and
PaginatedCycleWorkItemResponse.model_validate to locate and implement the
change.

response = self._get(
f"{workspace_slug}/projects/{project_id}/cycles/{cycle_id}/cycle-issues", params=params
f"{workspace_slug}/projects/{project_id}/cycles/{cycle_id}/cycle-issues",
params=query_params,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Endpoint path naming/convention is out of policy

Line 162 uses cycle-issues, and Lines 162, 195, and 206 omit trailing /. This violates endpoint naming and URL-format rules for API resources.

Suggested patch
-            f"{workspace_slug}/projects/{project_id}/cycles/{cycle_id}/cycle-issues",
+            f"{workspace_slug}/projects/{project_id}/cycles/{cycle_id}/cycle-work-items/",
@@
-        self._post(f"{workspace_slug}/projects/{project_id}/cycles/{cycle_id}/archive", {})
+        self._post(f"{workspace_slug}/projects/{project_id}/cycles/{cycle_id}/archive/", {})
@@
-        self._delete(f"{workspace_slug}/projects/{project_id}/archived-cycles/{cycle_id}/unarchive")
+        self._delete(
+            f"{workspace_slug}/projects/{project_id}/archived-cycles/{cycle_id}/unarchive/"
+        )

As per coding guidelines, "Never use 'Issue' in endpoint or parameter names — always use 'Work Item' instead" and "All API endpoints should end with a trailing / and follow URL convention: {base_path}/api/v1{resource_base_path}/{endpoint}/."

Also applies to: 195-195, 206-206

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plane/api/cycles.py` around lines 162 - 163, Replace the non-compliant
endpoint segments and add trailing slashes: change any occurrences of
"cycle-issues" in the URL construction to "work-items" and ensure the URLs
follow the pattern "{base_path}/api/v1{resource_base_path}/{endpoint}/" with a
trailing "/" (e.g. update the string
f"{workspace_slug}/projects/{project_id}/cycles/{cycle_id}/cycle-issues" to use
"work-items" and append "/" ), and apply the same fix to the other occurrences
referenced (the similar URL constructions at the other reported locations) so
all endpoint strings end with a trailing slash and use "work-items" instead of
"issue"/"cycle-issues".

)
return PaginatedCycleWorkItemResponse.model_validate(response)

Expand Down Expand Up @@ -180,9 +192,7 @@ def archive(self, workspace_slug: str, project_id: str, cycle_id: str) -> bool:
project_id: UUID of the project
cycle_id: UUID of the cycle
"""
self._post(
f"{workspace_slug}/projects/{project_id}/cycles/{cycle_id}/archive", {}
)
self._post(f"{workspace_slug}/projects/{project_id}/cycles/{cycle_id}/archive", {})
return True

def unarchive(self, workspace_slug: str, project_id: str, cycle_id: str) -> bool:
Expand All @@ -193,7 +203,5 @@ def unarchive(self, workspace_slug: str, project_id: str, cycle_id: str) -> bool
project_id: UUID of the project
cycle_id: UUID of the cycle
"""
self._delete(
f"{workspace_slug}/projects/{project_id}/archived-cycles/{cycle_id}/unarchive"
)
self._delete(f"{workspace_slug}/projects/{project_id}/archived-cycles/{cycle_id}/unarchive")
return True
17 changes: 14 additions & 3 deletions plane/api/modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
PaginatedModuleWorkItemResponse,
UpdateModule,
)
from ..models.query_params import WorkItemQueryParams
from .base_resource import BaseResource
from .work_items.base import _prepare_work_item_params


class Modules(BaseResource):
Expand Down Expand Up @@ -136,19 +138,28 @@ def list_work_items(
workspace_slug: str,
project_id: str,
module_id: str,
params: Mapping[str, Any] | None = None,
params: WorkItemQueryParams | Mapping[str, Any] | None = None,
) -> PaginatedModuleWorkItemResponse:
"""List work items in a module.

Supports the same ``filters`` and ``pql`` query parameters as
:meth:`WorkItems.list`.

Args:
workspace_slug: The workspace slug identifier
project_id: UUID of the project
module_id: UUID of the module
params: Optional query parameters
params: Optional query parameters. Prefer ``WorkItemQueryParams``;
a plain mapping is also accepted for backwards compatibility
and is passed through unchanged.
"""
if isinstance(params, WorkItemQueryParams):
query_params: Mapping[str, Any] | None = _prepare_work_item_params(params)
else:
query_params = params
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Keep list_work_items strictly typed to query DTO

Line 141 and Line 159 permit raw mappings, bypassing DTO constraints and creating a second unvalidated input path.

Suggested direction
-    def list_work_items(
+    def list_work_items(
         self,
         workspace_slug: str,
         project_id: str,
         module_id: str,
-        params: WorkItemQueryParams | Mapping[str, Any] | None = None,
+        params: WorkItemQueryParams | None = None,
     ) -> PaginatedModuleWorkItemResponse:
@@
-        if isinstance(params, WorkItemQueryParams):
-            query_params: Mapping[str, Any] | None = _prepare_work_item_params(params)
-        else:
-            query_params = params
+        query_params: Mapping[str, Any] | None = (
+            _prepare_work_item_params(params) if params is not None else None
+        )

As per coding guidelines, "Resource methods must accept Pydantic DTOs, serialize with model_dump(exclude_none=True), and validate responses with Model.model_validate()."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
params: WorkItemQueryParams | Mapping[str, Any] | None = None,
) -> PaginatedModuleWorkItemResponse:
"""List work items in a module.
Supports the same ``filters`` and ``pql`` query parameters as
:meth:`WorkItems.list`.
Args:
workspace_slug: The workspace slug identifier
project_id: UUID of the project
module_id: UUID of the module
params: Optional query parameters
params: Optional query parameters. Prefer ``WorkItemQueryParams``;
a plain mapping is also accepted for backwards compatibility
and is passed through unchanged.
"""
if isinstance(params, WorkItemQueryParams):
query_params: Mapping[str, Any] | None = _prepare_work_item_params(params)
else:
query_params = params
params: WorkItemQueryParams | None = None,
) -> PaginatedModuleWorkItemResponse:
"""List work items in a module.
Supports the same ``filters`` and ``pql`` query parameters as
:meth:`WorkItems.list`.
Args:
workspace_slug: The workspace slug identifier
project_id: UUID of the project
module_id: UUID of the module
params: Optional query parameters. Prefer ``WorkItemQueryParams``;
a plain mapping is also accepted for backwards compatibility
and is passed through unchanged.
"""
query_params: Mapping[str, Any] | None = (
_prepare_work_item_params(params) if params is not None else None
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plane/api/modules.py` around lines 141 - 159, The list_work_items method
currently accepts a raw Mapping which bypasses DTO validation; change the
signature to accept only WorkItemQueryParams (remove Mapping[str, Any] | None),
and when building query_params use params.model_dump(exclude_none=True) (or call
_prepare_work_item_params(params) if that helper returns a dict from the DTO),
removing the raw-mapping branch; also ensure the method validates responses
using PaginatedModuleWorkItemResponse.model_validate(...) before returning.

response = self._get(
f"{workspace_slug}/projects/{project_id}/modules/{module_id}/module-issues",
params=params,
params=query_params,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

module-issues path and missing trailing slash violate API conventions

Line 161 uses module-issues and does not end the endpoint with /.

Suggested patch
-            f"{workspace_slug}/projects/{project_id}/modules/{module_id}/module-issues",
+            f"{workspace_slug}/projects/{project_id}/modules/{module_id}/module-work-items/",

As per coding guidelines, "Never use 'Issue' in endpoint or parameter names — always use 'Work Item' instead" and "All API endpoints should end with a trailing / and follow URL convention: {base_path}/api/v1{resource_base_path}/{endpoint}/."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
f"{workspace_slug}/projects/{project_id}/modules/{module_id}/module-issues",
params=params,
params=query_params,
f"{workspace_slug}/projects/{project_id}/modules/{module_id}/module-work-items/",
params=query_params,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plane/api/modules.py` around lines 161 - 162, The endpoint string using
"module-issues" should be renamed to use "module-work-items" and must include a
trailing slash to follow API conventions; update the f-string at the call that
builds the modules endpoint (the line containing
f"{workspace_slug}/projects/{project_id}/modules/{module_id}/module-issues") to
f"{workspace_slug}/projects/{project_id}/modules/{module_id}/module-work-items/"
ensuring the URL matches the required
{base_path}/api/v1{resource_base_path}/{endpoint}/ pattern and preserves
existing query_params handling.

)
return PaginatedModuleWorkItemResponse.model_validate(response)

Expand Down
89 changes: 75 additions & 14 deletions plane/api/work_items/base.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import json
from typing import Any

from ...models.query_params import RetrieveQueryParams, WorkItemQueryParams
Expand All @@ -23,6 +24,21 @@
from .work_logs import WorkLogs


def _prepare_work_item_params(params: WorkItemQueryParams | None) -> dict[str, Any] | None:
"""Serialize WorkItemQueryParams for use as HTTP query params.

The ``filters`` field is a structured object but the API expects it as a
JSON string in a single ``filters=`` query parameter. Everything else is
passed through as-is by ``requests``' query-string encoder.
"""
if params is None:
return None
payload = params.model_dump(exclude_none=True)
if "filters" in payload and isinstance(payload["filters"], dict):
payload["filters"] = json.dumps(payload["filters"], separators=(",", ":"))
return payload


class WorkItems(BaseResource):
def __init__(self, config: Any) -> None:
super().__init__(config, "/workspaces/")
Expand Down Expand Up @@ -157,23 +173,67 @@ def list(
project_id: UUID of the project
params: Optional query parameters for filtering, ordering, and pagination

Example:
from plane.models.schemas import WorkItemQueryParams
Example::

# List work items with filters
from plane.models.query_params import WorkItemQueryParams

# PQL filter (human-readable)
work_items = client.work_items.list(
"my-workspace",
"project-id",
params=WorkItemQueryParams(pql='priority = "urgent"'),
)

# Structured `filters` (JSON-encoded into the query string)
work_items = client.work_items.list(
"my-workspace",
"project-id",
params=WorkItemQueryParams(
priority="high",
state="state-id",
expand="assignees,labels"
)
filters={"and": [
{"priority": "urgent"},
{"state_group__in": ["unstarted", "started"]},
]},
),
)
"""
response = self._get(
f"{workspace_slug}/projects/{project_id}/work-items",
params=_prepare_work_item_params(params),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add trailing slashes to touched endpoint paths.

These changed URLs still omit the terminal /, which breaks the API path convention and can cause routing/redirect inconsistencies.

Suggested patch
-            f"{workspace_slug}/projects/{project_id}/work-items",
+            f"{workspace_slug}/projects/{project_id}/work-items/",
             params=_prepare_work_item_params(params),
@@
-            f"{workspace_slug}/work-items",
+            f"{workspace_slug}/work-items/",
             params=_prepare_work_item_params(params),
@@
-            f"{workspace_slug}/projects/{project_id}/archived-work-items",
+            f"{workspace_slug}/projects/{project_id}/archived-work-items/",
             params=_prepare_work_item_params(params),
@@
-        self._delete(f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/unarchive")
+        self._delete(f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/unarchive/")

As per coding guidelines, "All API endpoints should end with a trailing / and follow URL convention: {base_path}/api/v1{resource_base_path}/{endpoint}/".

Also applies to: 234-236, 319-320, 352-352

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plane/api/work_items/base.py` around lines 199 - 201, Several endpoint
f-strings used in calls to self._get/_post are missing the terminal slash (e.g.
f"{workspace_slug}/projects/{project_id}/work-items" in the response =
self._get(...) call); update these formatted paths to include a trailing '/' so
they follow the API convention. Locate calls referencing workspace_slug,
project_id, work_item_id (and uses of _prepare_work_item_params) and append '/'
to each endpoint string passed into self._get and similar HTTP helpers (also
adjust the other occurrences in the same module referenced near the other
f-strings) to ensure every "{...}/..." path ends with '/'.

)
return PaginatedWorkItemResponse.model_validate(response)

def list_workspace(
self,
workspace_slug: str,
params: WorkItemQueryParams | None = None,
) -> PaginatedWorkItemResponse:
"""List work items across an entire workspace.

Returns a paginated envelope of work items the caller can view,
spanning every project in the workspace (per-project authorization
and conditional grants are honored server-side).

Args:
workspace_slug: The workspace slug identifier
params: Optional query parameters — supports ``filters``, ``pql``,
``order_by``, ``cursor``, ``per_page``, ``fields``, ``expand``.

Example::

from plane.models.query_params import WorkItemQueryParams

results = client.work_items.list_workspace(
"my-workspace",
params=WorkItemQueryParams(
filters={"priority": "urgent"},
order_by="-created_at",
per_page=50,
),
)
"""
query_params = params.model_dump(exclude_none=True) if params else None
response = self._get(
f"{workspace_slug}/projects/{project_id}/work-items", params=query_params
f"{workspace_slug}/work-items",
params=_prepare_work_item_params(params),
)
return PaginatedWorkItemResponse.model_validate(response)

Expand Down Expand Up @@ -247,14 +307,17 @@ def list_archived(
) -> PaginatedWorkItemResponse:
"""List archived work items in a project.

Supports the same ``filters`` and ``pql`` query parameters as
:meth:`list`.

Args:
workspace_slug: The workspace slug identifier
project_id: UUID of the project
params: Optional query parameters for filtering, ordering, and pagination
"""
query_params = params.model_dump(exclude_none=True) if params else None
response = self._get(
f"{workspace_slug}/projects/{project_id}/archived-work-items", params=query_params
f"{workspace_slug}/projects/{project_id}/archived-work-items",
params=_prepare_work_item_params(params),
)
return PaginatedWorkItemResponse.model_validate(response)

Expand Down Expand Up @@ -286,6 +349,4 @@ def unarchive(self, workspace_slug: str, project_id: str, work_item_id: str) ->
Returns:
None (HTTP 204 No Content)
"""
self._delete(
f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/unarchive"
)
self._delete(f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/unarchive")
22 changes: 20 additions & 2 deletions plane/models/query_params.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Query parameter DTOs for list/retrieve endpoints."""

from typing import Any

from pydantic import BaseModel, ConfigDict, Field


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

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

pql: str | None = Field(None, description="PQL filters")
pql: str | None = Field(
None,
description=(
"Plane Query Language expression. Human-readable alternative to "
'`filters`. Example: `priority = "urgent" AND assignee = currentUser()`.'
),
)
filters: dict[str, Any] | None = Field(
None,
description=(
"Structured filter expression. Supports nested `and`/`or`/`not` groups "
"and field comparisons with operators like `__in`, `__gte`, `__range`, "
"`__isnull`, `__icontains`, etc. JSON-encoded into the `filters=` "
"query param by the client."
),
)


class RetrieveQueryParams(BaseQueryParams):
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "plane-sdk"
version = "0.2.12"
version = "0.2.13"
description = "Python SDK for Plane API"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
Loading