Skip to content

Commit 97e0307

Browse files
authored
[SILO-1143] feat: add epics write endpoints (#26)
* add epics write endpoints * add json serialization in initiative update
1 parent d6c824d commit 97e0307

5 files changed

Lines changed: 285 additions & 16 deletions

File tree

plane/api/epics.py

Lines changed: 103 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
1+
from __future__ import annotations
2+
13
from typing import Any
24

3-
from ..models.epics import Epic, PaginatedEpicResponse
5+
from ..models.epics import (
6+
AddEpicWorkItems,
7+
CreateEpic,
8+
Epic,
9+
EpicIssue,
10+
PaginatedEpicIssueResponse,
11+
PaginatedEpicResponse,
12+
UpdateEpic,
13+
)
414
from ..models.query_params import PaginatedQueryParams, RetrieveQueryParams
515
from .base_resource import BaseResource
616

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

22+
def create(self, workspace_slug: str, project_id: str, data: CreateEpic) -> Epic:
23+
"""Create a new epic in a project.
24+
25+
Args:
26+
workspace_slug: The workspace slug identifier
27+
project_id: UUID of the project
28+
data: Epic creation data
29+
"""
30+
# enable epics feature flag
31+
response = self._post(
32+
f"{workspace_slug}/projects/{project_id}/epics",
33+
data.model_dump(exclude_none=True),
34+
)
35+
return Epic.model_validate(response)
36+
37+
def retrieve(
38+
self,
39+
workspace_slug: str,
40+
project_id: str,
41+
epic_id: str,
42+
params: RetrieveQueryParams | None = None,
43+
) -> Epic:
44+
"""Retrieve an epic by ID.
45+
46+
Args:
47+
workspace_slug: The workspace slug identifier
48+
project_id: UUID of the project
49+
epic_id: UUID of the epic
50+
params: Optional query parameters for expand, fields, etc.
51+
"""
52+
query_params = params.model_dump(exclude_none=True) if params else None
53+
response = self._get(
54+
f"{workspace_slug}/projects/{project_id}/epics/{epic_id}", params=query_params
55+
)
56+
return Epic.model_validate(response)
57+
58+
def update(
59+
self, workspace_slug: str, project_id: str, epic_id: str, data: UpdateEpic
60+
) -> Epic:
61+
"""Partially update an existing epic.
62+
63+
Args:
64+
workspace_slug: The workspace slug identifier
65+
project_id: UUID of the project
66+
epic_id: UUID of the epic
67+
data: Epic update data
68+
"""
69+
response = self._patch(
70+
f"{workspace_slug}/projects/{project_id}/epics/{epic_id}",
71+
data.model_dump(exclude_none=True),
72+
)
73+
return Epic.model_validate(response)
74+
75+
def delete(self, workspace_slug: str, project_id: str, epic_id: str) -> None:
76+
"""Delete an epic.
77+
78+
Args:
79+
workspace_slug: The workspace slug identifier
80+
project_id: UUID of the project
81+
epic_id: UUID of the epic
82+
"""
83+
self._delete(f"{workspace_slug}/projects/{project_id}/epics/{epic_id}")
84+
1285
def list(
1386
self,
1487
workspace_slug: str,
@@ -26,23 +99,45 @@ def list(
2699
response = self._get(f"{workspace_slug}/projects/{project_id}/epics", params=query_params)
27100
return PaginatedEpicResponse.model_validate(response)
28101

29-
def retrieve(
102+
def list_issues(
30103
self,
31104
workspace_slug: str,
32105
project_id: str,
33106
epic_id: str,
34-
params: RetrieveQueryParams | None = None,
35-
) -> Epic:
36-
"""Retrieve an epic by ID.
107+
params: PaginatedQueryParams | None = None,
108+
) -> PaginatedEpicIssueResponse:
109+
"""List work items under an epic.
37110
38111
Args:
39112
workspace_slug: The workspace slug identifier
40113
project_id: UUID of the project
41114
epic_id: UUID of the epic
42-
params: Optional query parameters for expand, fields, etc.
115+
params: Optional query parameters for pagination
43116
"""
44117
query_params = params.model_dump(exclude_none=True) if params else None
45118
response = self._get(
46-
f"{workspace_slug}/projects/{project_id}/epics/{epic_id}", params=query_params
119+
f"{workspace_slug}/projects/{project_id}/epics/{epic_id}/issues",
120+
params=query_params,
47121
)
48-
return Epic.model_validate(response)
122+
return PaginatedEpicIssueResponse.model_validate(response)
123+
124+
def add_issues(
125+
self,
126+
workspace_slug: str,
127+
project_id: str,
128+
epic_id: str,
129+
data: AddEpicWorkItems,
130+
) -> list[EpicIssue]:
131+
"""Add work items as sub-issues under an epic.
132+
133+
Args:
134+
workspace_slug: The workspace slug identifier
135+
project_id: UUID of the project
136+
epic_id: UUID of the epic
137+
data: Work item IDs to add
138+
"""
139+
response = self._post(
140+
f"{workspace_slug}/projects/{project_id}/epics/{epic_id}/issues",
141+
data.model_dump(exclude_none=True),
142+
)
143+
return [EpicIssue.model_validate(item) for item in response]

plane/api/initiatives/base.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def create(self, workspace_slug: str, data: CreateInitiative) -> Initiative:
3636
"""
3737
response = self._post(
3838
f"{workspace_slug}/initiatives",
39-
data.model_dump(exclude_none=True),
39+
data.model_dump(exclude_none=True, mode="json"),
4040
)
4141
return Initiative.model_validate(response)
4242

@@ -53,9 +53,7 @@ def retrieve(self, workspace_slug: str, initiative_id: str) -> Initiative:
5353
response = self._get(f"{workspace_slug}/initiatives/{initiative_id}")
5454
return Initiative.model_validate(response)
5555

56-
def update(
57-
self, workspace_slug: str, initiative_id: str, data: UpdateInitiative
58-
) -> Initiative:
56+
def update(self, workspace_slug: str, initiative_id: str, data: UpdateInitiative) -> Initiative:
5957
"""Update an initiative by ID.
6058
6159
Args:
@@ -68,7 +66,7 @@ def update(
6866
"""
6967
response = self._patch(
7068
f"{workspace_slug}/initiatives/{initiative_id}",
71-
data.model_dump(exclude_none=True),
69+
data.model_dump(exclude_none=True, mode="json"),
7270
)
7371
return Initiative.model_validate(response)
7472

@@ -95,4 +93,3 @@ def list(
9593
"""
9694
response = self._get(f"{workspace_slug}/initiatives", params=params)
9795
return PaginatedInitiativeResponse.model_validate(response)
98-

plane/models/epics.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,101 @@ class Epic(BaseModel):
4343
labels: list[str] | None = None
4444

4545

46+
class CreateEpic(BaseModel):
47+
"""Request model for creating an epic."""
48+
49+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
50+
51+
name: str
52+
description_html: str | None = None
53+
state_id: str | None = None
54+
parent_id: str | None = None
55+
assignee_ids: list[str] | None = None
56+
label_ids: list[str] | None = None
57+
priority: PriorityEnum | None = None
58+
start_date: str | None = None
59+
target_date: str | None = None
60+
estimate_point: str | None = None
61+
external_source: str | None = None
62+
external_id: str | None = None
63+
64+
65+
class UpdateEpic(BaseModel):
66+
"""Request model for updating an epic."""
67+
68+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
69+
70+
name: str | None = None
71+
description_html: str | None = None
72+
state_id: str | None = None
73+
parent_id: str | None = None
74+
assignee_ids: list[str] | None = None
75+
label_ids: list[str] | None = None
76+
priority: PriorityEnum | None = None
77+
start_date: str | None = None
78+
target_date: str | None = None
79+
estimate_point: str | None = None
80+
external_source: str | None = None
81+
external_id: str | None = None
82+
83+
84+
class AddEpicWorkItems(BaseModel):
85+
"""Request model for adding work items to an epic."""
86+
87+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
88+
89+
work_item_ids: list[str]
90+
91+
92+
class EpicIssue(BaseModel):
93+
"""Response model for an epic's work item."""
94+
95+
model_config = ConfigDict(extra="allow", populate_by_name=True)
96+
97+
id: str
98+
type_id: str | None = None
99+
parent: str | None = None
100+
created_at: str | None = None
101+
updated_at: str | None = None
102+
deleted_at: str | None = None
103+
point: int | None = None
104+
name: str
105+
description_html: str | None = None
106+
description_stripped: str | None = None
107+
description_binary: str | None = None
108+
priority: PriorityEnum | None = None
109+
start_date: str | None = None
110+
target_date: str | None = None
111+
sequence_id: int | None = None
112+
sort_order: float | None = None
113+
completed_at: str | None = None
114+
archived_at: str | None = None
115+
last_activity_at: str | None = None
116+
is_draft: bool | None = None
117+
external_source: str | None = None
118+
external_id: str | None = None
119+
created_by: str | None = None
120+
updated_by: str | None = None
121+
project: str | None = None
122+
workspace: str | None = None
123+
state: str | None = None
124+
estimate_point: str | None = None
125+
type: str | None = None
126+
assignees: list[str] | None = None
127+
labels: list[str] | None = None
128+
129+
46130
class PaginatedEpicResponse(PaginatedResponse):
47131
"""Paginated response for epics."""
48132

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

51135
results: list[Epic]
136+
137+
138+
class PaginatedEpicIssueResponse(PaginatedResponse):
139+
"""Paginated response for epic issues."""
140+
141+
model_config = ConfigDict(extra="allow", populate_by_name=True)
142+
143+
results: list[EpicIssue]

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "plane-sdk"
7-
version = "0.2.9"
7+
version = "0.2.10"
88
description = "Python SDK for Plane API"
99
readme = "README.md"
1010
requires-python = ">=3.10"

tests/unit/test_epics.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
"""Unit tests for Epics API resource (smoke tests with real HTTP requests)."""
22

3+
from datetime import datetime
4+
5+
import pytest
6+
37
from plane.client import PlaneClient
4-
from plane.models.projects import Project
8+
from plane.models.epics import AddEpicWorkItems, CreateEpic, Epic, UpdateEpic
9+
from plane.models.projects import Project, ProjectFeature
510
from plane.models.query_params import PaginatedQueryParams
11+
from plane.models.work_items import CreateWorkItem
612

713

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

37+
38+
class TestEpicsAPICRUD:
39+
"""Test Epics API CRUD operations."""
40+
41+
@pytest.fixture
42+
def epic(
43+
self, client: PlaneClient, workspace_slug: str, project: Project
44+
) -> Epic:
45+
"""Create a test epic and yield it, then delete it."""
46+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
47+
data = CreateEpic(name=f"Test Epic {timestamp}", priority="high")
48+
epic = client.epics.create(workspace_slug, project.id, data)
49+
yield epic
50+
try:
51+
client.epics.delete(workspace_slug, project.id, epic.id)
52+
except Exception:
53+
pass
54+
55+
def test_create_epic(
56+
self, client: PlaneClient, workspace_slug: str, project: Project
57+
) -> None:
58+
"""Test creating an epic."""
59+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
60+
data = CreateEpic(name=f"Test Epic {timestamp}")
61+
client.projects.update_features(workspace_slug, project.id, ProjectFeature(epics=True))
62+
epic = client.epics.create(workspace_slug, project.id, data)
63+
assert epic is not None
64+
assert epic.id is not None
65+
assert epic.name == data.name
66+
67+
def test_retrieve_epic(
68+
self, client: PlaneClient, workspace_slug: str, project: Project, epic: Epic
69+
) -> None:
70+
"""Test retrieving an epic."""
71+
retrieved = client.epics.retrieve(workspace_slug, project.id, epic.id)
72+
assert retrieved is not None
73+
assert retrieved.id == epic.id
74+
assert retrieved.name == epic.name
75+
76+
def test_update_epic(
77+
self, client: PlaneClient, workspace_slug: str, project: Project, epic: Epic
78+
) -> None:
79+
"""Test updating an epic."""
80+
update_data = UpdateEpic(name="Updated Epic Name")
81+
updated = client.epics.update(workspace_slug, project.id, epic.id, update_data)
82+
assert updated is not None
83+
assert updated.id == epic.id
84+
assert updated.name == "Updated Epic Name"
85+
86+
def test_list_epic_issues(
87+
self, client: PlaneClient, workspace_slug: str, project: Project, epic: Epic
88+
) -> None:
89+
"""Test listing work items under an epic."""
90+
response = client.epics.list_issues(workspace_slug, project.id, epic.id)
91+
assert response is not None
92+
assert hasattr(response, "results")
93+
assert isinstance(response.results, list)
94+
95+
def test_add_issues_to_epic(
96+
self, client: PlaneClient, workspace_slug: str, project: Project, epic: Epic
97+
) -> None:
98+
"""Test adding work items to an epic."""
99+
work_item = client.work_items.create(
100+
workspace_slug,
101+
project.id,
102+
CreateWorkItem(name="Test Work Item for Epic"),
103+
)
104+
try:
105+
data = AddEpicWorkItems(work_item_ids=[work_item.id])
106+
added = client.epics.add_issues(workspace_slug, project.id, epic.id, data)
107+
assert added is not None
108+
assert isinstance(added, list)
109+
assert len(added) == 1
110+
assert added[0].parent == epic.id
111+
finally:
112+
try:
113+
client.work_items.delete(workspace_slug, project.id, work_item.id)
114+
except Exception:
115+
pass

0 commit comments

Comments
 (0)