From e17d1baba3260adb5d4305bccf63236582d6a1bf Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Fri, 17 Jul 2026 11:36:37 +0530 Subject: [PATCH 1/3] chore: enhance Releases API with pagination support --- plane/api/releases/base.py | 49 ++++- plane/api/releases/comments.py | 89 ++++++++ plane/api/releases/item_labels.py | 36 +++- plane/api/releases/labels.py | 62 +++++- plane/api/releases/links.py | 87 ++++++++ plane/api/releases/tags.py | 61 +++++- plane/api/releases/work_items.py | 60 ++++++ plane/models/releases.py | 258 +++++++++++++++++++++-- pyproject.toml | 2 +- tests/unit/test_releases.py | 335 +++++++++++++++++++++++------- 10 files changed, 919 insertions(+), 120 deletions(-) create mode 100644 plane/api/releases/comments.py create mode 100644 plane/api/releases/links.py create mode 100644 plane/api/releases/work_items.py diff --git a/plane/api/releases/base.py b/plane/api/releases/base.py index 9092d7e..0b7ab81 100644 --- a/plane/api/releases/base.py +++ b/plane/api/releases/base.py @@ -1,10 +1,21 @@ +from __future__ import annotations + +from collections.abc import Mapping from typing import Any -from ...models.releases import CreateRelease, Release, UpdateRelease +from ...models.releases import ( + CreateRelease, + PaginatedReleaseResponse, + Release, + UpdateRelease, +) from ..base_resource import BaseResource +from .comments import ReleaseComments from .item_labels import ReleaseItemLabels from .labels import ReleaseLabels +from .links import ReleaseLinks from .tags import ReleaseTags +from .work_items import ReleaseWorkItems class Releases(BaseResource): @@ -17,15 +28,34 @@ def __init__(self, config: Any) -> None: self.tags = ReleaseTags(config) self.labels = ReleaseLabels(config) self.item_labels = ReleaseItemLabels(config) + self.work_items = ReleaseWorkItems(config) + self.comments = ReleaseComments(config) + self.links = ReleaseLinks(config) + + def list( + self, workspace_slug: str, params: Mapping[str, Any] | None = None + ) -> PaginatedReleaseResponse: + """List releases in the workspace (paginated). - def list(self, workspace_slug: str) -> list[Release]: - """List all releases in the workspace. + Returns one page (20 by default). Pass `per_page`/`cursor` in params and + follow `next_cursor` to page through the rest. Args: workspace_slug: The workspace slug identifier + params: Optional query parameters, e.g. `per_page`, `cursor` """ - response = self._get(f"{workspace_slug}/releases/") - return [Release.model_validate(item) for item in response] + response = self._get(f"{workspace_slug}/releases/", params=params) + return PaginatedReleaseResponse.model_validate(response) + + def retrieve(self, workspace_slug: str, release_id: str) -> Release: + """Retrieve a release by ID. + + Args: + workspace_slug: The workspace slug identifier + release_id: UUID of the release + """ + response = self._get(f"{workspace_slug}/releases/{release_id}/") + return Release.model_validate(response) def create(self, workspace_slug: str, data: CreateRelease) -> Release: """Create a new release in the workspace. @@ -53,3 +83,12 @@ def update(self, workspace_slug: str, release_id: str, data: UpdateRelease) -> R data.model_dump(exclude_none=True), ) return Release.model_validate(response) + + def delete(self, workspace_slug: str, release_id: str) -> None: + """Delete a release by ID. + + Args: + workspace_slug: The workspace slug identifier + release_id: UUID of the release + """ + return self._delete(f"{workspace_slug}/releases/{release_id}/") diff --git a/plane/api/releases/comments.py b/plane/api/releases/comments.py new file mode 100644 index 0000000..c928fc5 --- /dev/null +++ b/plane/api/releases/comments.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ...models.releases import ( + CreateReleaseComment, + PaginatedReleaseCommentResponse, + ReleaseComment, + UpdateReleaseComment, +) +from ..base_resource import BaseResource + + +class ReleaseComments(BaseResource): + """API client for the comments on a release.""" + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def list( + self, workspace_slug: str, release_id: str, params: Mapping[str, Any] | None = None + ) -> PaginatedReleaseCommentResponse: + """List the comments on a release (paginated). + + Returns one page (20 by default). Pass `per_page`/`cursor` in params and + follow `next_cursor` to page through the rest. + + Args: + workspace_slug: The workspace slug identifier + release_id: UUID of the release + params: Optional query parameters, e.g. `per_page`, `cursor` + """ + response = self._get(f"{workspace_slug}/releases/{release_id}/comments/", params=params) + return PaginatedReleaseCommentResponse.model_validate(response) + + def retrieve(self, workspace_slug: str, release_id: str, comment_id: str) -> ReleaseComment: + """Retrieve a release comment by ID. + + Args: + workspace_slug: The workspace slug identifier + release_id: UUID of the release + comment_id: UUID of the comment + """ + response = self._get(f"{workspace_slug}/releases/{release_id}/comments/{comment_id}/") + return ReleaseComment.model_validate(response) + + def create( + self, workspace_slug: str, release_id: str, data: CreateReleaseComment + ) -> ReleaseComment: + """Create a comment on a release. + + Args: + workspace_slug: The workspace slug identifier + release_id: UUID of the release + data: Comment data (body as `comment_html`) + """ + response = self._post( + f"{workspace_slug}/releases/{release_id}/comments/", + data.model_dump(exclude_none=True), + ) + return ReleaseComment.model_validate(response) + + def update( + self, workspace_slug: str, release_id: str, comment_id: str, data: UpdateReleaseComment + ) -> ReleaseComment: + """Update a release comment by ID. + + Args: + workspace_slug: The workspace slug identifier + release_id: UUID of the release + comment_id: UUID of the comment + data: Updated comment data + """ + response = self._patch( + f"{workspace_slug}/releases/{release_id}/comments/{comment_id}/", + data.model_dump(exclude_none=True), + ) + return ReleaseComment.model_validate(response) + + def delete(self, workspace_slug: str, release_id: str, comment_id: str) -> None: + """Delete a release comment by ID. + + Args: + workspace_slug: The workspace slug identifier + release_id: UUID of the release + comment_id: UUID of the comment + """ + return self._delete(f"{workspace_slug}/releases/{release_id}/comments/{comment_id}/") diff --git a/plane/api/releases/item_labels.py b/plane/api/releases/item_labels.py index b3a3557..4281354 100644 --- a/plane/api/releases/item_labels.py +++ b/plane/api/releases/item_labels.py @@ -1,36 +1,47 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any -from ...models.releases import AddReleaseItemLabel, ReleaseLabel +from ...models.releases import ( + AddReleaseItemLabel, + PaginatedReleaseLabelResponse, + ReleaseLabel, +) from ..base_resource import BaseResource class ReleaseItemLabels(BaseResource): - """API client for managing labels on a specific release.""" + """API client for managing the labels attached to a specific release.""" def __init__(self, config: Any) -> None: super().__init__(config, "/workspaces/") - def list(self, workspace_slug: str, release_id: str) -> list[ReleaseLabel]: - """List labels assigned to a release. + def list( + self, workspace_slug: str, release_id: str, params: Mapping[str, Any] | None = None + ) -> PaginatedReleaseLabelResponse: + """List labels attached to a release (paginated). + + Returns one page (20 by default). Pass `per_page`/`cursor` in params and + follow `next_cursor` to page through the rest. Args: workspace_slug: The workspace slug identifier release_id: UUID of the release + params: Optional query parameters, e.g. `per_page`, `cursor` """ - response = self._get(f"{workspace_slug}/releases/{release_id}/labels/") - return [ReleaseLabel.model_validate(item) for item in response] + response = self._get(f"{workspace_slug}/releases/{release_id}/labels/", params=params) + return PaginatedReleaseLabelResponse.model_validate(response) def create( self, workspace_slug: str, release_id: str, data: AddReleaseItemLabel ) -> list[ReleaseLabel]: - """Add labels to a release. + """Attach labels to a release. Args: workspace_slug: The workspace slug identifier release_id: UUID of the release - data: Label IDs to add + data: Label IDs to attach """ response = self._post( f"{workspace_slug}/releases/{release_id}/labels/", @@ -39,11 +50,14 @@ def create( return [ReleaseLabel.model_validate(item) for item in response] def delete(self, workspace_slug: str, release_id: str, label_id: str) -> None: - """Remove a label from a release. + """Detach a label from a release (the label itself is not deleted). Args: workspace_slug: The workspace slug identifier release_id: UUID of the release - label_id: UUID of the label to remove + label_id: UUID of the label to detach """ - return self._delete(f"{workspace_slug}/releases/{release_id}/labels/{label_id}/") + return self._delete( + f"{workspace_slug}/releases/{release_id}/labels/", + data={"label_ids": [label_id]}, + ) diff --git a/plane/api/releases/labels.py b/plane/api/releases/labels.py index 6a5bfcf..980f80c 100644 --- a/plane/api/releases/labels.py +++ b/plane/api/releases/labels.py @@ -1,23 +1,47 @@ +from __future__ import annotations + +from collections.abc import Mapping from typing import Any -from ...models.releases import CreateReleaseLabel, ReleaseLabel +from ...models.releases import ( + CreateReleaseLabel, + PaginatedReleaseLabelResponse, + ReleaseLabel, + UpdateReleaseLabel, +) from ..base_resource import BaseResource class ReleaseLabels(BaseResource): - """API client for managing release labels.""" + """API client for managing workspace-level release labels.""" def __init__(self, config: Any) -> None: super().__init__(config, "/workspaces/") - def list(self, workspace_slug: str) -> list[ReleaseLabel]: - """List all release labels in the workspace. + def list( + self, workspace_slug: str, params: Mapping[str, Any] | None = None + ) -> PaginatedReleaseLabelResponse: + """List release labels in the workspace (paginated). + + Returns one page (20 by default). Pass `per_page`/`cursor` in params and + follow `next_cursor` to page through the rest. Args: workspace_slug: The workspace slug identifier + params: Optional query parameters, e.g. `per_page`, `cursor` """ - response = self._get(f"{workspace_slug}/releases/labels/") - return [ReleaseLabel.model_validate(item) for item in response] + response = self._get(f"{workspace_slug}/releases/labels/", params=params) + return PaginatedReleaseLabelResponse.model_validate(response) + + def retrieve(self, workspace_slug: str, label_id: str) -> ReleaseLabel: + """Retrieve a release label by ID. + + Args: + workspace_slug: The workspace slug identifier + label_id: UUID of the release label + """ + response = self._get(f"{workspace_slug}/releases/labels/{label_id}/") + return ReleaseLabel.model_validate(response) def create(self, workspace_slug: str, data: CreateReleaseLabel) -> ReleaseLabel: """Create a new release label in the workspace. @@ -31,3 +55,29 @@ def create(self, workspace_slug: str, data: CreateReleaseLabel) -> ReleaseLabel: data.model_dump(exclude_none=True), ) return ReleaseLabel.model_validate(response) + + def update(self, workspace_slug: str, label_id: str, data: UpdateReleaseLabel) -> ReleaseLabel: + """Update a release label by ID. + + Args: + workspace_slug: The workspace slug identifier + label_id: UUID of the release label + data: Updated label data + """ + response = self._patch( + f"{workspace_slug}/releases/labels/{label_id}/", + data.model_dump(exclude_none=True), + ) + return ReleaseLabel.model_validate(response) + + def delete(self, workspace_slug: str, label_id: str) -> None: + """Delete a release label by ID. + + This deletes the label itself from the workspace. To only detach a label + from a release, use `client.releases.item_labels.delete`. + + Args: + workspace_slug: The workspace slug identifier + label_id: UUID of the release label + """ + return self._delete(f"{workspace_slug}/releases/labels/{label_id}/") diff --git a/plane/api/releases/links.py b/plane/api/releases/links.py new file mode 100644 index 0000000..7c8cf03 --- /dev/null +++ b/plane/api/releases/links.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ...models.releases import ( + CreateReleaseLink, + PaginatedReleaseLinkResponse, + ReleaseLink, + UpdateReleaseLink, +) +from ..base_resource import BaseResource + + +class ReleaseLinks(BaseResource): + """API client for the links on a release.""" + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def list( + self, workspace_slug: str, release_id: str, params: Mapping[str, Any] | None = None + ) -> PaginatedReleaseLinkResponse: + """List the links on a release (paginated). + + Returns one page (20 by default). Pass `per_page`/`cursor` in params and + follow `next_cursor` to page through the rest. + + Args: + workspace_slug: The workspace slug identifier + release_id: UUID of the release + params: Optional query parameters, e.g. `per_page`, `cursor` + """ + response = self._get(f"{workspace_slug}/releases/{release_id}/links/", params=params) + return PaginatedReleaseLinkResponse.model_validate(response) + + def retrieve(self, workspace_slug: str, release_id: str, link_id: str) -> ReleaseLink: + """Retrieve a release link by ID. + + Args: + workspace_slug: The workspace slug identifier + release_id: UUID of the release + link_id: UUID of the link + """ + response = self._get(f"{workspace_slug}/releases/{release_id}/links/{link_id}/") + return ReleaseLink.model_validate(response) + + def create(self, workspace_slug: str, release_id: str, data: CreateReleaseLink) -> ReleaseLink: + """Create a link on a release. + + Args: + workspace_slug: The workspace slug identifier + release_id: UUID of the release + data: Link data (`url` required) + """ + response = self._post( + f"{workspace_slug}/releases/{release_id}/links/", + data.model_dump(exclude_none=True), + ) + return ReleaseLink.model_validate(response) + + def update( + self, workspace_slug: str, release_id: str, link_id: str, data: UpdateReleaseLink + ) -> ReleaseLink: + """Update a release link by ID. + + Args: + workspace_slug: The workspace slug identifier + release_id: UUID of the release + link_id: UUID of the link + data: Updated link data + """ + response = self._patch( + f"{workspace_slug}/releases/{release_id}/links/{link_id}/", + data.model_dump(exclude_none=True), + ) + return ReleaseLink.model_validate(response) + + def delete(self, workspace_slug: str, release_id: str, link_id: str) -> None: + """Delete a release link by ID. + + Args: + workspace_slug: The workspace slug identifier + release_id: UUID of the release + link_id: UUID of the link + """ + return self._delete(f"{workspace_slug}/releases/{release_id}/links/{link_id}/") diff --git a/plane/api/releases/tags.py b/plane/api/releases/tags.py index ff4f8ed..0caf82f 100644 --- a/plane/api/releases/tags.py +++ b/plane/api/releases/tags.py @@ -1,27 +1,53 @@ +from __future__ import annotations + +from collections.abc import Mapping from typing import Any -from ...models.releases import CreateReleaseTag, ReleaseTag +from ...models.releases import ( + CreateReleaseTag, + PaginatedReleaseTagResponse, + ReleaseTag, + UpdateReleaseTag, +) from ..base_resource import BaseResource class ReleaseTags(BaseResource): - """API client for managing release tags.""" + """API client for managing release tags (version markers).""" def __init__(self, config: Any) -> None: super().__init__(config, "/workspaces/") - def list(self, workspace_slug: str) -> list[ReleaseTag]: - """List all release tags in the workspace. + def list( + self, workspace_slug: str, params: Mapping[str, Any] | None = None + ) -> PaginatedReleaseTagResponse: + """List release tags in the workspace (paginated). + + Returns one page (20 by default). Pass `per_page`/`cursor` in params and + follow `next_cursor` to page through the rest. Args: workspace_slug: The workspace slug identifier + params: Optional query parameters, e.g. `per_page`, `cursor` """ - response = self._get(f"{workspace_slug}/releases/tags/") - return [ReleaseTag.model_validate(item) for item in response] + response = self._get(f"{workspace_slug}/releases/tags/", params=params) + return PaginatedReleaseTagResponse.model_validate(response) + + def retrieve(self, workspace_slug: str, tag_id: str) -> ReleaseTag: + """Retrieve a release tag by ID. + + Args: + workspace_slug: The workspace slug identifier + tag_id: UUID of the release tag + """ + response = self._get(f"{workspace_slug}/releases/tags/{tag_id}/") + return ReleaseTag.model_validate(response) def create(self, workspace_slug: str, data: CreateReleaseTag) -> ReleaseTag: """Create a new release tag in the workspace. + `data.version` is required and must be unique in the workspace. + Args: workspace_slug: The workspace slug identifier data: Release tag data @@ -31,3 +57,26 @@ def create(self, workspace_slug: str, data: CreateReleaseTag) -> ReleaseTag: data.model_dump(exclude_none=True), ) return ReleaseTag.model_validate(response) + + def update(self, workspace_slug: str, tag_id: str, data: UpdateReleaseTag) -> ReleaseTag: + """Update a release tag by ID. + + Args: + workspace_slug: The workspace slug identifier + tag_id: UUID of the release tag + data: Updated tag data + """ + response = self._patch( + f"{workspace_slug}/releases/tags/{tag_id}/", + data.model_dump(exclude_none=True), + ) + return ReleaseTag.model_validate(response) + + def delete(self, workspace_slug: str, tag_id: str) -> None: + """Delete a release tag by ID. + + Args: + workspace_slug: The workspace slug identifier + tag_id: UUID of the release tag + """ + return self._delete(f"{workspace_slug}/releases/tags/{tag_id}/") diff --git a/plane/api/releases/work_items.py b/plane/api/releases/work_items.py new file mode 100644 index 0000000..210a1fb --- /dev/null +++ b/plane/api/releases/work_items.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ...models.releases import ( + AddReleaseWorkItems, + PaginatedReleaseWorkItemResponse, + ReleaseWorkItem, +) +from ..base_resource import BaseResource + + +class ReleaseWorkItems(BaseResource): + """API client for the work items linked to a release.""" + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def list( + self, workspace_slug: str, release_id: str, params: Mapping[str, Any] | None = None + ) -> PaginatedReleaseWorkItemResponse: + """List the work items linked to a release (paginated). + + Returns one page (20 by default). Pass `per_page`/`cursor` in params and + follow `next_cursor` to page through the rest. + + Args: + workspace_slug: The workspace slug identifier + release_id: UUID of the release + params: Optional query parameters, e.g. `per_page`, `cursor` + """ + response = self._get(f"{workspace_slug}/releases/{release_id}/work-items/", params=params) + return PaginatedReleaseWorkItemResponse.model_validate(response) + + def add(self, workspace_slug: str, release_id: str, data: AddReleaseWorkItems) -> None: + """Link work items to a release. Already-linked work items are skipped. + + Args: + workspace_slug: The workspace slug identifier + release_id: UUID of the release + data: Work item IDs to link + """ + self._post( + f"{workspace_slug}/releases/{release_id}/work-items/", + data.model_dump(exclude_none=True), + ) + + def remove(self, workspace_slug: str, release_id: str, work_item_ids: list[str]) -> None: + """Unlink work items from a release. + + Args: + workspace_slug: The workspace slug identifier + release_id: UUID of the release + work_item_ids: UUIDs of the work items to unlink + """ + return self._delete( + f"{workspace_slug}/releases/{release_id}/work-items/", + data={"work_item_ids": work_item_ids}, + ) diff --git a/plane/models/releases.py b/plane/models/releases.py index a807d60..41102d6 100644 --- a/plane/models/releases.py +++ b/plane/models/releases.py @@ -4,34 +4,66 @@ from pydantic import BaseModel, ConfigDict +from .pagination import PaginatedResponse + class Release(BaseModel): - """Release response model.""" + """Release response model. + + `description` is returned as a nested object ({description_html, description_json, + ...}), not a plain string. Write it with `description_html` / `description_json` + on create/update. + """ model_config = ConfigDict(extra="allow", populate_by_name=True) id: str | None = None name: str | None = None - description: str | None = None - start_date: str | None = None - release_date: str | None = None + description: Any | None = None status: str | None = None - logo_props: Any | None = None + target_date: str | None = None + release_date: str | None = None + lead: str | None = None + tag: str | None = None + is_latest: bool | None = None + is_prerelease: bool | None = None + external_source: str | None = None + external_id: str | None = None created_at: str | None = None updated_at: str | None = None + created_by: str | None = None + updated_by: str | None = None workspace: str | None = None + # Deprecated: never populated by the API. Kept so existing callers do not break. + start_date: str | None = None + logo_props: Any | None = None + class CreateRelease(BaseModel): - """Request model for creating a release.""" + """Request model for creating a release. + + Set the body with `description_html` / `description_json`. `status` is one of + "unreleased" (default), "released", "cancelled". + """ model_config = ConfigDict(extra="ignore", populate_by_name=True) name: str + description_html: str | None = None + description_json: Any | None = None + status: str | None = None + target_date: str | None = None + release_date: str | None = None + lead: str | None = None + tag: str | None = None + is_prerelease: bool | None = None + external_source: str | None = None + external_id: str | None = None + + # Deprecated: ignored by the API. Kept for backward compatibility. description: str | None = None start_date: str | None = None - release_date: str | None = None - status: str | None = None logo_props: Any | None = None @@ -41,34 +73,77 @@ class UpdateRelease(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) name: str | None = None + description_html: str | None = None + description_json: Any | None = None + status: str | None = None + target_date: str | None = None + release_date: str | None = None + lead: str | None = None + tag: str | None = None + is_prerelease: bool | None = None + external_source: str | None = None + external_id: str | None = None + + # Deprecated: ignored by the API. Kept for backward compatibility. description: str | None = None start_date: str | None = None - release_date: str | None = None - status: str | None = None logo_props: Any | None = None class ReleaseTag(BaseModel): - """Release tag response model.""" + """Release tag response model. + + A tag is a version marker (version + optional git metadata), not a label. + """ model_config = ConfigDict(extra="allow", populate_by_name=True) id: str | None = None + version: str | None = None + description: str | None = None + commit_hash: str | None = None + git_tag: str | None = None + created_at: str | None = None + updated_at: str | None = None + workspace: str | None = None + + # Deprecated: a tag has no name/color/sort_order. Kept so old readers do not break. name: str | None = None color: str | None = None sort_order: float | None = None - workspace: str | None = None class CreateReleaseTag(BaseModel): - """Request model for creating a release tag.""" + """Request model for creating a release tag. + + `version` is required by the API and must be unique in the workspace. `name` + and `color` are ignored (a tag is not a label) and kept only for backward + compatibility. + """ model_config = ConfigDict(extra="ignore", populate_by_name=True) - name: str + version: str | None = None + description: str | None = None + commit_hash: str | None = None + git_tag: str | None = None + + # Deprecated: ignored by the API. Kept for backward compatibility. + name: str | None = None color: str | None = None +class UpdateReleaseTag(BaseModel): + """Request model for updating a release tag.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + version: str | None = None + description: str | None = None + commit_hash: str | None = None + git_tag: str | None = None + + class ReleaseLabel(BaseModel): """Release label response model.""" @@ -88,11 +163,164 @@ class CreateReleaseLabel(BaseModel): name: str color: str | None = None + sort_order: int | None = None + + +class UpdateReleaseLabel(BaseModel): + """Request model for updating a release label.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + name: str | None = None + color: str | None = None + sort_order: int | None = None class AddReleaseItemLabel(BaseModel): - """Request model for adding labels to a release item.""" + """Request model for adding labels to a release.""" model_config = ConfigDict(extra="ignore", populate_by_name=True) label_ids: list[str] + + +class ReleaseWorkItem(BaseModel): + """A work item linked to a release, as returned by the list endpoint.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str + name: str | None = None + project_id: str | None = None + + +class AddReleaseWorkItems(BaseModel): + """Request model for linking work items to a release.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + work_item_ids: list[str] + + +class ReleaseComment(BaseModel): + """Release comment response model. + + `comment` is a nested object ({description_html, ...}). Write it with + `comment_html`. + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + comment: Any | None = None + is_hidden: bool | None = None + is_resolved: bool | None = None + parent: str | None = None + edited_at: str | None = None + created_at: str | None = None + updated_at: str | None = None + created_by: str | None = None + updated_by: str | None = None + workspace: str | None = None + release: str | None = None + + +class CreateReleaseComment(BaseModel): + """Request model for creating a release comment.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + comment_html: str + parent: str | None = None + + +class UpdateReleaseComment(BaseModel): + """Request model for updating a release comment.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + comment_html: str | None = None + is_resolved: bool | None = None + + +class ReleaseLink(BaseModel): + """Release link response model.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + title: str | None = None + url: str | None = None + metadata: Any | None = None + created_at: str | None = None + updated_at: str | None = None + workspace: str | None = None + release: str | None = None + + +class CreateReleaseLink(BaseModel): + """Request model for creating a release link.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + url: str + title: str | None = None + metadata: Any | None = None + + +class UpdateReleaseLink(BaseModel): + """Request model for updating a release link.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + title: str | None = None + url: str | None = None + metadata: Any | None = None + + +class PaginatedReleaseResponse(PaginatedResponse): + """Paginated response for the releases list endpoint.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + results: list[Release] + + +class PaginatedReleaseTagResponse(PaginatedResponse): + """Paginated response for the release tags list endpoint.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + results: list[ReleaseTag] + + +class PaginatedReleaseLabelResponse(PaginatedResponse): + """Paginated response for the release labels list endpoint.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + results: list[ReleaseLabel] + + +class PaginatedReleaseWorkItemResponse(PaginatedResponse): + """Paginated response for the release work-items list endpoint.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + results: list[ReleaseWorkItem] + + +class PaginatedReleaseCommentResponse(PaginatedResponse): + """Paginated response for the release comments list endpoint.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + results: list[ReleaseComment] + + +class PaginatedReleaseLinkResponse(PaginatedResponse): + """Paginated response for the release links list endpoint.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + results: list[ReleaseLink] diff --git a/pyproject.toml b/pyproject.toml index ee42cd6..b10ada4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plane-sdk" -version = "0.2.19" +version = "0.2.20" description = "Python SDK for Plane API" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/unit/test_releases.py b/tests/unit/test_releases.py index 0c6b2da..a279249 100644 --- a/tests/unit/test_releases.py +++ b/tests/unit/test_releases.py @@ -1,123 +1,306 @@ -"""Unit tests for Releases API resource (smoke tests with real HTTP requests).""" +"""Unit tests for the Releases API resource (smoke tests with real HTTP requests).""" from uuid import uuid4 import pytest from plane.client import PlaneClient +from plane.errors.errors import HttpError from plane.models.releases import ( AddReleaseItemLabel, + AddReleaseWorkItems, CreateRelease, + CreateReleaseComment, CreateReleaseLabel, + CreateReleaseLink, CreateReleaseTag, UpdateRelease, + UpdateReleaseLabel, + UpdateReleaseLink, + UpdateReleaseTag, ) +def _uid() -> str: + return uuid4().hex[:8] + + class TestReleases: - """Test Releases API resource.""" + """Release CRUD.""" - def test_list_releases(self, client: PlaneClient, workspace_slug: str) -> None: - """Test listing releases.""" + @pytest.fixture + def release(self, client: PlaneClient, workspace_slug: str): + rel = client.releases.create(workspace_slug, CreateRelease(name=f"release-{_uid()}")) + yield rel + try: + client.releases.delete(workspace_slug, rel.id) + except Exception: + pass + + def test_list_releases_returns_list(self, client: PlaneClient, workspace_slug: str) -> None: + """list() returns a plain list (it used to raise on the paginated envelope).""" releases = client.releases.list(workspace_slug) assert isinstance(releases, list) - def test_create_update_release( + def test_list_paginated_returns_envelope( self, client: PlaneClient, workspace_slug: str ) -> None: - """Test creating and updating a release.""" - name = f"test-release-{uuid4().hex[:8]}" - data = CreateRelease(name=name, description="Test release", status="unreleased") - created = client.releases.create(workspace_slug, data) - assert created is not None - assert created.id is not None - assert created.name == name - - updated = client.releases.update( + response = client.releases.list_paginated(workspace_slug) + assert hasattr(response, "results") + assert isinstance(response.results, list) + assert isinstance(response.total_count, int) + + def test_create_release(self, release) -> None: + assert release.id is not None + assert release.name is not None + + def test_retrieve_release(self, client: PlaneClient, workspace_slug: str, release) -> None: + retrieved = client.releases.retrieve(workspace_slug, release.id) + assert retrieved.id == release.id + + def test_update_release(self, client: PlaneClient, workspace_slug: str, release) -> None: + new_name = f"release-{_uid()}" + updated = client.releases.update(workspace_slug, release.id, UpdateRelease(name=new_name)) + assert updated.id == release.id + assert updated.name == new_name + + def test_create_with_description_html(self, client: PlaneClient, workspace_slug: str) -> None: + """The body is written via description_html, and read back as a nested object.""" + rel = client.releases.create( workspace_slug, - created.id, - UpdateRelease(description="Updated release description"), + CreateRelease(name=f"release-{_uid()}", description_html="

notes

"), ) - assert updated.id == created.id - assert updated.description == "Updated release description" + try: + assert rel.id is not None + finally: + try: + client.releases.delete(workspace_slug, rel.id) + except Exception: + pass + + def test_delete_release(self, client: PlaneClient, workspace_slug: str) -> None: + rel = client.releases.create(workspace_slug, CreateRelease(name=f"release-{_uid()}")) + client.releases.delete(workspace_slug, rel.id) + with pytest.raises(HttpError): + client.releases.retrieve(workspace_slug, rel.id) class TestReleaseTags: - """Test Releases.tags sub-resource.""" + """Release tags are version markers.""" + + @pytest.fixture + def tag(self, client: PlaneClient, workspace_slug: str): + t = client.releases.tags.create( + workspace_slug, CreateReleaseTag(version=f"v{_uid()}", commit_hash="abc123") + ) + yield t + try: + client.releases.tags.delete(workspace_slug, t.id) + except Exception: + pass + + def test_list_tags(self, client: PlaneClient, workspace_slug: str) -> None: + assert isinstance(client.releases.tags.list(workspace_slug), list) + + def test_create_tag_carries_version(self, tag) -> None: + assert tag.id is not None + assert tag.version is not None - def test_list_release_tags(self, client: PlaneClient, workspace_slug: str) -> None: - """Test listing release tags.""" - tags = client.releases.tags.list(workspace_slug) - assert isinstance(tags, list) + def test_retrieve_tag(self, client: PlaneClient, workspace_slug: str, tag) -> None: + assert client.releases.tags.retrieve(workspace_slug, tag.id).id == tag.id - def test_create_release_tag(self, client: PlaneClient, workspace_slug: str) -> None: - """Test creating a release tag.""" - name = f"tag-{uuid4().hex[:6]}" - data = CreateReleaseTag(name=name, color="#AABBCC") - created = client.releases.tags.create(workspace_slug, data) - assert created is not None - assert created.id is not None - assert created.name == name + def test_update_tag(self, client: PlaneClient, workspace_slug: str, tag) -> None: + updated = client.releases.tags.update( + workspace_slug, tag.id, UpdateReleaseTag(description="updated") + ) + assert updated.id == tag.id + + def test_delete_tag(self, client: PlaneClient, workspace_slug: str) -> None: + t = client.releases.tags.create(workspace_slug, CreateReleaseTag(version=f"v{_uid()}")) + client.releases.tags.delete(workspace_slug, t.id) + with pytest.raises(HttpError): + client.releases.tags.retrieve(workspace_slug, t.id) + + def test_create_model_keeps_deprecated_fields(self) -> None: + """name/color stay on the create model for backward compatibility.""" + fields = set(CreateReleaseTag.model_fields) + assert {"version", "commit_hash", "git_tag"} <= fields + assert {"name", "color"} <= fields class TestReleaseLabels: - """Test Releases.labels sub-resource.""" + """Workspace-level release labels.""" - def test_list_release_labels(self, client: PlaneClient, workspace_slug: str) -> None: - """Test listing release labels.""" - labels = client.releases.labels.list(workspace_slug) - assert isinstance(labels, list) + @pytest.fixture + def label(self, client: PlaneClient, workspace_slug: str): + lbl = client.releases.labels.create( + workspace_slug, CreateReleaseLabel(name=f"label-{_uid()}", color="#112233") + ) + yield lbl + try: + client.releases.labels.delete(workspace_slug, lbl.id) + except Exception: + pass - def test_create_release_label(self, client: PlaneClient, workspace_slug: str) -> None: - """Test creating a release label.""" - name = f"label-{uuid4().hex[:6]}" - data = CreateReleaseLabel(name=name, color="#112233") - created = client.releases.labels.create(workspace_slug, data) - assert created is not None - assert created.id is not None - assert created.name == name + def test_list_labels(self, client: PlaneClient, workspace_slug: str) -> None: + assert isinstance(client.releases.labels.list(workspace_slug), list) + + def test_create_label(self, label) -> None: + assert label.id is not None + assert label.name is not None + + def test_retrieve_label(self, client: PlaneClient, workspace_slug: str, label) -> None: + assert client.releases.labels.retrieve(workspace_slug, label.id).id == label.id + + def test_update_label(self, client: PlaneClient, workspace_slug: str, label) -> None: + updated = client.releases.labels.update( + workspace_slug, label.id, UpdateReleaseLabel(name=f"label-{_uid()}") + ) + assert updated.id == label.id + + def test_delete_label(self, client: PlaneClient, workspace_slug: str) -> None: + lbl = client.releases.labels.create( + workspace_slug, CreateReleaseLabel(name=f"label-{_uid()}") + ) + client.releases.labels.delete(workspace_slug, lbl.id) + with pytest.raises(HttpError): + client.releases.labels.retrieve(workspace_slug, lbl.id) class TestReleaseItemLabels: - """Test Releases.item_labels sub-resource.""" + """Labels attached to a specific release.""" - def test_list_item_labels_for_release( - self, client: PlaneClient, workspace_slug: str + @pytest.fixture + def release(self, client: PlaneClient, workspace_slug: str): + rel = client.releases.create(workspace_slug, CreateRelease(name=f"release-{_uid()}")) + yield rel + try: + client.releases.delete(workspace_slug, rel.id) + except Exception: + pass + + def test_list_item_labels(self, client: PlaneClient, workspace_slug: str, release) -> None: + assert isinstance(client.releases.item_labels.list(workspace_slug, release.id), list) + + def test_attach_and_detach_label( + self, client: PlaneClient, workspace_slug: str, release ) -> None: - """Test listing labels for a specific release.""" - releases = client.releases.list(workspace_slug) - if not releases: - pytest.skip("No releases available to test item labels listing") + label = client.releases.labels.create( + workspace_slug, CreateReleaseLabel(name=f"label-{_uid()}") + ) + try: + attached = client.releases.item_labels.create( + workspace_slug, release.id, AddReleaseItemLabel(label_ids=[label.id]) + ) + assert isinstance(attached, list) + assert label.id in [ + x.id for x in client.releases.item_labels.list(workspace_slug, release.id) + ] - release = releases[0] - assert release.id is not None - labels = client.releases.item_labels.list(workspace_slug, release.id) - assert isinstance(labels, list) + client.releases.item_labels.delete(workspace_slug, release.id, label.id) + assert label.id not in [ + x.id for x in client.releases.item_labels.list(workspace_slug, release.id) + ] + finally: + try: + client.releases.labels.delete(workspace_slug, label.id) + except Exception: + pass - def test_add_remove_label_to_release( - self, client: PlaneClient, workspace_slug: str + +class TestReleaseWorkItems: + """Work items linked to a release.""" + + @pytest.fixture + def release(self, client: PlaneClient, workspace_slug: str): + rel = client.releases.create(workspace_slug, CreateRelease(name=f"release-{_uid()}")) + yield rel + try: + client.releases.delete(workspace_slug, rel.id) + except Exception: + pass + + def test_list_work_items(self, client: PlaneClient, workspace_slug: str, release) -> None: + assert isinstance(client.releases.work_items.list(workspace_slug, release.id), list) + + def test_add_and_remove_work_item( + self, client: PlaneClient, workspace_slug: str, release, project ) -> None: - """Test adding and removing labels from a release.""" - # Create a release - release_name = f"test-release-{uuid4().hex[:8]}" - release = client.releases.create( - workspace_slug, CreateRelease(name=release_name) + from plane.models.work_items import CreateWorkItem + + wi = client.work_items.create( + workspace_slug, project.id, CreateWorkItem(name=f"wi-{_uid()}") ) - assert release is not None - assert release.id is not None + try: + client.releases.work_items.add( + workspace_slug, release.id, AddReleaseWorkItems(work_item_ids=[wi.id]) + ) + assert wi.id in [ + w.id for w in client.releases.work_items.list(workspace_slug, release.id) + ] - # Create a label - label_name = f"label-{uuid4().hex[:6]}" - label = client.releases.labels.create( - workspace_slug, CreateReleaseLabel(name=label_name) + client.releases.work_items.remove(workspace_slug, release.id, [wi.id]) + assert wi.id not in [ + w.id for w in client.releases.work_items.list(workspace_slug, release.id) + ] + finally: + try: + client.work_items.delete(workspace_slug, project.id, wi.id) + except Exception: + pass + + +class TestReleaseComments: + """Comments on a release.""" + + @pytest.fixture + def release(self, client: PlaneClient, workspace_slug: str): + rel = client.releases.create(workspace_slug, CreateRelease(name=f"release-{_uid()}")) + yield rel + try: + client.releases.delete(workspace_slug, rel.id) + except Exception: + pass + + def test_comment_lifecycle(self, client: PlaneClient, workspace_slug: str, release) -> None: + comment = client.releases.comments.create( + workspace_slug, release.id, CreateReleaseComment(comment_html="

ship it

") ) - assert label is not None - assert label.id is not None + assert comment.id is not None + + assert comment.id in [ + c.id for c in client.releases.comments.list(workspace_slug, release.id) + ] + assert ( + client.releases.comments.retrieve(workspace_slug, release.id, comment.id).id + == comment.id + ) + client.releases.comments.delete(workspace_slug, release.id, comment.id) - # Add label to release - add_data = AddReleaseItemLabel(label_ids=[label.id]) - result = client.releases.item_labels.create(workspace_slug, release.id, add_data) - assert isinstance(result, list) - # Remove label from release - client.releases.item_labels.delete(workspace_slug, release.id, label.id) +class TestReleaseLinks: + """Links on a release.""" + + @pytest.fixture + def release(self, client: PlaneClient, workspace_slug: str): + rel = client.releases.create(workspace_slug, CreateRelease(name=f"release-{_uid()}")) + yield rel + try: + client.releases.delete(workspace_slug, rel.id) + except Exception: + pass + + def test_link_lifecycle(self, client: PlaneClient, workspace_slug: str, release) -> None: + link = client.releases.links.create( + workspace_slug, + release.id, + CreateReleaseLink(url=f"https://example.com/{_uid()}", title="Docs"), + ) + assert link.id is not None + + assert link.id in [x.id for x in client.releases.links.list(workspace_slug, release.id)] + updated = client.releases.links.update( + workspace_slug, release.id, link.id, UpdateReleaseLink(title="Updated") + ) + assert updated.id == link.id + client.releases.links.delete(workspace_slug, release.id, link.id) From 7491e830617e3608cf8ce9d68ca0cae449d63550 Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Fri, 17 Jul 2026 11:41:43 +0530 Subject: [PATCH 2/3] refactor: update work items and release tests to use paginated response structure --- plane/api/releases/work_items.py | 1 - tests/unit/test_releases.py | 36 +++++++++++++++----------------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/plane/api/releases/work_items.py b/plane/api/releases/work_items.py index 210a1fb..c163058 100644 --- a/plane/api/releases/work_items.py +++ b/plane/api/releases/work_items.py @@ -6,7 +6,6 @@ from ...models.releases import ( AddReleaseWorkItems, PaginatedReleaseWorkItemResponse, - ReleaseWorkItem, ) from ..base_resource import BaseResource diff --git a/tests/unit/test_releases.py b/tests/unit/test_releases.py index a279249..a2cbf77 100644 --- a/tests/unit/test_releases.py +++ b/tests/unit/test_releases.py @@ -37,15 +37,9 @@ def release(self, client: PlaneClient, workspace_slug: str): except Exception: pass - def test_list_releases_returns_list(self, client: PlaneClient, workspace_slug: str) -> None: - """list() returns a plain list (it used to raise on the paginated envelope).""" - releases = client.releases.list(workspace_slug) - assert isinstance(releases, list) - - def test_list_paginated_returns_envelope( - self, client: PlaneClient, workspace_slug: str - ) -> None: - response = client.releases.list_paginated(workspace_slug) + def test_list_releases_returns_envelope(self, client: PlaneClient, workspace_slug: str) -> None: + """list() returns the paginated envelope (it used to raise on the dict).""" + response = client.releases.list(workspace_slug) assert hasattr(response, "results") assert isinstance(response.results, list) assert isinstance(response.total_count, int) @@ -100,7 +94,7 @@ def tag(self, client: PlaneClient, workspace_slug: str): pass def test_list_tags(self, client: PlaneClient, workspace_slug: str) -> None: - assert isinstance(client.releases.tags.list(workspace_slug), list) + assert isinstance(client.releases.tags.list(workspace_slug).results, list) def test_create_tag_carries_version(self, tag) -> None: assert tag.id is not None @@ -143,7 +137,7 @@ def label(self, client: PlaneClient, workspace_slug: str): pass def test_list_labels(self, client: PlaneClient, workspace_slug: str) -> None: - assert isinstance(client.releases.labels.list(workspace_slug), list) + assert isinstance(client.releases.labels.list(workspace_slug).results, list) def test_create_label(self, label) -> None: assert label.id is not None @@ -180,7 +174,9 @@ def release(self, client: PlaneClient, workspace_slug: str): pass def test_list_item_labels(self, client: PlaneClient, workspace_slug: str, release) -> None: - assert isinstance(client.releases.item_labels.list(workspace_slug, release.id), list) + assert isinstance( + client.releases.item_labels.list(workspace_slug, release.id).results, list + ) def test_attach_and_detach_label( self, client: PlaneClient, workspace_slug: str, release @@ -194,12 +190,12 @@ def test_attach_and_detach_label( ) assert isinstance(attached, list) assert label.id in [ - x.id for x in client.releases.item_labels.list(workspace_slug, release.id) + x.id for x in client.releases.item_labels.list(workspace_slug, release.id).results ] client.releases.item_labels.delete(workspace_slug, release.id, label.id) assert label.id not in [ - x.id for x in client.releases.item_labels.list(workspace_slug, release.id) + x.id for x in client.releases.item_labels.list(workspace_slug, release.id).results ] finally: try: @@ -221,7 +217,7 @@ def release(self, client: PlaneClient, workspace_slug: str): pass def test_list_work_items(self, client: PlaneClient, workspace_slug: str, release) -> None: - assert isinstance(client.releases.work_items.list(workspace_slug, release.id), list) + assert isinstance(client.releases.work_items.list(workspace_slug, release.id).results, list) def test_add_and_remove_work_item( self, client: PlaneClient, workspace_slug: str, release, project @@ -236,12 +232,12 @@ def test_add_and_remove_work_item( workspace_slug, release.id, AddReleaseWorkItems(work_item_ids=[wi.id]) ) assert wi.id in [ - w.id for w in client.releases.work_items.list(workspace_slug, release.id) + w.id for w in client.releases.work_items.list(workspace_slug, release.id).results ] client.releases.work_items.remove(workspace_slug, release.id, [wi.id]) assert wi.id not in [ - w.id for w in client.releases.work_items.list(workspace_slug, release.id) + w.id for w in client.releases.work_items.list(workspace_slug, release.id).results ] finally: try: @@ -269,7 +265,7 @@ def test_comment_lifecycle(self, client: PlaneClient, workspace_slug: str, relea assert comment.id is not None assert comment.id in [ - c.id for c in client.releases.comments.list(workspace_slug, release.id) + c.id for c in client.releases.comments.list(workspace_slug, release.id).results ] assert ( client.releases.comments.retrieve(workspace_slug, release.id, comment.id).id @@ -298,7 +294,9 @@ def test_link_lifecycle(self, client: PlaneClient, workspace_slug: str, release) ) assert link.id is not None - assert link.id in [x.id for x in client.releases.links.list(workspace_slug, release.id)] + assert link.id in [ + x.id for x in client.releases.links.list(workspace_slug, release.id).results + ] updated = client.releases.links.update( workspace_slug, release.id, link.id, UpdateReleaseLink(title="Updated") ) From 84ce79d327573764549c5cdab4188d4f90bbbfa6 Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Fri, 17 Jul 2026 13:14:12 +0530 Subject: [PATCH 3/3] refactor: update ReleaseItemLabels and ReleaseWorkItems to use new request models for deletion --- plane/api/releases/item_labels.py | 9 +++++---- plane/api/releases/work_items.py | 9 +++++---- plane/models/releases.py | 25 ++++++++++++++++++++----- tests/unit/test_releases.py | 12 +++++++++--- 4 files changed, 39 insertions(+), 16 deletions(-) diff --git a/plane/api/releases/item_labels.py b/plane/api/releases/item_labels.py index 4281354..8b29552 100644 --- a/plane/api/releases/item_labels.py +++ b/plane/api/releases/item_labels.py @@ -7,6 +7,7 @@ AddReleaseItemLabel, PaginatedReleaseLabelResponse, ReleaseLabel, + RemoveReleaseItemLabel, ) from ..base_resource import BaseResource @@ -49,15 +50,15 @@ def create( ) return [ReleaseLabel.model_validate(item) for item in response] - def delete(self, workspace_slug: str, release_id: str, label_id: str) -> None: - """Detach a label from a release (the label itself is not deleted). + def delete(self, workspace_slug: str, release_id: str, data: RemoveReleaseItemLabel) -> None: + """Detach labels from a release (the labels themselves are not deleted). Args: workspace_slug: The workspace slug identifier release_id: UUID of the release - label_id: UUID of the label to detach + data: Label IDs to detach """ return self._delete( f"{workspace_slug}/releases/{release_id}/labels/", - data={"label_ids": [label_id]}, + data.model_dump(exclude_none=True), ) diff --git a/plane/api/releases/work_items.py b/plane/api/releases/work_items.py index c163058..1b64827 100644 --- a/plane/api/releases/work_items.py +++ b/plane/api/releases/work_items.py @@ -6,6 +6,7 @@ from ...models.releases import ( AddReleaseWorkItems, PaginatedReleaseWorkItemResponse, + RemoveReleaseWorkItems, ) from ..base_resource import BaseResource @@ -32,7 +33,7 @@ def list( response = self._get(f"{workspace_slug}/releases/{release_id}/work-items/", params=params) return PaginatedReleaseWorkItemResponse.model_validate(response) - def add(self, workspace_slug: str, release_id: str, data: AddReleaseWorkItems) -> None: + def create(self, workspace_slug: str, release_id: str, data: AddReleaseWorkItems) -> None: """Link work items to a release. Already-linked work items are skipped. Args: @@ -45,15 +46,15 @@ def add(self, workspace_slug: str, release_id: str, data: AddReleaseWorkItems) - data.model_dump(exclude_none=True), ) - def remove(self, workspace_slug: str, release_id: str, work_item_ids: list[str]) -> None: + def delete(self, workspace_slug: str, release_id: str, data: RemoveReleaseWorkItems) -> None: """Unlink work items from a release. Args: workspace_slug: The workspace slug identifier release_id: UUID of the release - work_item_ids: UUIDs of the work items to unlink + data: Work item IDs to unlink """ return self._delete( f"{workspace_slug}/releases/{release_id}/work-items/", - data={"work_item_ids": work_item_ids}, + data.model_dump(exclude_none=True), ) diff --git a/plane/models/releases.py b/plane/models/releases.py index 41102d6..a19569d 100644 --- a/plane/models/releases.py +++ b/plane/models/releases.py @@ -116,14 +116,13 @@ class ReleaseTag(BaseModel): class CreateReleaseTag(BaseModel): """Request model for creating a release tag. - `version` is required by the API and must be unique in the workspace. `name` - and `color` are ignored (a tag is not a label) and kept only for backward - compatibility. + `version` must be unique in the workspace. `name` and `color` are ignored (a + tag is not a label) and kept only for backward compatibility. """ model_config = ConfigDict(extra="ignore", populate_by_name=True) - version: str | None = None + version: str description: str | None = None commit_hash: str | None = None git_tag: str | None = None @@ -177,7 +176,15 @@ class UpdateReleaseLabel(BaseModel): class AddReleaseItemLabel(BaseModel): - """Request model for adding labels to a release.""" + """Request model for attaching labels to a release.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + label_ids: list[str] + + +class RemoveReleaseItemLabel(BaseModel): + """Request model for detaching labels from a release.""" model_config = ConfigDict(extra="ignore", populate_by_name=True) @@ -202,6 +209,14 @@ class AddReleaseWorkItems(BaseModel): work_item_ids: list[str] +class RemoveReleaseWorkItems(BaseModel): + """Request model for unlinking work items from a release.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + work_item_ids: list[str] + + class ReleaseComment(BaseModel): """Release comment response model. diff --git a/tests/unit/test_releases.py b/tests/unit/test_releases.py index a2cbf77..3410dce 100644 --- a/tests/unit/test_releases.py +++ b/tests/unit/test_releases.py @@ -14,6 +14,8 @@ CreateReleaseLabel, CreateReleaseLink, CreateReleaseTag, + RemoveReleaseItemLabel, + RemoveReleaseWorkItems, UpdateRelease, UpdateReleaseLabel, UpdateReleaseLink, @@ -193,7 +195,9 @@ def test_attach_and_detach_label( x.id for x in client.releases.item_labels.list(workspace_slug, release.id).results ] - client.releases.item_labels.delete(workspace_slug, release.id, label.id) + client.releases.item_labels.delete( + workspace_slug, release.id, RemoveReleaseItemLabel(label_ids=[label.id]) + ) assert label.id not in [ x.id for x in client.releases.item_labels.list(workspace_slug, release.id).results ] @@ -228,14 +232,16 @@ def test_add_and_remove_work_item( workspace_slug, project.id, CreateWorkItem(name=f"wi-{_uid()}") ) try: - client.releases.work_items.add( + client.releases.work_items.create( workspace_slug, release.id, AddReleaseWorkItems(work_item_ids=[wi.id]) ) assert wi.id in [ w.id for w in client.releases.work_items.list(workspace_slug, release.id).results ] - client.releases.work_items.remove(workspace_slug, release.id, [wi.id]) + client.releases.work_items.delete( + workspace_slug, release.id, RemoveReleaseWorkItems(work_item_ids=[wi.id]) + ) assert wi.id not in [ w.id for w in client.releases.work_items.list(workspace_slug, release.id).results ]