Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
98 changes: 89 additions & 9 deletions plane/api/work_items/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,21 @@
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,
CreateWorkItem,
PaginatedWorkItemResponse,
UpdateWorkItem,
WorkItem,
WorkItemCountResponse,
WorkItemDetail,
WorkItemGroupedCountResponse,
WorkItemSearch,
)
from ..base_resource import BaseResource
Expand Down Expand Up @@ -47,6 +53,23 @@ 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
if params.sub_group_by is not None and params.group_by is None:
raise ValueError("sub_group_by can only be used when group_by is also provided")
payload: dict[str, Any] = params.model_dump(exclude_none=True)
Comment thread
Copilot marked this conversation as resolved.
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 @@ -245,22 +268,79 @@ 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:
Comment thread
sangeethailango marked this conversation as resolved.
Comment thread
sangeethailango marked this conversation as resolved.
"""Return the count of work items across an entire workspace.

Always returns :class:`WorkItemGroupedCountResponse` with fields
``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 for filtering, ordering, and pagination
params: Optional query parameters — supports ``filters``, ``pql``,
``group_by``, and ``sub_group_by``.

Example::

from plane.models.query_params import WorkItemCountQueryParams

# Total count (no grouping)
result = client.work_items.count_workspace(
"my-workspace",
params=WorkItemCountQueryParams(
filters={"priority__in": ["urgent", "high"]},
),
)
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.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",
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)
return WorkItemGroupedCountResponse.model_validate(response)

def search(
self,
Expand Down
67 changes: 66 additions & 1 deletion plane/models/query_params.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -90,9 +90,74 @@ 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.

Always returns a grouped envelope matching :class:`~plane.models.work_items.WorkItemGroupedCountResponse`.
When ``group_by`` is omitted, ``grouped_counts`` is empty and ``total_count`` holds the overall count.
When ``group_by`` is provided, ``grouped_counts`` contains per-group counts, optionally nested when ``sub_group_by`` is also provided."""

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."
),
Comment thread
Copilot marked this conversation as resolved.
)

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."
),
Comment thread
sangeethailango marked this conversation as resolved.
)

Comment thread
sangeethailango marked this conversation as resolved.

__all__ = [
"BaseQueryParams",
"PaginatedQueryParams",
"RetrieveQueryParams",
"WorkItemCountGroupBy",
"WorkItemCountQueryParams",
"WorkItemQueryParams",
]
80 changes: 80 additions & 0 deletions plane/models/work_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -594,3 +594,83 @@ class PaginatedWorkItemLinkResponse(PaginatedResponse):
model_config = ConfigDict(extra="allow", populate_by_name=True)

results: list[WorkItemLink]


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.

Returned for all calls to ``GET /workspaces/<slug>/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}}
}
Comment thread
Copilot marked this conversation as resolved.

**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,
"grouped_counts": {
"urgent": {
"total_count": 3,
"sub_grouped_counts": {
"949645da-a9dd-4a90-94b0-6c8fa16245ee": {"count": 2},
"94d35657-a48c-44fd-bed8-87d895386ba4": {"count": 1}
}
}
}
}

``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.
"""

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

grouped_by: str | None = None
sub_grouped_by: str | None = None
total_count: int
grouped_counts: dict[str, WorkItemGroupCountEntry] = Field(default_factory=dict)


WorkItemCountResponse = WorkItemGroupedCountResponse
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.14"
version = "0.2.15"
description = "Python SDK for Plane API"
readme = "README.md"
requires-python = ">=3.10"
Expand Down