Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 103 additions & 8 deletions plane/api/epics.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
from __future__ import annotations

from typing import Any

from ..models.epics import Epic, PaginatedEpicResponse
from ..models.epics import (
AddEpicWorkItems,
CreateEpic,
Epic,
EpicIssue,
PaginatedEpicIssueResponse,
PaginatedEpicResponse,
UpdateEpic,
)
from ..models.query_params import PaginatedQueryParams, RetrieveQueryParams
from .base_resource import BaseResource

Expand All @@ -9,6 +19,69 @@ class Epics(BaseResource):
def __init__(self, config: Any) -> None:
super().__init__(config, "/workspaces/")

def create(self, workspace_slug: str, project_id: str, data: CreateEpic) -> Epic:
"""Create a new epic in a project.

Args:
workspace_slug: The workspace slug identifier
project_id: UUID of the project
data: Epic creation data
"""
# enable epics feature flag
response = self._post(
f"{workspace_slug}/projects/{project_id}/epics",
data.model_dump(exclude_none=True),
)
return Epic.model_validate(response)
Comment on lines +30 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove orphaned comment and add trailing slash to endpoint.

The comment "enable epics feature flag" has no corresponding code. Additionally, the endpoint URL should end with a trailing / per coding guidelines.

🔧 Proposed fix
-        # enable epics feature flag
         response = self._post(
-            f"{workspace_slug}/projects/{project_id}/epics",
+            f"{workspace_slug}/projects/{project_id}/epics/",
             data.model_dump(exclude_none=True),
         )

As per coding guidelines: "All API endpoints should end with a trailing /"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plane/api/epics.py` around lines 30 - 35, Remove the orphaned comment "enable
epics feature flag" and ensure the API URL passed to self._post ends with a
trailing slash: update the call that currently uses
f"{workspace_slug}/projects/{project_id}/epics" to
f"{workspace_slug}/projects/{project_id}/epics/" (the block that returns
Epic.model_validate(response) should remain unchanged).


def retrieve(
self,
workspace_slug: str,
project_id: str,
epic_id: str,
params: RetrieveQueryParams | None = None,
) -> Epic:
"""Retrieve an epic by ID.

Args:
workspace_slug: The workspace slug identifier
project_id: UUID of the project
epic_id: UUID of the epic
params: Optional query parameters for expand, fields, etc.
"""
query_params = params.model_dump(exclude_none=True) if params else None
response = self._get(
f"{workspace_slug}/projects/{project_id}/epics/{epic_id}", params=query_params
)
return Epic.model_validate(response)
Comment on lines +52 to +56
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add trailing slash to endpoint URLs.

The retrieve, update, and delete endpoints are missing trailing slashes.

🔧 Proposed fixes for lines 54, 70, 83
         response = self._get(
-            f"{workspace_slug}/projects/{project_id}/epics/{epic_id}", params=query_params
+            f"{workspace_slug}/projects/{project_id}/epics/{epic_id}/", params=query_params
         )
         response = self._patch(
-            f"{workspace_slug}/projects/{project_id}/epics/{epic_id}",
+            f"{workspace_slug}/projects/{project_id}/epics/{epic_id}/",
             data.model_dump(exclude_none=True),
         )
-        self._delete(f"{workspace_slug}/projects/{project_id}/epics/{epic_id}")
+        self._delete(f"{workspace_slug}/projects/{project_id}/epics/{epic_id}/")

As per coding guidelines: "All API endpoints should end with a trailing /"

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
query_params = params.model_dump(exclude_none=True) if params else None
response = self._get(
f"{workspace_slug}/projects/{project_id}/epics/{epic_id}", params=query_params
)
return Epic.model_validate(response)
query_params = params.model_dump(exclude_none=True) if params else None
response = self._get(
f"{workspace_slug}/projects/{project_id}/epics/{epic_id}/", params=query_params
)
return Epic.model_validate(response)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plane/api/epics.py` around lines 52 - 56, The endpoint URL strings for epic
retrieve/update/delete are missing the required trailing slash; update the
f-strings like f"{workspace_slug}/projects/{project_id}/epics/{epic_id}" (and
the analogous strings used in the update and delete calls) to include a trailing
"/" at the end (e.g. f".../epics/{epic_id}/") so all requests issued by methods
that call self._get, self._patch, and self._delete use endpoints that end with a
slash.


