From 16f376b84fa3999f95918d65a6490d8e6d33c52f Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Thu, 11 Jun 2026 16:21:58 +0530 Subject: [PATCH 1/6] feat: add support for work item dependencies and custom relations --- plane/api/work_items/base.py | 4 + plane/api/work_items/custom_relations.py | 84 ++++++ plane/api/work_items/dependencies.py | 78 +++++ plane/models/work_items.py | 106 ++++++- tests/unit/test_work_item_relations.py | 352 +++++++++++++++++++++++ 5 files changed, 623 insertions(+), 1 deletion(-) create mode 100644 plane/api/work_items/custom_relations.py create mode 100644 plane/api/work_items/dependencies.py create mode 100644 tests/unit/test_work_item_relations.py diff --git a/plane/api/work_items/base.py b/plane/api/work_items/base.py index 719c002..35f4743 100644 --- a/plane/api/work_items/base.py +++ b/plane/api/work_items/base.py @@ -25,6 +25,8 @@ from .activities import WorkItemActivities from .attachments import WorkItemAttachments from .comments import WorkItemComments +from .custom_relations import WorkItemCustomRelations +from .dependencies import WorkItemDependencies from .links import WorkItemLinks from .pages import WorkItemPages from .relations import WorkItemRelations @@ -76,6 +78,8 @@ def __init__(self, config: Any) -> None: # Initialize sub-resources self.relations = WorkItemRelations(config) + self.dependencies = WorkItemDependencies(config) + self.custom_relations = WorkItemCustomRelations(config) self.links = WorkItemLinks(config) self.attachments = WorkItemAttachments(config) self.comments = WorkItemComments(config) diff --git a/plane/api/work_items/custom_relations.py b/plane/api/work_items/custom_relations.py new file mode 100644 index 0000000..4501096 --- /dev/null +++ b/plane/api/work_items/custom_relations.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from typing import Any + +from ...models.work_items import ( + CreateWorkItemCustomRelation, + RemoveWorkItemCustomRelation, + WorkItemWithRelationType, +) +from ..base_resource import BaseResource + + +class WorkItemCustomRelations(BaseResource): + """API client for managing custom (definition-based) work item relations. + + Custom relations are workspace-level types defined via the + work-item-relation-definitions endpoint. Each definition has an outward label + and an inward label that controls directionality. + """ + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def list( + self, workspace_slug: str, project_id: str, work_item_id: str + ) -> dict[str, list[WorkItemWithRelationType]]: + """List all custom relations for a work item grouped by definition label. + + Response keys are the outward/inward labels from active workspace relation + definitions (e.g. 'implements', 'implemented by', 'relates to'). + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + work_item_id: UUID of the work item + """ + response = self._get( + f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/work-item-relations/" + ) + return { + label: [WorkItemWithRelationType.model_validate(item) for item in items] + for label, items in response.items() + } + + def create( + self, + workspace_slug: str, + project_id: str, + work_item_id: str, + data: CreateWorkItemCustomRelation, + ) -> list[WorkItemWithRelationType]: + """Create one or more custom relations for a work item. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + work_item_id: UUID of the work item + data: Custom relation creation payload + """ + response = self._post( + f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/work-item-relations/", + data.model_dump(exclude_none=True), + ) + return [WorkItemWithRelationType.model_validate(item) for item in response] + + def remove( + self, + workspace_slug: str, + project_id: str, + work_item_id: str, + data: RemoveWorkItemCustomRelation, + ) -> None: + """Remove a custom relation between this work item and a target. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + work_item_id: UUID of the work item + data: Removal payload containing the related work item UUID + """ + return self._post( + f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/work-item-relations/remove/", + data.model_dump(exclude_none=True), + ) diff --git a/plane/api/work_items/dependencies.py b/plane/api/work_items/dependencies.py new file mode 100644 index 0000000..a314e43 --- /dev/null +++ b/plane/api/work_items/dependencies.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import Any + +from ...models.work_items import ( + CreateWorkItemDependency, + RemoveWorkItemDependency, + WorkItemDependencyResponse, + WorkItemWithRelationType, +) +from ..base_resource import BaseResource + + +class WorkItemDependencies(BaseResource): + """API client for managing work item dependency relations. + + Covers the six built-in dependency directions: + blocking / blocked_by / start_before / start_after / finish_before / finish_after. + """ + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def list( + self, workspace_slug: str, project_id: str, work_item_id: str + ) -> WorkItemDependencyResponse: + """List all dependency relations for a work item grouped by direction. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + work_item_id: UUID of the work item + """ + response = self._get( + f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/relation-dependencies/" + ) + return WorkItemDependencyResponse.model_validate(response) + + def create( + self, + workspace_slug: str, + project_id: str, + work_item_id: str, + data: CreateWorkItemDependency, + ) -> list[WorkItemWithRelationType]: + """Create one or more dependency relations for a work item. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + work_item_id: UUID of the work item + data: Dependency creation payload + """ + response = self._post( + f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/relation-dependencies/", + data.model_dump(exclude_none=True), + ) + return [WorkItemWithRelationType.model_validate(item) for item in response] + + def remove( + self, + workspace_slug: str, + project_id: str, + work_item_id: str, + data: RemoveWorkItemDependency, + ) -> None: + """Remove a dependency relation between this work item and a target. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + work_item_id: UUID of the work item + data: Removal payload containing the related work item UUID + """ + return self._post( + f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/relation-dependencies/remove/", + data.model_dump(exclude_none=True), + ) diff --git a/plane/models/work_items.py b/plane/models/work_items.py index 243557e..8d5834d 100644 --- a/plane/models/work_items.py +++ b/plane/models/work_items.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from pydantic import BaseModel, ConfigDict, Field @@ -525,6 +525,110 @@ class WorkItemRelationResponse(BaseModel): ) +DependencyTypeEnum = Literal[ + "blocking", + "blocked_by", + "start_before", + "start_after", + "finish_before", + "finish_after", +] + + +class WorkItemWithRelationType(BaseModel): + """Work item with an injected relation_type label.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + name: str | None = None + sequence_id: int | None = None + project_id: str | None = None + state_id: str | None = None + priority: str | None = None + type_id: str | None = None + is_epic: bool | None = None + label_ids: list[str] = Field(default_factory=list) + assignee_ids: list[str] = Field(default_factory=list) + sort_order: float | None = None + created_at: str | None = None + updated_at: str | None = None + created_by: str | None = None + updated_by: str | None = None + relation_type: str | None = None + + +class WorkItemDependencyResponse(BaseModel): + """Response model for GET /relation-dependencies/.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + blocking: list[WorkItemWithRelationType] = Field(default_factory=list) + blocked_by: list[WorkItemWithRelationType] = Field(default_factory=list) + start_before: list[WorkItemWithRelationType] = Field(default_factory=list) + start_after: list[WorkItemWithRelationType] = Field(default_factory=list) + finish_before: list[WorkItemWithRelationType] = Field(default_factory=list) + finish_after: list[WorkItemWithRelationType] = Field(default_factory=list) + + +class CreateWorkItemDependency(BaseModel): + """Request model for creating work item dependency relations.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + relation_type: DependencyTypeEnum = Field( + ..., + description="Dependency direction from the perspective of this work item", + ) + work_item_ids: list[str] = Field( + ..., + description="UUIDs of work items to create dependencies with", + min_length=1, + ) + + +class RemoveWorkItemDependency(BaseModel): + """Request model for removing a work item dependency.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + work_item_id: str = Field( + ..., + description="UUID of the related work item whose dependency should be removed", + ) + + +class CreateWorkItemCustomRelation(BaseModel): + """Request model for creating a custom (definition-based) work item relation.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + relation_definition_id: str = Field( + ..., + description="UUID of the workspace relation definition", + ) + relation_definition_type: str = Field( + ..., + description="The outward or inward label of the definition (controls directionality)", + ) + work_item_ids: list[str] = Field( + ..., + description="UUIDs of work items to create the relation with", + min_length=1, + ) + + +class RemoveWorkItemCustomRelation(BaseModel): + """Request model for removing a custom work item relation.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + work_item_id: str = Field( + ..., + description="UUID of the related work item whose custom relation should be removed", + ) + + class WorkItemWorkLog(BaseModel): """Work item work log model.""" diff --git a/tests/unit/test_work_item_relations.py b/tests/unit/test_work_item_relations.py new file mode 100644 index 0000000..b66097e --- /dev/null +++ b/tests/unit/test_work_item_relations.py @@ -0,0 +1,352 @@ +"""Integration tests for work item dependency and custom relation endpoints.""" + +import warnings +from uuid import uuid4 + +import pytest + +from plane.client import PlaneClient +from plane.errors.errors import HttpError +from plane.models.projects import Project +from plane.models.work_item_relation_definitions import ( + CreateWorkItemRelationDefinition, +) +from plane.models.work_items import ( + CreateWorkItem, + CreateWorkItemCustomRelation, + CreateWorkItemDependency, + RemoveWorkItemCustomRelation, + RemoveWorkItemDependency, + WorkItemDependencyResponse, + WorkItemWithRelationType, +) + +# ── Fixtures ────────────────────────────────────────────────────────────────── + + +@pytest.fixture(scope="class") +def work_item_a(client: PlaneClient, workspace_slug: str, project: Project): + """First work item used as the source in relation tests.""" + item = client.work_items.create( + workspace_slug, project.id, CreateWorkItem(name=f"relation-test-a-{uuid4().hex[:6]}") + ) + yield item + try: + client.work_items.delete(workspace_slug, project.id, item.id) + except HttpError as exc: + warnings.warn(f"Teardown failed for work item {item.id}: {exc}", stacklevel=1) + + +@pytest.fixture(scope="class") +def work_item_b(client: PlaneClient, workspace_slug: str, project: Project): + """Second work item used as the target in relation tests.""" + item = client.work_items.create( + workspace_slug, project.id, CreateWorkItem(name=f"relation-test-b-{uuid4().hex[:6]}") + ) + yield item + try: + client.work_items.delete(workspace_slug, project.id, item.id) + except HttpError as exc: + warnings.warn(f"Teardown failed for work item {item.id}: {exc}", stacklevel=1) + + +@pytest.fixture(scope="class") +def custom_definition(client: PlaneClient, workspace_slug: str): + """Workspace relation definition used in custom relation tests.""" + suffix = uuid4().hex[:8] + defn = client.work_item_relation_definitions.create( + workspace_slug, + CreateWorkItemRelationDefinition( + name=f"test-rel-{suffix}", + outward=f"test-outward-{suffix}", + inward=f"test-inward-{suffix}", + is_active=True, + ), + ) + yield defn + try: + client.work_item_relation_definitions.delete(workspace_slug, defn.id) + except HttpError as exc: + warnings.warn(f"Teardown failed for definition {defn.id}: {exc}", stacklevel=1) + + +# ── Dependency tests ────────────────────────────────────────────────────────── + + +class TestWorkItemDependencies: + """Tests for the /relation-dependencies/ endpoint.""" + + def test_list_dependencies_empty( + self, client: PlaneClient, workspace_slug: str, project: Project, work_item_a + ) -> None: + """Listing dependencies on a fresh work item returns empty groups.""" + result = client.work_items.dependencies.list(workspace_slug, project.id, work_item_a.id) + assert isinstance(result, WorkItemDependencyResponse) + assert result.blocking == [] + assert result.blocked_by == [] + assert result.start_before == [] + assert result.start_after == [] + assert result.finish_before == [] + assert result.finish_after == [] + + def test_create_blocking_dependency( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + work_item_a, + work_item_b, + ) -> None: + """Creating a 'blocking' dependency returns the target with relation_type set.""" + created = client.work_items.dependencies.create( + workspace_slug, + project.id, + work_item_a.id, + CreateWorkItemDependency( + relation_type="blocking", + work_item_ids=[work_item_b.id], + ), + ) + assert isinstance(created, list) + assert len(created) == 1 + item = created[0] + assert isinstance(item, WorkItemWithRelationType) + assert item.id == work_item_b.id + assert item.relation_type == "blocking" + + def test_list_dependencies_after_create( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + work_item_a, + work_item_b, + ) -> None: + """After creating a blocking dependency, list returns it in the blocking group.""" + result = client.work_items.dependencies.list(workspace_slug, project.id, work_item_a.id) + assert isinstance(result, WorkItemDependencyResponse) + blocking_ids = [wi.id for wi in result.blocking] + assert work_item_b.id in blocking_ids + + def test_list_reverse_dependency( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + work_item_a, + work_item_b, + ) -> None: + """The target sees the reverse dependency (blocked_by) in its own list.""" + result = client.work_items.dependencies.list(workspace_slug, project.id, work_item_b.id) + blocked_by_ids = [wi.id for wi in result.blocked_by] + assert work_item_a.id in blocked_by_ids + + def test_remove_dependency( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + work_item_a, + work_item_b, + ) -> None: + """Removing a dependency clears it from both sides.""" + client.work_items.dependencies.remove( + workspace_slug, + project.id, + work_item_a.id, + RemoveWorkItemDependency(work_item_id=work_item_b.id), + ) + result = client.work_items.dependencies.list(workspace_slug, project.id, work_item_a.id) + blocking_ids = [wi.id for wi in result.blocking] + assert work_item_b.id not in blocking_ids + + def test_create_all_dependency_types( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + work_item_a, + work_item_b, + ) -> None: + """All six dependency directions are accepted by the API.""" + dep_types = [ + "blocking", + "blocked_by", + "start_before", + "start_after", + "finish_before", + "finish_after", + ] + created_any = False + for dep_type in dep_types: + result = client.work_items.dependencies.create( + workspace_slug, + project.id, + work_item_a.id, + CreateWorkItemDependency( + relation_type=dep_type, + work_item_ids=[work_item_b.id], + ), + ) + assert isinstance(result, list) + created_any = True + # clean up immediately to avoid duplicate constraint issues + client.work_items.dependencies.remove( + workspace_slug, + project.id, + work_item_a.id, + RemoveWorkItemDependency(work_item_id=work_item_b.id), + ) + assert created_any + + +# ── Custom relation tests ────────────────────────────────────────────────────── + + +class TestWorkItemCustomRelations: + """Tests for the /work-item-relations/ endpoint.""" + + def test_list_custom_relations_empty( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + work_item_a, + custom_definition, + ) -> None: + """Listing custom relations returns a dict keyed by definition labels.""" + result = client.work_items.custom_relations.list(workspace_slug, project.id, work_item_a.id) + assert isinstance(result, dict) + # Active definitions must appear as keys + assert custom_definition.outward in result + assert custom_definition.inward in result + assert result[custom_definition.outward] == [] + assert result[custom_definition.inward] == [] + + def test_create_custom_relation_outward( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + work_item_a, + work_item_b, + custom_definition, + ) -> None: + """Creating an outward relation returns targets with relation_type as outward label.""" + created = client.work_items.custom_relations.create( + workspace_slug, + project.id, + work_item_a.id, + CreateWorkItemCustomRelation( + relation_definition_id=custom_definition.id, + relation_definition_type=custom_definition.outward, + work_item_ids=[work_item_b.id], + ), + ) + assert isinstance(created, list) + assert len(created) == 1 + item = created[0] + assert isinstance(item, WorkItemWithRelationType) + assert item.id == work_item_b.id + assert item.relation_type == custom_definition.outward + + def test_list_custom_relations_after_create( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + work_item_a, + work_item_b, + custom_definition, + ) -> None: + """After creating an outward relation, the source sees it under the outward label.""" + result = client.work_items.custom_relations.list(workspace_slug, project.id, work_item_a.id) + outward_ids = [wi.id for wi in result.get(custom_definition.outward, [])] + assert work_item_b.id in outward_ids + + def test_list_custom_relations_inward_side( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + work_item_a, + work_item_b, + custom_definition, + ) -> None: + """The target sees the relation under the inward label.""" + result = client.work_items.custom_relations.list(workspace_slug, project.id, work_item_b.id) + inward_ids = [wi.id for wi in result.get(custom_definition.inward, [])] + assert work_item_a.id in inward_ids + + def test_remove_custom_relation( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + work_item_a, + work_item_b, + custom_definition, + ) -> None: + """Removing a custom relation clears it from both sides.""" + client.work_items.custom_relations.remove( + workspace_slug, + project.id, + work_item_a.id, + RemoveWorkItemCustomRelation(work_item_id=work_item_b.id), + ) + result = client.work_items.custom_relations.list(workspace_slug, project.id, work_item_a.id) + outward_ids = [wi.id for wi in result.get(custom_definition.outward, [])] + assert work_item_b.id not in outward_ids + + def test_create_custom_relation_inward( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + work_item_a, + work_item_b, + custom_definition, + ) -> None: + """Creating an inward relation (reversed directionality) is accepted by the API.""" + created = client.work_items.custom_relations.create( + workspace_slug, + project.id, + work_item_a.id, + CreateWorkItemCustomRelation( + relation_definition_id=custom_definition.id, + relation_definition_type=custom_definition.inward, + work_item_ids=[work_item_b.id], + ), + ) + assert isinstance(created, list) + assert len(created) == 1 + assert created[0].relation_type == custom_definition.inward + + # clean up + client.work_items.custom_relations.remove( + workspace_slug, + project.id, + work_item_a.id, + RemoveWorkItemCustomRelation(work_item_id=work_item_b.id), + ) + + def test_create_relation_invalid_definition( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + work_item_a, + work_item_b, + ) -> None: + """Using a non-existent definition UUID returns a 4xx error.""" + with pytest.raises(HttpError): + client.work_items.custom_relations.create( + workspace_slug, + project.id, + work_item_a.id, + CreateWorkItemCustomRelation( + relation_definition_id=str(uuid4()), + relation_definition_type="nonexistent", + work_item_ids=[work_item_b.id], + ), + ) From cea30b84c7669679039db1c37205ef9fa48e11df Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Fri, 12 Jun 2026 21:33:09 +0530 Subject: [PATCH 2/6] chore: add level attribute to CreateWorkItemType and UpdateWorkItemType models --- plane/models/work_item_types.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plane/models/work_item_types.py b/plane/models/work_item_types.py index 6351fc4..10235e9 100644 --- a/plane/models/work_item_types.py +++ b/plane/models/work_item_types.py @@ -38,6 +38,7 @@ class CreateWorkItemType(BaseModel): logo_props: Any | None = None is_epic: bool | None = None is_active: bool | None = None + level: int | None = None external_source: str | None = None external_id: str | None = None @@ -53,6 +54,7 @@ class UpdateWorkItemType(BaseModel): logo_props: Any | None = None is_epic: bool | None = None is_active: bool | None = None + level: int | None = None external_source: str | None = None external_id: str | None = None From 57036aad7f156ac135fd4d1082af25c7e2fa06a5 Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Mon, 15 Jun 2026 11:06:17 +0530 Subject: [PATCH 3/6] refactor: update work item relations endpoints to use direct identifiers for removal --- plane/api/work_items/custom_relations.py | 10 ++++------ plane/api/work_items/dependencies.py | 14 ++++++-------- tests/unit/test_work_item_relations.py | 12 +++++------- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/plane/api/work_items/custom_relations.py b/plane/api/work_items/custom_relations.py index 4501096..94efb8f 100644 --- a/plane/api/work_items/custom_relations.py +++ b/plane/api/work_items/custom_relations.py @@ -4,7 +4,6 @@ from ...models.work_items import ( CreateWorkItemCustomRelation, - RemoveWorkItemCustomRelation, WorkItemWithRelationType, ) from ..base_resource import BaseResource @@ -68,7 +67,7 @@ def remove( workspace_slug: str, project_id: str, work_item_id: str, - data: RemoveWorkItemCustomRelation, + related_work_item_id: str, ) -> None: """Remove a custom relation between this work item and a target. @@ -76,9 +75,8 @@ def remove( workspace_slug: The workspace slug identifier project_id: UUID of the project work_item_id: UUID of the work item - data: Removal payload containing the related work item UUID + related_work_item_id: UUID of the related work item to remove the relation with """ - return self._post( - f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/work-item-relations/remove/", - data.model_dump(exclude_none=True), + self._delete( + f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/work-item-relations/{related_work_item_id}/" ) diff --git a/plane/api/work_items/dependencies.py b/plane/api/work_items/dependencies.py index a314e43..858af35 100644 --- a/plane/api/work_items/dependencies.py +++ b/plane/api/work_items/dependencies.py @@ -4,7 +4,6 @@ from ...models.work_items import ( CreateWorkItemDependency, - RemoveWorkItemDependency, WorkItemDependencyResponse, WorkItemWithRelationType, ) @@ -32,7 +31,7 @@ def list( work_item_id: UUID of the work item """ response = self._get( - f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/relation-dependencies/" + f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/dependencies/" ) return WorkItemDependencyResponse.model_validate(response) @@ -52,7 +51,7 @@ def create( data: Dependency creation payload """ response = self._post( - f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/relation-dependencies/", + f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/dependencies/", data.model_dump(exclude_none=True), ) return [WorkItemWithRelationType.model_validate(item) for item in response] @@ -62,7 +61,7 @@ def remove( workspace_slug: str, project_id: str, work_item_id: str, - data: RemoveWorkItemDependency, + related_work_item_id: str, ) -> None: """Remove a dependency relation between this work item and a target. @@ -70,9 +69,8 @@ def remove( workspace_slug: The workspace slug identifier project_id: UUID of the project work_item_id: UUID of the work item - data: Removal payload containing the related work item UUID + related_work_item_id: UUID of the related work item to remove the dependency with """ - return self._post( - f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/relation-dependencies/remove/", - data.model_dump(exclude_none=True), + self._delete( + f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/dependencies/{related_work_item_id}/" ) diff --git a/tests/unit/test_work_item_relations.py b/tests/unit/test_work_item_relations.py index b66097e..0f5e416 100644 --- a/tests/unit/test_work_item_relations.py +++ b/tests/unit/test_work_item_relations.py @@ -15,8 +15,6 @@ CreateWorkItem, CreateWorkItemCustomRelation, CreateWorkItemDependency, - RemoveWorkItemCustomRelation, - RemoveWorkItemDependency, WorkItemDependencyResponse, WorkItemWithRelationType, ) @@ -74,7 +72,7 @@ def custom_definition(client: PlaneClient, workspace_slug: str): class TestWorkItemDependencies: - """Tests for the /relation-dependencies/ endpoint.""" + """Tests for the /dependencies/ endpoint.""" def test_list_dependencies_empty( self, client: PlaneClient, workspace_slug: str, project: Project, work_item_a @@ -154,7 +152,7 @@ def test_remove_dependency( workspace_slug, project.id, work_item_a.id, - RemoveWorkItemDependency(work_item_id=work_item_b.id), + work_item_b.id, ) result = client.work_items.dependencies.list(workspace_slug, project.id, work_item_a.id) blocking_ids = [wi.id for wi in result.blocking] @@ -195,7 +193,7 @@ def test_create_all_dependency_types( workspace_slug, project.id, work_item_a.id, - RemoveWorkItemDependency(work_item_id=work_item_b.id), + work_item_b.id, ) assert created_any @@ -292,7 +290,7 @@ def test_remove_custom_relation( workspace_slug, project.id, work_item_a.id, - RemoveWorkItemCustomRelation(work_item_id=work_item_b.id), + work_item_b.id, ) result = client.work_items.custom_relations.list(workspace_slug, project.id, work_item_a.id) outward_ids = [wi.id for wi in result.get(custom_definition.outward, [])] @@ -327,7 +325,7 @@ def test_create_custom_relation_inward( workspace_slug, project.id, work_item_a.id, - RemoveWorkItemCustomRelation(work_item_id=work_item_b.id), + work_item_b.id, ) def test_create_relation_invalid_definition( From ecf5ba0dee2d48884a72bb295f8904376af0b57e Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Mon, 15 Jun 2026 14:53:27 +0530 Subject: [PATCH 4/6] chore: implement pagination for work item relation definitions endpoint --- plane/api/work_item_relation_definitions.py | 15 ++++++++++++--- plane/models/work_item_relation_definitions.py | 10 ++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/plane/api/work_item_relation_definitions.py b/plane/api/work_item_relation_definitions.py index a97cb14..052a78d 100644 --- a/plane/api/work_item_relation_definitions.py +++ b/plane/api/work_item_relation_definitions.py @@ -2,6 +2,7 @@ from ..models.work_item_relation_definitions import ( CreateWorkItemRelationDefinition, + PaginatedWorkItemRelationDefinitionResponse, UpdateWorkItemRelationDefinition, WorkItemRelationDefinition, ) @@ -19,25 +20,33 @@ def list( workspace_slug: str, is_default: bool | None = None, is_active: bool | None = None, - ) -> list[WorkItemRelationDefinition]: - """List all work item relation definitions in the workspace. + per_page: int | None = None, + cursor: str | None = None, + ) -> PaginatedWorkItemRelationDefinitionResponse: + """List work item relation definitions in the workspace. Args: workspace_slug: The workspace slug identifier is_default: Optional filter by default status is_active: Optional filter by active status + per_page: Number of results per page (default 100) + cursor: Pagination cursor from a previous response's next_cursor """ params: dict[str, Any] = {} if is_default is not None: params["is_default"] = str(is_default).lower() if is_active is not None: params["is_active"] = str(is_active).lower() + if per_page is not None: + params["per_page"] = per_page + if cursor is not None: + params["cursor"] = cursor response = self._get( f"{workspace_slug}/work-item-relation-definitions/", params=params if params else None, ) - return [WorkItemRelationDefinition.model_validate(item) for item in response] + return PaginatedWorkItemRelationDefinitionResponse.model_validate(response) def create( self, workspace_slug: str, data: CreateWorkItemRelationDefinition diff --git a/plane/models/work_item_relation_definitions.py b/plane/models/work_item_relation_definitions.py index 01a8823..9e92caf 100644 --- a/plane/models/work_item_relation_definitions.py +++ b/plane/models/work_item_relation_definitions.py @@ -2,6 +2,8 @@ from pydantic import BaseModel, ConfigDict +from .pagination import PaginatedResponse + class WorkItemRelationDefinition(BaseModel): """Work item relation definition response model.""" @@ -47,3 +49,11 @@ class UpdateWorkItemRelationDefinition(BaseModel): is_active: bool | None = None color: str | None = None sort_order: float | None = None + + +class PaginatedWorkItemRelationDefinitionResponse(PaginatedResponse): + """Paginated response for work item relation definitions.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + results: list[WorkItemRelationDefinition] From b8af866636e6fbeb3d62d19adca77d88925648fd Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Mon, 15 Jun 2026 15:20:29 +0530 Subject: [PATCH 5/6] chore: add optional dependencies for development and enhance test dependency management --- pyproject.toml | 3 +++ tests/unit/test_work_item_relations.py | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 10eec4e..4bba1eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,9 @@ license = { text = "MIT" } authors = [{ name = "Plane", email = "engineering@plane.so" }] dependencies = ["requests>=2.31.0", "pydantic>=2.4.0"] +[project.optional-dependencies] +dev = ["pytest", "pytest-dependency"] + [tool.setuptools.packages.find] where = ["."] include = ["plane*"] diff --git a/tests/unit/test_work_item_relations.py b/tests/unit/test_work_item_relations.py index 0f5e416..861ed14 100644 --- a/tests/unit/test_work_item_relations.py +++ b/tests/unit/test_work_item_relations.py @@ -87,6 +87,7 @@ def test_list_dependencies_empty( assert result.finish_before == [] assert result.finish_after == [] + @pytest.mark.dependency(name="create_blocking_dep") def test_create_blocking_dependency( self, client: PlaneClient, @@ -112,6 +113,7 @@ def test_create_blocking_dependency( assert item.id == work_item_b.id assert item.relation_type == "blocking" + @pytest.mark.dependency(depends=["create_blocking_dep"]) def test_list_dependencies_after_create( self, client: PlaneClient, @@ -126,6 +128,7 @@ def test_list_dependencies_after_create( blocking_ids = [wi.id for wi in result.blocking] assert work_item_b.id in blocking_ids + @pytest.mark.dependency(depends=["create_blocking_dep"]) def test_list_reverse_dependency( self, client: PlaneClient, @@ -139,6 +142,7 @@ def test_list_reverse_dependency( blocked_by_ids = [wi.id for wi in result.blocked_by] assert work_item_a.id in blocked_by_ids + @pytest.mark.dependency(depends=["create_blocking_dep"]) def test_remove_dependency( self, client: PlaneClient, @@ -221,6 +225,7 @@ def test_list_custom_relations_empty( assert result[custom_definition.outward] == [] assert result[custom_definition.inward] == [] + @pytest.mark.dependency(name="create_custom_relation_outward") def test_create_custom_relation_outward( self, client: PlaneClient, @@ -248,6 +253,7 @@ def test_create_custom_relation_outward( assert item.id == work_item_b.id assert item.relation_type == custom_definition.outward + @pytest.mark.dependency(depends=["create_custom_relation_outward"]) def test_list_custom_relations_after_create( self, client: PlaneClient, @@ -262,6 +268,7 @@ def test_list_custom_relations_after_create( outward_ids = [wi.id for wi in result.get(custom_definition.outward, [])] assert work_item_b.id in outward_ids + @pytest.mark.dependency(depends=["create_custom_relation_outward"]) def test_list_custom_relations_inward_side( self, client: PlaneClient, @@ -276,6 +283,7 @@ def test_list_custom_relations_inward_side( inward_ids = [wi.id for wi in result.get(custom_definition.inward, [])] assert work_item_a.id in inward_ids + @pytest.mark.dependency(depends=["create_custom_relation_outward"]) def test_remove_custom_relation( self, client: PlaneClient, From db1b4717e859b0290e3d46d34fd60fabb81d2cdc Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Tue, 16 Jun 2026 10:10:07 +0530 Subject: [PATCH 6/6] fix: update search parameter key and change sequence_id type to int in work item model --- plane/api/work_items/base.py | 2 +- plane/models/work_items.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plane/api/work_items/base.py b/plane/api/work_items/base.py index 35f4743..11450cb 100644 --- a/plane/api/work_items/base.py +++ b/plane/api/work_items/base.py @@ -359,7 +359,7 @@ def search( query: Search query string params: Optional query parameters for expand, fields, etc. """ - search_params = {"q": query} + search_params = {"search": query} if params: search_params.update(params.model_dump(exclude_none=True)) response = self._get(f"{workspace_slug}/work-items/search", params=search_params) diff --git a/plane/models/work_items.py b/plane/models/work_items.py index 8d5834d..e1f33aa 100644 --- a/plane/models/work_items.py +++ b/plane/models/work_items.py @@ -201,7 +201,7 @@ class WorkItemSearchItem(BaseModel): id: str = Field(..., description="Issue ID") name: str = Field(..., description="Issue name") - sequence_id: str = Field(..., description="Issue sequence ID") + sequence_id: int = Field(..., description="Issue sequence ID") project__identifier: str = Field(..., description="Project identifier") project_id: str = Field(..., description="Project ID") workspace__slug: str = Field(..., description="Workspace slug")