Skip to content

Commit ce9c0b3

Browse files
feat: add dependency and custom relation sub-resources (#50)
* feat: add support for work item dependencies and custom relations * chore: add level attribute to CreateWorkItemType and UpdateWorkItemType models * refactor: update work item relations endpoints to use direct identifiers for removal * chore: implement pagination for work item relation definitions endpoint * chore: add optional dependencies for development and enhance test dependency management * fix: update search parameter key and change sequence_id type to int in work item model
1 parent bbe69ef commit ce9c0b3

9 files changed

Lines changed: 654 additions & 6 deletions

plane/api/work_item_relation_definitions.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from ..models.work_item_relation_definitions import (
44
CreateWorkItemRelationDefinition,
5+
PaginatedWorkItemRelationDefinitionResponse,
56
UpdateWorkItemRelationDefinition,
67
WorkItemRelationDefinition,
78
)
@@ -19,25 +20,33 @@ def list(
1920
workspace_slug: str,
2021
is_default: bool | None = None,
2122
is_active: bool | None = None,
22-
) -> list[WorkItemRelationDefinition]:
23-
"""List all work item relation definitions in the workspace.
23+
per_page: int | None = None,
24+
cursor: str | None = None,
25+
) -> PaginatedWorkItemRelationDefinitionResponse:
26+
"""List work item relation definitions in the workspace.
2427
2528
Args:
2629
workspace_slug: The workspace slug identifier
2730
is_default: Optional filter by default status
2831
is_active: Optional filter by active status
32+
per_page: Number of results per page (default 100)
33+
cursor: Pagination cursor from a previous response's next_cursor
2934
"""
3035
params: dict[str, Any] = {}
3136
if is_default is not None:
3237
params["is_default"] = str(is_default).lower()
3338
if is_active is not None:
3439
params["is_active"] = str(is_active).lower()
40+
if per_page is not None:
41+
params["per_page"] = per_page
42+
if cursor is not None:
43+
params["cursor"] = cursor
3544

3645
response = self._get(
3746
f"{workspace_slug}/work-item-relation-definitions/",
3847
params=params if params else None,
3948
)
40-
return [WorkItemRelationDefinition.model_validate(item) for item in response]
49+
return PaginatedWorkItemRelationDefinitionResponse.model_validate(response)
4150

4251
def create(
4352
self, workspace_slug: str, data: CreateWorkItemRelationDefinition

plane/api/work_items/base.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
from .activities import WorkItemActivities
2626
from .attachments import WorkItemAttachments
2727
from .comments import WorkItemComments
28+
from .custom_relations import WorkItemCustomRelations
29+
from .dependencies import WorkItemDependencies
2830
from .links import WorkItemLinks
2931
from .pages import WorkItemPages
3032
from .relations import WorkItemRelations
@@ -76,6 +78,8 @@ def __init__(self, config: Any) -> None:
7678

7779
# Initialize sub-resources
7880
self.relations = WorkItemRelations(config)
81+
self.dependencies = WorkItemDependencies(config)
82+
self.custom_relations = WorkItemCustomRelations(config)
7983
self.links = WorkItemLinks(config)
8084
self.attachments = WorkItemAttachments(config)
8185
self.comments = WorkItemComments(config)
@@ -355,7 +359,7 @@ def search(
355359
query: Search query string
356360
params: Optional query parameters for expand, fields, etc.
357361
"""
358-
search_params = {"q": query}
362+
search_params = {"search": query}
359363
if params:
360364
search_params.update(params.model_dump(exclude_none=True))
361365
response = self._get(f"{workspace_slug}/work-items/search", params=search_params)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
from ...models.work_items import (
6+
CreateWorkItemCustomRelation,
7+
WorkItemWithRelationType,
8+
)
9+
from ..base_resource import BaseResource
10+
11+
12+
class WorkItemCustomRelations(BaseResource):
13+
"""API client for managing custom (definition-based) work item relations.
14+
15+
Custom relations are workspace-level types defined via the
16+
work-item-relation-definitions endpoint. Each definition has an outward label
17+
and an inward label that controls directionality.
18+
"""
19+
20+
def __init__(self, config: Any) -> None:
21+
super().__init__(config, "/workspaces/")
22+
23+
def list(
24+
self, workspace_slug: str, project_id: str, work_item_id: str
25+
) -> dict[str, list[WorkItemWithRelationType]]:
26+
"""List all custom relations for a work item grouped by definition label.
27+
28+
Response keys are the outward/inward labels from active workspace relation
29+
definitions (e.g. 'implements', 'implemented by', 'relates to').
30+
31+
Args:
32+
workspace_slug: The workspace slug identifier
33+
project_id: UUID of the project
34+
work_item_id: UUID of the work item
35+
"""
36+
response = self._get(
37+
f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/work-item-relations/"
38+
)
39+
return {
40+
label: [WorkItemWithRelationType.model_validate(item) for item in items]
41+
for label, items in response.items()
42+
}
43+
44+
def create(
45+
self,
46+
workspace_slug: str,
47+
project_id: str,
48+
work_item_id: str,
49+
data: CreateWorkItemCustomRelation,
50+
) -> list[WorkItemWithRelationType]:
51+
"""Create one or more custom relations for a work item.
52+
53+
Args:
54+
workspace_slug: The workspace slug identifier
55+
project_id: UUID of the project
56+
work_item_id: UUID of the work item
57+
data: Custom relation creation payload
58+
"""
59+
response = self._post(
60+
f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/work-item-relations/",
61+
data.model_dump(exclude_none=True),
62+
)
63+
return [WorkItemWithRelationType.model_validate(item) for item in response]
64+
65+
def remove(
66+
self,
67+
workspace_slug: str,
68+
project_id: str,
69+
work_item_id: str,
70+
related_work_item_id: str,
71+
) -> None:
72+
"""Remove a custom relation between this work item and a target.
73+
74+
Args:
75+
workspace_slug: The workspace slug identifier
76+
project_id: UUID of the project
77+
work_item_id: UUID of the work item
78+
related_work_item_id: UUID of the related work item to remove the relation with
79+
"""
80+
self._delete(
81+
f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/work-item-relations/{related_work_item_id}/"
82+
)
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
from ...models.work_items import (
6+
CreateWorkItemDependency,
7+
WorkItemDependencyResponse,
8+
WorkItemWithRelationType,
9+
)
10+
from ..base_resource import BaseResource
11+
12+
13+
class WorkItemDependencies(BaseResource):
14+
"""API client for managing work item dependency relations.
15+
16+
Covers the six built-in dependency directions:
17+
blocking / blocked_by / start_before / start_after / finish_before / finish_after.
18+
"""
19+
20+
def __init__(self, config: Any) -> None:
21+
super().__init__(config, "/workspaces/")
22+
23+
def list(
24+
self, workspace_slug: str, project_id: str, work_item_id: str
25+
) -> WorkItemDependencyResponse:
26+
"""List all dependency relations for a work item grouped by direction.
27+
28+
Args:
29+
workspace_slug: The workspace slug identifier
30+
project_id: UUID of the project
31+
work_item_id: UUID of the work item
32+
"""
33+
response = self._get(
34+
f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/dependencies/"
35+
)
36+
return WorkItemDependencyResponse.model_validate(response)
37+
38+
def create(
39+
self,
40+
workspace_slug: str,
41+
project_id: str,
42+
work_item_id: str,
43+
data: CreateWorkItemDependency,
44+
) -> list[WorkItemWithRelationType]:
45+
"""Create one or more dependency relations for a work item.
46+
47+
Args:
48+
workspace_slug: The workspace slug identifier
49+
project_id: UUID of the project
50+
work_item_id: UUID of the work item
51+
data: Dependency creation payload
52+
"""
53+
response = self._post(
54+
f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/dependencies/",
55+
data.model_dump(exclude_none=True),
56+
)
57+
return [WorkItemWithRelationType.model_validate(item) for item in response]
58+
59+
def remove(
60+
self,
61+
workspace_slug: str,
62+
project_id: str,
63+
work_item_id: str,
64+
related_work_item_id: str,
65+
) -> None:
66+
"""Remove a dependency relation between this work item and a target.
67+
68+
Args:
69+
workspace_slug: The workspace slug identifier
70+
project_id: UUID of the project
71+
work_item_id: UUID of the work item
72+
related_work_item_id: UUID of the related work item to remove the dependency with
73+
"""
74+
self._delete(
75+
f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/dependencies/{related_work_item_id}/"
76+
)

plane/models/work_item_relation_definitions.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from pydantic import BaseModel, ConfigDict
44

5+
from .pagination import PaginatedResponse
6+
57

68
class WorkItemRelationDefinition(BaseModel):
79
"""Work item relation definition response model."""
@@ -47,3 +49,11 @@ class UpdateWorkItemRelationDefinition(BaseModel):
4749
is_active: bool | None = None
4850
color: str | None = None
4951
sort_order: float | None = None
52+
53+
54+
class PaginatedWorkItemRelationDefinitionResponse(PaginatedResponse):
55+
"""Paginated response for work item relation definitions."""
56+
57+
model_config = ConfigDict(extra="allow", populate_by_name=True)
58+
59+
results: list[WorkItemRelationDefinition]

plane/models/work_item_types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class CreateWorkItemType(BaseModel):
3838
logo_props: Any | None = None
3939
is_epic: bool | None = None
4040
is_active: bool | None = None
41+
level: int | None = None
4142
external_source: str | None = None
4243
external_id: str | None = None
4344

@@ -53,6 +54,7 @@ class UpdateWorkItemType(BaseModel):
5354
logo_props: Any | None = None
5455
is_epic: bool | None = None
5556
is_active: bool | None = None
57+
level: int | None = None
5658
external_source: str | None = None
5759
external_id: str | None = None
5860

plane/models/work_items.py

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import TYPE_CHECKING, Any
1+
from typing import TYPE_CHECKING, Any, Literal
22

33
from pydantic import BaseModel, ConfigDict, Field
44

@@ -201,7 +201,7 @@ class WorkItemSearchItem(BaseModel):
201201

202202
id: str = Field(..., description="Issue ID")
203203
name: str = Field(..., description="Issue name")
204-
sequence_id: str = Field(..., description="Issue sequence ID")
204+
sequence_id: int = Field(..., description="Issue sequence ID")
205205
project__identifier: str = Field(..., description="Project identifier")
206206
project_id: str = Field(..., description="Project ID")
207207
workspace__slug: str = Field(..., description="Workspace slug")
@@ -525,6 +525,110 @@ class WorkItemRelationResponse(BaseModel):
525525
)
526526

