diff --git a/plane/api/intake.py b/plane/api/intake.py index 449d07d..8c8a6d7 100644 --- a/plane/api/intake.py +++ b/plane/api/intake.py @@ -94,6 +94,28 @@ def update( ) return IntakeWorkItem.model_validate(response) + def update_status( + self, + workspace_slug: str, + project_id: str, + work_item_id: str, + data: UpdateIntakeWorkItem, + ) -> IntakeWorkItem: + """Update the triage status of an intake work item. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + work_item_id: UUID of the work item (use the issue field from + IntakeWorkItem response, not the intake work item ID) + data: Triage data — status, snoozed_till, duplicate_to + """ + response = self._patch( + f"{workspace_slug}/projects/{project_id}/intake-issues/{work_item_id}/status", + data.model_dump(exclude_none=True), + ) + return IntakeWorkItem.model_validate(response) + def delete(self, workspace_slug: str, project_id: str, work_item_id: str) -> None: """Delete an intake work item by work item ID. diff --git a/plane/api/pages.py b/plane/api/pages.py index 0ed082e..dabc58d 100644 --- a/plane/api/pages.py +++ b/plane/api/pages.py @@ -1,4 +1,3 @@ -from collections.abc import Mapping from typing import Any from ..models.pages import CreatePage, Page, PaginatedPageResponse @@ -10,6 +9,40 @@ class Pages(BaseResource): def __init__(self, config: Any) -> None: super().__init__(config, "/workspaces/") + def list_workspace_pages( + self, + workspace_slug: str, + params: PaginatedQueryParams | None = None, + ) -> PaginatedPageResponse: + """List all workspace pages. + + Args: + workspace_slug: The workspace slug identifier + params: Optional pagination/query parameters + """ + query_params = params.model_dump(exclude_none=True) if params else None + response = self._get(f"{workspace_slug}/pages", params=query_params) + return PaginatedPageResponse.model_validate(response) + + def list_project_pages( + self, + workspace_slug: str, + project_id: str, + params: PaginatedQueryParams | None = None, + ) -> PaginatedPageResponse: + """List all pages in a project. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + params: Optional pagination/query parameters + """ + query_params = params.model_dump(exclude_none=True) if params else None + response = self._get( + f"{workspace_slug}/projects/{project_id}/pages", params=query_params + ) + return PaginatedPageResponse.model_validate(response) + def retrieve_workspace_page( self, workspace_slug: str, @@ -83,3 +116,22 @@ def create_project_page( data.model_dump(exclude_none=True), ) return Page.model_validate(response) + + def delete_workspace_page(self, workspace_slug: str, page_id: str) -> None: + """Delete a workspace page by ID. + + Args: + workspace_slug: The workspace slug identifier + page_id: UUID of the page + """ + return self._delete(f"{workspace_slug}/pages/{page_id}") + + def delete_project_page(self, workspace_slug: str, project_id: str, page_id: str) -> None: + """Delete a project page by ID. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + page_id: UUID of the page + """ + return self._delete(f"{workspace_slug}/projects/{project_id}/pages/{page_id}") diff --git a/plane/api/work_items/base.py b/plane/api/work_items/base.py index 2599ad8..1a9d82b 100644 --- a/plane/api/work_items/base.py +++ b/plane/api/work_items/base.py @@ -18,6 +18,7 @@ from .attachments import WorkItemAttachments from .comments import WorkItemComments from .links import WorkItemLinks +from .pages import WorkItemPages from .relations import WorkItemRelations from .work_logs import WorkLogs @@ -33,6 +34,7 @@ def __init__(self, config: Any) -> None: self.comments = WorkItemComments(config) self.activities = WorkItemActivities(config) self.work_logs = WorkLogs(config) + self.pages = WorkItemPages(config) def create(self, workspace_slug: str, project_id: str, data: CreateWorkItem) -> WorkItem: """Create a new work item. @@ -237,6 +239,25 @@ def advanced_search( ) return [AdvancedSearchResult.model_validate(item) for item in response] + def list_archived( + self, + workspace_slug: str, + project_id: str, + params: WorkItemQueryParams | None = None, + ) -> PaginatedWorkItemResponse: + """List archived work items in a project. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + params: Optional query parameters for filtering, ordering, and pagination + """ + query_params = params.model_dump(exclude_none=True) if params else None + response = self._get( + f"{workspace_slug}/projects/{project_id}/archived-work-items", params=query_params + ) + return PaginatedWorkItemResponse.model_validate(response) + def archive(self, workspace_slug: str, project_id: str, work_item_id: str) -> None: """Archive a work item. diff --git a/plane/api/work_items/pages.py b/plane/api/work_items/pages.py new file mode 100644 index 0000000..199aa07 --- /dev/null +++ b/plane/api/work_items/pages.py @@ -0,0 +1,95 @@ +from collections.abc import Mapping +from typing import Any + +from ...models.work_item_pages import ( + CreateWorkItemPage, + PaginatedWorkItemPageResponse, + WorkItemPage, +) +from ..base_resource import BaseResource + + +class WorkItemPages(BaseResource): + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def list( + self, + workspace_slug: str, + project_id: str, + work_item_id: str, + params: Mapping[str, Any] | None = None, + ) -> PaginatedWorkItemPageResponse: + """List page links 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 + params: Optional query parameters + """ + response = self._get( + f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/pages", + params=params, + ) + return PaginatedWorkItemPageResponse.model_validate(response) + + def retrieve( + self, + workspace_slug: str, + project_id: str, + work_item_id: str, + work_item_page_id: str, + ) -> WorkItemPage: + """Retrieve a specific page link 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 + work_item_page_id: UUID of the work item page link + """ + response = self._get( + f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/pages/{work_item_page_id}" + ) + return WorkItemPage.model_validate(response) + + def create( + self, + workspace_slug: str, + project_id: str, + work_item_id: str, + data: CreateWorkItemPage, + ) -> WorkItemPage: + """Link a page to 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: Page link data containing page_id + """ + response = self._post( + f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/pages", + data.model_dump(exclude_none=True), + ) + return WorkItemPage.model_validate(response) + + def delete( + self, + workspace_slug: str, + project_id: str, + work_item_id: str, + work_item_page_id: str, + ) -> None: + """Remove a page link from a work item. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + work_item_id: UUID of the work item + work_item_page_id: UUID of the work item page link + """ + return self._delete( + f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/pages/{work_item_page_id}" + ) diff --git a/plane/models/enums.py b/plane/models/enums.py index 1bc6823..6b61a51 100644 --- a/plane/models/enums.py +++ b/plane/models/enums.py @@ -45,8 +45,9 @@ "URL", "EMAIL", "FILE", + "FORMULA", ] -RelationTypeEnum = Literal["ISSUE", "USER"] +RelationTypeEnum = Literal["ISSUE", "USER", "RELEASE"] # Proper Enum classes for better type safety and IDE support @@ -62,6 +63,7 @@ class PropertyType(Enum): URL = "URL" EMAIL = "EMAIL" FILE = "FILE" + FORMULA = "FORMULA" class RelationType(Enum): @@ -69,6 +71,7 @@ class RelationType(Enum): ISSUE = "ISSUE" USER = "USER" + RELEASE = "RELEASE" class Priority(Enum): diff --git a/plane/models/projects.py b/plane/models/projects.py index a2a36c8..2153c82 100644 --- a/plane/models/projects.py +++ b/plane/models/projects.py @@ -92,6 +92,7 @@ class UpdateProject(BaseModel): name: str | None = None description: str | None = None + network: NetworkEnum | None = None project_lead: str | None = None default_assignee: str | None = None identifier: str | None = None diff --git a/plane/models/work_item_pages.py b/plane/models/work_item_pages.py new file mode 100644 index 0000000..b7d50a3 --- /dev/null +++ b/plane/models/work_item_pages.py @@ -0,0 +1,50 @@ +from typing import Any + +from pydantic import BaseModel, ConfigDict + +from .pagination import PaginatedResponse + + +class WorkItemPageLite(BaseModel): + """Nested page info returned inside a WorkItemPage response.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + name: str | None = None + created_at: str | None = None + updated_at: str | None = None + created_by: str | None = None + is_global: bool | None = None + logo_props: Any | None = None + + +class WorkItemPage(BaseModel): + """Work item to page link.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + page: WorkItemPageLite | None = None + issue: str | None = None + project: str | None = None + workspace: str | None = None + created_at: str | None = None + updated_at: str | None = None + created_by: str | None = None + + +class CreateWorkItemPage(BaseModel): + """Request model for linking a page to a work item.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + page_id: str + + +class PaginatedWorkItemPageResponse(PaginatedResponse): + """Paginated response for work item page links.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + results: list[WorkItemPage] diff --git a/plane/models/work_item_properties.py b/plane/models/work_item_properties.py index e78bcf1..92828f9 100644 --- a/plane/models/work_item_properties.py +++ b/plane/models/work_item_properties.py @@ -44,11 +44,11 @@ class WorkItemProperty(BaseModel): options: list["WorkItemPropertyOption"] | None = None @field_serializer("property_type") - def serialize_property_type(self, value: PropertyType) -> str: + def serialize_property_type(self, value: PropertyType) -> str | None: return value.value if value else None @field_serializer("relation_type") - def serialize_relation_type(self, value: RelationType) -> str: + def serialize_relation_type(self, value: RelationType) -> str | None: return value.value if value else None @@ -72,11 +72,11 @@ class CreateWorkItemProperty(BaseModel): options: list["CreateWorkItemPropertyOption"] | None = None @field_serializer("property_type") - def serialize_property_type(self, value: PropertyType) -> str: + def serialize_property_type(self, value: PropertyType) -> str | None: return value.value if value else None @field_serializer("relation_type") - def serialize_relation_type(self, value: RelationType) -> str: + def serialize_relation_type(self, value: RelationType) -> str | None: return value.value if value else None @model_validator(mode="after") @@ -131,11 +131,11 @@ class UpdateWorkItemProperty(BaseModel): external_id: str | None = None @field_serializer("property_type") - def serialize_property_type(self, value: PropertyType) -> str: + def serialize_property_type(self, value: PropertyType) -> str | None: return value.value if value else None @field_serializer("relation_type") - def serialize_relation_type(self, value: RelationType) -> str: + def serialize_relation_type(self, value: RelationType) -> str | None: return value.value if value else None @model_validator(mode="after") diff --git a/plane/models/work_items.py b/plane/models/work_items.py index 9e93080..98e7d67 100644 --- a/plane/models/work_items.py +++ b/plane/models/work_items.py @@ -368,6 +368,7 @@ class CreateWorkItemLink(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) url: str + title: str | None = None class UpdateWorkItemLink(BaseModel): @@ -376,6 +377,7 @@ class UpdateWorkItemLink(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) url: str | None = None + title: str | None = None class WorkItemAttachment(BaseModel): diff --git a/pyproject.toml b/pyproject.toml index 43e140c..6e7fcae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plane-sdk" -version = "0.2.11" +version = "0.2.12" description = "Python SDK for Plane API" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/unit/test_intake.py b/tests/unit/test_intake.py index 3f8541e..8ddc0e5 100644 --- a/tests/unit/test_intake.py +++ b/tests/unit/test_intake.py @@ -4,7 +4,7 @@ from plane.client import PlaneClient from plane.models.intake import CreateIntakeWorkItem -from plane.models.projects import Project, ProjectFeature +from plane.models.projects import Project class TestIntakeAPI: @@ -113,3 +113,37 @@ def test_retrieve_intake_work_item( assert retrieved is not None assert hasattr(retrieved, "issue") + def test_update_intake_status_accepted( + self, client: PlaneClient, workspace_slug: str, project_with_intake, intake_work_item + ) -> None: + """Test accepting an intake work item (status=1).""" + if not (hasattr(intake_work_item, "issue") and intake_work_item.issue): + pytest.skip("intake_work_item has no issue field") + from plane.models.intake import UpdateIntakeWorkItem + + updated = client.intake.update_status( + workspace_slug, + project_with_intake.id, + intake_work_item.issue, + UpdateIntakeWorkItem(status=1), + ) + assert updated is not None + assert updated.status == 1 + + def test_update_intake_status_declined( + self, client: PlaneClient, workspace_slug: str, project_with_intake, intake_work_item + ) -> None: + """Test declining an intake work item (status=-1).""" + if not (hasattr(intake_work_item, "issue") and intake_work_item.issue): + pytest.skip("intake_work_item has no issue field") + from plane.models.intake import UpdateIntakeWorkItem + + updated = client.intake.update_status( + workspace_slug, + project_with_intake.id, + intake_work_item.issue, + UpdateIntakeWorkItem(status=-1), + ) + assert updated is not None + assert updated.status == -1 + diff --git a/tests/unit/test_pages.py b/tests/unit/test_pages.py index 385e7d1..6f2ea43 100644 --- a/tests/unit/test_pages.py +++ b/tests/unit/test_pages.py @@ -1,7 +1,9 @@ """Unit tests for Pages API resource (smoke tests with real HTTP requests).""" +import time + from plane.client import PlaneClient -from plane.models.pages import CreatePage +from plane.models.pages import CreatePage, PaginatedPageResponse from plane.models.projects import Project @@ -12,13 +14,12 @@ def test_create_project_page( self, client: PlaneClient, workspace_slug: str, project: Project ) -> None: """Test creating a project page.""" - import time page_data = CreatePage( name=f"Test Page {int(time.time())}", description_html="

Test page description

", color="#4ECDC4", ) - + page = client.pages.create_project_page(workspace_slug, project.id, page_data) assert page is not None assert page.id is not None @@ -26,15 +27,68 @@ def test_create_project_page( def test_create_workspace_page(self, client: PlaneClient, workspace_slug: str) -> None: """Test creating a workspace page.""" - import time page_data = CreatePage( name=f"Test Workspace Page {int(time.time())}", description_html="

Test workspace page

", color="#FF6B6B", ) - + page = client.pages.create_workspace_page(workspace_slug, page_data) assert page is not None assert page.id is not None assert page.name == page_data.name + def test_list_workspace_pages(self, client: PlaneClient, workspace_slug: str) -> None: + """Test listing workspace pages returns paginated response.""" + response = client.pages.list_workspace_pages(workspace_slug) + assert isinstance(response, PaginatedPageResponse) + assert hasattr(response, "results") + assert isinstance(response.results, list) + + def test_list_workspace_pages_contains_created_page( + self, client: PlaneClient, workspace_slug: str + ) -> None: + """Test that a freshly created workspace page appears in the list.""" + page_data = CreatePage( + name=f"Listable Workspace Page {int(time.time())}", + description_html="

list test

", + ) + created = client.pages.create_workspace_page(workspace_slug, page_data) + try: + response = client.pages.list_workspace_pages(workspace_slug) + page_ids = [p.id for p in response.results] + assert created.id in page_ids + finally: + try: + client.pages.delete_workspace_page(workspace_slug, created.id) + except Exception: + pass + + def test_list_project_pages( + self, client: PlaneClient, workspace_slug: str, project: Project + ) -> None: + """Test listing project pages returns paginated response.""" + response = client.pages.list_project_pages(workspace_slug, project.id) + assert isinstance(response, PaginatedPageResponse) + assert hasattr(response, "results") + assert isinstance(response.results, list) + + def test_list_project_pages_contains_created_page( + self, client: PlaneClient, workspace_slug: str, project: Project + ) -> None: + """Test that a freshly created project page appears in the list.""" + page_data = CreatePage( + name=f"Listable Project Page {int(time.time())}", + description_html="

list test

", + ) + created = client.pages.create_project_page(workspace_slug, project.id, page_data) + try: + response = client.pages.list_project_pages(workspace_slug, project.id) + page_ids = [p.id for p in response.results] + assert created.id in page_ids + finally: + try: + client.pages.delete_project_page(workspace_slug, project.id, created.id) + except Exception: + pass + diff --git a/tests/unit/test_work_item_pages.py b/tests/unit/test_work_item_pages.py new file mode 100644 index 0000000..decde48 --- /dev/null +++ b/tests/unit/test_work_item_pages.py @@ -0,0 +1,134 @@ +"""Unit tests for WorkItemPages sub-resource (smoke tests with real HTTP requests).""" + +import time + +import pytest + +from plane.client import PlaneClient +from plane.models.pages import CreatePage +from plane.models.projects import Project +from plane.models.work_item_pages import CreateWorkItemPage +from plane.models.work_items import CreateWorkItem + + +class TestWorkItemPagesAPI: + """Test work item page link CRUD operations.""" + + @pytest.fixture + def work_item(self, client: PlaneClient, workspace_slug: str, project: Project): + """Create a work item and delete it after the test.""" + wi = client.work_items.create( + workspace_slug, + project.id, + CreateWorkItem(name=f"Test WI for pages {int(time.time())}"), + ) + yield wi + try: + client.work_items.delete(workspace_slug, project.id, wi.id) + except Exception: + pass + + @pytest.fixture + def page(self, client: PlaneClient, workspace_slug: str): + """Create a workspace page to use in link tests.""" + return client.pages.create_workspace_page( + workspace_slug, + CreatePage( + name=f"Test Page for WI link {int(time.time())}", + description_html="

page for work item link test

", + ), + ) + + def test_create_work_item_page_link( + self, client: PlaneClient, workspace_slug: str, project: Project, work_item, page + ) -> None: + """Link a page to a work item.""" + link = None + try: + link = client.work_items.pages.create( + workspace_slug, + project.id, + work_item.id, + CreateWorkItemPage(page_id=page.id), + ) + assert link is not None + assert link.id is not None + assert link.page is not None + assert link.page.id == page.id + finally: + if link and link.id: + try: + client.work_items.pages.delete( + workspace_slug, project.id, work_item.id, link.id + ) + except Exception: + pass + + def test_list_work_item_pages( + self, client: PlaneClient, workspace_slug: str, project: Project, work_item, page + ) -> None: + """List page links for a work item.""" + link = client.work_items.pages.create( + workspace_slug, + project.id, + work_item.id, + CreateWorkItemPage(page_id=page.id), + ) + try: + response = client.work_items.pages.list( + workspace_slug, project.id, work_item.id + ) + assert response is not None + assert hasattr(response, "results") + assert isinstance(response.results, list) + assert any(r.id == link.id for r in response.results) + finally: + try: + client.work_items.pages.delete( + workspace_slug, project.id, work_item.id, link.id + ) + except Exception: + pass + + def test_retrieve_work_item_page( + self, client: PlaneClient, workspace_slug: str, project: Project, work_item, page + ) -> None: + """Retrieve a specific page link by ID.""" + link = client.work_items.pages.create( + workspace_slug, + project.id, + work_item.id, + CreateWorkItemPage(page_id=page.id), + ) + try: + retrieved = client.work_items.pages.retrieve( + workspace_slug, project.id, work_item.id, link.id + ) + assert retrieved is not None + assert retrieved.id == link.id + assert retrieved.page is not None + assert retrieved.page.id == page.id + finally: + try: + client.work_items.pages.delete( + workspace_slug, project.id, work_item.id, link.id + ) + except Exception: + pass + + def test_delete_work_item_page( + self, client: PlaneClient, workspace_slug: str, project: Project, work_item, page + ) -> None: + """Remove a page link from a work item.""" + link = client.work_items.pages.create( + workspace_slug, + project.id, + work_item.id, + CreateWorkItemPage(page_id=page.id), + ) + client.work_items.pages.delete(workspace_slug, project.id, work_item.id, link.id) + + response = client.work_items.pages.list( + workspace_slug, project.id, work_item.id + ) + assert not any(r.id == link.id for r in response.results) diff --git a/tests/unit/test_work_items.py b/tests/unit/test_work_items.py index b19098f..6759029 100644 --- a/tests/unit/test_work_items.py +++ b/tests/unit/test_work_items.py @@ -5,7 +5,13 @@ from plane.client import PlaneClient from plane.models.projects import Project from plane.models.query_params import PaginatedQueryParams, WorkItemQueryParams -from plane.models.work_items import AdvancedSearchWorkItem, CreateWorkItem, UpdateWorkItem +from plane.models.work_items import ( + AdvancedSearchWorkItem, + CreateWorkItem, + CreateWorkItemLink, + UpdateWorkItem, + UpdateWorkItemLink, +) class TestWorkItemsAPI: @@ -253,3 +259,109 @@ def test_list_attachments( response = client.work_items.attachments.list(workspace_slug, project.id, work_item.id) assert response is not None assert isinstance(response, list) + + def test_create_link_with_title( + self, client: PlaneClient, workspace_slug: str, project: Project, work_item + ) -> None: + """Test creating a work item link with title.""" + link = None + try: + link = client.work_items.links.create( + workspace_slug, + project.id, + work_item.id, + CreateWorkItemLink(url="https://example.com", title="Example"), + ) + assert link is not None + assert link.url == "https://example.com" + assert link.title == "Example" + finally: + if link is not None: + client.work_items.links.delete(workspace_slug, project.id, work_item.id, link.id) + + def test_update_link_title( + self, client: PlaneClient, workspace_slug: str, project: Project, work_item + ) -> None: + """Test updating a work item link title.""" + link = None + try: + link = client.work_items.links.create( + workspace_slug, + project.id, + work_item.id, + CreateWorkItemLink(url="https://example.com", title="Original"), + ) + updated = client.work_items.links.update( + workspace_slug, + project.id, + work_item.id, + link.id, + UpdateWorkItemLink(title="Updated"), + ) + assert updated.title == "Updated" + finally: + if link is not None: + client.work_items.links.delete(workspace_slug, project.id, work_item.id, link.id) + + +class TestWorkItemsArchive: + """Test archive, unarchive, and list_archived operations.""" + + def test_list_archived_work_items( + self, client: PlaneClient, workspace_slug: str, project: Project + ) -> None: + """Test listing archived work items returns paginated response.""" + response = client.work_items.list_archived(workspace_slug, project.id) + assert response is not None + assert hasattr(response, "results") + assert hasattr(response, "count") + assert isinstance(response.results, list) + + def test_archive_and_unarchive_work_item( + self, client: PlaneClient, workspace_slug: str, project: Project + ) -> None: + """Test archiving and unarchiving a work item. + + Archiving requires a completed or cancelled state, so we look up an + appropriate state before creating the work item. + """ + import time + + # Find a completed or cancelled state + states = client.states.list(workspace_slug, project.id) + target_state = next( + (s for s in states.results if s.group in ("completed", "cancelled")), + None, + ) + if target_state is None: + pytest.skip("No completed or cancelled state found in project") + + wi = client.work_items.create( + workspace_slug, + project.id, + CreateWorkItem( + name=f"Archive Test WI {int(time.time())}", + state=target_state.id, + ), + ) + try: + # Archive + client.work_items.archive(workspace_slug, project.id, wi.id) + + # Verify appears in archived list + archived = client.work_items.list_archived(workspace_slug, project.id) + archived_ids = [w.id for w in archived.results] + assert wi.id in archived_ids + + # Unarchive + client.work_items.unarchive(workspace_slug, project.id, wi.id) + + # Verify absent from archived list + archived_after = client.work_items.list_archived(workspace_slug, project.id) + archived_ids_after = [w.id for w in archived_after.results] + assert wi.id not in archived_ids_after + finally: + try: + client.work_items.delete(workspace_slug, project.id, wi.id) + except Exception: + pass