Skip to content
Merged
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
2 changes: 2 additions & 0 deletions plane/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from .api.agent_runs import AgentRuns
from .api.cycles import Cycles
from .api.estimates import Estimates
from .api.initiatives import Initiatives
from .api.labels import Labels
from .api.milestones import Milestones
Expand Down Expand Up @@ -44,6 +45,7 @@
"Milestones",
"Modules",
"Cycles",
"Estimates",
"Pages",
"Workspaces",
"PlaneError",
Expand Down
4 changes: 3 additions & 1 deletion plane/api/base_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ def _get(self, endpoint: str, params: Mapping[str, Any] | None = None) -> Any:
)
return self._handle_response(response)

def _post(self, endpoint: str, data: Mapping[str, Any] | None = None) -> Any:
def _post(
self, endpoint: str, data: Mapping[str, Any] | list[Any] | None = None
) -> Any:
url = self._build_url(endpoint)
response = self.session.post(
url, headers=self._headers(), json=data, timeout=self.config.timeout
Expand Down
204 changes: 204 additions & 0 deletions plane/api/estimates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
from __future__ import annotations

from typing import Any

from ..models.estimates import (
CreateEstimate,
CreateEstimatePoint,
Estimate,
EstimatePoint,
UpdateEstimate,
UpdateEstimatePoint,
)
from .base_resource import BaseResource


class Estimates(BaseResource):
"""Resource for managing project estimates and estimate points."""

def __init__(self, config: Any) -> None:
super().__init__(config, "/workspaces/")

# ── Estimate CRUD ────────────────────────────────────────────

def create(
self,
workspace_slug: str,
project_id: str,
data: CreateEstimate,
) -> Estimate:
"""Create a new estimate for a project.

Args:
workspace_slug: The workspace slug identifier.
project_id: UUID of the project.
data: Estimate creation data.
"""
response = self._post(
f"{workspace_slug}/projects/{project_id}/estimates",
data.model_dump(exclude_none=True),
)
return Estimate.model_validate(response)

def link_to_project(
self,
workspace_slug: str,
project_id: str,
estimate_id: str,
) -> Any:
"""Link an estimate to a project so that it becomes the active estimate system.

Args:
workspace_slug: The workspace slug identifier.
project_id: UUID of the project.
estimate_id: UUID of the estimate to link.
"""
from ..models.projects import UpdateProject
from .projects import Projects

projects_client = Projects(self.config)
return projects_client.update(
workspace_slug,
project_id,
UpdateProject(estimate=estimate_id),
)
Comment on lines +43 to +64

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 | 🟠 Major

The estimate CRUD methods only address the active project estimate.

link_to_project(..., estimate_id) and all point operations already show that estimates are individual records, but retrieve(), update(), and delete() only accept project_id. After more than one estimate exists, callers have no way to target a specific non-active estimate for read/update/delete.

Also applies to: 66-114, 118-204

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

In `@plane/api/estimates.py` around lines 43 - 64, The CRUD methods currently only
accept project_id so they operate on the active project estimate; update
link_to_project and all estimate handlers (retrieve, update, delete, and any
point operations referenced between lines 66-204) to accept an optional
estimate_id parameter (or required where appropriate) and route requests to the
endpoint that includes the estimate UUID when provided; update the signatures
for retrieve(), update(), delete() (and any point methods) to include
estimate_id: str | None, adjust the request construction to use
/projects/{project_id}/estimates/{estimate_id} when estimate_id is present
(falling back to the active-estimate project-level endpoint when omitted), and
update docstrings and callers accordingly so callers can target non-active
estimates by ID.


def retrieve(
self,
workspace_slug: str,
project_id: str,
) -> Estimate:
"""Retrieve the estimate configured for a project.

Args:
workspace_slug: The workspace slug identifier.
project_id: UUID of the project.
"""
response = self._get(
f"{workspace_slug}/projects/{project_id}/estimates",
)
return Estimate.model_validate(response)

def update(
self,
workspace_slug: str,
project_id: str,
data: UpdateEstimate,
) -> Estimate:
"""Update the estimate for a project.

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

def delete(
self,
workspace_slug: str,
project_id: str,
) -> None:
"""Delete the estimate for a project.

Args:
workspace_slug: The workspace slug identifier.
project_id: UUID of the project.
"""
return self._delete(
f"{workspace_slug}/projects/{project_id}/estimates",
)

# ── Estimate Points ──────────────────────────────────────────

def list_points(
self,
workspace_slug: str,
project_id: str,
estimate_id: str,
) -> list[EstimatePoint]:
"""List all estimate points for a project estimate.

Args:
workspace_slug: The workspace slug identifier.
project_id: UUID of the project.
estimate_id: UUID of the estimate.
"""
response = self._get(
f"{workspace_slug}/projects/{project_id}"
f"/estimates/{estimate_id}/estimate-points",
)
return [EstimatePoint.model_validate(item) for item in response]

def create_points(
self,
workspace_slug: str,
project_id: str,
estimate_id: str,
data: list[CreateEstimatePoint],
) -> list[EstimatePoint]:
"""Create estimate points for a project estimate.

The API accepts a JSON array directly as the request body.

Args:
workspace_slug: The workspace slug identifier.
project_id: UUID of the project.
estimate_id: UUID of the estimate.
data: List of estimate point creation data.
"""
payload = [item.model_dump(exclude_none=True) for item in data]
response = self._post(
f"{workspace_slug}/projects/{project_id}"
f"/estimates/{estimate_id}/estimate-points",
payload,
)
return [EstimatePoint.model_validate(item) for item in response]

def update_point(
self,
workspace_slug: str,
project_id: str,
estimate_id: str,
estimate_point_id: str,
data: UpdateEstimatePoint,
) -> EstimatePoint:
"""Update a single estimate point.

Args:
workspace_slug: The workspace slug identifier.
project_id: UUID of the project.
estimate_id: UUID of the estimate.
estimate_point_id: UUID of the estimate point.
data: Fields to update.
"""
response = self._patch(
f"{workspace_slug}/projects/{project_id}"
f"/estimates/{estimate_id}/estimate-points/{estimate_point_id}",
data.model_dump(exclude_none=True),
)
return EstimatePoint.model_validate(response)

def delete_point(
self,
workspace_slug: str,
project_id: str,
estimate_id: str,
estimate_point_id: str,
) -> None:
"""Delete a single estimate point.

Args:
workspace_slug: The workspace slug identifier.
project_id: UUID of the project.
estimate_id: UUID of the estimate.
estimate_point_id: UUID of the estimate point.
"""
return self._delete(
f"{workspace_slug}/projects/{project_id}"
f"/estimates/{estimate_id}/estimate-points/{estimate_point_id}",
)
29 changes: 29 additions & 0 deletions plane/api/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,32 @@ def update_features(
f"{workspace_slug}/projects/{project_id}/features", data.model_dump(exclude_none=True)
)
return ProjectFeature.model_validate(response)

def archive(self, workspace_slug: str, project_id: str) -> None:
"""Archive a project.

Move a project to archived status, hiding it from active project lists.

Args:
workspace_slug: The workspace slug identifier
project_id: UUID of the project

Returns:
None (HTTP 204 No Content)
"""
self._post(f"{workspace_slug}/projects/{project_id}/archive", {})

def unarchive(self, workspace_slug: str, project_id: str) -> None:
"""Unarchive a project.

Restore an archived project to active status, making it available
in regular workflows.

Args:
workspace_slug: The workspace slug identifier
project_id: UUID of the project

Returns:
None (HTTP 204 No Content)
"""
self._delete(f"{workspace_slug}/projects/{project_id}/archive")
32 changes: 32 additions & 0 deletions plane/api/work_items/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,35 @@ def advanced_search(
data.model_dump(exclude_none=True),
)
return [AdvancedSearchResult.model_validate(item) for item in response]

def archive(self, workspace_slug: str, project_id: str, work_item_id: str) -> None:
"""Archive a work item.

Only work items in a completed or cancelled state can be archived.

Args:
workspace_slug: The workspace slug identifier
project_id: UUID of the project
work_item_id: UUID of the work item
"""
self._post(
f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/archive",
{},
)

def unarchive(self, workspace_slug: str, project_id: str, work_item_id: str) -> None:
"""Unarchive a work item.

Restore an archived work item to active status.

Args:
workspace_slug: The workspace slug identifier
project_id: UUID of the project
work_item_id: UUID of the work item

Returns:
None (HTTP 204 No Content)
"""
self._delete(
f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}/unarchive"
)
2 changes: 2 additions & 0 deletions plane/client/plane_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from ..api.customers import Customers
from ..api.cycles import Cycles
from ..api.epics import Epics
from ..api.estimates import Estimates
from ..api.initiatives import Initiatives
from ..api.intake import Intake
from ..api.labels import Labels
Expand Down Expand Up @@ -55,6 +56,7 @@ def __init__(
self.milestones = Milestones(self.config)
self.modules = Modules(self.config)
self.cycles = Cycles(self.config)
self.estimates = Estimates(self.config)
self.work_item_types = WorkItemTypes(self.config)
self.work_item_properties = WorkItemProperties(self.config)
self.customers = Customers(self.config)
Expand Down
Loading