527527

528+
DependencyTypeEnum = Literal[
529+
"blocking",
530+
"blocked_by",
531+
"start_before",
532+
"start_after",
533+
"finish_before",
534+
"finish_after",
535+
]
536+
537+
538+
class WorkItemWithRelationType(BaseModel):
539+
"""Work item with an injected relation_type label."""
540+
541+
model_config = ConfigDict(extra="allow", populate_by_name=True)
542+
543+
id: str | None = None
544+
name: str | None = None
545+
sequence_id: int | None = None
546+
project_id: str | None = None
547+
state_id: str | None = None
548+
priority: str | None = None
549+
type_id: str | None = None
550+
is_epic: bool | None = None
551+
label_ids: list[str] = Field(default_factory=list)
552+
assignee_ids: list[str] = Field(default_factory=list)
553+
sort_order: float | None = None
554+
created_at: str | None = None
555+
updated_at: str | None = None
556+
created_by: str | None = None
557+
updated_by: str | None = None
558+
relation_type: str | None = None
559+
560+
561+
class WorkItemDependencyResponse(BaseModel):
562+
"""Response model for GET /relation-dependencies/."""
563+
564+
model_config = ConfigDict(extra="allow", populate_by_name=True)
565+
566+
blocking: list[WorkItemWithRelationType] = Field(default_factory=list)
567+
blocked_by: list[WorkItemWithRelationType] = Field(default_factory=list)
568+
start_before: list[WorkItemWithRelationType] = Field(default_factory=list)
569+
start_after: list[WorkItemWithRelationType] = Field(default_factory=list)
570+
finish_before: list[WorkItemWithRelationType] = Field(default_factory=list)
571+
finish_after: list[WorkItemWithRelationType] = Field(default_factory=list)
572+
573+
574+
class CreateWorkItemDependency(BaseModel):
575+
"""Request model for creating work item dependency relations."""
576+
577+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
578+
579+
relation_type: DependencyTypeEnum = Field(
580+
...,
581+
description="Dependency direction from the perspective of this work item",
582+
)
583+
work_item_ids: list[str] = Field(
584+
...,
585+
description="UUIDs of work items to create dependencies with",
586+
min_length=1,
587+
)
588+
589+
590+
class RemoveWorkItemDependency(BaseModel):
591+
"""Request model for removing a work item dependency."""
592+
593+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
594+
595+
work_item_id: str = Field(
596+
...,
597+
description="UUID of the related work item whose dependency should be removed",
598+
)
599+
600+
601+
class CreateWorkItemCustomRelation(BaseModel):
602+
"""Request model for creating a custom (definition-based) work item relation."""
603+
604+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
605+
606+
relation_definition_id: str = Field(
607+
...,
608+
description="UUID of the workspace relation definition",
609+
)
610+
relation_definition_type: str = Field(
611+
...,
612+
description="The outward or inward label of the definition (controls directionality)",
613+
)
614+
work_item_ids: list[str] = Field(
615+
...,
616+
description="UUIDs of work items to create the relation with",
617+
min_length=1,
618+
)
619+
620+
621+
class RemoveWorkItemCustomRelation(BaseModel):
622+
"""Request model for removing a custom work item relation."""
623+
624+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
625+
626+
work_item_id: str = Field(
627+
...,
628+
description="UUID of the related work item whose custom relation should be removed",
629+
)
630+
631+
528632
class WorkItemWorkLog(BaseModel):
529633
"""Work item work log model."""
530634

0 commit comments

Comments
 (0)