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/api/work_items/base.py b/plane/api/work_items/base.py index 719c002..11450cb 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) @@ -355,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/api/work_items/custom_relations.py b/plane/api/work_items/custom_relations.py new file mode 100644 index 0000000..94efb8f --- /dev/null +++ b/plane/api/work_items/custom_relations.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from typing import Any + +from ...models.work_items import ( + CreateWorkItemCustomRelation, + 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, + related_work_item_id: str, + ) -> 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 + related_work_item_id: UUID of the related work item to remove the relation with + """ + 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 new file mode 100644 index 0000000..858af35 --- /dev/null +++ b/plane/api/work_items/dependencies.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from typing import Any + +from ...models.work_items import ( + CreateWorkItemDependency, + 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}/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}/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, + related_work_item_id: str, + ) -> 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 + related_work_item_id: UUID of the related work item to remove the dependency with + """ + self._delete( + f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/dependencies/{related_work_item_id}/" + ) 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] 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 diff --git a/plane/models/work_items.py b/plane/models/work_items.py index 243557e..e1f33aa 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 @@ -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") @@ -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/pyproject.toml b/pyproject.toml index 18218a5..637c774 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 new file mode 100644 index 0000000..861ed14 --- /dev/null +++ b/tests/unit/test_work_item_relations.py @@ -0,0 +1,358 @@ +"""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, + 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 /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 == [] + + @pytest.mark.dependency(name="create_blocking_dep") + 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" + + @pytest.mark.dependency(depends=["create_blocking_dep"]) + 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 + + @pytest.mark.dependency(depends=["create_blocking_dep"]) + 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 + + @pytest.mark.dependency(depends=["create_blocking_dep"]) + 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, + 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, + 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] == [] + + @pytest.mark.dependency(name="create_custom_relation_outward") + 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 + + @pytest.mark.dependency(depends=["create_custom_relation_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 + + @pytest.mark.dependency(depends=["create_custom_relation_outward"]) + 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 + + @pytest.mark.dependency(depends=["create_custom_relation_outward"]) + 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, + 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, + 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], + ), + )