diff --git a/README.md b/README.md index d5aacf5..ecadafb 100644 --- a/README.md +++ b/README.md @@ -747,10 +747,56 @@ The SDK provides comprehensive Pydantic v2 models for all API operations. ### Query Parameters - `BaseQueryParams` - Base query parameters -- `PaginatedQueryParams` - Pagination support (per_page, page) -- `WorkItemQueryParams` - Work item specific queries (expand, order_by, etc.) +- `PaginatedQueryParams` - Cursor-based pagination support (cursor, per_page) +- `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 `PaginatedResponse` and include: diff --git a/plane/api/cycles.py b/plane/api/cycles.py index 44edda0..4ffb998 100644 --- a/plane/api/cycles.py +++ b/plane/api/cycles.py @@ -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): @@ -137,18 +139,25 @@ 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`. ``filters`` is JSON-encoded into the query + string for both the DTO and the mapping path, so callers can pass + a dict either way. + 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. """ 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=prepare_work_item_params(params), ) return PaginatedCycleWorkItemResponse.model_validate(response) @@ -180,9 +189,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: @@ -193,7 +200,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 diff --git a/plane/api/modules.py b/plane/api/modules.py index ba8b87c..19751a8 100644 --- a/plane/api/modules.py +++ b/plane/api/modules.py @@ -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): @@ -136,19 +138,25 @@ 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`. ``filters`` is JSON-encoded into the query + string for both the DTO and the mapping path, so callers can pass + a dict either way. + 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. """ response = self._get( f"{workspace_slug}/projects/{project_id}/modules/{module_id}/module-issues", - params=params, + params=prepare_work_item_params(params), ) return PaginatedModuleWorkItemResponse.model_validate(response) diff --git a/plane/api/work_items/base.py b/plane/api/work_items/base.py index 1a9d82b..6b7697e 100644 --- a/plane/api/work_items/base.py +++ b/plane/api/work_items/base.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json +from collections.abc import Mapping from typing import Any from ...models.query_params import RetrieveQueryParams, WorkItemQueryParams @@ -23,6 +25,28 @@ from .work_logs import WorkLogs +def prepare_work_item_params( + params: WorkItemQueryParams | Mapping[str, Any] | None, +) -> dict[str, Any] | None: + """Serialize work-item query params for use as HTTP query params. + + Accepts either a :class:`WorkItemQueryParams` DTO or a plain mapping, + and normalises the ``filters`` field: the API expects it as a JSON + string in a single ``filters=`` query parameter, but callers are free + to pass it as a dict for ergonomics. Everything else is passed through + as-is by ``requests``' query-string encoder. + """ + if params is None: + return None + if isinstance(params, WorkItemQueryParams): + payload: dict[str, Any] = params.model_dump(exclude_none=True) + else: + payload = {k: v for k, v in params.items() if v is not None} + 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/") @@ -157,23 +181,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:: + + 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"'), + ) - # List work items with filters + # 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"]}, + ]}, + ), ) """ - 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}/projects/{project_id}/work-items", + params=prepare_work_item_params(params), + ) + 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, + ), + ) + """ + response = self._get( + f"{workspace_slug}/work-items", + params=prepare_work_item_params(params), ) return PaginatedWorkItemResponse.model_validate(response) @@ -247,14 +315,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) @@ -286,6 +357,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") diff --git a/plane/models/query_params.py b/plane/models/query_params.py index 56040a5..49fa24b 100644 --- a/plane/models/query_params.py +++ b/plane/models/query_params.py @@ -1,5 +1,7 @@ """Query parameter DTOs for list/retrieve endpoints.""" +from typing import Any + from pydantic import BaseModel, ConfigDict, Field @@ -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): diff --git a/pyproject.toml b/pyproject.toml index 6e7fcae..d3f1c71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/unit/test_work_items.py b/tests/unit/test_work_items.py index 6759029..53a2328 100644 --- a/tests/unit/test_work_items.py +++ b/tests/unit/test_work_items.py @@ -78,6 +78,75 @@ def test_list_work_items_with_pql_filter( if created_item_2 is not None: client.work_items.delete(workspace_slug, project.id, created_item_2.id) + def test_list_work_items_with_filters( + self, client: PlaneClient, workspace_slug: str, project: Project + ) -> None: + """Test listing work items with a structured `filters` query parameter.""" + created_high = None + created_low = None + + try: + created_high = client.work_items.create( + workspace_slug, + project.id, + CreateWorkItem(name="filters-urgent-item", priority="urgent"), + ) + created_low = client.work_items.create( + workspace_slug, + project.id, + CreateWorkItem(name="filters-low-item", priority="low"), + ) + + params = WorkItemQueryParams(filters={"priority": "urgent"}) + response = client.work_items.list(workspace_slug, project.id, params=params) + assert response is not None + assert isinstance(response.results, list) + result_ids = [item.id for item in response.results] + assert created_high.id in result_ids + assert created_low.id not in result_ids + finally: + if created_high is not None: + client.work_items.delete(workspace_slug, project.id, created_high.id) + if created_low is not None: + client.work_items.delete(workspace_slug, project.id, created_low.id) + + def test_list_workspace_work_items( + self, client: PlaneClient, workspace_slug: str, project: Project + ) -> None: + """`list_workspace` returns a paginated envelope spanning the workspace.""" + response = client.work_items.list_workspace(workspace_slug) + assert response is not None + assert hasattr(response, "results") + assert hasattr(response, "total_results") + assert isinstance(response.results, list) + + def test_list_workspace_work_items_with_filters( + self, client: PlaneClient, workspace_slug: str, project: Project + ) -> None: + """`list_workspace` honors `filters` and reduces the result set.""" + created = None + try: + created = client.work_items.create( + workspace_slug, + project.id, + CreateWorkItem(name="workspace-list-urgent-item", priority="urgent"), + ) + unfiltered = client.work_items.list_workspace(workspace_slug) + filtered = client.work_items.list_workspace( + workspace_slug, + params=WorkItemQueryParams(filters={"priority": "urgent"}), + ) + assert filtered is not None + assert filtered.total_results <= unfiltered.total_results + # Every returned item must satisfy the filter — this is the + # assertion that actually proves filtering worked. + assert len(filtered.results) > 0 + for item in filtered.results: + assert item.priority == "urgent" + finally: + if created is not None: + client.work_items.delete(workspace_slug, project.id, created.id) + def test_search_work_items(self, client: PlaneClient, workspace_slug: str) -> None: """Test searching work items.""" response = client.work_items.search(workspace_slug, "test")