From c770094cd82a849e024e44995dcdf43a278b9842 Mon Sep 17 00:00:00 2001 From: sangeethailango Date: Fri, 5 Jun 2026 17:08:27 +0530 Subject: [PATCH 1/5] feat: added work items count endpoint --- plane/api/work_items/base.py | 83 ++++++++++++++++++++++++++++++++---- plane/models/query_params.py | 59 ++++++++++++++++++++++++- plane/models/work_items.py | 38 +++++++++++++++++ 3 files changed, 170 insertions(+), 10 deletions(-) diff --git a/plane/api/work_items/base.py b/plane/api/work_items/base.py index 5120f55..67ab391 100644 --- a/plane/api/work_items/base.py +++ b/plane/api/work_items/base.py @@ -4,7 +4,11 @@ from collections.abc import Mapping from typing import Any -from ...models.query_params import RetrieveQueryParams, WorkItemQueryParams +from ...models.query_params import ( + RetrieveQueryParams, + WorkItemCountQueryParams, + WorkItemQueryParams, +) from ...models.work_items import ( AdvancedSearchResult, AdvancedSearchWorkItem, @@ -12,7 +16,10 @@ PaginatedWorkItemResponse, UpdateWorkItem, WorkItem, + WorkItemCountResponse, WorkItemDetail, + WorkItemFlatCountResponse, + WorkItemGroupedCountResponse, WorkItemSearch, ) from ..base_resource import BaseResource @@ -47,6 +54,21 @@ def prepare_work_item_params( return payload +def prepare_work_item_count_params( + params: WorkItemCountQueryParams | None, +) -> dict[str, Any] | None: + """Serialize work-item count query params for use as HTTP query params. + + Same ``filters`` JSON-encoding logic as :func:`prepare_work_item_params`. + """ + if params is None: + return None + payload: dict[str, Any] = 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/") @@ -245,22 +267,65 @@ def list_workspace( ) return PaginatedWorkItemResponse.model_validate(response) - def list_workspace( + def count_workspace( self, workspace_slug: str, - params: WorkItemQueryParams | None = None, - ) -> PaginatedWorkItemResponse: - """List work items across the entire workspace. + params: WorkItemCountQueryParams | None = None, + ) -> WorkItemCountResponse: + """Return the count of work items across an entire workspace. + + Without ``group_by`` returns a flat :class:`WorkItemFlatCountResponse` + ``{"count": N}``. + + With ``group_by`` returns a :class:`WorkItemGroupedCountResponse` + ``{"grouped_by": ..., "total_count": N, "results": {group_key: {"count": N}}}``. + Keys in ``results`` are raw ORM field values (UUID strings for FK/M2M + dimensions, plain strings for ``priority`` / ``state__group``, ISO-date + strings for ``target_date`` / ``start_date``). The special key + ``"None"`` represents work items with no value in that dimension. Args: workspace_slug: The workspace slug identifier - params: Optional query parameters for filtering, ordering, and pagination + params: Optional query parameters — supports ``filters``, ``pql``, + and ``group_by``. + + Example:: + + from plane.models.query_params import WorkItemCountQueryParams + + # Flat count + result = client.work_items.count_workspace( + "my-workspace", + params=WorkItemCountQueryParams( + filters={"priority__in": ["urgent", "high"]}, + ), + ) + print(result.count) # e.g. 12 + + # Grouped by priority + result = client.work_items.count_workspace( + "my-workspace", + params=WorkItemCountQueryParams(group_by="priority"), + ) + for group, entry in result.results.items(): + print(f"{group}: {entry.count}") + + # Grouped by state, filtered by PQL + result = client.work_items.count_workspace( + "my-workspace", + params=WorkItemCountQueryParams( + pql='assignee = currentUser()', + group_by="state_id", + ), + ) """ - query_params = params.model_dump(exclude_none=True) if params else None response = self._get( - f"{workspace_slug}/work-items", params=query_params + f"{workspace_slug}/work-items/count", + params=prepare_work_item_count_params(params), ) - return PaginatedWorkItemResponse.model_validate(response) + if "grouped_by" in response: + return WorkItemGroupedCountResponse.model_validate(response) + return WorkItemFlatCountResponse.model_validate(response) def search( self, diff --git a/plane/models/query_params.py b/plane/models/query_params.py index 49fa24b..9705863 100644 --- a/plane/models/query_params.py +++ b/plane/models/query_params.py @@ -1,6 +1,6 @@ """Query parameter DTOs for list/retrieve endpoints.""" -from typing import Any +from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field @@ -90,9 +90,66 @@ class RetrieveQueryParams(BaseQueryParams): model_config = ConfigDict(extra="ignore", populate_by_name=True) +WorkItemCountGroupBy = Literal[ + "state_id", + "state__group", + "priority", + "project_id", + "type_id", + "labels__id", + "assignees__id", + "issue_module__module_id", + "release_work_items__release_id", + "cycle_id", + "milestone_id", + "created_by", + "target_date", + "start_date", +] + + +class WorkItemCountQueryParams(BaseModel): + """Query parameters for the workspace work item count endpoint. + + Accepts the same ``filters`` and ``pql`` as :class:`WorkItemQueryParams` + plus an optional ``group_by`` field. + + Without ``group_by`` the response is ``{"count": N}``. + With ``group_by`` the response is + ``{"grouped_by": ..., "total_count": N, "results": {group_key: {"count": N}}}``. + """ + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + 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. JSON-encoded into the `filters=` " + "query param by the client." + ), + ) + group_by: WorkItemCountGroupBy | None = Field( + None, + description=( + "ORM field to group counts by. When supplied the response shape " + "changes from a flat ``{count}`` to a grouped " + "``{grouped_by, total_count, results}`` envelope." + ), + ) + + __all__ = [ "BaseQueryParams", "PaginatedQueryParams", "RetrieveQueryParams", + "WorkItemCountGroupBy", + "WorkItemCountQueryParams", "WorkItemQueryParams", ] diff --git a/plane/models/work_items.py b/plane/models/work_items.py index 09c91a0..15fda04 100644 --- a/plane/models/work_items.py +++ b/plane/models/work_items.py @@ -594,3 +594,41 @@ class PaginatedWorkItemLinkResponse(PaginatedResponse): model_config = ConfigDict(extra="allow", populate_by_name=True) results: list[WorkItemLink] + + +class WorkItemGroupCountEntry(BaseModel): + """Count for a single group in a grouped count response.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + count: int + + +class WorkItemFlatCountResponse(BaseModel): + """Response from the workspace work item count endpoint when ``group_by`` + is not supplied.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + count: int + + +class WorkItemGroupedCountResponse(BaseModel): + """Response from the workspace work item count endpoint when ``group_by`` + is supplied. + + ``results`` keys are raw ORM field values: UUID strings for FK/M2M + dimensions, plain strings for ``priority`` / ``state__group``, and + ISO-date strings for ``target_date`` / ``start_date``. The special + key ``"None"`` is used for work items with no value in that dimension + (unassigned, unlabelled, no release, etc.). + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + grouped_by: str + total_count: int + results: dict[str, WorkItemGroupCountEntry] + + +WorkItemCountResponse = WorkItemFlatCountResponse | WorkItemGroupedCountResponse From 0f432b0e9ab928b578c6a4221eb55fe639239c0b Mon Sep 17 00:00:00 2001 From: sangeethailango Date: Fri, 5 Jun 2026 18:26:29 +0530 Subject: [PATCH 2/5] feat: update work item count response to always return grouped counts --- plane/api/work_items/base.py | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/plane/api/work_items/base.py b/plane/api/work_items/base.py index 67ab391..dfb5bd6 100644 --- a/plane/api/work_items/base.py +++ b/plane/api/work_items/base.py @@ -18,7 +18,6 @@ WorkItem, WorkItemCountResponse, WorkItemDetail, - WorkItemFlatCountResponse, WorkItemGroupedCountResponse, WorkItemSearch, ) @@ -274,15 +273,13 @@ def count_workspace( ) -> WorkItemCountResponse: """Return the count of work items across an entire workspace. - Without ``group_by`` returns a flat :class:`WorkItemFlatCountResponse` - ``{"count": N}``. + Always returns :class:`WorkItemGroupedCountResponse` with fields + ``grouped_by``, ``total_count``, and ``grouped_counts``. - With ``group_by`` returns a :class:`WorkItemGroupedCountResponse` - ``{"grouped_by": ..., "total_count": N, "results": {group_key: {"count": N}}}``. - Keys in ``results`` are raw ORM field values (UUID strings for FK/M2M - dimensions, plain strings for ``priority`` / ``state__group``, ISO-date - strings for ``target_date`` / ``start_date``). The special key - ``"None"`` represents work items with no value in that dimension. + ``grouped_counts`` keys are raw ORM field values: UUID strings for + FK/M2M dimensions, plain strings for ``priority`` / ``state__group``, + ISO-date strings for ``target_date`` / ``start_date``. ``"None"`` is + used for work items with no value in that dimension. Args: workspace_slug: The workspace slug identifier @@ -293,21 +290,21 @@ def count_workspace( from plane.models.query_params import WorkItemCountQueryParams - # Flat count + # Total count result = client.work_items.count_workspace( "my-workspace", params=WorkItemCountQueryParams( filters={"priority__in": ["urgent", "high"]}, ), ) - print(result.count) # e.g. 12 + print(result.total_count) # e.g. 12 # Grouped by priority result = client.work_items.count_workspace( "my-workspace", params=WorkItemCountQueryParams(group_by="priority"), ) - for group, entry in result.results.items(): + for group, entry in result.grouped_counts.items(): print(f"{group}: {entry.count}") # Grouped by state, filtered by PQL @@ -323,9 +320,7 @@ def count_workspace( f"{workspace_slug}/work-items/count", params=prepare_work_item_count_params(params), ) - if "grouped_by" in response: - return WorkItemGroupedCountResponse.model_validate(response) - return WorkItemFlatCountResponse.model_validate(response) + return WorkItemGroupedCountResponse.model_validate(response) def search( self, From eac359fadca4fe01282baed91ede79ba53abbe8a Mon Sep 17 00:00:00 2001 From: sangeethailango Date: Fri, 5 Jun 2026 18:27:34 +0530 Subject: [PATCH 3/5] fix: update docs --- plane/models/work_items.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/plane/models/work_items.py b/plane/models/work_items.py index 15fda04..44a7329 100644 --- a/plane/models/work_items.py +++ b/plane/models/work_items.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING, Any -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from .enums import AccessEnum, PriorityEnum, WorkItemRelationTypeEnum from .labels import Label @@ -604,31 +604,31 @@ class WorkItemGroupCountEntry(BaseModel): count: int -class WorkItemFlatCountResponse(BaseModel): - """Response from the workspace work item count endpoint when ``group_by`` - is not supplied.""" +class WorkItemGroupedCountResponse(BaseModel): + """Response from the workspace work item count endpoint. - model_config = ConfigDict(extra="allow", populate_by_name=True) + Handles both response shapes from ``GET /workspaces//work-items/count``: - count: int + **Grouped** (with ``group_by`` param):: - -class WorkItemGroupedCountResponse(BaseModel): - """Response from the workspace work item count endpoint when ``group_by`` - is supplied. + { + "grouped_by": "priority", + "total_count": 42, + "results": {"urgent": {"count": 3}, "none": {"count": 6}} + } ``results`` keys are raw ORM field values: UUID strings for FK/M2M dimensions, plain strings for ``priority`` / ``state__group``, and ISO-date strings for ``target_date`` / ``start_date``. The special - key ``"None"`` is used for work items with no value in that dimension - (unassigned, unlabelled, no release, etc.). + key ``"None"`` represents work items with no value in that dimension. """ model_config = ConfigDict(extra="allow", populate_by_name=True) - grouped_by: str - total_count: int - results: dict[str, WorkItemGroupCountEntry] + # grouped response + grouped_by: str | None = None + total_count: int | None = None + grouped_counts: dict[str, WorkItemGroupCountEntry] | None = None -WorkItemCountResponse = WorkItemFlatCountResponse | WorkItemGroupedCountResponse +WorkItemCountResponse = WorkItemGroupedCountResponse From ea4e7b2834115866b7ec86596d6349e5184fae41 Mon Sep 17 00:00:00 2001 From: sangeethailango Date: Mon, 8 Jun 2026 17:03:53 +0530 Subject: [PATCH 4/5] chore: updated params and response to support sub group pagination --- plane/api/work_items/base.py | 24 ++++++++++++++-- plane/models/query_params.py | 9 ++++++ plane/models/work_items.py | 56 +++++++++++++++++++++++++++++++----- 3 files changed, 79 insertions(+), 10 deletions(-) diff --git a/plane/api/work_items/base.py b/plane/api/work_items/base.py index dfb5bd6..95e8a70 100644 --- a/plane/api/work_items/base.py +++ b/plane/api/work_items/base.py @@ -274,23 +274,28 @@ def count_workspace( """Return the count of work items across an entire workspace. Always returns :class:`WorkItemGroupedCountResponse` with fields - ``grouped_by``, ``total_count``, and ``grouped_counts``. + ``grouped_by``, ``sub_grouped_by``, ``total_count``, and + ``grouped_counts``. ``grouped_counts`` keys are raw ORM field values: UUID strings for FK/M2M dimensions, plain strings for ``priority`` / ``state__group``, ISO-date strings for ``target_date`` / ``start_date``. ``"None"`` is used for work items with no value in that dimension. + When only ``group_by`` is supplied each ``grouped_counts`` entry has + shape ``{"count": N}``. When ``sub_group_by`` is also supplied the + shape becomes ``{"total_count": N, "sub_grouped_counts": {sub_key: {"count": N}}}``. + Args: workspace_slug: The workspace slug identifier params: Optional query parameters — supports ``filters``, ``pql``, - and ``group_by``. + ``group_by``, and ``sub_group_by``. Example:: from plane.models.query_params import WorkItemCountQueryParams - # Total count + # Total count (no grouping) result = client.work_items.count_workspace( "my-workspace", params=WorkItemCountQueryParams( @@ -307,6 +312,19 @@ def count_workspace( for group, entry in result.grouped_counts.items(): print(f"{group}: {entry.count}") + # Sub-grouped by state inside each priority + result = client.work_items.count_workspace( + "my-workspace", + params=WorkItemCountQueryParams( + group_by="priority", + sub_group_by="state_id", + ), + ) + for group, entry in result.grouped_counts.items(): + print(f"{group}: {entry.total_count} total") + for sub_group, sub_entry in (entry.sub_grouped_counts or {}).items(): + print(f" {sub_group}: {sub_entry.count}") + # Grouped by state, filtered by PQL result = client.work_items.count_workspace( "my-workspace", diff --git a/plane/models/query_params.py b/plane/models/query_params.py index 9705863..a62d131 100644 --- a/plane/models/query_params.py +++ b/plane/models/query_params.py @@ -144,6 +144,15 @@ class WorkItemCountQueryParams(BaseModel): ), ) + sub_group_by: WorkItemCountGroupBy | None = Field( + None, + description=( + "Optional second field to group by, for nested grouping. Only valid if " + "`group_by` is also supplied. The response shape changes to include an " + "additional nesting level in the `results` envelope." + ), + ) + __all__ = [ "BaseQueryParams", diff --git a/plane/models/work_items.py b/plane/models/work_items.py index 44a7329..a4a902c 100644 --- a/plane/models/work_items.py +++ b/plane/models/work_items.py @@ -596,28 +596,70 @@ class PaginatedWorkItemLinkResponse(PaginatedResponse): results: list[WorkItemLink] -class WorkItemGroupCountEntry(BaseModel): - """Count for a single group in a grouped count response.""" +class WorkItemSubGroupCountEntry(BaseModel): + """Count for a single sub-group inside a sub-grouped count response.""" model_config = ConfigDict(extra="allow", populate_by_name=True) count: int +class WorkItemGroupCountEntry(BaseModel): + """Count entry for a single group in a grouped count response. + + Shape depends on whether ``sub_group_by`` was supplied: + + * **Flat** (``group_by`` only): ``{"count": N}`` + * **Nested** (``group_by`` + ``sub_group_by``): + ``{"total_count": N, "sub_grouped_counts": {sub_key: {"count": N}}}`` + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + # flat grouped shape (group_by only) + count: int | None = None + # sub-grouped shape (group_by + sub_group_by) + total_count: int | None = None + sub_grouped_counts: dict[str, WorkItemSubGroupCountEntry] | None = None + + class WorkItemGroupedCountResponse(BaseModel): """Response from the workspace work item count endpoint. - Handles both response shapes from ``GET /workspaces//work-items/count``: + Returned for all calls to ``GET /workspaces//work-items/count``. + + **No** ``group_by``:: + + {"grouped_by": null, "sub_grouped_by": null, "total_count": N, "grouped_counts": {}} + + **With** ``group_by`` only — ``grouped_counts`` values are ``{"count": N}``:: + + { + "grouped_by": "priority", + "sub_grouped_by": null, + "total_count": 42, + "grouped_counts": {"urgent": {"count": 3}, "none": {"count": 6}} + } - **Grouped** (with ``group_by`` param):: + **With** ``group_by`` and ``sub_group_by`` — values carry ``total_count`` + and a nested ``sub_grouped_counts`` dict:: { "grouped_by": "priority", + "sub_grouped_by": "state_id", "total_count": 42, - "results": {"urgent": {"count": 3}, "none": {"count": 6}} + "grouped_counts": { + "urgent": { + "total_count": 3, + "sub_grouped_counts": { + "949645da-a9dd-4a90-94b0-6c8fa16245ee": {"count": 2}, + "94d35657-a48c-44fd-bed8-87d895386ba4": {"count": 1} + } + } + } } - ``results`` keys are raw ORM field values: UUID strings for FK/M2M + ``grouped_counts`` keys are raw ORM field values: UUID strings for FK/M2M dimensions, plain strings for ``priority`` / ``state__group``, and ISO-date strings for ``target_date`` / ``start_date``. The special key ``"None"`` represents work items with no value in that dimension. @@ -625,8 +667,8 @@ class WorkItemGroupedCountResponse(BaseModel): model_config = ConfigDict(extra="allow", populate_by_name=True) - # grouped response grouped_by: str | None = None + sub_grouped_by: str | None = None total_count: int | None = None grouped_counts: dict[str, WorkItemGroupCountEntry] | None = None From 3c65a69a9679c3abce3843af16d215b9b0f8192c Mon Sep 17 00:00:00 2001 From: sangeethailango Date: Mon, 8 Jun 2026 17:42:10 +0530 Subject: [PATCH 5/5] fix: lint --- plane/models/work_items.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plane/models/work_items.py b/plane/models/work_items.py index a4a902c..56a03fc 100644 --- a/plane/models/work_items.py +++ b/plane/models/work_items.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING, Any -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field from .enums import AccessEnum, PriorityEnum, WorkItemRelationTypeEnum from .labels import Label