diff --git a/README.md b/README.md index 0db2858..9635e65 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,98 @@ 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 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.id, state_b.id]), +) +client.workspace_workflows.states.mark_default(workspace_slug, workflow.id, state_a.id) + +# Transitions +transition = client.workspace_workflows.transitions.create( + workspace_slug, + workflow.id, + data=CreateWorkspaceWorkflowTransition(state_id=state_a.id, transition_state_id=state_b.id), +) + +# 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 +# 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) + +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), +) + +# 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.project_workflows.update_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..e8f613c 100644 --- a/plane/__init__.py +++ b/plane/__init__.py @@ -15,14 +15,22 @@ 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 from .api.workspaces import Workspaces from .client import ( OAuthAuthorizationParams, @@ -44,16 +52,51 @@ WorkItemTemplate, ) from .models.projects import ProjectFeature, ProjectMember +from .models.states import CreateWorkspaceState, UpdateWorkspaceState +from .models.work_item_type_governance import ( + CreateWorkItemTypeWorkflowPins, + GovernancePreview, + ProjectTypeWorkflow, + ProjectWorkflowPickResult, + SetProjectWorkflowPick, + TypeGovernance, + TypeGovernancePreviewRequest, + UpdateTypeGovernance, + WorkflowFallbackPreviewRequest, + WorkItemTypeWorkflowPin, +) from .models.workflows import ( AttachWorkflowStates, CreateWorkflow, CreateWorkflowTransition, + CreateWorkflowTransitionHook, + SubmitWorkItemApproval, UpdateWorkflow, + UpdateWorkflowState, UpdateWorkflowTransition, + UpdateWorkflowTransitionHook, Workflow, + WorkflowActivity, WorkflowTransition, + WorkflowTransitionHook, + WorkItemApprovalResult, +) +from .models.workspace_workflows import ( + AddWorkspaceWorkflowStates, + CreateWorkspaceWorkflow, + CreateWorkspaceWorkflowTransition, + PaginatedWorkspaceWorkflowResponse, + RemoveWorkspaceWorkflowState, + UpdateWorkspaceWorkflow, + UpdateWorkspaceWorkflowState, + UpdateWorkspaceWorkflowTransition, + WorkspaceWorkflow, + WorkspaceWorkflowState, + WorkspaceWorkflowTransition, + WorkspaceWorkflowUsage, + WorkspaceWorkflowUsageProject, ) -from .models.workspaces import WorkspaceMember +from .models.workspaces import WorkspaceFeature, WorkspaceMember __all__ = [ "PlaneClient", @@ -83,12 +126,16 @@ "ProjectTemplates", "ProjectWorkItemTemplates", "ProjectPageTemplates", + "ProjectWorkflowTransitionHooks", "Releases", "WorkspaceTemplates", "WorkspaceWorkItemTypes", "WorkspaceWorkItemProperties", "WorkspaceProjectLabels", "WorkspaceProjectStates", + "WorkspaceStates", + "WorkspaceWorkflows", + "WorkItemTypeGovernance", "PlaneError", "ConfigurationError", "HttpError", @@ -102,11 +149,47 @@ "CreateWorkflow", "UpdateWorkflow", "AttachWorkflowStates", + "UpdateWorkflowState", + "WorkflowActivity", "WorkflowTransition", "CreateWorkflowTransition", "UpdateWorkflowTransition", + "WorkflowTransitionHook", + "CreateWorkflowTransitionHook", + "UpdateWorkflowTransitionHook", + "SubmitWorkItemApproval", + "WorkItemApprovalResult", + # Workspace state models + "CreateWorkspaceState", + "UpdateWorkspaceState", + # Workspace workflow models + "WorkspaceWorkflow", + "WorkspaceWorkflowState", + "WorkspaceWorkflowTransition", + "PaginatedWorkspaceWorkflowResponse", + "CreateWorkspaceWorkflow", + "UpdateWorkspaceWorkflow", + "AddWorkspaceWorkflowStates", + "UpdateWorkspaceWorkflowState", + "RemoveWorkspaceWorkflowState", + "CreateWorkspaceWorkflowTransition", + "UpdateWorkspaceWorkflowTransition", + "WorkspaceWorkflowUsage", + "WorkspaceWorkflowUsageProject", + # Type governance models + "TypeGovernance", + "UpdateTypeGovernance", + "TypeGovernancePreviewRequest", + "GovernancePreview", + "WorkItemTypeWorkflowPin", + "CreateWorkItemTypeWorkflowPins", + "ProjectTypeWorkflow", + "SetProjectWorkflowPick", + "ProjectWorkflowPickResult", + "WorkflowFallbackPreviewRequest", "ProjectFeature", "ProjectMember", + "WorkspaceFeature", "WorkspaceMember", # Project template models "WorkItemTemplate", 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..1790336 --- /dev/null +++ b/plane/api/work_item_type_governance/project_workflows.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from typing import Any + +from ...models.work_item_type_governance import ( + GovernancePreview, + ProjectTypeWorkflow, + ProjectWorkflowPickResult, + 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, + ) -> 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). + + 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), + ) + 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 + ) -> 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..9c73b81 100644 --- a/plane/api/workflows/base.py +++ b/plane/api/workflows/base.py @@ -1,19 +1,36 @@ +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, + SubmitWorkItemApproval, + UpdateWorkflow, + Workflow, + WorkflowActivity, + WorkItemApprovalResult, +) 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 +91,68 @@ 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, + 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 + data: The approval decision (``type="approve"`` or ``type="reject"``) + """ + response = self._post( + f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}" + "/workflow-approval/", + 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 new file mode 100644 index 0000000..0cd0866 --- /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(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] + + 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( + f"{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..9992500 --- /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..b86156c --- /dev/null +++ b/plane/api/workspace_workflows/__init__.py @@ -0,0 +1,3 @@ +from .base import WorkspaceWorkflows + +__all__ = ["WorkspaceWorkflows"] diff --git a/plane/api/workspace_workflows/base.py b/plane/api/workspace_workflows/base.py new file mode 100644 index 0000000..b70bd81 --- /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..1b237fa --- /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(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] + + 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( + f"{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..bc8e2b3 --- /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..f4f469e --- /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/__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 5af11fb..e2ddba8 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", @@ -578,6 +587,7 @@ class Group(Enum): __all__ = [ "AccessEnum", + "CatalogGroupEnum", "EntityTypeEnum", "GroupEnum", "WorkItemRelationTypeEnum", 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..0af1ac6 --- /dev/null +++ b/plane/models/work_item_type_governance.py @@ -0,0 +1,165 @@ +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 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. + + 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..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 @@ -87,3 +87,103 @@ 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 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. + + ``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..3e263d0 --- /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.pins.list(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.project_workflows.list( + 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..36453ac --- /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" + ), + ) + 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 + + 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..bbf3f93 --- /dev/null +++ b/tests/unit/test_workspace_workflows.py @@ -0,0 +1,98 @@ +"""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 = 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}") + ) + 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))) + 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) + except Exception as exc: + warnings.warn(f"Teardown failed: {exc}", stacklevel=1)