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/2] 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/2] 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