From 77d581d53b0e0a376f9f2201cb4c522287713d1c Mon Sep 17 00:00:00 2001 From: sunder Date: Thu, 30 Jul 2026 16:18:02 +0530 Subject: [PATCH 1/4] feat: add workspace governance resources (workspace states, workspace workflows, type governance) Plane's workspace governance moves states, workflows, and work item type governance to a workspace-level catalog (dual-mode, per-workspace opt-in). This adds the corresponding public-API coverage: - workspace_states: list/retrieve (dual-mode reads), create/update/delete against the workspace states catalog, plus external-id lookup - workspace_workflows: catalog CRUD, usage report, activity log, chain state management (add/update/remove/mark-default), state transitions, and transition hooks (incl. webhook secret regeneration and executions) - work_item_type_governance: governance mode (any/constrained/required), change previews, project pins, project-side effective workflows and workflow picks, and workflow fallback previews - WorkspaceFeature: type the new work_item_types, releases, and read-only states_owned_by_workspace fields (the governed-mode detection flag) Project workflows parity housekeeping: retrieve/delete/activities, chain state list/update/transfer, transition retrieve, transition hooks, and work item workflow approval. --- README.md | 111 ++++++++++ plane/__init__.py | 83 +++++++- plane/api/work_item_type_governance.py | 205 +++++++++++++++++++ plane/api/workflows/__init__.py | 8 +- plane/api/workflows/base.py | 74 ++++++- plane/api/workflows/hooks.py | 132 ++++++++++++ plane/api/workflows/states.py | 62 +++++- plane/api/workflows/transitions.py | 21 ++ plane/api/workspace_states.py | 110 ++++++++++ plane/api/workspace_workflows/__init__.py | 11 + plane/api/workspace_workflows/base.py | 124 +++++++++++ plane/api/workspace_workflows/hooks.py | 149 ++++++++++++++ plane/api/workspace_workflows/states.py | 113 ++++++++++ plane/api/workspace_workflows/transitions.py | 93 +++++++++ plane/client/plane_client.py | 6 + plane/models/enums.py | 9 + plane/models/states.py | 39 +++- plane/models/work_item_type_governance.py | 157 ++++++++++++++ plane/models/workflows.py | 83 ++++++++ plane/models/workspace_workflows.py | 182 ++++++++++++++++ plane/models/workspaces.py | 6 + pyproject.toml | 2 +- tests/unit/test_work_item_type_governance.py | 74 +++++++ tests/unit/test_workspace_states.py | 86 ++++++++ tests/unit/test_workspace_workflows.py | 93 +++++++++ 25 files changed, 2025 insertions(+), 8 deletions(-) create mode 100644 plane/api/work_item_type_governance.py create mode 100644 plane/api/workflows/hooks.py create mode 100644 plane/api/workspace_states.py create mode 100644 plane/api/workspace_workflows/__init__.py create mode 100644 plane/api/workspace_workflows/base.py create mode 100644 plane/api/workspace_workflows/hooks.py create mode 100644 plane/api/workspace_workflows/states.py create mode 100644 plane/api/workspace_workflows/transitions.py create mode 100644 plane/models/work_item_type_governance.py create mode 100644 plane/models/workspace_workflows.py create mode 100644 tests/unit/test_work_item_type_governance.py create mode 100644 tests/unit/test_workspace_states.py create mode 100644 tests/unit/test_workspace_workflows.py diff --git a/README.md b/README.md index 0db2858..a5544c7 100644 --- a/README.md +++ b/README.md @@ -677,6 +677,37 @@ state = client.states.update( client.states.delete(workspace_slug, project_id, state_id) ``` +#### Workspace States + +Workspace-level work-item states. Reads are dual-mode: under workspace +governance they serve the workspace states catalog; in ungoverned workspaces +they aggregate the states of every project the caller can access. Writes +require the workspace to own states and workflows (check +`client.workspaces.get_features(workspace_slug).states_owned_by_workspace`). + +```python +# List states at workspace scope (works in both modes) +states = client.workspace_states.list(workspace_slug) + +# Create a workspace (catalog) state — governed workspaces only +from plane.models.states import CreateWorkspaceState + +state = client.workspace_states.create( + workspace_slug, + data=CreateWorkspaceState(name="In Review", color="#3b82f6", group="started"), +) + +# Retrieve / update / delete +state = client.workspace_states.retrieve(workspace_slug, state_id) + +from plane.models.states import UpdateWorkspaceState + +state = client.workspace_states.update( + workspace_slug, state_id, data=UpdateWorkspaceState(color="#22c55e") +) +client.workspace_states.delete(workspace_slug, state_id) +``` + #### Labels ```python @@ -737,6 +768,86 @@ wit = client.work_item_types.update( client.work_item_types.delete(workspace_slug, project_id, type_id) ``` +#### Workspace Workflows + +The workspace workflow catalog (workspace governance). `list` is dual-mode; +all writes require the workspace to own states and workflows. + +```python +# List workspace workflows +workflows = client.workspace_workflows.list(workspace_slug) + +# Create a workflow draft, then configure its chain +from plane.models.workspace_workflows import ( + AddWorkspaceWorkflowStates, + CreateWorkspaceWorkflow, + CreateWorkspaceWorkflowTransition, +) + +workflow = client.workspace_workflows.create( + workspace_slug, data=CreateWorkspaceWorkflow(name="Engineering") +) +client.workspace_workflows.states.add( + workspace_slug, workflow.id, data=AddWorkspaceWorkflowStates(state_ids=[state_a, state_b]) +) +client.workspace_workflows.states.mark_default(workspace_slug, workflow.id, state_a) + +# Transitions +client.workspace_workflows.transitions.create( + workspace_slug, + workflow.id, + data=CreateWorkspaceWorkflowTransition(state_id=state_a, transition_state_id=state_b), +) + +# Full chain, usage report, and activity log +workflow = client.workspace_workflows.retrieve(workspace_slug, workflow.id) +usage = client.workspace_workflows.usage(workspace_slug, workflow.id) +activities = client.workspace_workflows.activities(workspace_slug, workflow.id) + +# Transition hooks (validation/action hooks, webhook secrets, executions) +hooks = client.workspace_workflows.hooks.list(workspace_slug, workflow_id, transition_id) +``` + +#### Work Item Type Governance + +Governs which workflows a workspace-level work item type may use +(`any` / `constrained` / `required` modes, allowlists, and per-project pins). +Workspace governance only. + +```python +# Read and change a type's governance +governance = client.work_item_type_governance.retrieve(workspace_slug, type_id) + +from plane.models.work_item_type_governance import UpdateTypeGovernance + +governance = client.work_item_type_governance.update( + workspace_slug, + type_id, + data=UpdateTypeGovernance(mode="constrained", workflow_ids=[workflow_id]), +) + +# Dry-run the impact first +from plane.models.work_item_type_governance import TypeGovernancePreviewRequest + +preview = client.work_item_type_governance.preview( + workspace_slug, + type_id, + data=TypeGovernancePreviewRequest(mode="required", required_workflow_id=workflow_id), +) + +# Pins, and the project-side view (effective workflows + picks) +pins = client.work_item_type_governance.list_pins(workspace_slug, type_id) +entries = client.work_item_type_governance.list_project_type_workflows( + workspace_slug, project_id +) + +from plane.models.work_item_type_governance import SetProjectWorkflowPick + +client.work_item_type_governance.set_project_pick( + workspace_slug, project_id, type_id, data=SetProjectWorkflowPick(workflow_id=workflow_id) +) +``` + #### Work Item Properties ```python diff --git a/plane/__init__.py b/plane/__init__.py index 182fe42..ac14632 100644 --- a/plane/__init__.py +++ b/plane/__init__.py @@ -15,14 +15,27 @@ from .api.users import Users from .api.work_item_properties import WorkItemProperties from .api.work_item_relation_definitions import WorkItemRelationDefinitions +from .api.work_item_type_governance import WorkItemTypeGovernance from .api.work_item_types import WorkItemTypes from .api.work_items import WorkItems -from .api.workflows import Workflows, WorkflowStates, WorkflowTransitions +from .api.workflows import ( + ProjectWorkflowTransitionHooks, + Workflows, + WorkflowStates, + WorkflowTransitions, +) from .api.workspace_project_labels import WorkspaceProjectLabels from .api.workspace_project_states import WorkspaceProjectStates +from .api.workspace_states import WorkspaceStates from .api.workspace_templates import WorkspaceTemplates from .api.workspace_work_item_properties import WorkspaceWorkItemProperties from .api.workspace_work_item_types import WorkspaceWorkItemTypes +from .api.workspace_workflows import ( + WorkspaceWorkflows, + WorkspaceWorkflowStates, + WorkspaceWorkflowTransitionHooks, + WorkspaceWorkflowTransitions, +) from .api.workspaces import Workspaces from .client import ( OAuthAuthorizationParams, @@ -44,16 +57,45 @@ WorkItemTemplate, ) from .models.projects import ProjectFeature, ProjectMember +from .models.states import CreateWorkspaceState, UpdateWorkspaceState +from .models.work_item_type_governance import ( + CreateWorkItemTypeWorkflowPins, + GovernancePreview, + ProjectTypeWorkflow, + SetProjectWorkflowPick, + TypeGovernance, + TypeGovernancePreviewRequest, + UpdateTypeGovernance, + WorkflowFallbackPreviewRequest, + WorkItemTypeWorkflowPin, +) from .models.workflows import ( AttachWorkflowStates, CreateWorkflow, CreateWorkflowTransition, + CreateWorkflowTransitionHook, UpdateWorkflow, + UpdateWorkflowState, UpdateWorkflowTransition, + UpdateWorkflowTransitionHook, Workflow, + WorkflowActivity, WorkflowTransition, + WorkflowTransitionHook, +) +from .models.workspace_workflows import ( + AddWorkspaceWorkflowStates, + CreateWorkspaceWorkflow, + CreateWorkspaceWorkflowTransition, + PaginatedWorkspaceWorkflowResponse, + RemoveWorkspaceWorkflowState, + UpdateWorkspaceWorkflow, + UpdateWorkspaceWorkflowState, + UpdateWorkspaceWorkflowTransition, + WorkspaceWorkflow, + WorkspaceWorkflowUsage, ) -from .models.workspaces import WorkspaceMember +from .models.workspaces import WorkspaceFeature, WorkspaceMember __all__ = [ "PlaneClient", @@ -83,12 +125,19 @@ "ProjectTemplates", "ProjectWorkItemTemplates", "ProjectPageTemplates", + "ProjectWorkflowTransitionHooks", "Releases", "WorkspaceTemplates", "WorkspaceWorkItemTypes", "WorkspaceWorkItemProperties", "WorkspaceProjectLabels", "WorkspaceProjectStates", + "WorkspaceStates", + "WorkspaceWorkflows", + "WorkspaceWorkflowStates", + "WorkspaceWorkflowTransitions", + "WorkspaceWorkflowTransitionHooks", + "WorkItemTypeGovernance", "PlaneError", "ConfigurationError", "HttpError", @@ -102,11 +151,41 @@ "CreateWorkflow", "UpdateWorkflow", "AttachWorkflowStates", + "UpdateWorkflowState", + "WorkflowActivity", "WorkflowTransition", "CreateWorkflowTransition", "UpdateWorkflowTransition", + "WorkflowTransitionHook", + "CreateWorkflowTransitionHook", + "UpdateWorkflowTransitionHook", + # Workspace state models + "CreateWorkspaceState", + "UpdateWorkspaceState", + # Workspace workflow models + "WorkspaceWorkflow", + "PaginatedWorkspaceWorkflowResponse", + "CreateWorkspaceWorkflow", + "UpdateWorkspaceWorkflow", + "AddWorkspaceWorkflowStates", + "UpdateWorkspaceWorkflowState", + "RemoveWorkspaceWorkflowState", + "CreateWorkspaceWorkflowTransition", + "UpdateWorkspaceWorkflowTransition", + "WorkspaceWorkflowUsage", + # Type governance models + "TypeGovernance", + "UpdateTypeGovernance", + "TypeGovernancePreviewRequest", + "GovernancePreview", + "WorkItemTypeWorkflowPin", + "CreateWorkItemTypeWorkflowPins", + "ProjectTypeWorkflow", + "SetProjectWorkflowPick", + "WorkflowFallbackPreviewRequest", "ProjectFeature", "ProjectMember", + "WorkspaceFeature", "WorkspaceMember", # Project template models "WorkItemTemplate", diff --git a/plane/api/work_item_type_governance.py b/plane/api/work_item_type_governance.py new file mode 100644 index 0000000..f3f30ee --- /dev/null +++ b/plane/api/work_item_type_governance.py @@ -0,0 +1,205 @@ +from typing import Any + +from ..models.work_item_type_governance import ( + CreateWorkItemTypeWorkflowPins, + GovernancePreview, + ProjectTypeWorkflow, + SetProjectWorkflowPick, + TypeGovernance, + TypeGovernancePreviewRequest, + UpdateTypeGovernance, + WorkflowFallbackPreviewRequest, + WorkItemTypeWorkflowPin, +) +from .base_resource import BaseResource + + +class WorkItemTypeGovernance(BaseResource): + """API client for work item type governance (workspace governance only). + + Governs which workflows a workspace-level work item type may use + (``any`` / ``constrained`` / ``required`` modes, allowlists, and pins) and + exposes the project-side view: each type's effective workflow and the + project's workflow pick. Every endpoint requires the workspace to own + states and workflows — otherwise the API responds 400 with code + ``workspace_not_managed``. + """ + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + # --- type-side governance (workspace scope) --- + + def retrieve(self, workspace_slug: str, type_id: str) -> TypeGovernance: + """Get a type's governance settings (mode, required workflow, allowlist). + + Args: + workspace_slug: The workspace slug identifier + type_id: UUID of the workspace work item type + """ + response = self._get(f"{workspace_slug}/work-item-types/{type_id}/governance") + return TypeGovernance.model_validate(response) + + def update( + self, workspace_slug: str, type_id: str, data: UpdateTypeGovernance + ) -> TypeGovernance: + """Change a type's governance mode / allowlist / required workflow. + + Destructive changes (dropping in-use workflows, mandating one) require + ``acknowledge`` and may need a ``state_mapping`` for orphaned items. + + Args: + workspace_slug: The workspace slug identifier + type_id: UUID of the workspace work item type + data: The governance change + """ + response = self._patch( + f"{workspace_slug}/work-item-types/{type_id}/governance", + data.model_dump(exclude_none=True), + ) + return TypeGovernance.model_validate(response) + + def preview( + self, workspace_slug: str, type_id: str, data: TypeGovernancePreviewRequest + ) -> GovernancePreview: + """Dry-run a governance change and report affected work items (no writes). + + Args: + workspace_slug: The workspace slug identifier + type_id: UUID of the workspace work item type + data: The governance change to preview + """ + response = self._post( + f"{workspace_slug}/work-item-types/{type_id}/governance/preview", + data.model_dump(exclude_none=True), + ) + payload = response.get("preview", response) if isinstance(response, dict) else response + return GovernancePreview.model_validate(payload) + + # --- pins (workspace scope) --- + + def list_pins(self, workspace_slug: str, type_id: str) -> list[WorkItemTypeWorkflowPin]: + """List a type's project-to-workflow pins. + + Args: + workspace_slug: The workspace slug identifier + type_id: UUID of the workspace work item type + """ + data = self._get(f"{workspace_slug}/work-item-types/{type_id}/governance/pins") + items = data.get("results", data) if isinstance(data, dict) else data + return [WorkItemTypeWorkflowPin.model_validate(item) for item in items] + + def create_pins( + self, workspace_slug: str, type_id: str, data: CreateWorkItemTypeWorkflowPins + ) -> list[WorkItemTypeWorkflowPin]: + """Pin a workflow for this type across one or more projects. + + Args: + workspace_slug: The workspace slug identifier + type_id: UUID of the workspace work item type + data: The workflow and target projects + """ + response = self._post( + f"{workspace_slug}/work-item-types/{type_id}/governance/pins", + data.model_dump(exclude_none=True), + ) + items = response.get("results", response) if isinstance(response, dict) else response + return [WorkItemTypeWorkflowPin.model_validate(item) for item in items] + + def delete_pin(self, workspace_slug: str, type_id: str, pin_id: str) -> None: + """Remove a pin. + + Args: + workspace_slug: The workspace slug identifier + type_id: UUID of the workspace work item type + pin_id: UUID of the pin + """ + return self._delete(f"{workspace_slug}/work-item-types/{type_id}/governance/pins/{pin_id}") + + # --- project-side view (picks and effective workflows) --- + + def list_project_type_workflows( + self, workspace_slug: str, project_id: str + ) -> list[ProjectTypeWorkflow]: + """List every active type's governance pill, effective workflow, and + pickable options for a project. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + """ + data = self._get(f"{workspace_slug}/projects/{project_id}/work-item-types/workflows") + items = data.get("results", data) if isinstance(data, dict) else data + return [ProjectTypeWorkflow.model_validate(item) for item in items] + + def retrieve_project_type_workflow( + self, workspace_slug: str, project_id: str, type_id: str + ) -> ProjectTypeWorkflow: + """Get one type's governance pill and effective workflow in a project. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + type_id: UUID of the work item type + """ + response = self._get( + f"{workspace_slug}/projects/{project_id}/work-item-types/{type_id}/workflows" + ) + return ProjectTypeWorkflow.model_validate(response) + + def get_project_pick( + self, workspace_slug: str, project_id: str, type_id: str + ) -> ProjectTypeWorkflow: + """Get the project's current workflow pick context for a type. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + type_id: UUID of the work item type + """ + response = self._get( + f"{workspace_slug}/projects/{project_id}/work-item-types/{type_id}/workflow" + ) + return ProjectTypeWorkflow.model_validate(response) + + def set_project_pick( + self, + workspace_slug: str, + project_id: str, + type_id: str, + data: SetProjectWorkflowPick, + ) -> dict[str, Any]: + """Set the project's workflow pick for a type. + + Runs the workflow fallback for stranded work items; every orphan must + be covered by ``data.state_mapping`` (400 with an orphan report + otherwise). Returns ``{"workflow_id": ""}``. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + type_id: UUID of the work item type + data: The pick (workflow and optional orphan state mapping) + """ + response = self._put( + f"{workspace_slug}/projects/{project_id}/work-item-types/{type_id}/workflow", + data.model_dump(exclude_none=True), + ) + return response if isinstance(response, dict) else {"workflow_id": response} + + def preview_project_workflow_fallback( + self, workspace_slug: str, project_id: str, data: WorkflowFallbackPreviewRequest + ) -> GovernancePreview: + """Dry-run the project's workflow fallback (re-type / switch dialogs). + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + data: The scenario to preview (re-type or workflow switch) + """ + response = self._post( + f"{workspace_slug}/projects/{project_id}/workflow-fallback-preview", + data.model_dump(exclude_none=True), + ) + payload = response.get("preview", response) if isinstance(response, dict) else response + return GovernancePreview.model_validate(payload) diff --git a/plane/api/workflows/__init__.py b/plane/api/workflows/__init__.py index 20d00da..b2b471c 100644 --- a/plane/api/workflows/__init__.py +++ b/plane/api/workflows/__init__.py @@ -1,5 +1,11 @@ from .base import Workflows +from .hooks import ProjectWorkflowTransitionHooks from .states import WorkflowStates from .transitions import WorkflowTransitions -__all__ = ["WorkflowStates", "WorkflowTransitions", "Workflows"] +__all__ = [ + "ProjectWorkflowTransitionHooks", + "WorkflowStates", + "WorkflowTransitions", + "Workflows", +] diff --git a/plane/api/workflows/base.py b/plane/api/workflows/base.py index 2f93920..a6aabdf 100644 --- a/plane/api/workflows/base.py +++ b/plane/api/workflows/base.py @@ -1,19 +1,29 @@ +from __future__ import annotations + +from collections.abc import Mapping from typing import Any -from ...models.workflows import CreateWorkflow, UpdateWorkflow, Workflow +from ...models.workflows import CreateWorkflow, UpdateWorkflow, Workflow, WorkflowActivity from ..base_resource import BaseResource +from .hooks import ProjectWorkflowTransitionHooks from .states import WorkflowStates from .transitions import WorkflowTransitions class Workflows(BaseResource): - """API client for managing project workflows.""" + """API client for managing project workflows. + + Under workspace governance workflows are managed at the workspace level + (see ``WorkspaceWorkflows``) and project-scoped writes respond 400 with + code ``workspace_managed``. + """ def __init__(self, config: Any) -> None: super().__init__(config, "/workspaces/") self.states = WorkflowStates(config) self.transitions = WorkflowTransitions(config) + self.hooks = ProjectWorkflowTransitionHooks(config) def list(self, workspace_slug: str, project_id: str) -> list[Workflow]: """List all workflows for a project. @@ -74,3 +84,63 @@ def update( data.model_dump(exclude_none=True), ) return Workflow.model_validate(response) + + def retrieve(self, workspace_slug: str, project_id: str, workflow_id: str) -> Workflow: + """Retrieve a workflow by ID. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + workflow_id: UUID of the workflow + """ + response = self._get(f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}/") + return Workflow.model_validate(response) + + def delete(self, workspace_slug: str, project_id: str, workflow_id: str) -> None: + """Delete a workflow by ID. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + workflow_id: UUID of the workflow + """ + return self._delete(f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}/") + + def activities( + self, + workspace_slug: str, + project_id: str, + workflow_id: str, + params: Mapping[str, Any] | None = None, + ) -> list[WorkflowActivity]: + """List the workflow's activity/audit entries. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + workflow_id: UUID of the workflow + params: Optional query parameters (e.g. created_at__gt) + """ + data = self._get( + f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}/activities/", + params=params, + ) + items = data.get("results", data) if isinstance(data, dict) else data + return [WorkflowActivity.model_validate(item) for item in items] + + def submit_work_item_approval( + self, workspace_slug: str, project_id: str, work_item_id: str, action: str + ) -> Any: + """Approve or reject a work item's pending workflow transition. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + work_item_id: UUID of the work item + action: ``"approve"`` or ``"reject"`` + """ + return self._post( + f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}" + "/workflow-approval/", + {"type": action}, + ) diff --git a/plane/api/workflows/hooks.py b/plane/api/workflows/hooks.py new file mode 100644 index 0000000..4130395 --- /dev/null +++ b/plane/api/workflows/hooks.py @@ -0,0 +1,132 @@ +from typing import Any + +from ...models.workflows import ( + CreateWorkflowTransitionHook, + UpdateWorkflowTransitionHook, + WorkflowTransitionHook, +) +from ..base_resource import BaseResource + + +class ProjectWorkflowTransitionHooks(BaseResource): + """API client for hooks attached to project workflow transitions.""" + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def _base( + self, workspace_slug: str, project_id: str, workflow_id: str, transition_id: str + ) -> str: + return ( + f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}" + f"/state-transitions/{transition_id}/hooks" + ) + + def list( + self, workspace_slug: str, project_id: str, workflow_id: str, transition_id: str + ) -> list[WorkflowTransitionHook]: + """List hooks on a workflow transition. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + workflow_id: UUID of the workflow + transition_id: UUID of the transition + """ + data = self._get(self._base(workspace_slug, project_id, workflow_id, transition_id)) + items = data.get("results", data) if isinstance(data, dict) else data + return [WorkflowTransitionHook.model_validate(item) for item in items] + + def create( + self, + workspace_slug: str, + project_id: str, + workflow_id: str, + transition_id: str, + data: CreateWorkflowTransitionHook, + ) -> WorkflowTransitionHook: + """Create a hook on a workflow transition. + + For send_webhook handlers the one-shot ``secret_plaintext`` is included + in the response of this call only. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + workflow_id: UUID of the workflow + transition_id: UUID of the transition + data: Hook data (phase, handler_name, config) + """ + response = self._post( + self._base(workspace_slug, project_id, workflow_id, transition_id), + data.model_dump(exclude_none=True), + ) + return WorkflowTransitionHook.model_validate(response) + + def retrieve( + self, + workspace_slug: str, + project_id: str, + workflow_id: str, + transition_id: str, + hook_id: str, + ) -> WorkflowTransitionHook: + """Retrieve a hook by ID. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + workflow_id: UUID of the workflow + transition_id: UUID of the transition + hook_id: UUID of the hook + """ + response = self._get( + f"{self._base(workspace_slug, project_id, workflow_id, transition_id)}/{hook_id}" + ) + return WorkflowTransitionHook.model_validate(response) + + def update( + self, + workspace_slug: str, + project_id: str, + workflow_id: str, + transition_id: str, + hook_id: str, + data: UpdateWorkflowTransitionHook, + ) -> WorkflowTransitionHook: + """Update a hook (``phase`` and ``handler_name`` are immutable). + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + workflow_id: UUID of the workflow + transition_id: UUID of the transition + hook_id: UUID of the hook + data: Updated hook data + """ + response = self._patch( + f"{self._base(workspace_slug, project_id, workflow_id, transition_id)}/{hook_id}", + data.model_dump(exclude_none=True), + ) + return WorkflowTransitionHook.model_validate(response) + + def delete( + self, + workspace_slug: str, + project_id: str, + workflow_id: str, + transition_id: str, + hook_id: str, + ) -> None: + """Delete a hook. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + workflow_id: UUID of the workflow + transition_id: UUID of the transition + hook_id: UUID of the hook + """ + return self._delete( + f"{self._base(workspace_slug, project_id, workflow_id, transition_id)}/{hook_id}" + ) diff --git a/plane/api/workflows/states.py b/plane/api/workflows/states.py index 28c3d86..d53c163 100644 --- a/plane/api/workflows/states.py +++ b/plane/api/workflows/states.py @@ -1,6 +1,6 @@ from typing import Any -from ...models.workflows import AttachWorkflowStates +from ...models.workflows import AttachWorkflowStates, UpdateWorkflowState, WorkflowState from ..base_resource import BaseResource @@ -10,6 +10,66 @@ class WorkflowStates(BaseResource): def __init__(self, config: Any) -> None: super().__init__(config, "/workspaces/") + def list(self, workspace_slug: str, project_id: str, workflow_id: str) -> list[WorkflowState]: + """List the states attached to a workflow. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + workflow_id: UUID of the workflow + """ + data = self._get(f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}/states/") + items = data.get("results", data) if isinstance(data, dict) else data + return [WorkflowState.model_validate(item) for item in items] + + def update( + self, + workspace_slug: str, + project_id: str, + workflow_id: str, + state_id: str, + data: UpdateWorkflowState, + ) -> WorkflowState | None: + """Update a state's membership row (type, allow_issue_creation, is_default). + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + workflow_id: UUID of the workflow + state_id: UUID of the state + data: Updated membership data + """ + response = self._patch( + f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}/states/{state_id}/", + data.model_dump(exclude_none=True), + ) + if response is None: + return None + return WorkflowState.model_validate(response) + + def transfer( + self, + workspace_slug: str, + project_id: str, + workflow_id: str, + state_id: str, + new_state_id: str, + ) -> None: + """Transfer work items off a state and remove it from the workflow. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + workflow_id: UUID of the workflow + state_id: UUID of the state being removed + new_state_id: UUID of the state that receives its work items + """ + self._post( + f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}" + f"/states/{state_id}/transfer/", + {"new_state_id": new_state_id}, + ) + def attach( self, workspace_slug: str, diff --git a/plane/api/workflows/transitions.py b/plane/api/workflows/transitions.py index 509451e..c922838 100644 --- a/plane/api/workflows/transitions.py +++ b/plane/api/workflows/transitions.py @@ -69,6 +69,27 @@ def create( raise return WorkflowTransition.model_validate(response) + def retrieve( + self, + workspace_slug: str, + project_id: str, + workflow_id: str, + transition_id: str, + ) -> WorkflowTransition: + """Retrieve a workflow state transition by ID. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + workflow_id: UUID of the workflow + transition_id: UUID of the transition + """ + response = self._get( + f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}" + f"/state-transitions/{transition_id}/" + ) + return WorkflowTransition.model_validate(response) + def update( self, workspace_slug: str, diff --git a/plane/api/workspace_states.py b/plane/api/workspace_states.py new file mode 100644 index 0000000..5f1b40f --- /dev/null +++ b/plane/api/workspace_states.py @@ -0,0 +1,110 @@ +from collections.abc import Mapping +from typing import Any + +from ..models.states import ( + CreateWorkspaceState, + PaginatedStateResponse, + State, + UpdateWorkspaceState, +) +from .base_resource import BaseResource + + +class WorkspaceStates(BaseResource): + """API client for workspace-level work-item states. + + Reads are dual-mode: under workspace governance ``list``/``retrieve`` serve + the workspace states catalog; in ungoverned workspaces they aggregate the + states of every project the caller can access. Writes are only available + when the workspace owns states and workflows — otherwise the API responds + 400 with code ``workspace_not_managed``. Check + ``Workspaces.get_features(...).states_owned_by_workspace`` to know which + mode a workspace is in. + + Not to be confused with ``WorkspaceProjectStates`` (``{slug}/project-states/``), + which manages project *lifecycle* states. + """ + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def list( + self, workspace_slug: str, params: Mapping[str, Any] | None = None + ) -> PaginatedStateResponse: + """List states at workspace scope. + + Args: + workspace_slug: The workspace slug identifier + params: Optional query parameters (e.g. cursor, per_page) + """ + response = self._get(f"{workspace_slug}/states", params=params) + return PaginatedStateResponse.model_validate(response) + + def retrieve_by_external_id( + self, workspace_slug: str, external_id: str, external_source: str + ) -> State: + """Retrieve a workspace state by its external ID and source. + + Args: + workspace_slug: The workspace slug identifier + external_id: External identifier of the state + external_source: External source system name + """ + response = self._get( + f"{workspace_slug}/states", + params={"external_id": external_id, "external_source": external_source}, + ) + return State.model_validate(response) + + def create(self, workspace_slug: str, data: CreateWorkspaceState) -> State: + """Create a new workspace (catalog) state. + + Governed workspaces only (400 ``workspace_not_managed`` otherwise). + Catalog state names are workspace-unique — a duplicate name responds + 409 with code ``state_name_in_use``. + + Args: + workspace_slug: The workspace slug identifier + data: State data (name, color, and one of the five lifecycle groups) + """ + response = self._post(f"{workspace_slug}/states", data.model_dump(exclude_none=True)) + return State.model_validate(response) + + def retrieve(self, workspace_slug: str, state_id: str) -> State: + """Retrieve a workspace state by ID. + + Args: + workspace_slug: The workspace slug identifier + state_id: UUID of the state + """ + response = self._get(f"{workspace_slug}/states/{state_id}") + return State.model_validate(response) + + def update(self, workspace_slug: str, state_id: str, data: UpdateWorkspaceState) -> State: + """Update a workspace (catalog) state by ID. + + Governed workspaces only. The triage state cannot be updated, and the + ``default`` flag is managed by the workflow's default state. + + Args: + workspace_slug: The workspace slug identifier + state_id: UUID of the state + data: Updated state data + """ + response = self._patch( + f"{workspace_slug}/states/{state_id}", data.model_dump(exclude_none=True) + ) + return State.model_validate(response) + + def delete(self, workspace_slug: str, state_id: str) -> None: + """Delete a workspace (catalog) state by ID. + + Governed workspaces only. Deletion is blocked (400) while any workflow + chain references the state or any work item still points at it; the + triage state is never deletable. + + Args: + workspace_slug: The workspace slug identifier + state_id: UUID of the state + """ + return self._delete(f"{workspace_slug}/states/{state_id}") diff --git a/plane/api/workspace_workflows/__init__.py b/plane/api/workspace_workflows/__init__.py new file mode 100644 index 0000000..9f9b980 --- /dev/null +++ b/plane/api/workspace_workflows/__init__.py @@ -0,0 +1,11 @@ +from .base import WorkspaceWorkflows +from .hooks import WorkspaceWorkflowTransitionHooks +from .states import WorkspaceWorkflowStates +from .transitions import WorkspaceWorkflowTransitions + +__all__ = [ + "WorkspaceWorkflows", + "WorkspaceWorkflowStates", + "WorkspaceWorkflowTransitions", + "WorkspaceWorkflowTransitionHooks", +] diff --git a/plane/api/workspace_workflows/base.py b/plane/api/workspace_workflows/base.py new file mode 100644 index 0000000..670b117 --- /dev/null +++ b/plane/api/workspace_workflows/base.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ...models.workflows import WorkflowActivity +from ...models.workspace_workflows import ( + CreateWorkspaceWorkflow, + PaginatedWorkspaceWorkflowResponse, + UpdateWorkspaceWorkflow, + WorkspaceWorkflow, + WorkspaceWorkflowUsage, +) +from ..base_resource import BaseResource +from .hooks import WorkspaceWorkflowTransitionHooks +from .states import WorkspaceWorkflowStates +from .transitions import WorkspaceWorkflowTransitions + + +class WorkspaceWorkflows(BaseResource): + """API client for the workspace workflow catalog. + + ``list`` is dual-mode: under workspace governance it serves the workspace + workflow catalog; in ungoverned workspaces it aggregates project workflows. + All writes require the workspace to own states and workflows — otherwise + the API responds 400 with code ``workspace_not_managed``. + """ + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + self.states = WorkspaceWorkflowStates(config) + self.transitions = WorkspaceWorkflowTransitions(config) + self.hooks = WorkspaceWorkflowTransitionHooks(config) + + def list( + self, workspace_slug: str, params: Mapping[str, Any] | None = None + ) -> PaginatedWorkspaceWorkflowResponse: + """List workspace workflows. + + Args: + workspace_slug: The workspace slug identifier + params: Optional query parameters (search, is_active, sort_by, + sort_order, cursor, per_page) + """ + response = self._get(f"{workspace_slug}/workflows", params=params) + return PaginatedWorkspaceWorkflowResponse.model_validate(response) + + def create(self, workspace_slug: str, data: CreateWorkspaceWorkflow) -> WorkspaceWorkflow: + """Create a workspace workflow draft (configure its chain via ``states``). + + Workflow names are workspace-unique. + + Args: + workspace_slug: The workspace slug identifier + data: Workflow data + """ + response = self._post(f"{workspace_slug}/workflows", data.model_dump(exclude_none=True)) + return WorkspaceWorkflow.model_validate(response) + + def retrieve(self, workspace_slug: str, workflow_id: str) -> WorkspaceWorkflow: + """Retrieve a workspace workflow with its full chain. + + Args: + workspace_slug: The workspace slug identifier + workflow_id: UUID of the workflow + """ + response = self._get(f"{workspace_slug}/workflows/{workflow_id}") + return WorkspaceWorkflow.model_validate(response) + + def update( + self, workspace_slug: str, workflow_id: str, data: UpdateWorkspaceWorkflow + ) -> WorkspaceWorkflow: + """Update workspace workflow metadata (name, description, is_active). + + Args: + workspace_slug: The workspace slug identifier + workflow_id: UUID of the workflow + data: Updated workflow data + """ + response = self._patch( + f"{workspace_slug}/workflows/{workflow_id}", data.model_dump(exclude_none=True) + ) + return WorkspaceWorkflow.model_validate(response) + + def delete(self, workspace_slug: str, workflow_id: str) -> None: + """Delete a workspace workflow. + + The default workflow and workflows in use by projects cannot be deleted. + + Args: + workspace_slug: The workspace slug identifier + workflow_id: UUID of the workflow + """ + return self._delete(f"{workspace_slug}/workflows/{workflow_id}") + + def usage(self, workspace_slug: str, workflow_id: str) -> WorkspaceWorkflowUsage: + """Report which projects/types resolve to this workflow, and which types + mandate or allow it. + + Args: + workspace_slug: The workspace slug identifier + workflow_id: UUID of the workflow + """ + response = self._get(f"{workspace_slug}/workflows/{workflow_id}/usage") + return WorkspaceWorkflowUsage.model_validate(response) + + def activities( + self, + workspace_slug: str, + workflow_id: str, + params: Mapping[str, Any] | None = None, + ) -> list[WorkflowActivity]: + """List the workflow's activity/audit entries. + + Args: + workspace_slug: The workspace slug identifier + workflow_id: UUID of the workflow + params: Optional query parameters (e.g. created_at__gt, cursor, + per_page) + """ + data = self._get(f"{workspace_slug}/workflows/{workflow_id}/activities", params=params) + items = data.get("results", data) if isinstance(data, dict) else data + return [WorkflowActivity.model_validate(item) for item in items] diff --git a/plane/api/workspace_workflows/hooks.py b/plane/api/workspace_workflows/hooks.py new file mode 100644 index 0000000..504025d --- /dev/null +++ b/plane/api/workspace_workflows/hooks.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from typing import Any + +from ...models.workflows import ( + CreateWorkflowTransitionHook, + UpdateWorkflowTransitionHook, + WorkflowTransitionHook, +) +from ..base_resource import BaseResource + + +class WorkspaceWorkflowTransitionHooks(BaseResource): + """API client for hooks attached to workspace workflow transitions.""" + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def _base(self, workspace_slug: str, workflow_id: str, transition_id: str) -> str: + return f"{workspace_slug}/workflows/{workflow_id}/state-transitions/{transition_id}/hooks" + + def list( + self, workspace_slug: str, workflow_id: str, transition_id: str + ) -> list[WorkflowTransitionHook]: + """List hooks on a workspace workflow transition. + + Args: + workspace_slug: The workspace slug identifier + workflow_id: UUID of the workflow + transition_id: UUID of the transition + """ + data = self._get(self._base(workspace_slug, workflow_id, transition_id)) + items = data.get("results", data) if isinstance(data, dict) else data + return [WorkflowTransitionHook.model_validate(item) for item in items] + + def create( + self, + workspace_slug: str, + workflow_id: str, + transition_id: str, + data: CreateWorkflowTransitionHook, + ) -> WorkflowTransitionHook: + """Create a hook on a workspace workflow transition. + + For send_webhook handlers the one-shot ``secret_plaintext`` is included + in the response of this call only. + + Args: + workspace_slug: The workspace slug identifier + workflow_id: UUID of the workflow + transition_id: UUID of the transition + data: Hook data (phase, handler_name, config) + """ + response = self._post( + self._base(workspace_slug, workflow_id, transition_id), + data.model_dump(exclude_none=True), + ) + return WorkflowTransitionHook.model_validate(response) + + def retrieve( + self, workspace_slug: str, workflow_id: str, transition_id: str, hook_id: str + ) -> WorkflowTransitionHook: + """Retrieve a hook by ID. + + Args: + workspace_slug: The workspace slug identifier + workflow_id: UUID of the workflow + transition_id: UUID of the transition + hook_id: UUID of the hook + """ + response = self._get(f"{self._base(workspace_slug, workflow_id, transition_id)}/{hook_id}") + return WorkflowTransitionHook.model_validate(response) + + def update( + self, + workspace_slug: str, + workflow_id: str, + transition_id: str, + hook_id: str, + data: UpdateWorkflowTransitionHook, + ) -> WorkflowTransitionHook: + """Update a hook (``phase`` and ``handler_name`` are immutable). + + Args: + workspace_slug: The workspace slug identifier + workflow_id: UUID of the workflow + transition_id: UUID of the transition + hook_id: UUID of the hook + data: Updated hook data + """ + response = self._patch( + f"{self._base(workspace_slug, workflow_id, transition_id)}/{hook_id}", + data.model_dump(exclude_none=True), + ) + return WorkflowTransitionHook.model_validate(response) + + def delete( + self, workspace_slug: str, workflow_id: str, transition_id: str, hook_id: str + ) -> None: + """Delete a hook. + + Args: + workspace_slug: The workspace slug identifier + workflow_id: UUID of the workflow + transition_id: UUID of the transition + hook_id: UUID of the hook + """ + return self._delete(f"{self._base(workspace_slug, workflow_id, transition_id)}/{hook_id}") + + def regenerate_secret( + self, workspace_slug: str, workflow_id: str, transition_id: str, hook_id: str + ) -> WorkflowTransitionHook: + """Regenerate a send_webhook hook's secret. + + The new one-shot ``secret_plaintext`` is included in this response only. + + Args: + workspace_slug: The workspace slug identifier + workflow_id: UUID of the workflow + transition_id: UUID of the transition + hook_id: UUID of the hook + """ + response = self._post( + f"{self._base(workspace_slug, workflow_id, transition_id)}/{hook_id}" + "/regenerate-webhook-secret", + None, + ) + return WorkflowTransitionHook.model_validate(response) + + def executions( + self, + workspace_slug: str, + workflow_id: str, + transition_id: str, + hook_id: str, + ) -> list[dict[str, Any]]: + """List a hook's execution history entries. + + Args: + workspace_slug: The workspace slug identifier + workflow_id: UUID of the workflow + transition_id: UUID of the transition + hook_id: UUID of the hook + """ + data = self._get( + f"{self._base(workspace_slug, workflow_id, transition_id)}/{hook_id}/executions" + ) + items = data.get("results", data) if isinstance(data, dict) else data + return list(items) if isinstance(items, list) else [items] diff --git a/plane/api/workspace_workflows/states.py b/plane/api/workspace_workflows/states.py new file mode 100644 index 0000000..76c5faa --- /dev/null +++ b/plane/api/workspace_workflows/states.py @@ -0,0 +1,113 @@ +from typing import Any + +from ...models.workspace_workflows import ( + AddWorkspaceWorkflowStates, + RemoveWorkspaceWorkflowState, + UpdateWorkspaceWorkflowState, + WorkspaceWorkflowState, +) +from ..base_resource import BaseResource + + +def _validate_chain(response: Any) -> list[WorkspaceWorkflowState]: + items = response.get("results", response) if isinstance(response, dict) else response + if not isinstance(items, list): + items = [items] + return [WorkspaceWorkflowState.model_validate(item) for item in items] + + +class WorkspaceWorkflowStates(BaseResource): + """API client for a workspace workflow's chain (state memberships).""" + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def add( + self, + workspace_slug: str, + workflow_id: str, + data: AddWorkspaceWorkflowStates, + ) -> list[WorkspaceWorkflowState]: + """Append catalog states to the workflow's chain. + + Args: + workspace_slug: The workspace slug identifier + workflow_id: UUID of the workflow + data: Request body with the catalog state IDs to append + + Returns: + The updated chain rows + """ + response = self._post( + f"{workspace_slug}/workflows/{workflow_id}/states", + data.model_dump(exclude_none=True), + ) + return _validate_chain(response) + + def update( + self, + workspace_slug: str, + workflow_id: str, + state_id: str, + data: UpdateWorkspaceWorkflowState, + ) -> WorkspaceWorkflowState: + """Update a chain membership row (type, allow_issue_creation, is_default). + + Changing ``type`` removes the row's existing transitions server-side. + + Args: + workspace_slug: The workspace slug identifier + workflow_id: UUID of the workflow + state_id: UUID of the catalog state + data: Updated membership data + """ + response = self._patch( + f"{workspace_slug}/workflows/{workflow_id}/states/{state_id}", + data.model_dump(exclude_none=True), + ) + return WorkspaceWorkflowState.model_validate(response) + + def remove( + self, + workspace_slug: str, + workflow_id: str, + state_id: str, + data: RemoveWorkspaceWorkflowState | None = None, + ) -> None: + """Remove a state from the chain. + + Orphan-gated: every work item stranded by the removal must be covered + by ``data.state_mapping`` (409 with an orphan report otherwise). + Removing the default state requires ``data.new_default_state_id``; + transitions referencing the state must be removed first. + + Args: + workspace_slug: The workspace slug identifier + workflow_id: UUID of the workflow + state_id: UUID of the catalog state + data: Optional removal options (new default, orphan state mapping) + """ + return self._delete( + f"{workspace_slug}/workflows/{workflow_id}/states/{state_id}", + data=data.model_dump(exclude_none=True) if data is not None else None, + ) + + def mark_default( + self, workspace_slug: str, workflow_id: str, state_id: str + ) -> WorkspaceWorkflowState | None: + """Mark a chain state as the workflow's default. + + Also force-enables work-item creation on that state. + + Args: + workspace_slug: The workspace slug identifier + workflow_id: UUID of the workflow + state_id: UUID of the catalog state + """ + response = self._post( + f"{workspace_slug}/workflows/{workflow_id}/states/{state_id}/mark-default", + None, + ) + if response is None: + return None + return WorkspaceWorkflowState.model_validate(response) diff --git a/plane/api/workspace_workflows/transitions.py b/plane/api/workspace_workflows/transitions.py new file mode 100644 index 0000000..fa1f238 --- /dev/null +++ b/plane/api/workspace_workflows/transitions.py @@ -0,0 +1,93 @@ +from typing import Any + +from ...models.workspace_workflows import ( + CreateWorkspaceWorkflowTransition, + UpdateWorkspaceWorkflowTransition, + WorkspaceWorkflowTransition, +) +from ..base_resource import BaseResource + + +class WorkspaceWorkflowTransitions(BaseResource): + """API client for state transitions within a workspace workflow.""" + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def list(self, workspace_slug: str, workflow_id: str) -> list[WorkspaceWorkflowTransition]: + """List all state transitions for a workspace workflow. + + Args: + workspace_slug: The workspace slug identifier + workflow_id: UUID of the workflow + """ + data = self._get(f"{workspace_slug}/workflows/{workflow_id}/state-transitions") + items = data.get("results", data) if isinstance(data, dict) else data + return [WorkspaceWorkflowTransition.model_validate(item) for item in items] + + def create( + self, + workspace_slug: str, + workflow_id: str, + data: CreateWorkspaceWorkflowTransition, + ) -> WorkspaceWorkflowTransition: + """Create a state transition for a workspace workflow. + + Args: + workspace_slug: The workspace slug identifier + workflow_id: UUID of the workflow + data: Transition data (from-state, target state, optional approvers) + """ + response = self._post( + f"{workspace_slug}/workflows/{workflow_id}/state-transitions", + data.model_dump(exclude_none=True), + ) + return WorkspaceWorkflowTransition.model_validate(response) + + def retrieve( + self, workspace_slug: str, workflow_id: str, transition_id: str + ) -> WorkspaceWorkflowTransition: + """Retrieve a workspace workflow transition by ID. + + Args: + workspace_slug: The workspace slug identifier + workflow_id: UUID of the workflow + transition_id: UUID of the transition + """ + response = self._get( + f"{workspace_slug}/workflows/{workflow_id}/state-transitions/{transition_id}" + ) + return WorkspaceWorkflowTransition.model_validate(response) + + def update( + self, + workspace_slug: str, + workflow_id: str, + transition_id: str, + data: UpdateWorkspaceWorkflowTransition, + ) -> WorkspaceWorkflowTransition: + """Update a workspace workflow transition. + + Args: + workspace_slug: The workspace slug identifier + workflow_id: UUID of the workflow + transition_id: UUID of the transition + data: Updated transition data + """ + response = self._patch( + f"{workspace_slug}/workflows/{workflow_id}/state-transitions/{transition_id}", + data.model_dump(exclude_none=True), + ) + return WorkspaceWorkflowTransition.model_validate(response) + + def delete(self, workspace_slug: str, workflow_id: str, transition_id: str) -> None: + """Delete a workspace workflow transition. + + Args: + workspace_slug: The workspace slug identifier + workflow_id: UUID of the workflow + transition_id: UUID of the transition + """ + return self._delete( + f"{workspace_slug}/workflows/{workflow_id}/state-transitions/{transition_id}" + ) diff --git a/plane/client/plane_client.py b/plane/client/plane_client.py index 4e565c1..9907cf7 100644 --- a/plane/client/plane_client.py +++ b/plane/client/plane_client.py @@ -20,14 +20,17 @@ from ..api.users import Users from ..api.work_item_properties import WorkItemProperties from ..api.work_item_relation_definitions import WorkItemRelationDefinitions +from ..api.work_item_type_governance import WorkItemTypeGovernance from ..api.work_item_types import WorkItemTypes from ..api.work_items import WorkItems from ..api.workflows import Workflows from ..api.workspace_project_labels import WorkspaceProjectLabels from ..api.workspace_project_states import WorkspaceProjectStates +from ..api.workspace_states import WorkspaceStates from ..api.workspace_templates import WorkspaceTemplates from ..api.workspace_work_item_properties import WorkspaceWorkItemProperties from ..api.workspace_work_item_types import WorkspaceWorkItemTypes +from ..api.workspace_workflows import WorkspaceWorkflows from ..api.workspaces import Workspaces from ..config import Configuration from ..errors import ConfigurationError @@ -84,6 +87,9 @@ def __init__( self.workspace_work_item_properties = WorkspaceWorkItemProperties(self.config) self.workspace_project_labels = WorkspaceProjectLabels(self.config) self.workspace_project_states = WorkspaceProjectStates(self.config) + self.workspace_states = WorkspaceStates(self.config) + self.workspace_workflows = WorkspaceWorkflows(self.config) + self.work_item_type_governance = WorkItemTypeGovernance(self.config) self.work_item_relation_definitions = WorkItemRelationDefinitions(self.config) self.releases = Releases(self.config) self.roles = Roles(self.config) diff --git a/plane/models/enums.py b/plane/models/enums.py index 5af11fb..c9c02f5 100644 --- a/plane/models/enums.py +++ b/plane/models/enums.py @@ -15,6 +15,15 @@ "cancelled", "triage", ] +# Workspace (catalog) states accept only the five lifecycle groups — the +# triage state is system-managed under workspace governance. +CatalogGroupEnum = Literal[ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", +] WorkItemRelationTypeEnum = Literal[ "blocking", "blocked_by", diff --git a/plane/models/states.py b/plane/models/states.py index 7b5637f..64d97ad 100644 --- a/plane/models/states.py +++ b/plane/models/states.py @@ -1,6 +1,6 @@ from pydantic import BaseModel, ConfigDict -from .enums import GroupEnum +from .enums import CatalogGroupEnum, GroupEnum from .pagination import PaginatedResponse @@ -71,6 +71,43 @@ class UpdateState(BaseModel): external_id: str | None = None +class CreateWorkspaceState(BaseModel): + """Request model for creating a workspace (catalog) state. + + Only accepted when the workspace owns states and workflows (workspace + governance). ``group`` is required and must be one of the five lifecycle + groups — the triage state is system-managed and cannot be created. Catalog + states carry no ``default`` flag; the workspace default lives on the + workflow's default state. + """ + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + name: str + color: str + group: CatalogGroupEnum + description: str | None = None + external_source: str | None = None + external_id: str | None = None + + +class UpdateWorkspaceState(BaseModel): + """Request model for updating a workspace (catalog) state. + + ``default`` is not accepted for catalog states — the API rejects it with + code ``workspace_managed``. + """ + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + name: str | None = None + color: str | None = None + group: CatalogGroupEnum | None = None + description: str | None = None + external_source: str | None = None + external_id: str | None = None + + class PaginatedStateResponse(PaginatedResponse): """Paginated response for states.""" diff --git a/plane/models/work_item_type_governance.py b/plane/models/work_item_type_governance.py new file mode 100644 index 0000000..7898801 --- /dev/null +++ b/plane/models/work_item_type_governance.py @@ -0,0 +1,157 @@ +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict + + +class WorkflowLite(BaseModel): + """Minimal workflow shape for embedding (pickers, pins).""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + name: str | None = None + + +class GovernanceProjectRef(BaseModel): + """Minimal project reference in governance payloads.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + name: str | None = None + + +class GovernanceAllowlistEntry(BaseModel): + """One allowlist/mandate row, enriched with its in-use projects. + + ``locked`` marks workflows a project's pick already uses — a constrained + allowlist must retain them. + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + workflow_id: str | None = None + name: str | None = None + in_use_by_projects: list[GovernanceProjectRef] = [] + locked: bool | None = None + + +class TypeGovernance(BaseModel): + """Governance settings for a work item type: mode, required workflow, and + allowlist. Pins are served by the dedicated pins endpoints.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + mode: str | None = None + required_workflow: WorkflowLite | None = None + allowlist: list[GovernanceAllowlistEntry] = [] + + +class UpdateTypeGovernance(BaseModel): + """Request model for changing a type's governance mode. + + - ``any``: no restriction; ``workflow_ids``/``required_workflow_id`` unused. + - ``constrained``: ``workflow_ids`` is the allowlist (in-use workflows are + locked in). Removing picks in use requires ``acknowledge`` and may need a + ``state_mapping`` for orphaned work items. + - ``required``: ``required_workflow_id`` mandates one workflow everywhere; + requires ``acknowledge`` and may need a ``state_mapping``. + """ + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + mode: Literal["any", "constrained", "required"] + workflow_ids: list[str] | None = None + required_workflow_id: str | None = None + acknowledge: bool | None = None + state_mapping: dict[str, str] | None = None + + +class TypeGovernancePreviewRequest(BaseModel): + """Request model for previewing a governance mode change (no writes).""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + mode: Literal["any", "constrained", "required"] + workflow_ids: list[str] | None = None + required_workflow_id: str | None = None + + +class GovernancePreview(BaseModel): + """Dry-run impact report for a governance change or workflow fallback.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + total: int | None = None + type_total: int | None = None + per_state: list[dict[str, Any]] | None = None + + +class WorkItemTypeWorkflowPin(BaseModel): + """A single project-to-workflow pin for a work item type.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + project: GovernanceProjectRef | None = None + workflow: WorkflowLite | None = None + + +class CreateWorkItemTypeWorkflowPins(BaseModel): + """Request model for pinning a workflow across one or more projects.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + workflow_id: str + project_ids: list[str] + + +class WorkflowOption(BaseModel): + """A pickable workflow option for a project's type.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + workflow_id: str | None = None + name: str | None = None + + +class ProjectTypeWorkflow(BaseModel): + """One active type's governance pill, effective workflow, and pickable + options within a project.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + type_id: str | None = None + governance: str | None = None # any | constrained | required | pinned + allowlist_count: int | None = None + allowlist_total: int | None = None + effective_workflow_id: str | None = None + source: str | None = None + options: list[WorkflowOption] = [] + + +class SetProjectWorkflowPick(BaseModel): + """Request model for setting a project's workflow pick for a type. + + Orphan-gated: work items whose state falls outside the target chain must be + covered by ``state_mapping``. + """ + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + workflow_id: str + state_mapping: dict[str, str] | None = None + + +class WorkflowFallbackPreviewRequest(BaseModel): + """Request model for previewing the project workflow fallback. + + Provide ``new_type_id`` (+ current ``type_id``) for a re-type preview, or + ``workflow_id`` (+ ``type_id``) for a workflow-switch preview. + """ + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + type_id: str | None = None + new_type_id: str | None = None + workflow_id: str | None = None diff --git a/plane/models/workflows.py b/plane/models/workflows.py index 9292bcd..fc905ab 100644 --- a/plane/models/workflows.py +++ b/plane/models/workflows.py @@ -87,3 +87,86 @@ class UpdateWorkflowTransition(BaseModel): pre_rules: list[dict[str, Any]] | None = None post_rules: list[dict[str, Any]] | None = None + + +class WorkflowState(BaseModel): + """A state's membership row within a workflow chain.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + state_id: str | None = None + workflow_id: str | None = None + type: str | None = None + allow_issue_creation: bool | None = None + is_default: bool | None = None + created_at: str | None = None + updated_at: str | None = None + + +class UpdateWorkflowState(BaseModel): + """Request model for updating a workflow state membership row.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + type: str | None = None + allow_issue_creation: bool | None = None + is_default: bool | None = None + + +class WorkflowActivity(BaseModel): + """One workflow activity/audit entry.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + verb: str | None = None + field: str | None = None + old_value: Any | None = None + new_value: Any | None = None + actor: Any | None = None + created_at: str | None = None + + +class WorkflowTransitionHook(BaseModel): + """A validation/action hook attached to a workflow transition. + + ``config.secret`` is masked for send_webhook handlers; the one-shot + ``secret_plaintext`` appears on create and regenerate responses only. + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + phase: str | None = None + handler_name: str | None = None + rule_type: str | None = None + execution_order: int | None = None + is_enabled: bool | None = None + config: dict[str, Any] | None = None + secret_plaintext: str | None = None + + +class CreateWorkflowTransitionHook(BaseModel): + """Request model for creating a workflow transition hook. + + ``phase`` and ``handler_name`` are immutable post-create. The per-handler + ``config`` shape is validated server-side (e.g. send_webhook requires + ``config.url``; run_script requires ``config.script_id``). + """ + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + phase: str + handler_name: str + config: dict[str, Any] + is_enabled: bool | None = None + + +class UpdateWorkflowTransitionHook(BaseModel): + """Request model for updating a workflow transition hook.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + config: dict[str, Any] | None = None + is_enabled: bool | None = None diff --git a/plane/models/workspace_workflows.py b/plane/models/workspace_workflows.py new file mode 100644 index 0000000..f850adf --- /dev/null +++ b/plane/models/workspace_workflows.py @@ -0,0 +1,182 @@ +from typing import Any + +from pydantic import BaseModel, ConfigDict + +from .pagination import PaginatedResponse + + +class WorkspaceWorkflowState(BaseModel): + """One state row in a workspace workflow's chain. + + Rows are keyed by catalog state IDs. ``transitions`` embeds the outgoing + transitions (with approvers) when the payload is the full chain projection. + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + state_id: str | None = None + type: str | None = None + allow_issue_creation: bool | None = None + is_default: bool | None = None + sequence: float | None = None + transitions: list[dict[str, Any]] | None = None + + +class WorkspaceWorkflow(BaseModel): + """Workspace workflow model (catalog list row / detail superset). + + The list endpoint returns the count fields only; ``retrieve`` adds the full + ``states`` chain, ``project_ids``/``work_item_type_ids`` usage, and + ``referenced_resources``. + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + name: str + description: str | None = None + is_default: bool | None = None + is_active: bool | None = None + workspace_id: str | None = None + project_id: str | None = None + states_count: int | None = None + projects_count: int | None = None + work_item_types_count: int | None = None + project_ids: list[str] | None = None + work_item_type_ids: list[str] | None = None + states: list[WorkspaceWorkflowState] | None = None + referenced_resources: Any | None = None + created_at: str | None = None + updated_at: str | None = None + created_by: str | None = None + updated_by: str | None = None + + +class PaginatedWorkspaceWorkflowResponse(PaginatedResponse): + """Paginated response for workspace workflows.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + results: list[WorkspaceWorkflow] + + +class CreateWorkspaceWorkflow(BaseModel): + """Request model for creating a workspace workflow (a draft until its chain + is configured via the states endpoints).""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + name: str + description: str | None = None + + +class UpdateWorkspaceWorkflow(BaseModel): + """Request model for updating workspace workflow metadata.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + name: str | None = None + description: str | None = None + is_active: bool | None = None + + +class AddWorkspaceWorkflowStates(BaseModel): + """Request model for appending catalog states to a workflow's chain.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + state_ids: list[str] + + +class UpdateWorkspaceWorkflowState(BaseModel): + """Request model for updating a chain membership row. + + Changing ``type`` (e.g. to/from ``approval``) removes the row's existing + transitions server-side. + """ + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + type: str | None = None + allow_issue_creation: bool | None = None + is_default: bool | None = None + + +class RemoveWorkspaceWorkflowState(BaseModel): + """Request body for removing a state from a chain. + + Orphan-gated: every work item stranded by the removal must be covered by + ``state_mapping`` (409 with an orphan report otherwise). Removing the + default state requires ``new_default_state_id``. + """ + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + new_default_state_id: str | None = None + state_mapping: dict[str, str] | None = None + + +class WorkspaceWorkflowUsageProject(BaseModel): + """One project resolving to a workflow in the usage report.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + project_id: str | None = None + name: str | None = None + types: list[str | None] = [] + + +class WorkspaceWorkflowUsage(BaseModel): + """Usage report for a workspace workflow: projects/types resolving to it, + plus the types mandating or allowing it.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + projects: list[WorkspaceWorkflowUsageProject] = [] + types_mandating: list[str] = [] + types_allowing: list[str] = [] + + +class WorkspaceWorkflowTransition(BaseModel): + """State transition within a workspace workflow.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + workflow_state_id: str | None = None + transition_state_id: str | None = None + rejection_state_id: str | None = None + required_approvals: int | None = None + member_ids: list[str] | None = None + pre_hooks: list[dict[str, Any]] | None = None + post_hooks: list[dict[str, Any]] | None = None + created_at: str | None = None + updated_at: str | None = None + + +class CreateWorkspaceWorkflowTransition(BaseModel): + """Request model for creating a workspace workflow transition. + + ``state_id`` is the chain state the transition starts from; + ``member_ids`` are the approvers (approval-type states only). + """ + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + state_id: str + transition_state_id: str + rejection_state_id: str | None = None + required_approvals: int | None = None + member_ids: list[str] | None = None + + +class UpdateWorkspaceWorkflowTransition(BaseModel): + """Request model for updating a workspace workflow transition.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + transition_state_id: str | None = None + rejection_state_id: str | None = None + required_approvals: int | None = None + member_ids: list[str] | None = None diff --git a/plane/models/workspaces.py b/plane/models/workspaces.py index ef0e4fb..e7aabf1 100644 --- a/plane/models/workspaces.py +++ b/plane/models/workspaces.py @@ -41,6 +41,12 @@ class WorkspaceFeature(BaseModel): customers: bool | None = None wiki: bool | None = None pi: bool | None = None + work_item_types: bool | None = None + releases: bool | None = None + # Read-only: reports whether states and workflows live at the workspace + # level (workspace governance). Only the governance migration can change + # it — the update endpoint rejects it as an input. + states_owned_by_workspace: bool | None = None class ProjectRoleDistributionEntry(BaseModel): diff --git a/pyproject.toml b/pyproject.toml index b1a451c..03afcb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plane-sdk" -version = "0.2.21" +version = "0.2.22" description = "Python SDK for Plane API" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/unit/test_work_item_type_governance.py b/tests/unit/test_work_item_type_governance.py new file mode 100644 index 0000000..6171f33 --- /dev/null +++ b/tests/unit/test_work_item_type_governance.py @@ -0,0 +1,74 @@ +"""Unit tests for WorkItemTypeGovernance API resource (smoke tests with real HTTP requests).""" + +import warnings +from uuid import uuid4 + +import pytest + +from plane.client import PlaneClient +from plane.models.work_item_type_governance import TypeGovernancePreviewRequest +from plane.models.work_item_types import CreateWorkItemType + + +@pytest.fixture(scope="module") +def governed(client: PlaneClient, workspace_slug: str) -> bool: + """Whether the test workspace owns states and workflows (workspace governance).""" + features = client.workspaces.get_features(workspace_slug) + return bool(features.states_owned_by_workspace) + + +@pytest.fixture(scope="module") +def workspace_type(client: PlaneClient, workspace_slug: str, governed: bool): + """A throwaway workspace-level work item type for governance reads.""" + if not governed: + pytest.skip("workspace does not own states and workflows") + created = client.workspace_work_item_types.create( + workspace_slug, CreateWorkItemType(name=f"test-gov-type-{uuid4().hex[:8]}") + ) + yield created + try: + client.workspace_work_item_types.delete(workspace_slug, created.id) + except Exception as exc: + warnings.warn(f"Teardown failed for work item type {created.id}: {exc}", stacklevel=1) + + +class TestWorkItemTypeGovernance: + """Test WorkItemTypeGovernance API resource.""" + + def test_retrieve_governance_defaults( + self, client: PlaneClient, workspace_slug: str, workspace_type + ) -> None: + """A fresh type starts in mode 'any' with an empty allowlist.""" + governance = client.work_item_type_governance.retrieve(workspace_slug, workspace_type.id) + assert governance.mode == "any" + assert governance.allowlist == [] + + def test_preview_mode_any_is_empty( + self, client: PlaneClient, workspace_slug: str, workspace_type + ) -> None: + """Previewing mode 'any' is a no-op impact report.""" + preview = client.work_item_type_governance.preview( + workspace_slug, workspace_type.id, TypeGovernancePreviewRequest(mode="any") + ) + assert preview.total == 0 + + def test_list_pins_empty( + self, client: PlaneClient, workspace_slug: str, workspace_type + ) -> None: + """A fresh type has no pins.""" + pins = client.work_item_type_governance.list_pins(workspace_slug, workspace_type.id) + assert pins == [] + + def test_project_type_workflows( + self, client: PlaneClient, workspace_slug: str, governed: bool, project + ) -> None: + """The project-side view lists an entry per active type.""" + if not governed: + pytest.skip("workspace does not own states and workflows") + entries = client.work_item_type_governance.list_project_type_workflows( + workspace_slug, project.id + ) + assert isinstance(entries, list) + for entry in entries: + assert entry.type_id is not None + assert entry.governance in ("any", "constrained", "required", "pinned") diff --git a/tests/unit/test_workspace_states.py b/tests/unit/test_workspace_states.py new file mode 100644 index 0000000..4bc1e11 --- /dev/null +++ b/tests/unit/test_workspace_states.py @@ -0,0 +1,86 @@ +"""Unit tests for WorkspaceStates API resource (smoke tests with real HTTP requests).""" + +import warnings +from uuid import uuid4 + +import pytest + +from plane.client import PlaneClient +from plane.errors.errors import HttpError +from plane.models.states import CreateWorkspaceState, UpdateWorkspaceState + + +@pytest.fixture(scope="module") +def governed(client: PlaneClient, workspace_slug: str) -> bool: + """Whether the test workspace owns states and workflows (workspace governance).""" + features = client.workspaces.get_features(workspace_slug) + return bool(features.states_owned_by_workspace) + + +class TestWorkspaceStates: + """Test WorkspaceStates API resource.""" + + def test_list_workspace_states(self, client: PlaneClient, workspace_slug: str) -> None: + """The list endpoint is dual-mode and must work in every workspace.""" + response = client.workspace_states.list(workspace_slug) + assert isinstance(response.results, list) + for state in response.results: + assert state.id is not None + assert state.name + + def test_features_report_governance_flag( + self, client: PlaneClient, workspace_slug: str + ) -> None: + """states_owned_by_workspace must be present and typed on the features payload.""" + features = client.workspaces.get_features(workspace_slug) + assert features.states_owned_by_workspace in (True, False) + + def test_create_rejected_when_ungoverned( + self, client: PlaneClient, workspace_slug: str, governed: bool + ) -> None: + """Catalog writes must respond 400 workspace_not_managed in ungoverned workspaces.""" + if governed: + pytest.skip("workspace is governed; the ungoverned rejection does not apply") + data = CreateWorkspaceState( + name=f"test-ws-state-{uuid4().hex[:8]}", color="#FF0000", group="unstarted" + ) + with pytest.raises(HttpError) as exc_info: + client.workspace_states.create(workspace_slug, data) + assert exc_info.value.status_code == 400 + + def test_create_update_delete_workspace_state( + self, client: PlaneClient, workspace_slug: str, governed: bool + ) -> None: + """Full CRUD cycle against the workspace states catalog (governed only).""" + if not governed: + pytest.skip("workspace does not own states and workflows") + + name = f"test-ws-state-{uuid4().hex[:8]}" + created = client.workspace_states.create( + workspace_slug, + CreateWorkspaceState( + name=name, color="#FF0000", group="unstarted", description="SDK test state" + ), + ) + assert created.id is not None + assert created.name == name + assert created.project is None + + try: + retrieved = client.workspace_states.retrieve(workspace_slug, created.id) + assert retrieved.id == created.id + + updated = client.workspace_states.update( + workspace_slug, + created.id, + UpdateWorkspaceState(description="Updated description"), + ) + assert updated.id == created.id + assert updated.description == "Updated description" + finally: + try: + client.workspace_states.delete(workspace_slug, created.id) + except Exception as exc: + warnings.warn( + f"Teardown failed for workspace state {created.id}: {exc}", stacklevel=1 + ) diff --git a/tests/unit/test_workspace_workflows.py b/tests/unit/test_workspace_workflows.py new file mode 100644 index 0000000..7c09fb8 --- /dev/null +++ b/tests/unit/test_workspace_workflows.py @@ -0,0 +1,93 @@ +"""Unit tests for WorkspaceWorkflows API resource (smoke tests with real HTTP requests).""" + +import warnings +from uuid import uuid4 + +import pytest + +from plane.client import PlaneClient +from plane.models.states import CreateWorkspaceState +from plane.models.workspace_workflows import ( + AddWorkspaceWorkflowStates, + CreateWorkspaceWorkflow, + UpdateWorkspaceWorkflow, +) + + +@pytest.fixture(scope="module") +def governed(client: PlaneClient, workspace_slug: str) -> bool: + """Whether the test workspace owns states and workflows (workspace governance).""" + features = client.workspaces.get_features(workspace_slug) + return bool(features.states_owned_by_workspace) + + +class TestWorkspaceWorkflows: + """Test WorkspaceWorkflows API resource.""" + + def test_list_workspace_workflows(self, client: PlaneClient, workspace_slug: str) -> None: + """The list endpoint is dual-mode and must work in every workspace.""" + response = client.workspace_workflows.list(workspace_slug) + assert isinstance(response.results, list) + for workflow in response.results: + assert workflow.id is not None + assert workflow.name + + def test_workflow_lifecycle_with_chain( + self, client: PlaneClient, workspace_slug: str, governed: bool + ) -> None: + """Create a workflow, configure a two-state chain, and clean up (governed only).""" + if not governed: + pytest.skip("workspace does not own states and workflows") + + suffix = uuid4().hex[:8] + state_a = client.workspace_states.create( + workspace_slug, + CreateWorkspaceState(name=f"test-wf-a-{suffix}", color="#FF0000", group="unstarted"), + ) + state_b = client.workspace_states.create( + workspace_slug, + CreateWorkspaceState(name=f"test-wf-b-{suffix}", color="#00FF00", group="started"), + ) + workflow = None + try: + workflow = client.workspace_workflows.create( + workspace_slug, CreateWorkspaceWorkflow(name=f"test-workflow-{suffix}") + ) + assert workflow.id is not None + + chain = client.workspace_workflows.states.add( + workspace_slug, + workflow.id, + AddWorkspaceWorkflowStates(state_ids=[state_a.id, state_b.id]), + ) + assert isinstance(chain, list) + + client.workspace_workflows.states.mark_default(workspace_slug, workflow.id, state_a.id) + + detail = client.workspace_workflows.retrieve(workspace_slug, workflow.id) + assert detail.id == workflow.id + assert detail.states is not None and len(detail.states) >= 2 + + updated = client.workspace_workflows.update( + workspace_slug, + workflow.id, + UpdateWorkspaceWorkflow(description="SDK test workflow"), + ) + assert updated.id == workflow.id + + usage = client.workspace_workflows.usage(workspace_slug, workflow.id) + assert isinstance(usage.projects, list) + + transitions = client.workspace_workflows.transitions.list(workspace_slug, workflow.id) + assert isinstance(transitions, list) + finally: + teardown = [] + if workflow is not None and workflow.id: + teardown.append((client.workspace_workflows.delete, (workspace_slug, workflow.id))) + teardown.append((client.workspace_states.delete, (workspace_slug, state_a.id))) + teardown.append((client.workspace_states.delete, (workspace_slug, state_b.id))) + for func, args in teardown: + try: + func(*args) + except Exception as exc: + warnings.warn(f"Teardown failed: {exc}", stacklevel=1) From f033e33f21ada8c7e6472bc6461ca294d5fbb6c0 Mon Sep 17 00:00:00 2001 From: sunder Date: Thu, 30 Jul 2026 17:08:37 +0530 Subject: [PATCH 2/4] fix: address review feedback on workspace governance resources - Append trailing slashes to every new endpoint path (URL convention) - Rename get_project_pick/set_project_pick to retrieve_project_pick/ update_project_pick (CRUD verb convention) - submit_work_item_approval now takes a SubmitWorkItemApproval DTO and returns a typed WorkItemApprovalResult - Export WorkspaceWorkflowState, WorkspaceWorkflowTransition, WorkspaceWorkflowUsageProject, and the new approval models from the package root; export CatalogGroupEnum from plane.models - README: capture created IDs in the workspace-workflow example and document placeholder IDs in the governance example - Tests: enter try/finally immediately after each resource creation so failed setup or assertions cannot leak catalog data --- README.md | 26 ++++++++++++++------ plane/__init__.py | 10 ++++++++ plane/api/work_item_type_governance.py | 26 ++++++++++---------- plane/api/workflows/base.py | 24 +++++++++++++----- plane/api/workflows/hooks.py | 10 ++++---- plane/api/workspace_states.py | 12 ++++----- plane/api/workspace_workflows/base.py | 14 +++++------ plane/api/workspace_workflows/hooks.py | 14 +++++------ plane/api/workspace_workflows/states.py | 8 +++--- plane/api/workspace_workflows/transitions.py | 10 ++++---- plane/models/__init__.py | 2 ++ plane/models/enums.py | 1 + plane/models/workflows.py | 19 +++++++++++++- tests/unit/test_workspace_states.py | 8 +++--- tests/unit/test_workspace_workflows.py | 25 +++++++++++-------- 15 files changed, 134 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index a5544c7..ac2abcc 100644 --- a/README.md +++ b/README.md @@ -777,26 +777,35 @@ all writes require the workspace to own states and workflows. # List workspace workflows workflows = client.workspace_workflows.list(workspace_slug) -# Create a workflow draft, then configure its chain +# Create a workflow draft, then configure its chain from catalog states +from plane.models.states import CreateWorkspaceState from plane.models.workspace_workflows import ( AddWorkspaceWorkflowStates, CreateWorkspaceWorkflow, CreateWorkspaceWorkflowTransition, ) +state_a = client.workspace_states.create( + workspace_slug, data=CreateWorkspaceState(name="Todo", color="#94a3b8", group="unstarted") +) +state_b = client.workspace_states.create( + workspace_slug, data=CreateWorkspaceState(name="Doing", color="#3b82f6", group="started") +) workflow = client.workspace_workflows.create( workspace_slug, data=CreateWorkspaceWorkflow(name="Engineering") ) client.workspace_workflows.states.add( - workspace_slug, workflow.id, data=AddWorkspaceWorkflowStates(state_ids=[state_a, state_b]) + workspace_slug, + workflow.id, + data=AddWorkspaceWorkflowStates(state_ids=[state_a.id, state_b.id]), ) -client.workspace_workflows.states.mark_default(workspace_slug, workflow.id, state_a) +client.workspace_workflows.states.mark_default(workspace_slug, workflow.id, state_a.id) # Transitions -client.workspace_workflows.transitions.create( +transition = client.workspace_workflows.transitions.create( workspace_slug, workflow.id, - data=CreateWorkspaceWorkflowTransition(state_id=state_a, transition_state_id=state_b), + data=CreateWorkspaceWorkflowTransition(state_id=state_a.id, transition_state_id=state_b.id), ) # Full chain, usage report, and activity log @@ -805,7 +814,7 @@ usage = client.workspace_workflows.usage(workspace_slug, workflow.id) activities = client.workspace_workflows.activities(workspace_slug, workflow.id) # Transition hooks (validation/action hooks, webhook secrets, executions) -hooks = client.workspace_workflows.hooks.list(workspace_slug, workflow_id, transition_id) +hooks = client.workspace_workflows.hooks.list(workspace_slug, workflow.id, transition.id) ``` #### Work Item Type Governance @@ -815,6 +824,9 @@ Governs which workflows a workspace-level work item type may use Workspace governance only. ```python +# type_id: UUID of a workspace work item type; workflow_id: UUID of a +# workspace workflow (e.g. workflow.id from the example above) + # Read and change a type's governance governance = client.work_item_type_governance.retrieve(workspace_slug, type_id) @@ -843,7 +855,7 @@ entries = client.work_item_type_governance.list_project_type_workflows( from plane.models.work_item_type_governance import SetProjectWorkflowPick -client.work_item_type_governance.set_project_pick( +client.work_item_type_governance.update_project_pick( workspace_slug, project_id, type_id, data=SetProjectWorkflowPick(workflow_id=workflow_id) ) ``` diff --git a/plane/__init__.py b/plane/__init__.py index ac14632..84a193b 100644 --- a/plane/__init__.py +++ b/plane/__init__.py @@ -74,6 +74,7 @@ CreateWorkflow, CreateWorkflowTransition, CreateWorkflowTransitionHook, + SubmitWorkItemApproval, UpdateWorkflow, UpdateWorkflowState, UpdateWorkflowTransition, @@ -82,6 +83,7 @@ WorkflowActivity, WorkflowTransition, WorkflowTransitionHook, + WorkItemApprovalResult, ) from .models.workspace_workflows import ( AddWorkspaceWorkflowStates, @@ -93,7 +95,10 @@ UpdateWorkspaceWorkflowState, UpdateWorkspaceWorkflowTransition, WorkspaceWorkflow, + WorkspaceWorkflowState, + WorkspaceWorkflowTransition, WorkspaceWorkflowUsage, + WorkspaceWorkflowUsageProject, ) from .models.workspaces import WorkspaceFeature, WorkspaceMember @@ -159,11 +164,15 @@ "WorkflowTransitionHook", "CreateWorkflowTransitionHook", "UpdateWorkflowTransitionHook", + "SubmitWorkItemApproval", + "WorkItemApprovalResult", # Workspace state models "CreateWorkspaceState", "UpdateWorkspaceState", # Workspace workflow models "WorkspaceWorkflow", + "WorkspaceWorkflowState", + "WorkspaceWorkflowTransition", "PaginatedWorkspaceWorkflowResponse", "CreateWorkspaceWorkflow", "UpdateWorkspaceWorkflow", @@ -173,6 +182,7 @@ "CreateWorkspaceWorkflowTransition", "UpdateWorkspaceWorkflowTransition", "WorkspaceWorkflowUsage", + "WorkspaceWorkflowUsageProject", # Type governance models "TypeGovernance", "UpdateTypeGovernance", diff --git a/plane/api/work_item_type_governance.py b/plane/api/work_item_type_governance.py index f3f30ee..705cf9f 100644 --- a/plane/api/work_item_type_governance.py +++ b/plane/api/work_item_type_governance.py @@ -37,7 +37,7 @@ def retrieve(self, workspace_slug: str, type_id: str) -> TypeGovernance: workspace_slug: The workspace slug identifier type_id: UUID of the workspace work item type """ - response = self._get(f"{workspace_slug}/work-item-types/{type_id}/governance") + response = self._get(f"{workspace_slug}/work-item-types/{type_id}/governance/") return TypeGovernance.model_validate(response) def update( @@ -54,7 +54,7 @@ def update( data: The governance change """ response = self._patch( - f"{workspace_slug}/work-item-types/{type_id}/governance", + f"{workspace_slug}/work-item-types/{type_id}/governance/", data.model_dump(exclude_none=True), ) return TypeGovernance.model_validate(response) @@ -70,7 +70,7 @@ def preview( data: The governance change to preview """ response = self._post( - f"{workspace_slug}/work-item-types/{type_id}/governance/preview", + f"{workspace_slug}/work-item-types/{type_id}/governance/preview/", data.model_dump(exclude_none=True), ) payload = response.get("preview", response) if isinstance(response, dict) else response @@ -85,7 +85,7 @@ def list_pins(self, workspace_slug: str, type_id: str) -> list[WorkItemTypeWorkf workspace_slug: The workspace slug identifier type_id: UUID of the workspace work item type """ - data = self._get(f"{workspace_slug}/work-item-types/{type_id}/governance/pins") + data = self._get(f"{workspace_slug}/work-item-types/{type_id}/governance/pins/") items = data.get("results", data) if isinstance(data, dict) else data return [WorkItemTypeWorkflowPin.model_validate(item) for item in items] @@ -100,7 +100,7 @@ def create_pins( data: The workflow and target projects """ response = self._post( - f"{workspace_slug}/work-item-types/{type_id}/governance/pins", + f"{workspace_slug}/work-item-types/{type_id}/governance/pins/", data.model_dump(exclude_none=True), ) items = response.get("results", response) if isinstance(response, dict) else response @@ -114,7 +114,7 @@ def delete_pin(self, workspace_slug: str, type_id: str, pin_id: str) -> None: type_id: UUID of the workspace work item type pin_id: UUID of the pin """ - return self._delete(f"{workspace_slug}/work-item-types/{type_id}/governance/pins/{pin_id}") + return self._delete(f"{workspace_slug}/work-item-types/{type_id}/governance/pins/{pin_id}/") # --- project-side view (picks and effective workflows) --- @@ -128,7 +128,7 @@ def list_project_type_workflows( workspace_slug: The workspace slug identifier project_id: UUID of the project """ - data = self._get(f"{workspace_slug}/projects/{project_id}/work-item-types/workflows") + data = self._get(f"{workspace_slug}/projects/{project_id}/work-item-types/workflows/") items = data.get("results", data) if isinstance(data, dict) else data return [ProjectTypeWorkflow.model_validate(item) for item in items] @@ -143,11 +143,11 @@ def retrieve_project_type_workflow( type_id: UUID of the work item type """ response = self._get( - f"{workspace_slug}/projects/{project_id}/work-item-types/{type_id}/workflows" + f"{workspace_slug}/projects/{project_id}/work-item-types/{type_id}/workflows/" ) return ProjectTypeWorkflow.model_validate(response) - def get_project_pick( + def retrieve_project_pick( self, workspace_slug: str, project_id: str, type_id: str ) -> ProjectTypeWorkflow: """Get the project's current workflow pick context for a type. @@ -158,11 +158,11 @@ def get_project_pick( type_id: UUID of the work item type """ response = self._get( - f"{workspace_slug}/projects/{project_id}/work-item-types/{type_id}/workflow" + f"{workspace_slug}/projects/{project_id}/work-item-types/{type_id}/workflow/" ) return ProjectTypeWorkflow.model_validate(response) - def set_project_pick( + def update_project_pick( self, workspace_slug: str, project_id: str, @@ -182,7 +182,7 @@ def set_project_pick( data: The pick (workflow and optional orphan state mapping) """ response = self._put( - f"{workspace_slug}/projects/{project_id}/work-item-types/{type_id}/workflow", + f"{workspace_slug}/projects/{project_id}/work-item-types/{type_id}/workflow/", data.model_dump(exclude_none=True), ) return response if isinstance(response, dict) else {"workflow_id": response} @@ -198,7 +198,7 @@ def preview_project_workflow_fallback( data: The scenario to preview (re-type or workflow switch) """ response = self._post( - f"{workspace_slug}/projects/{project_id}/workflow-fallback-preview", + f"{workspace_slug}/projects/{project_id}/workflow-fallback-preview/", data.model_dump(exclude_none=True), ) payload = response.get("preview", response) if isinstance(response, dict) else response diff --git a/plane/api/workflows/base.py b/plane/api/workflows/base.py index a6aabdf..9c73b81 100644 --- a/plane/api/workflows/base.py +++ b/plane/api/workflows/base.py @@ -3,7 +3,14 @@ from collections.abc import Mapping from typing import Any -from ...models.workflows import CreateWorkflow, UpdateWorkflow, Workflow, WorkflowActivity +from ...models.workflows import ( + CreateWorkflow, + SubmitWorkItemApproval, + UpdateWorkflow, + Workflow, + WorkflowActivity, + WorkItemApprovalResult, +) from ..base_resource import BaseResource from .hooks import ProjectWorkflowTransitionHooks from .states import WorkflowStates @@ -129,18 +136,23 @@ def activities( return [WorkflowActivity.model_validate(item) for item in items] def submit_work_item_approval( - self, workspace_slug: str, project_id: str, work_item_id: str, action: str - ) -> Any: + self, + workspace_slug: str, + project_id: str, + work_item_id: str, + data: SubmitWorkItemApproval, + ) -> WorkItemApprovalResult: """Approve or reject a work item's pending workflow transition. Args: workspace_slug: The workspace slug identifier project_id: UUID of the project work_item_id: UUID of the work item - action: ``"approve"`` or ``"reject"`` + data: The approval decision (``type="approve"`` or ``type="reject"``) """ - return self._post( + response = self._post( f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}" "/workflow-approval/", - {"type": action}, + data.model_dump(exclude_none=True), ) + return WorkItemApprovalResult.model_validate(response) diff --git a/plane/api/workflows/hooks.py b/plane/api/workflows/hooks.py index 4130395..0cd0866 100644 --- a/plane/api/workflows/hooks.py +++ b/plane/api/workflows/hooks.py @@ -33,7 +33,7 @@ def list( workflow_id: UUID of the workflow transition_id: UUID of the transition """ - data = self._get(self._base(workspace_slug, project_id, workflow_id, transition_id)) + data = self._get(f"{self._base(workspace_slug, project_id, workflow_id, transition_id)}/") items = data.get("results", data) if isinstance(data, dict) else data return [WorkflowTransitionHook.model_validate(item) for item in items] @@ -58,7 +58,7 @@ def create( data: Hook data (phase, handler_name, config) """ response = self._post( - self._base(workspace_slug, project_id, workflow_id, transition_id), + f"{self._base(workspace_slug, project_id, workflow_id, transition_id)}/", data.model_dump(exclude_none=True), ) return WorkflowTransitionHook.model_validate(response) @@ -81,7 +81,7 @@ def retrieve( hook_id: UUID of the hook """ response = self._get( - f"{self._base(workspace_slug, project_id, workflow_id, transition_id)}/{hook_id}" + f"{self._base(workspace_slug, project_id, workflow_id, transition_id)}/{hook_id}/" ) return WorkflowTransitionHook.model_validate(response) @@ -105,7 +105,7 @@ def update( data: Updated hook data """ response = self._patch( - f"{self._base(workspace_slug, project_id, workflow_id, transition_id)}/{hook_id}", + f"{self._base(workspace_slug, project_id, workflow_id, transition_id)}/{hook_id}/", data.model_dump(exclude_none=True), ) return WorkflowTransitionHook.model_validate(response) @@ -128,5 +128,5 @@ def delete( hook_id: UUID of the hook """ return self._delete( - f"{self._base(workspace_slug, project_id, workflow_id, transition_id)}/{hook_id}" + f"{self._base(workspace_slug, project_id, workflow_id, transition_id)}/{hook_id}/" ) diff --git a/plane/api/workspace_states.py b/plane/api/workspace_states.py index 5f1b40f..9992500 100644 --- a/plane/api/workspace_states.py +++ b/plane/api/workspace_states.py @@ -37,7 +37,7 @@ def list( workspace_slug: The workspace slug identifier params: Optional query parameters (e.g. cursor, per_page) """ - response = self._get(f"{workspace_slug}/states", params=params) + response = self._get(f"{workspace_slug}/states/", params=params) return PaginatedStateResponse.model_validate(response) def retrieve_by_external_id( @@ -51,7 +51,7 @@ def retrieve_by_external_id( external_source: External source system name """ response = self._get( - f"{workspace_slug}/states", + f"{workspace_slug}/states/", params={"external_id": external_id, "external_source": external_source}, ) return State.model_validate(response) @@ -67,7 +67,7 @@ def create(self, workspace_slug: str, data: CreateWorkspaceState) -> State: workspace_slug: The workspace slug identifier data: State data (name, color, and one of the five lifecycle groups) """ - response = self._post(f"{workspace_slug}/states", data.model_dump(exclude_none=True)) + response = self._post(f"{workspace_slug}/states/", data.model_dump(exclude_none=True)) return State.model_validate(response) def retrieve(self, workspace_slug: str, state_id: str) -> State: @@ -77,7 +77,7 @@ def retrieve(self, workspace_slug: str, state_id: str) -> State: workspace_slug: The workspace slug identifier state_id: UUID of the state """ - response = self._get(f"{workspace_slug}/states/{state_id}") + response = self._get(f"{workspace_slug}/states/{state_id}/") return State.model_validate(response) def update(self, workspace_slug: str, state_id: str, data: UpdateWorkspaceState) -> State: @@ -92,7 +92,7 @@ def update(self, workspace_slug: str, state_id: str, data: UpdateWorkspaceState) data: Updated state data """ response = self._patch( - f"{workspace_slug}/states/{state_id}", data.model_dump(exclude_none=True) + f"{workspace_slug}/states/{state_id}/", data.model_dump(exclude_none=True) ) return State.model_validate(response) @@ -107,4 +107,4 @@ def delete(self, workspace_slug: str, state_id: str) -> None: workspace_slug: The workspace slug identifier state_id: UUID of the state """ - return self._delete(f"{workspace_slug}/states/{state_id}") + return self._delete(f"{workspace_slug}/states/{state_id}/") diff --git a/plane/api/workspace_workflows/base.py b/plane/api/workspace_workflows/base.py index 670b117..b70bd81 100644 --- a/plane/api/workspace_workflows/base.py +++ b/plane/api/workspace_workflows/base.py @@ -43,7 +43,7 @@ def list( params: Optional query parameters (search, is_active, sort_by, sort_order, cursor, per_page) """ - response = self._get(f"{workspace_slug}/workflows", params=params) + response = self._get(f"{workspace_slug}/workflows/", params=params) return PaginatedWorkspaceWorkflowResponse.model_validate(response) def create(self, workspace_slug: str, data: CreateWorkspaceWorkflow) -> WorkspaceWorkflow: @@ -55,7 +55,7 @@ def create(self, workspace_slug: str, data: CreateWorkspaceWorkflow) -> Workspac workspace_slug: The workspace slug identifier data: Workflow data """ - response = self._post(f"{workspace_slug}/workflows", data.model_dump(exclude_none=True)) + response = self._post(f"{workspace_slug}/workflows/", data.model_dump(exclude_none=True)) return WorkspaceWorkflow.model_validate(response) def retrieve(self, workspace_slug: str, workflow_id: str) -> WorkspaceWorkflow: @@ -65,7 +65,7 @@ def retrieve(self, workspace_slug: str, workflow_id: str) -> WorkspaceWorkflow: workspace_slug: The workspace slug identifier workflow_id: UUID of the workflow """ - response = self._get(f"{workspace_slug}/workflows/{workflow_id}") + response = self._get(f"{workspace_slug}/workflows/{workflow_id}/") return WorkspaceWorkflow.model_validate(response) def update( @@ -79,7 +79,7 @@ def update( data: Updated workflow data """ response = self._patch( - f"{workspace_slug}/workflows/{workflow_id}", data.model_dump(exclude_none=True) + f"{workspace_slug}/workflows/{workflow_id}/", data.model_dump(exclude_none=True) ) return WorkspaceWorkflow.model_validate(response) @@ -92,7 +92,7 @@ def delete(self, workspace_slug: str, workflow_id: str) -> None: workspace_slug: The workspace slug identifier workflow_id: UUID of the workflow """ - return self._delete(f"{workspace_slug}/workflows/{workflow_id}") + return self._delete(f"{workspace_slug}/workflows/{workflow_id}/") def usage(self, workspace_slug: str, workflow_id: str) -> WorkspaceWorkflowUsage: """Report which projects/types resolve to this workflow, and which types @@ -102,7 +102,7 @@ def usage(self, workspace_slug: str, workflow_id: str) -> WorkspaceWorkflowUsage workspace_slug: The workspace slug identifier workflow_id: UUID of the workflow """ - response = self._get(f"{workspace_slug}/workflows/{workflow_id}/usage") + response = self._get(f"{workspace_slug}/workflows/{workflow_id}/usage/") return WorkspaceWorkflowUsage.model_validate(response) def activities( @@ -119,6 +119,6 @@ def activities( params: Optional query parameters (e.g. created_at__gt, cursor, per_page) """ - data = self._get(f"{workspace_slug}/workflows/{workflow_id}/activities", params=params) + data = self._get(f"{workspace_slug}/workflows/{workflow_id}/activities/", params=params) items = data.get("results", data) if isinstance(data, dict) else data return [WorkflowActivity.model_validate(item) for item in items] diff --git a/plane/api/workspace_workflows/hooks.py b/plane/api/workspace_workflows/hooks.py index 504025d..1b237fa 100644 --- a/plane/api/workspace_workflows/hooks.py +++ b/plane/api/workspace_workflows/hooks.py @@ -29,7 +29,7 @@ def list( workflow_id: UUID of the workflow transition_id: UUID of the transition """ - data = self._get(self._base(workspace_slug, workflow_id, transition_id)) + data = self._get(f"{self._base(workspace_slug, workflow_id, transition_id)}/") items = data.get("results", data) if isinstance(data, dict) else data return [WorkflowTransitionHook.model_validate(item) for item in items] @@ -52,7 +52,7 @@ def create( data: Hook data (phase, handler_name, config) """ response = self._post( - self._base(workspace_slug, workflow_id, transition_id), + f"{self._base(workspace_slug, workflow_id, transition_id)}/", data.model_dump(exclude_none=True), ) return WorkflowTransitionHook.model_validate(response) @@ -68,7 +68,7 @@ def retrieve( transition_id: UUID of the transition hook_id: UUID of the hook """ - response = self._get(f"{self._base(workspace_slug, workflow_id, transition_id)}/{hook_id}") + response = self._get(f"{self._base(workspace_slug, workflow_id, transition_id)}/{hook_id}/") return WorkflowTransitionHook.model_validate(response) def update( @@ -89,7 +89,7 @@ def update( data: Updated hook data """ response = self._patch( - f"{self._base(workspace_slug, workflow_id, transition_id)}/{hook_id}", + f"{self._base(workspace_slug, workflow_id, transition_id)}/{hook_id}/", data.model_dump(exclude_none=True), ) return WorkflowTransitionHook.model_validate(response) @@ -105,7 +105,7 @@ def delete( transition_id: UUID of the transition hook_id: UUID of the hook """ - return self._delete(f"{self._base(workspace_slug, workflow_id, transition_id)}/{hook_id}") + return self._delete(f"{self._base(workspace_slug, workflow_id, transition_id)}/{hook_id}/") def regenerate_secret( self, workspace_slug: str, workflow_id: str, transition_id: str, hook_id: str @@ -122,7 +122,7 @@ def regenerate_secret( """ response = self._post( f"{self._base(workspace_slug, workflow_id, transition_id)}/{hook_id}" - "/regenerate-webhook-secret", + "/regenerate-webhook-secret/", None, ) return WorkflowTransitionHook.model_validate(response) @@ -143,7 +143,7 @@ def executions( hook_id: UUID of the hook """ data = self._get( - f"{self._base(workspace_slug, workflow_id, transition_id)}/{hook_id}/executions" + f"{self._base(workspace_slug, workflow_id, transition_id)}/{hook_id}/executions/" ) items = data.get("results", data) if isinstance(data, dict) else data return list(items) if isinstance(items, list) else [items] diff --git a/plane/api/workspace_workflows/states.py b/plane/api/workspace_workflows/states.py index 76c5faa..bc8e2b3 100644 --- a/plane/api/workspace_workflows/states.py +++ b/plane/api/workspace_workflows/states.py @@ -39,7 +39,7 @@ def add( The updated chain rows """ response = self._post( - f"{workspace_slug}/workflows/{workflow_id}/states", + f"{workspace_slug}/workflows/{workflow_id}/states/", data.model_dump(exclude_none=True), ) return _validate_chain(response) @@ -62,7 +62,7 @@ def update( data: Updated membership data """ response = self._patch( - f"{workspace_slug}/workflows/{workflow_id}/states/{state_id}", + f"{workspace_slug}/workflows/{workflow_id}/states/{state_id}/", data.model_dump(exclude_none=True), ) return WorkspaceWorkflowState.model_validate(response) @@ -88,7 +88,7 @@ def remove( data: Optional removal options (new default, orphan state mapping) """ return self._delete( - f"{workspace_slug}/workflows/{workflow_id}/states/{state_id}", + f"{workspace_slug}/workflows/{workflow_id}/states/{state_id}/", data=data.model_dump(exclude_none=True) if data is not None else None, ) @@ -105,7 +105,7 @@ def mark_default( state_id: UUID of the catalog state """ response = self._post( - f"{workspace_slug}/workflows/{workflow_id}/states/{state_id}/mark-default", + f"{workspace_slug}/workflows/{workflow_id}/states/{state_id}/mark-default/", None, ) if response is None: diff --git a/plane/api/workspace_workflows/transitions.py b/plane/api/workspace_workflows/transitions.py index fa1f238..f4f469e 100644 --- a/plane/api/workspace_workflows/transitions.py +++ b/plane/api/workspace_workflows/transitions.py @@ -21,7 +21,7 @@ def list(self, workspace_slug: str, workflow_id: str) -> list[WorkspaceWorkflowT workspace_slug: The workspace slug identifier workflow_id: UUID of the workflow """ - data = self._get(f"{workspace_slug}/workflows/{workflow_id}/state-transitions") + data = self._get(f"{workspace_slug}/workflows/{workflow_id}/state-transitions/") items = data.get("results", data) if isinstance(data, dict) else data return [WorkspaceWorkflowTransition.model_validate(item) for item in items] @@ -39,7 +39,7 @@ def create( data: Transition data (from-state, target state, optional approvers) """ response = self._post( - f"{workspace_slug}/workflows/{workflow_id}/state-transitions", + f"{workspace_slug}/workflows/{workflow_id}/state-transitions/", data.model_dump(exclude_none=True), ) return WorkspaceWorkflowTransition.model_validate(response) @@ -55,7 +55,7 @@ def retrieve( transition_id: UUID of the transition """ response = self._get( - f"{workspace_slug}/workflows/{workflow_id}/state-transitions/{transition_id}" + f"{workspace_slug}/workflows/{workflow_id}/state-transitions/{transition_id}/" ) return WorkspaceWorkflowTransition.model_validate(response) @@ -75,7 +75,7 @@ def update( data: Updated transition data """ response = self._patch( - f"{workspace_slug}/workflows/{workflow_id}/state-transitions/{transition_id}", + f"{workspace_slug}/workflows/{workflow_id}/state-transitions/{transition_id}/", data.model_dump(exclude_none=True), ) return WorkspaceWorkflowTransition.model_validate(response) @@ -89,5 +89,5 @@ def delete(self, workspace_slug: str, workflow_id: str, transition_id: str) -> N transition_id: UUID of the transition """ return self._delete( - f"{workspace_slug}/workflows/{workflow_id}/state-transitions/{transition_id}" + f"{workspace_slug}/workflows/{workflow_id}/state-transitions/{transition_id}/" ) diff --git a/plane/models/__init__.py b/plane/models/__init__.py index 926bdce..7b3c069 100644 --- a/plane/models/__init__.py +++ b/plane/models/__init__.py @@ -1,5 +1,6 @@ from .enums import ( AccessEnum, + CatalogGroupEnum, EntityTypeEnum, GroupEnum, IntakeWorkItemStatusEnum, @@ -29,6 +30,7 @@ __all__ = [ # enums "AccessEnum", + "CatalogGroupEnum", "EntityTypeEnum", "GroupEnum", "WorkItemRelationTypeEnum", diff --git a/plane/models/enums.py b/plane/models/enums.py index c9c02f5..e2ddba8 100644 --- a/plane/models/enums.py +++ b/plane/models/enums.py @@ -587,6 +587,7 @@ class Group(Enum): __all__ = [ "AccessEnum", + "CatalogGroupEnum", "EntityTypeEnum", "GroupEnum", "WorkItemRelationTypeEnum", diff --git a/plane/models/workflows.py b/plane/models/workflows.py index fc905ab..48dc9d8 100644 --- a/plane/models/workflows.py +++ b/plane/models/workflows.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Literal from pydantic import BaseModel, ConfigDict @@ -128,6 +128,23 @@ class WorkflowActivity(BaseModel): created_at: str | None = None +class SubmitWorkItemApproval(BaseModel): + """Request model for approving or rejecting a work item's pending workflow + transition. ``type`` is ``"approve"`` or ``"reject"``.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + type: Literal["approve", "reject"] + + +class WorkItemApprovalResult(BaseModel): + """Response of a work item workflow approval: the state the item moved to.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + state_id: str | None = None + + class WorkflowTransitionHook(BaseModel): """A validation/action hook attached to a workflow transition. diff --git a/tests/unit/test_workspace_states.py b/tests/unit/test_workspace_states.py index 4bc1e11..36453ac 100644 --- a/tests/unit/test_workspace_states.py +++ b/tests/unit/test_workspace_states.py @@ -62,11 +62,11 @@ def test_create_update_delete_workspace_state( name=name, color="#FF0000", group="unstarted", description="SDK test state" ), ) - assert created.id is not None - assert created.name == name - assert created.project is None - try: + assert created.id is not None + assert created.name == name + assert created.project is None + retrieved = client.workspace_states.retrieve(workspace_slug, created.id) assert retrieved.id == created.id diff --git a/tests/unit/test_workspace_workflows.py b/tests/unit/test_workspace_workflows.py index 7c09fb8..bbf3f93 100644 --- a/tests/unit/test_workspace_workflows.py +++ b/tests/unit/test_workspace_workflows.py @@ -40,16 +40,20 @@ def test_workflow_lifecycle_with_chain( pytest.skip("workspace does not own states and workflows") suffix = uuid4().hex[:8] - state_a = client.workspace_states.create( - workspace_slug, - CreateWorkspaceState(name=f"test-wf-a-{suffix}", color="#FF0000", group="unstarted"), - ) - state_b = client.workspace_states.create( - workspace_slug, - CreateWorkspaceState(name=f"test-wf-b-{suffix}", color="#00FF00", group="started"), - ) + state_a = None + state_b = None workflow = None try: + state_a = client.workspace_states.create( + workspace_slug, + CreateWorkspaceState( + name=f"test-wf-a-{suffix}", color="#FF0000", group="unstarted" + ), + ) + state_b = client.workspace_states.create( + workspace_slug, + CreateWorkspaceState(name=f"test-wf-b-{suffix}", color="#00FF00", group="started"), + ) workflow = client.workspace_workflows.create( workspace_slug, CreateWorkspaceWorkflow(name=f"test-workflow-{suffix}") ) @@ -84,8 +88,9 @@ def test_workflow_lifecycle_with_chain( teardown = [] if workflow is not None and workflow.id: teardown.append((client.workspace_workflows.delete, (workspace_slug, workflow.id))) - teardown.append((client.workspace_states.delete, (workspace_slug, state_a.id))) - teardown.append((client.workspace_states.delete, (workspace_slug, state_b.id))) + for state in (state_a, state_b): + if state is not None and state.id: + teardown.append((client.workspace_states.delete, (workspace_slug, state.id))) for func, args in teardown: try: func(*args) From 7a7e1651f49637c754047e0fd89d6186660a17a6 Mon Sep 17 00:00:00 2001 From: sunder Date: Thu, 30 Jul 2026 17:36:44 +0530 Subject: [PATCH 3/4] refactor: restructure type governance as a package with sub-resources Match the resource-package convention used by collections/ and releases/: a base.py for the top-level class plus one file per sub-resource, each exposing plain CRUD verbs instead of prefixed method families. work_item_type_governance/ base.py -> WorkItemTypeGovernance: retrieve/update/preview pins.py -> WorkItemTypeWorkflowPins: list/create/delete project_workflows.py -> ProjectTypeWorkflows: list/retrieve/ retrieve_pick/update_pick/preview_fallback Call sites become client.work_item_type_governance.pins.list(...) and .project_workflows.update_pick(...), replacing list_pins/create_pins/ delete_pin/list_project_type_workflows/retrieve_project_type_workflow/ retrieve_project_pick/update_project_pick. Also narrow the new packages' __init__ exports to the top-level resource class only, as collections/ and releases/ do; sub-resources are reached through the parent (the pre-existing workflows/ package keeps its own exports). Response models stay exported from the package root. --- README.md | 12 +- plane/__init__.py | 10 +- plane/api/work_item_type_governance.py | 205 ------------------ .../api/work_item_type_governance/__init__.py | 3 + plane/api/work_item_type_governance/base.py | 79 +++++++ plane/api/work_item_type_governance/pins.py | 61 ++++++ .../project_workflows.py | 105 +++++++++ plane/api/workspace_workflows/__init__.py | 10 +- tests/unit/test_work_item_type_governance.py | 4 +- 9 files changed, 258 insertions(+), 231 deletions(-) delete mode 100644 plane/api/work_item_type_governance.py create mode 100644 plane/api/work_item_type_governance/__init__.py create mode 100644 plane/api/work_item_type_governance/base.py create mode 100644 plane/api/work_item_type_governance/pins.py create mode 100644 plane/api/work_item_type_governance/project_workflows.py diff --git a/README.md b/README.md index ac2abcc..9635e65 100644 --- a/README.md +++ b/README.md @@ -847,15 +847,15 @@ preview = client.work_item_type_governance.preview( data=TypeGovernancePreviewRequest(mode="required", required_workflow_id=workflow_id), ) -# Pins, and the project-side view (effective workflows + picks) -pins = client.work_item_type_governance.list_pins(workspace_slug, type_id) -entries = client.work_item_type_governance.list_project_type_workflows( - workspace_slug, project_id -) +# Per-project pins +pins = client.work_item_type_governance.pins.list(workspace_slug, type_id) + +# Project-side view: each type's effective workflow, and the project's pick +entries = client.work_item_type_governance.project_workflows.list(workspace_slug, project_id) from plane.models.work_item_type_governance import SetProjectWorkflowPick -client.work_item_type_governance.update_project_pick( +client.work_item_type_governance.project_workflows.update_pick( workspace_slug, project_id, type_id, data=SetProjectWorkflowPick(workflow_id=workflow_id) ) ``` diff --git a/plane/__init__.py b/plane/__init__.py index 84a193b..6a81dff 100644 --- a/plane/__init__.py +++ b/plane/__init__.py @@ -30,12 +30,7 @@ from .api.workspace_templates import WorkspaceTemplates from .api.workspace_work_item_properties import WorkspaceWorkItemProperties from .api.workspace_work_item_types import WorkspaceWorkItemTypes -from .api.workspace_workflows import ( - WorkspaceWorkflows, - WorkspaceWorkflowStates, - WorkspaceWorkflowTransitionHooks, - WorkspaceWorkflowTransitions, -) +from .api.workspace_workflows import WorkspaceWorkflows from .api.workspaces import Workspaces from .client import ( OAuthAuthorizationParams, @@ -139,9 +134,6 @@ "WorkspaceProjectStates", "WorkspaceStates", "WorkspaceWorkflows", - "WorkspaceWorkflowStates", - "WorkspaceWorkflowTransitions", - "WorkspaceWorkflowTransitionHooks", "WorkItemTypeGovernance", "PlaneError", "ConfigurationError", diff --git a/plane/api/work_item_type_governance.py b/plane/api/work_item_type_governance.py deleted file mode 100644 index 705cf9f..0000000 --- a/plane/api/work_item_type_governance.py +++ /dev/null @@ -1,205 +0,0 @@ -from typing import Any - -from ..models.work_item_type_governance import ( - CreateWorkItemTypeWorkflowPins, - GovernancePreview, - ProjectTypeWorkflow, - SetProjectWorkflowPick, - TypeGovernance, - TypeGovernancePreviewRequest, - UpdateTypeGovernance, - WorkflowFallbackPreviewRequest, - WorkItemTypeWorkflowPin, -) -from .base_resource import BaseResource - - -class WorkItemTypeGovernance(BaseResource): - """API client for work item type governance (workspace governance only). - - Governs which workflows a workspace-level work item type may use - (``any`` / ``constrained`` / ``required`` modes, allowlists, and pins) and - exposes the project-side view: each type's effective workflow and the - project's workflow pick. Every endpoint requires the workspace to own - states and workflows — otherwise the API responds 400 with code - ``workspace_not_managed``. - """ - - def __init__(self, config: Any) -> None: - super().__init__(config, "/workspaces/") - - # --- type-side governance (workspace scope) --- - - def retrieve(self, workspace_slug: str, type_id: str) -> TypeGovernance: - """Get a type's governance settings (mode, required workflow, allowlist). - - Args: - workspace_slug: The workspace slug identifier - type_id: UUID of the workspace work item type - """ - response = self._get(f"{workspace_slug}/work-item-types/{type_id}/governance/") - return TypeGovernance.model_validate(response) - - def update( - self, workspace_slug: str, type_id: str, data: UpdateTypeGovernance - ) -> TypeGovernance: - """Change a type's governance mode / allowlist / required workflow. - - Destructive changes (dropping in-use workflows, mandating one) require - ``acknowledge`` and may need a ``state_mapping`` for orphaned items. - - Args: - workspace_slug: The workspace slug identifier - type_id: UUID of the workspace work item type - data: The governance change - """ - response = self._patch( - f"{workspace_slug}/work-item-types/{type_id}/governance/", - data.model_dump(exclude_none=True), - ) - return TypeGovernance.model_validate(response) - - def preview( - self, workspace_slug: str, type_id: str, data: TypeGovernancePreviewRequest - ) -> GovernancePreview: - """Dry-run a governance change and report affected work items (no writes). - - Args: - workspace_slug: The workspace slug identifier - type_id: UUID of the workspace work item type - data: The governance change to preview - """ - response = self._post( - f"{workspace_slug}/work-item-types/{type_id}/governance/preview/", - data.model_dump(exclude_none=True), - ) - payload = response.get("preview", response) if isinstance(response, dict) else response - return GovernancePreview.model_validate(payload) - - # --- pins (workspace scope) --- - - def list_pins(self, workspace_slug: str, type_id: str) -> list[WorkItemTypeWorkflowPin]: - """List a type's project-to-workflow pins. - - Args: - workspace_slug: The workspace slug identifier - type_id: UUID of the workspace work item type - """ - data = self._get(f"{workspace_slug}/work-item-types/{type_id}/governance/pins/") - items = data.get("results", data) if isinstance(data, dict) else data - return [WorkItemTypeWorkflowPin.model_validate(item) for item in items] - - def create_pins( - self, workspace_slug: str, type_id: str, data: CreateWorkItemTypeWorkflowPins - ) -> list[WorkItemTypeWorkflowPin]: - """Pin a workflow for this type across one or more projects. - - Args: - workspace_slug: The workspace slug identifier - type_id: UUID of the workspace work item type - data: The workflow and target projects - """ - response = self._post( - f"{workspace_slug}/work-item-types/{type_id}/governance/pins/", - data.model_dump(exclude_none=True), - ) - items = response.get("results", response) if isinstance(response, dict) else response - return [WorkItemTypeWorkflowPin.model_validate(item) for item in items] - - def delete_pin(self, workspace_slug: str, type_id: str, pin_id: str) -> None: - """Remove a pin. - - Args: - workspace_slug: The workspace slug identifier - type_id: UUID of the workspace work item type - pin_id: UUID of the pin - """ - return self._delete(f"{workspace_slug}/work-item-types/{type_id}/governance/pins/{pin_id}/") - - # --- project-side view (picks and effective workflows) --- - - def list_project_type_workflows( - self, workspace_slug: str, project_id: str - ) -> list[ProjectTypeWorkflow]: - """List every active type's governance pill, effective workflow, and - pickable options for a project. - - Args: - workspace_slug: The workspace slug identifier - project_id: UUID of the project - """ - data = self._get(f"{workspace_slug}/projects/{project_id}/work-item-types/workflows/") - items = data.get("results", data) if isinstance(data, dict) else data - return [ProjectTypeWorkflow.model_validate(item) for item in items] - - def retrieve_project_type_workflow( - self, workspace_slug: str, project_id: str, type_id: str - ) -> ProjectTypeWorkflow: - """Get one type's governance pill and effective workflow in a project. - - Args: - workspace_slug: The workspace slug identifier - project_id: UUID of the project - type_id: UUID of the work item type - """ - response = self._get( - f"{workspace_slug}/projects/{project_id}/work-item-types/{type_id}/workflows/" - ) - return ProjectTypeWorkflow.model_validate(response) - - def retrieve_project_pick( - self, workspace_slug: str, project_id: str, type_id: str - ) -> ProjectTypeWorkflow: - """Get the project's current workflow pick context for a type. - - Args: - workspace_slug: The workspace slug identifier - project_id: UUID of the project - type_id: UUID of the work item type - """ - response = self._get( - f"{workspace_slug}/projects/{project_id}/work-item-types/{type_id}/workflow/" - ) - return ProjectTypeWorkflow.model_validate(response) - - def update_project_pick( - self, - workspace_slug: str, - project_id: str, - type_id: str, - data: SetProjectWorkflowPick, - ) -> dict[str, Any]: - """Set the project's workflow pick for a type. - - Runs the workflow fallback for stranded work items; every orphan must - be covered by ``data.state_mapping`` (400 with an orphan report - otherwise). Returns ``{"workflow_id": ""}``. - - Args: - workspace_slug: The workspace slug identifier - project_id: UUID of the project - type_id: UUID of the work item type - data: The pick (workflow and optional orphan state mapping) - """ - response = self._put( - f"{workspace_slug}/projects/{project_id}/work-item-types/{type_id}/workflow/", - data.model_dump(exclude_none=True), - ) - return response if isinstance(response, dict) else {"workflow_id": response} - - def preview_project_workflow_fallback( - self, workspace_slug: str, project_id: str, data: WorkflowFallbackPreviewRequest - ) -> GovernancePreview: - """Dry-run the project's workflow fallback (re-type / switch dialogs). - - Args: - workspace_slug: The workspace slug identifier - project_id: UUID of the project - data: The scenario to preview (re-type or workflow switch) - """ - response = self._post( - f"{workspace_slug}/projects/{project_id}/workflow-fallback-preview/", - data.model_dump(exclude_none=True), - ) - payload = response.get("preview", response) if isinstance(response, dict) else response - return GovernancePreview.model_validate(payload) diff --git a/plane/api/work_item_type_governance/__init__.py b/plane/api/work_item_type_governance/__init__.py new file mode 100644 index 0000000..9dfaedb --- /dev/null +++ b/plane/api/work_item_type_governance/__init__.py @@ -0,0 +1,3 @@ +from .base import WorkItemTypeGovernance + +__all__ = ["WorkItemTypeGovernance"] diff --git a/plane/api/work_item_type_governance/base.py b/plane/api/work_item_type_governance/base.py new file mode 100644 index 0000000..12879af --- /dev/null +++ b/plane/api/work_item_type_governance/base.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from typing import Any + +from ...models.work_item_type_governance import ( + GovernancePreview, + TypeGovernance, + TypeGovernancePreviewRequest, + UpdateTypeGovernance, +) +from ..base_resource import BaseResource +from .pins import WorkItemTypeWorkflowPins +from .project_workflows import ProjectTypeWorkflows + + +class WorkItemTypeGovernance(BaseResource): + """API client for work item type governance (workspace governance only). + + Governs which workflows a workspace-level work item type may use + (``any`` / ``constrained`` / ``required`` modes and allowlists). Per-project + pins live on ``.pins``; the project-side view of effective workflows and + picks lives on ``.project_workflows``. Every endpoint requires the workspace + to own states and workflows — otherwise the API responds 400 with code + ``workspace_not_managed``. + """ + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + # Initialize sub-resources + self.pins = WorkItemTypeWorkflowPins(config) + self.project_workflows = ProjectTypeWorkflows(config) + + def retrieve(self, workspace_slug: str, type_id: str) -> TypeGovernance: + """Retrieve a type's governance settings (mode, required workflow, allowlist). + + Args: + workspace_slug: The workspace slug identifier + type_id: UUID of the workspace work item type + """ + response = self._get(f"{workspace_slug}/work-item-types/{type_id}/governance/") + return TypeGovernance.model_validate(response) + + def update( + self, workspace_slug: str, type_id: str, data: UpdateTypeGovernance + ) -> TypeGovernance: + """Update a type's governance mode / allowlist / required workflow. + + Destructive changes (dropping in-use workflows, mandating one) require + ``data.acknowledge`` and may need a ``data.state_mapping`` for orphaned + work items. + + Args: + workspace_slug: The workspace slug identifier + type_id: UUID of the workspace work item type + data: The governance change + """ + response = self._patch( + f"{workspace_slug}/work-item-types/{type_id}/governance/", + data.model_dump(exclude_none=True), + ) + return TypeGovernance.model_validate(response) + + def preview( + self, workspace_slug: str, type_id: str, data: TypeGovernancePreviewRequest + ) -> GovernancePreview: + """Dry-run a governance change and report affected work items (no writes). + + Args: + workspace_slug: The workspace slug identifier + type_id: UUID of the workspace work item type + data: The governance change to preview + """ + response = self._post( + f"{workspace_slug}/work-item-types/{type_id}/governance/preview/", + data.model_dump(exclude_none=True), + ) + payload = response.get("preview", response) if isinstance(response, dict) else response + return GovernancePreview.model_validate(payload) diff --git a/plane/api/work_item_type_governance/pins.py b/plane/api/work_item_type_governance/pins.py new file mode 100644 index 0000000..37ded6d --- /dev/null +++ b/plane/api/work_item_type_governance/pins.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from typing import Any + +from ...models.work_item_type_governance import ( + CreateWorkItemTypeWorkflowPins, + WorkItemTypeWorkflowPin, +) +from ..base_resource import BaseResource + + +class WorkItemTypeWorkflowPins(BaseResource): + """API client for a work item type's per-project workflow pins. + + A pin forces one project to resolve a type to a specific workflow, + overriding the workspace default and the constrained allowlist. + """ + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def list(self, workspace_slug: str, type_id: str) -> list[WorkItemTypeWorkflowPin]: + """List a type's project-to-workflow pins. + + Args: + workspace_slug: The workspace slug identifier + type_id: UUID of the workspace work item type + """ + data = self._get(f"{workspace_slug}/work-item-types/{type_id}/governance/pins/") + items = data.get("results", data) if isinstance(data, dict) else data + return [WorkItemTypeWorkflowPin.model_validate(item) for item in items] + + def create( + self, workspace_slug: str, type_id: str, data: CreateWorkItemTypeWorkflowPins + ) -> list[WorkItemTypeWorkflowPin]: + """Pin a workflow for this type across one or more projects. + + Args: + workspace_slug: The workspace slug identifier + type_id: UUID of the workspace work item type + data: The workflow and the target projects + + Returns: + The type's pins after the change + """ + response = self._post( + f"{workspace_slug}/work-item-types/{type_id}/governance/pins/", + data.model_dump(exclude_none=True), + ) + items = response.get("results", response) if isinstance(response, dict) else response + return [WorkItemTypeWorkflowPin.model_validate(item) for item in items] + + def delete(self, workspace_slug: str, type_id: str, pin_id: str) -> None: + """Remove a pin. + + Args: + workspace_slug: The workspace slug identifier + type_id: UUID of the workspace work item type + pin_id: UUID of the pin + """ + return self._delete(f"{workspace_slug}/work-item-types/{type_id}/governance/pins/{pin_id}/") diff --git a/plane/api/work_item_type_governance/project_workflows.py b/plane/api/work_item_type_governance/project_workflows.py new file mode 100644 index 0000000..db7a332 --- /dev/null +++ b/plane/api/work_item_type_governance/project_workflows.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from typing import Any + +from ...models.work_item_type_governance import ( + GovernancePreview, + ProjectTypeWorkflow, + SetProjectWorkflowPick, + WorkflowFallbackPreviewRequest, +) +from ..base_resource import BaseResource + + +class ProjectTypeWorkflows(BaseResource): + """API client for the project side of work item type governance. + + Reports each active type's governance mode and the workflow it effectively + resolves to within a project, and manages the project's own workflow pick + for a type. + """ + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def list(self, workspace_slug: str, project_id: str) -> list[ProjectTypeWorkflow]: + """List every active type's governance mode, effective workflow, and + pickable options for a project. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + """ + data = self._get(f"{workspace_slug}/projects/{project_id}/work-item-types/workflows/") + items = data.get("results", data) if isinstance(data, dict) else data + return [ProjectTypeWorkflow.model_validate(item) for item in items] + + def retrieve(self, workspace_slug: str, project_id: str, type_id: str) -> ProjectTypeWorkflow: + """Retrieve one type's governance mode and effective workflow in a project. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + type_id: UUID of the work item type + """ + response = self._get( + f"{workspace_slug}/projects/{project_id}/work-item-types/{type_id}/workflows/" + ) + return ProjectTypeWorkflow.model_validate(response) + + def retrieve_pick( + self, workspace_slug: str, project_id: str, type_id: str + ) -> ProjectTypeWorkflow: + """Retrieve the project's current workflow pick context for a type. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + type_id: UUID of the work item type + """ + response = self._get( + f"{workspace_slug}/projects/{project_id}/work-item-types/{type_id}/workflow/" + ) + return ProjectTypeWorkflow.model_validate(response) + + def update_pick( + self, + workspace_slug: str, + project_id: str, + type_id: str, + data: SetProjectWorkflowPick, + ) -> dict[str, Any]: + """Set the project's workflow pick for a type. + + Runs the workflow fallback for stranded work items; every orphan must be + covered by ``data.state_mapping`` (400 with an orphan report otherwise). + Returns ``{"workflow_id": ""}``. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + type_id: UUID of the work item type + data: The pick (workflow and optional orphan state mapping) + """ + response = self._put( + f"{workspace_slug}/projects/{project_id}/work-item-types/{type_id}/workflow/", + data.model_dump(exclude_none=True), + ) + return response if isinstance(response, dict) else {"workflow_id": response} + + def preview_fallback( + self, workspace_slug: str, project_id: str, data: WorkflowFallbackPreviewRequest + ) -> GovernancePreview: + """Dry-run the project's workflow fallback (re-type / switch dialogs). + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + data: The scenario to preview (re-type or workflow switch) + """ + response = self._post( + f"{workspace_slug}/projects/{project_id}/workflow-fallback-preview/", + data.model_dump(exclude_none=True), + ) + payload = response.get("preview", response) if isinstance(response, dict) else response + return GovernancePreview.model_validate(payload) diff --git a/plane/api/workspace_workflows/__init__.py b/plane/api/workspace_workflows/__init__.py index 9f9b980..b86156c 100644 --- a/plane/api/workspace_workflows/__init__.py +++ b/plane/api/workspace_workflows/__init__.py @@ -1,11 +1,3 @@ from .base import WorkspaceWorkflows -from .hooks import WorkspaceWorkflowTransitionHooks -from .states import WorkspaceWorkflowStates -from .transitions import WorkspaceWorkflowTransitions -__all__ = [ - "WorkspaceWorkflows", - "WorkspaceWorkflowStates", - "WorkspaceWorkflowTransitions", - "WorkspaceWorkflowTransitionHooks", -] +__all__ = ["WorkspaceWorkflows"] diff --git a/tests/unit/test_work_item_type_governance.py b/tests/unit/test_work_item_type_governance.py index 6171f33..3e263d0 100644 --- a/tests/unit/test_work_item_type_governance.py +++ b/tests/unit/test_work_item_type_governance.py @@ -56,7 +56,7 @@ def test_list_pins_empty( self, client: PlaneClient, workspace_slug: str, workspace_type ) -> None: """A fresh type has no pins.""" - pins = client.work_item_type_governance.list_pins(workspace_slug, workspace_type.id) + pins = client.work_item_type_governance.pins.list(workspace_slug, workspace_type.id) assert pins == [] def test_project_type_workflows( @@ -65,7 +65,7 @@ def test_project_type_workflows( """The project-side view lists an entry per active type.""" if not governed: pytest.skip("workspace does not own states and workflows") - entries = client.work_item_type_governance.list_project_type_workflows( + entries = client.work_item_type_governance.project_workflows.list( workspace_slug, project.id ) assert isinstance(entries, list) From a857baabfb94fce4d292a5e469067be1bcc749e4 Mon Sep 17 00:00:00 2001 From: sunder Date: Thu, 30 Jul 2026 18:46:15 +0530 Subject: [PATCH 4/4] fix: validate the workflow-pick response with a typed model update_pick was the only resource method returning an unvalidated dict. Add ProjectWorkflowPickResult (mirroring WorkItemApprovalResult for the analogous {"state_id": ...} approval response), normalize the scalar fallback into a mapping, and validate through it. --- plane/__init__.py | 2 ++ .../api/work_item_type_governance/project_workflows.py | 10 +++++++--- plane/models/work_item_type_governance.py | 8 ++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/plane/__init__.py b/plane/__init__.py index 6a81dff..e8f613c 100644 --- a/plane/__init__.py +++ b/plane/__init__.py @@ -57,6 +57,7 @@ CreateWorkItemTypeWorkflowPins, GovernancePreview, ProjectTypeWorkflow, + ProjectWorkflowPickResult, SetProjectWorkflowPick, TypeGovernance, TypeGovernancePreviewRequest, @@ -184,6 +185,7 @@ "CreateWorkItemTypeWorkflowPins", "ProjectTypeWorkflow", "SetProjectWorkflowPick", + "ProjectWorkflowPickResult", "WorkflowFallbackPreviewRequest", "ProjectFeature", "ProjectMember", diff --git a/plane/api/work_item_type_governance/project_workflows.py b/plane/api/work_item_type_governance/project_workflows.py index db7a332..1790336 100644 --- a/plane/api/work_item_type_governance/project_workflows.py +++ b/plane/api/work_item_type_governance/project_workflows.py @@ -5,6 +5,7 @@ from ...models.work_item_type_governance import ( GovernancePreview, ProjectTypeWorkflow, + ProjectWorkflowPickResult, SetProjectWorkflowPick, WorkflowFallbackPreviewRequest, ) @@ -68,24 +69,27 @@ def update_pick( project_id: str, type_id: str, data: SetProjectWorkflowPick, - ) -> dict[str, Any]: + ) -> ProjectWorkflowPickResult: """Set the project's workflow pick for a type. Runs the workflow fallback for stranded work items; every orphan must be covered by ``data.state_mapping`` (400 with an orphan report otherwise). - Returns ``{"workflow_id": ""}``. Args: workspace_slug: The workspace slug identifier project_id: UUID of the project type_id: UUID of the work item type data: The pick (workflow and optional orphan state mapping) + + Returns: + The workflow now in effect for this project and type """ response = self._put( f"{workspace_slug}/projects/{project_id}/work-item-types/{type_id}/workflow/", data.model_dump(exclude_none=True), ) - return response if isinstance(response, dict) else {"workflow_id": response} + payload = response if isinstance(response, dict) else {"workflow_id": response} + return ProjectWorkflowPickResult.model_validate(payload) def preview_fallback( self, workspace_slug: str, project_id: str, data: WorkflowFallbackPreviewRequest diff --git a/plane/models/work_item_type_governance.py b/plane/models/work_item_type_governance.py index 7898801..0af1ac6 100644 --- a/plane/models/work_item_type_governance.py +++ b/plane/models/work_item_type_governance.py @@ -143,6 +143,14 @@ class SetProjectWorkflowPick(BaseModel): state_mapping: dict[str, str] | None = None +class ProjectWorkflowPickResult(BaseModel): + """Response of setting a project's workflow pick: the workflow now in effect.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + workflow_id: str | None = None + + class WorkflowFallbackPreviewRequest(BaseModel): """Request model for previewing the project workflow fallback.