def update(
self, workspace_slug: str, project_id: str, epic_id: str, data: UpdateEpic
) -> Epic:
"""Partially update an existing epic.

Args:
workspace_slug: The workspace slug identifier
project_id: UUID of the project
epic_id: UUID of the epic
data: Epic update data
"""
response = self._patch(
f"{workspace_slug}/projects/{project_id}/epics/{epic_id}",
data.model_dump(exclude_none=True),
)
return Epic.model_validate(response)

def delete(self, workspace_slug: str, project_id: str, epic_id: str) -> None:
"""Delete an epic.

Args:
workspace_slug: The workspace slug identifier
project_id: UUID of the project
epic_id: UUID of the epic
"""
self._delete(f"{workspace_slug}/projects/{project_id}/epics/{epic_id}")

def list(
self,
workspace_slug: str,
Expand All @@ -26,23 +99,45 @@ def list(
response = self._get(f"{workspace_slug}/projects/{project_id}/epics", params=query_params)
return PaginatedEpicResponse.model_validate(response)

def retrieve(
def list_issues(
self,
workspace_slug: str,
project_id: str,
epic_id: str,
params: RetrieveQueryParams | None = None,
) -> Epic:
"""Retrieve an epic by ID.
params: PaginatedQueryParams | None = None,
) -> PaginatedEpicIssueResponse:
"""List work items under an epic.

Args:
workspace_slug: The workspace slug identifier
project_id: UUID of the project
epic_id: UUID of the epic
params: Optional query parameters for expand, fields, etc.
params: Optional query parameters for pagination
"""
query_params = params.model_dump(exclude_none=True) if params else None
response = self._get(
f"{workspace_slug}/projects/{project_id}/epics/{epic_id}", params=query_params
f"{workspace_slug}/projects/{project_id}/epics/{epic_id}/issues",
params=query_params,
)
Comment on lines 118 to 121
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add trailing slash to endpoint URL.

🔧 Proposed fix
         response = self._get(
-            f"{workspace_slug}/projects/{project_id}/epics/{epic_id}/issues",
+            f"{workspace_slug}/projects/{project_id}/epics/{epic_id}/issues/",
             params=query_params,
         )

As per coding guidelines: "All API endpoints should end with a trailing /"

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
response = self._get(
f"{workspace_slug}/projects/{project_id}/epics/{epic_id}", params=query_params
f"{workspace_slug}/projects/{project_id}/epics/{epic_id}/issues",
params=query_params,
)
response = self._get(
f"{workspace_slug}/projects/{project_id}/epics/{epic_id}/issues/",
params=query_params,
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plane/api/epics.py` around lines 118 - 121, The API call constructing the
endpoint in the expression using self._get (the f-string that builds
"{workspace_slug}/projects/{project_id}/epics/{epic_id}/issues") must include a
trailing slash; update the f-string used when calling self._get so the endpoint
ends with "/". Ensure the change is applied in the same call site (the response
= self._get(...) invocation) so all requests conform to the guideline that
endpoints end with a trailing slash.

return Epic.model_validate(response)
return PaginatedEpicIssueResponse.model_validate(response)

def add_issues(
self,
workspace_slug: str,
project_id: str,
epic_id: str,
data: AddEpicWorkItems,
) -> list[EpicIssue]:
"""Add work items as sub-issues under an epic.

Args:
workspace_slug: The workspace slug identifier
project_id: UUID of the project
epic_id: UUID of the epic
data: Work item IDs to add
"""
response = self._post(
f"{workspace_slug}/projects/{project_id}/epics/{epic_id}/issues",
data.model_dump(exclude_none=True),
)
return [EpicIssue.model_validate(item) for item in response]
92 changes: 92 additions & 0 deletions plane/models/epics.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,101 @@ class Epic(BaseModel):
labels: list[str] | None = None


class CreateEpic(BaseModel):
"""Request model for creating an epic."""

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

name: str
description_html: str | None = None
state_id: str | None = None
parent_id: str | None = None
assignee_ids: list[str] | None = None
label_ids: list[str] | None = None
priority: PriorityEnum | None = None
start_date: str | None = None
target_date: str | None = None
estimate_point: str | None = None
external_source: str | None = None
external_id: str | None = None


class UpdateEpic(BaseModel):
"""Request model for updating an epic."""

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

name: str | None = None
description_html: str | None = None
state_id: str | None = None
parent_id: str | None = None
assignee_ids: list[str] | None = None
label_ids: list[str] | None = None
priority: PriorityEnum | None = None
start_date: str | None = None
target_date: str | None = None
estimate_point: str | None = None
external_source: str | None = None
external_id: str | None = None


class AddEpicWorkItems(BaseModel):
"""Request model for adding work items to an epic."""

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

work_item_ids: list[str]


class EpicIssue(BaseModel):
"""Response model for an epic's work item."""

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

id: str
type_id: str | None = None
parent: str | None = None
created_at: str | None = None
updated_at: str | None = None
deleted_at: str | None = None
point: int | None = None
name: str
description_html: str | None = None
description_stripped: str | None = None
description_binary: str | None = None
priority: PriorityEnum | None = None
start_date: str | None = None
target_date: str | None = None
sequence_id: int | None = None
sort_order: float | None = None
completed_at: str | None = None
archived_at: str | None = None
last_activity_at: str | None = None
is_draft: bool | None = None
external_source: str | None = None
external_id: str | None = None
created_by: str | None = None
updated_by: str | None = None
project: str | None = None
workspace: str | None = None
state: str | None = None
estimate_point: str | None = None
type: str | None = None
assignees: list[str] | None = None
labels: list[str] | None = None


class PaginatedEpicResponse(PaginatedResponse):
"""Paginated response for epics."""

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

results: list[Epic]


class PaginatedEpicIssueResponse(PaginatedResponse):
"""Paginated response for epic issues."""

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

results: list[EpicIssue]
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "plane-sdk"
version = "0.2.8"
version = "0.2.9"
description = "Python SDK for Plane API"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
87 changes: 86 additions & 1 deletion tests/unit/test_epics.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
"""Unit tests for Epics API resource (smoke tests with real HTTP requests)."""

from datetime import datetime

import pytest

from plane.client import PlaneClient
from plane.models.projects import Project
from plane.models.epics import AddEpicWorkItems, CreateEpic, Epic, UpdateEpic
from plane.models.projects import Project, ProjectFeature
from plane.models.query_params import PaginatedQueryParams
from plane.models.work_items import CreateWorkItem


class TestEpicsAPI:
Expand All @@ -28,3 +34,82 @@ def test_list_epics_with_params(
assert hasattr(response, "results")
assert len(response.results) <= 10


class TestEpicsAPICRUD:
"""Test Epics API CRUD operations."""

@pytest.fixture
def epic(
self, client: PlaneClient, workspace_slug: str, project: Project
) -> Epic:
"""Create a test epic and yield it, then delete it."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
data = CreateEpic(name=f"Test Epic {timestamp}", priority="high")
epic = client.epics.create(workspace_slug, project.id, data)
yield epic
try:
client.epics.delete(workspace_slug, project.id, epic.id)
except Exception:
pass

def test_create_epic(
self, client: PlaneClient, workspace_slug: str, project: Project
) -> None:
"""Test creating an epic."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
data = CreateEpic(name=f"Test Epic {timestamp}")
client.projects.update_features(workspace_slug, project.id, ProjectFeature(epics=True))
epic = client.epics.create(workspace_slug, project.id, data)
assert epic is not None
assert epic.id is not None
assert epic.name == data.name
Comment on lines +55 to +65
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing cleanup for created epic.

This test creates an epic directly without using the epic fixture, leaving an orphan resource after test completion. Consider adding cleanup or restructuring to use the fixture.

🧹 Proposed fix to add cleanup
     def test_create_epic(
         self, client: PlaneClient, workspace_slug: str, project: Project
     ) -> None:
         """Test creating an epic."""
         timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
         data = CreateEpic(name=f"Test Epic {timestamp}")
         client.projects.update_features(workspace_slug, project.id, ProjectFeature(epics=True))
         epic = client.epics.create(workspace_slug, project.id, data)
-        assert epic is not None
-        assert epic.id is not None
-        assert epic.name == data.name
+        try:
+            assert epic is not None
+            assert epic.id is not None
+            assert epic.name == data.name
+        finally:
+            try:
+                client.epics.delete(workspace_slug, project.id, epic.id)
+            except Exception:
+                pass
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_create_epic(
self, client: PlaneClient, workspace_slug: str, project: Project
) -> None:
"""Test creating an epic."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
data = CreateEpic(name=f"Test Epic {timestamp}")
client.projects.update_features(workspace_slug, project.id, ProjectFeature(epics=True))
epic = client.epics.create(workspace_slug, project.id, data)
assert epic is not None
assert epic.id is not None
assert epic.name == data.name
def test_create_epic(
self, client: PlaneClient, workspace_slug: str, project: Project
) -> None:
"""Test creating an epic."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
data = CreateEpic(name=f"Test Epic {timestamp}")
client.projects.update_features(workspace_slug, project.id, ProjectFeature(epics=True))
epic = client.epics.create(workspace_slug, project.id, data)
try:
assert epic is not None
assert epic.id is not None
assert epic.name == data.name
finally:
try:
client.epics.delete(workspace_slug, project.id, epic.id)
except Exception:
pass
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/test_epics.py` around lines 55 - 65, The test_create_epic test
creates an epic via client.epics.create but never deletes it, leaving an orphan
resource; update the test to clean up after itself by either using the existing
epic fixture or deleting the created epic in a teardown path (e.g., call
client.epics.delete(workspace_slug, epic.id) in a finally block or register it
for cleanup), and ensure the code checks epic is not None before attempting
deletion; reference test_create_epic, client.epics.create, and
client.epics.delete (or the epic fixture) when making the change.


def test_retrieve_epic(
self, client: PlaneClient, workspace_slug: str, project: Project, epic: Epic
) -> None:
"""Test retrieving an epic."""
retrieved = client.epics.retrieve(workspace_slug, project.id, epic.id)
assert retrieved is not None
assert retrieved.id == epic.id
assert retrieved.name == epic.name

def test_update_epic(
self, client: PlaneClient, workspace_slug: str, project: Project, epic: Epic
) -> None:
"""Test updating an epic."""
update_data = UpdateEpic(name="Updated Epic Name")
updated = client.epics.update(workspace_slug, project.id, epic.id, update_data)
assert updated is not None
assert updated.id == epic.id
assert updated.name == "Updated Epic Name"

def test_list_epic_issues(
self, client: PlaneClient, workspace_slug: str, project: Project, epic: Epic
) -> None:
"""Test listing work items under an epic."""
response = client.epics.list_issues(workspace_slug, project.id, epic.id)
assert response is not None
assert hasattr(response, "results")
assert isinstance(response.results, list)

def test_add_issues_to_epic(
self, client: PlaneClient, workspace_slug: str, project: Project, epic: Epic
) -> None:
"""Test adding work items to an epic."""
work_item = client.work_items.create(
workspace_slug,
project.id,
CreateWorkItem(name="Test Work Item for Epic"),
)
try:
data = AddEpicWorkItems(work_item_ids=[work_item.id])
added = client.epics.add_issues(workspace_slug, project.id, epic.id, data)
assert added is not None
assert isinstance(added, list)
assert len(added) == 1
assert added[0].parent == epic.id
finally:
try:
client.work_items.delete(workspace_slug, project.id, work_item.id)
except Exception:
pass