From b27219efb0265185ff6e940982d47f40cdb1873b Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Wed, 5 Aug 2026 19:57:05 +0530 Subject: [PATCH] feat: introduce workspace-level governance features and enhance workflows --- README.md | 5 +- src/api/WorkItemTypeGovernance/Pins.ts | 48 ++++++ .../ProjectWorkflows.ts | 89 ++++++++++ src/api/WorkItemTypeGovernance/index.ts | 65 ++++++++ src/api/Workflows/Hooks.ts | 102 ++++++++++++ src/api/Workflows/States.ts | 45 ++++- src/api/Workflows/Transitions.ts | 14 ++ src/api/Workflows/index.ts | 69 +++++++- src/api/WorkspaceStates.ts | 78 +++++++++ src/api/WorkspaceWorkflows/Hooks.ts | 125 ++++++++++++++ src/api/WorkspaceWorkflows/States.ts | 77 +++++++++ src/api/WorkspaceWorkflows/Transitions.ts | 76 +++++++++ src/api/WorkspaceWorkflows/index.ts | 112 +++++++++++++ src/client/plane-client.ts | 9 + src/index.ts | 9 + src/models/State.ts | 35 ++++ src/models/WorkItemTypeGovernance.ts | 143 ++++++++++++++++ src/models/Workflow.ts | 71 ++++++++ src/models/WorkspaceFeatures.ts | 25 ++- src/models/WorkspaceWorkflow.ts | 157 ++++++++++++++++++ src/models/index.ts | 2 + tests/helpers/governance.ts | 24 +++ tests/unit/project-templates.test.ts | 52 ++++-- tests/unit/state.test.ts | 29 +++- .../work-item-type-governance.test.ts | 69 ++++++++ .../project-properties.test.ts | 7 + .../properties-options.test.ts | 7 + tests/unit/work-item-types/types.test.ts | 6 + tests/unit/workflows/workflow.test.ts | 103 ++++++++++-- tests/unit/workspace-states.test.ts | 81 +++++++++ .../workspace-workflow.test.ts | 89 ++++++++++ 31 files changed, 1779 insertions(+), 44 deletions(-) create mode 100644 src/api/WorkItemTypeGovernance/Pins.ts create mode 100644 src/api/WorkItemTypeGovernance/ProjectWorkflows.ts create mode 100644 src/api/WorkItemTypeGovernance/index.ts create mode 100644 src/api/Workflows/Hooks.ts create mode 100644 src/api/WorkspaceStates.ts create mode 100644 src/api/WorkspaceWorkflows/Hooks.ts create mode 100644 src/api/WorkspaceWorkflows/States.ts create mode 100644 src/api/WorkspaceWorkflows/Transitions.ts create mode 100644 src/api/WorkspaceWorkflows/index.ts create mode 100644 src/models/WorkItemTypeGovernance.ts create mode 100644 src/models/WorkspaceWorkflow.ts create mode 100644 tests/helpers/governance.ts create mode 100644 tests/unit/work-item-type-governance/work-item-type-governance.test.ts create mode 100644 tests/unit/workspace-states.test.ts create mode 100644 tests/unit/workspace-workflows/workspace-workflow.test.ts diff --git a/README.md b/README.md index b10e3e4..9c5dc97 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,12 @@ const project = await client.projects.create("workspace-slug", { - **Releases**: Release management with tags, labels, item labels, changelog, comments, links, and work items - **Collections**: Folders that group workspace pages, with member and page management - **AgentRuns**: AI agent run orchestration and activity tracking -- **Workflows**: Project workflow management with state attachments and transitions +- **Workflows**: Project workflow management with state attachments, transitions, transition hooks, activities, and work item approvals - **ProjectTemplates**: Work item and page template management per project - **Features**: Workspace and project features management +- **WorkspaceStates**: Workspace-level (catalog) work-item states under workspace governance — dual-mode reads, governed-only writes +- **WorkspaceWorkflows**: Workspace-level workflow catalog under workspace governance, with chain (states), transitions, usage, activities, and transition hooks +- **WorkItemTypeGovernance**: Governs which workflows a workspace-level work item type may use (any/constrained/required modes), with per-project pins and the project-side pick/fallback-preview endpoints ## Development diff --git a/src/api/WorkItemTypeGovernance/Pins.ts b/src/api/WorkItemTypeGovernance/Pins.ts new file mode 100644 index 0000000..6328895 --- /dev/null +++ b/src/api/WorkItemTypeGovernance/Pins.ts @@ -0,0 +1,48 @@ +import { BaseResource } from "../BaseResource"; +import { Configuration } from "../../Configuration"; +import { CreateWorkItemTypeWorkflowPins, WorkItemTypeWorkflowPin } from "../../models/WorkItemTypeGovernance"; + +/** + * WorkItemTypeGovernance.pins sub-resource + * + * A pin forces one project to resolve a type to a specific workflow, + * overriding the workspace default and the constrained allowlist. + */ +export class Pins extends BaseResource { + constructor(config: Configuration) { + super(config); + } + + /** + * List a type's project-to-workflow pins + */ + async list(workspaceSlug: string, typeId: string): Promise { + const data = await this.get( + `/workspaces/${workspaceSlug}/work-item-types/${typeId}/governance/pins/` + ); + return Array.isArray(data) ? data : data.results; + } + + /** + * Pin a workflow for this type across one or more projects. + * Returns the type's pins after the change. + */ + async create( + workspaceSlug: string, + typeId: string, + data: CreateWorkItemTypeWorkflowPins + ): Promise { + const response = await this.post( + `/workspaces/${workspaceSlug}/work-item-types/${typeId}/governance/pins/`, + data + ); + return Array.isArray(response) ? response : response.results; + } + + /** + * Remove a pin + */ + async delete(workspaceSlug: string, typeId: string, pinId: string): Promise { + return this.httpDelete(`/workspaces/${workspaceSlug}/work-item-types/${typeId}/governance/pins/${pinId}/`); + } +} diff --git a/src/api/WorkItemTypeGovernance/ProjectWorkflows.ts b/src/api/WorkItemTypeGovernance/ProjectWorkflows.ts new file mode 100644 index 0000000..e943470 --- /dev/null +++ b/src/api/WorkItemTypeGovernance/ProjectWorkflows.ts @@ -0,0 +1,89 @@ +import { BaseResource } from "../BaseResource"; +import { Configuration } from "../../Configuration"; +import { + GovernancePreview, + ProjectTypeWorkflow, + ProjectWorkflowPickResult, + SetProjectWorkflowPick, + WorkflowFallbackPreviewRequest, +} from "../../models/WorkItemTypeGovernance"; + +type GovernancePreviewResponse = { preview?: GovernancePreview } | GovernancePreview; + +function unwrapPreview(response: GovernancePreviewResponse): GovernancePreview { + return "preview" in response && response.preview ? response.preview : (response as GovernancePreview); +} + +/** + * WorkItemTypeGovernance.projectWorkflows sub-resource + * + * 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. + */ +export class ProjectWorkflows extends BaseResource { + constructor(config: Configuration) { + super(config); + } + + /** + * List every active type's governance mode, effective workflow, and + * pickable options for a project + */ + async list(workspaceSlug: string, projectId: string): Promise { + const data = await this.get( + `/workspaces/${workspaceSlug}/projects/${projectId}/work-item-types/workflows/` + ); + return Array.isArray(data) ? data : data.results; + } + + /** + * Retrieve one type's governance mode and effective workflow in a project + */ + async retrieve(workspaceSlug: string, projectId: string, typeId: string): Promise { + return this.get( + `/workspaces/${workspaceSlug}/projects/${projectId}/work-item-types/${typeId}/workflows/` + ); + } + + /** + * Retrieve the project's current workflow pick context for a type + */ + async retrievePick(workspaceSlug: string, projectId: string, typeId: string): Promise { + return this.get( + `/workspaces/${workspaceSlug}/projects/${projectId}/work-item-types/${typeId}/workflow/` + ); + } + + /** + * 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). + */ + async updatePick( + workspaceSlug: string, + projectId: string, + typeId: string, + data: SetProjectWorkflowPick + ): Promise { + return this.put( + `/workspaces/${workspaceSlug}/projects/${projectId}/work-item-types/${typeId}/workflow/`, + data + ); + } + + /** + * Dry-run the project's workflow fallback (re-type / switch dialogs) + */ + async previewFallback( + workspaceSlug: string, + projectId: string, + data: WorkflowFallbackPreviewRequest + ): Promise { + const response = await this.post( + `/workspaces/${workspaceSlug}/projects/${projectId}/workflow-fallback-preview/`, + data + ); + return unwrapPreview(response); + } +} diff --git a/src/api/WorkItemTypeGovernance/index.ts b/src/api/WorkItemTypeGovernance/index.ts new file mode 100644 index 0000000..bf23fda --- /dev/null +++ b/src/api/WorkItemTypeGovernance/index.ts @@ -0,0 +1,65 @@ +import { BaseResource } from "../BaseResource"; +import { Configuration } from "../../Configuration"; +import { + GovernancePreview, + TypeGovernance, + TypeGovernancePreviewRequest, + UpdateTypeGovernance, +} from "../../models/WorkItemTypeGovernance"; +import { Pins } from "./Pins"; +import { ProjectWorkflows } from "./ProjectWorkflows"; + +type GovernancePreviewResponse = { preview?: GovernancePreview } | GovernancePreview; + +function unwrapPreview(response: GovernancePreviewResponse): GovernancePreview { + return "preview" in response && response.preview ? response.preview : (response as GovernancePreview); +} + +/** + * WorkItemTypeGovernance API resource (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 `.projectWorkflows`. Every endpoint requires the workspace to own + * states and workflows — otherwise the API responds 400 with code + * `workspace_not_managed`. + */ +export class WorkItemTypeGovernance extends BaseResource { + public pins: Pins; + public projectWorkflows: ProjectWorkflows; + + constructor(config: Configuration) { + super(config); + this.pins = new Pins(config); + this.projectWorkflows = new ProjectWorkflows(config); + } + + /** + * Retrieve a type's governance settings (mode, required workflow, allowlist) + */ + async retrieve(workspaceSlug: string, typeId: string): Promise { + return this.get(`/workspaces/${workspaceSlug}/work-item-types/${typeId}/governance/`); + } + + /** + * 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. + */ + async update(workspaceSlug: string, typeId: string, data: UpdateTypeGovernance): Promise { + return this.patch(`/workspaces/${workspaceSlug}/work-item-types/${typeId}/governance/`, data); + } + + /** + * Dry-run a governance change and report affected work items (no writes) + */ + async preview(workspaceSlug: string, typeId: string, data: TypeGovernancePreviewRequest): Promise { + const response = await this.post( + `/workspaces/${workspaceSlug}/work-item-types/${typeId}/governance/preview/`, + data + ); + return unwrapPreview(response); + } +} diff --git a/src/api/Workflows/Hooks.ts b/src/api/Workflows/Hooks.ts new file mode 100644 index 0000000..03e0dc9 --- /dev/null +++ b/src/api/Workflows/Hooks.ts @@ -0,0 +1,102 @@ +import { BaseResource } from "../BaseResource"; +import { Configuration } from "../../Configuration"; +import { + CreateWorkflowTransitionHook, + UpdateWorkflowTransitionHook, + WorkflowTransitionHook, +} from "../../models/Workflow"; + +/** + * WorkflowTransitionHooks sub-resource + * Manages hooks attached to project workflow transitions + */ +export class Hooks extends BaseResource { + constructor(config: Configuration) { + super(config); + } + + private basePath(workspaceSlug: string, projectId: string, workflowId: string, transitionId: string): string { + return ( + `/workspaces/${workspaceSlug}/projects/${projectId}/workflows/${workflowId}` + + `/state-transitions/${transitionId}/hooks` + ); + } + + /** + * List hooks on a workflow transition + */ + async list( + workspaceSlug: string, + projectId: string, + workflowId: string, + transitionId: string + ): Promise { + const data = await this.get( + `${this.basePath(workspaceSlug, projectId, workflowId, transitionId)}/` + ); + return Array.isArray(data) ? data : data.results; + } + + /** + * 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. + */ + async create( + workspaceSlug: string, + projectId: string, + workflowId: string, + transitionId: string, + data: CreateWorkflowTransitionHook + ): Promise { + return this.post( + `${this.basePath(workspaceSlug, projectId, workflowId, transitionId)}/`, + data + ); + } + + /** + * Retrieve a hook by ID + */ + async retrieve( + workspaceSlug: string, + projectId: string, + workflowId: string, + transitionId: string, + hookId: string + ): Promise { + return this.get( + `${this.basePath(workspaceSlug, projectId, workflowId, transitionId)}/${hookId}/` + ); + } + + /** + * Update a hook (`phase` and `handler_name` are immutable) + */ + async update( + workspaceSlug: string, + projectId: string, + workflowId: string, + transitionId: string, + hookId: string, + data: UpdateWorkflowTransitionHook + ): Promise { + return this.patch( + `${this.basePath(workspaceSlug, projectId, workflowId, transitionId)}/${hookId}/`, + data + ); + } + + /** + * Delete a hook + */ + async del( + workspaceSlug: string, + projectId: string, + workflowId: string, + transitionId: string, + hookId: string + ): Promise { + return this.httpDelete(`${this.basePath(workspaceSlug, projectId, workflowId, transitionId)}/${hookId}/`); + } +} diff --git a/src/api/Workflows/States.ts b/src/api/Workflows/States.ts index 5684846..10b5ace 100644 --- a/src/api/Workflows/States.ts +++ b/src/api/Workflows/States.ts @@ -1,6 +1,6 @@ import { BaseResource } from "../BaseResource"; import { Configuration } from "../../Configuration"; -import { AttachWorkflowStates } from "../../models/Workflow"; +import { AttachWorkflowStates, UpdateWorkflowState, WorkflowState } from "../../models/Workflow"; /** * WorkflowStates sub-resource @@ -11,6 +11,16 @@ export class States extends BaseResource { super(config); } + /** + * List the states attached to a workflow + */ + async list(workspaceSlug: string, projectId: string, workflowId: string): Promise { + const data = await this.get( + `/workspaces/${workspaceSlug}/projects/${projectId}/workflows/${workflowId}/states/` + ); + return Array.isArray(data) ? data : data.results; + } + /** * Attach states to a workflow */ @@ -23,6 +33,23 @@ export class States extends BaseResource { return this.post(`/workspaces/${workspaceSlug}/projects/${projectId}/workflows/${workflowId}/states/`, data); } + /** + * Update a state's membership row (type, allow_issue_creation, is_default) + */ + async update( + workspaceSlug: string, + projectId: string, + workflowId: string, + stateId: string, + data: UpdateWorkflowState + ): Promise { + const response = await this.patch( + `/workspaces/${workspaceSlug}/projects/${projectId}/workflows/${workflowId}/states/${stateId}/`, + data + ); + return response ?? null; + } + /** * Detach a state from a workflow */ @@ -31,4 +58,20 @@ export class States extends BaseResource { `/workspaces/${workspaceSlug}/projects/${projectId}/workflows/${workflowId}/states/${stateId}/` ); } + + /** + * Transfer work items off a state and remove it from the workflow + */ + async transfer( + workspaceSlug: string, + projectId: string, + workflowId: string, + stateId: string, + newStateId: string + ): Promise { + return this.post( + `/workspaces/${workspaceSlug}/projects/${projectId}/workflows/${workflowId}/states/${stateId}/transfer/`, + { new_state_id: newStateId } + ); + } } diff --git a/src/api/Workflows/Transitions.ts b/src/api/Workflows/Transitions.ts index 24978d7..fd31af2 100644 --- a/src/api/Workflows/Transitions.ts +++ b/src/api/Workflows/Transitions.ts @@ -22,6 +22,20 @@ export class Transitions extends BaseResource { return Array.isArray(data) ? data : data.results; } + /** + * Retrieve a workflow state transition by ID + */ + async retrieve( + workspaceSlug: string, + projectId: string, + workflowId: string, + transitionId: string + ): Promise { + return this.get( + `/workspaces/${workspaceSlug}/projects/${projectId}/workflows/${workflowId}/state-transitions/${transitionId}/` + ); + } + /** * Create a state transition for a workflow. * Returns null if the transition already exists (HTTP 400 "already exists"). diff --git a/src/api/Workflows/index.ts b/src/api/Workflows/index.ts index 2675b4c..22de254 100644 --- a/src/api/Workflows/index.ts +++ b/src/api/Workflows/index.ts @@ -1,21 +1,40 @@ import { BaseResource } from "../BaseResource"; import { Configuration } from "../../Configuration"; -import { CreateWorkflow, UpdateWorkflow, Workflow } from "../../models/Workflow"; +import { + CreateWorkflow, + SubmitWorkItemApproval, + UpdateWorkflow, + WorkItemApprovalResult, + Workflow, + WorkflowActivity, +} from "../../models/Workflow"; import { States } from "./States"; import { Transitions } from "./Transitions"; +import { Hooks } from "./Hooks"; + +export type ListWorkflowActivitiesParams = { + created_at__gt?: string; +}; /** * Workflows API resource - * Handles project workflow operations and exposes states/transitions sub-resources + * Handles project workflow operations and exposes states/transitions/hooks + * sub-resources. + * + * Under workspace governance, workflows are managed at the workspace level + * (see `WorkspaceWorkflows`) and project-scoped writes respond 400 with code + * `workspace_managed`. */ export class Workflows extends BaseResource { public states: States; public transitions: Transitions; + public hooks: Hooks; constructor(config: Configuration) { super(config); this.states = new States(config); this.transitions = new Transitions(config); + this.hooks = new Hooks(config); } /** @@ -35,10 +54,56 @@ export class Workflows extends BaseResource { return this.post(`/workspaces/${workspaceSlug}/projects/${projectId}/workflows/`, data); } + /** + * Retrieve a workflow by ID + */ + async retrieve(workspaceSlug: string, projectId: string, workflowId: string): Promise { + return this.get(`/workspaces/${workspaceSlug}/projects/${projectId}/workflows/${workflowId}/`); + } + /** * Update a workflow by ID */ async update(workspaceSlug: string, projectId: string, workflowId: string, data: UpdateWorkflow): Promise { return this.patch(`/workspaces/${workspaceSlug}/projects/${projectId}/workflows/${workflowId}/`, data); } + + /** + * Delete a workflow by ID. + * The default workflow cannot be deleted. + */ + async delete(workspaceSlug: string, projectId: string, workflowId: string): Promise { + return this.httpDelete(`/workspaces/${workspaceSlug}/projects/${projectId}/workflows/${workflowId}/`); + } + + /** + * List the workflow's activity/audit entries + */ + async activities( + workspaceSlug: string, + projectId: string, + workflowId: string, + params?: ListWorkflowActivitiesParams + ): Promise { + const data = await this.get( + `/workspaces/${workspaceSlug}/projects/${projectId}/workflows/${workflowId}/activities/`, + params + ); + return Array.isArray(data) ? data : data.results; + } + + /** + * Approve or reject a work item's pending workflow transition + */ + async submitWorkItemApproval( + workspaceSlug: string, + projectId: string, + workItemId: string, + data: SubmitWorkItemApproval + ): Promise { + return this.post( + `/workspaces/${workspaceSlug}/projects/${projectId}/work-items/${workItemId}/workflow-approval/`, + data + ); + } } diff --git a/src/api/WorkspaceStates.ts b/src/api/WorkspaceStates.ts new file mode 100644 index 0000000..d1ec89a --- /dev/null +++ b/src/api/WorkspaceStates.ts @@ -0,0 +1,78 @@ +import { BaseResource } from "./BaseResource"; +import { Configuration } from "../Configuration"; +import { PaginatedResponse } from "../models/common"; +import { CreateWorkspaceState, ListWorkspaceStatesParams, State, UpdateWorkspaceState } from "../models/State"; + +/** + * WorkspaceStates API resource + * Manages 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 + * `Workspace.retrieveFeatures(...).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. + */ +export class WorkspaceStates extends BaseResource { + constructor(config: Configuration) { + super(config); + } + + /** + * List states at workspace scope (works in both modes) + */ + async list(workspaceSlug: string, params?: ListWorkspaceStatesParams): Promise> { + return this.get>(`/workspaces/${workspaceSlug}/states/`, params); + } + + /** + * Retrieve a workspace state by its external ID and source + */ + async retrieveByExternalId(workspaceSlug: string, externalId: string, externalSource: string): Promise { + return this.get(`/workspaces/${workspaceSlug}/states/`, { + external_id: externalId, + external_source: externalSource, + }); + } + + /** + * 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`. + */ + async create(workspaceSlug: string, data: CreateWorkspaceState): Promise { + return this.post(`/workspaces/${workspaceSlug}/states/`, data); + } + + /** + * Retrieve a workspace state by ID + */ + async retrieve(workspaceSlug: string, stateId: string): Promise { + return this.get(`/workspaces/${workspaceSlug}/states/${stateId}/`); + } + + /** + * 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. + */ + async update(workspaceSlug: string, stateId: string, data: UpdateWorkspaceState): Promise { + return this.patch(`/workspaces/${workspaceSlug}/states/${stateId}/`, data); + } + + /** + * 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. + */ + async delete(workspaceSlug: string, stateId: string): Promise { + return this.httpDelete(`/workspaces/${workspaceSlug}/states/${stateId}/`); + } +} diff --git a/src/api/WorkspaceWorkflows/Hooks.ts b/src/api/WorkspaceWorkflows/Hooks.ts new file mode 100644 index 0000000..e8c757b --- /dev/null +++ b/src/api/WorkspaceWorkflows/Hooks.ts @@ -0,0 +1,125 @@ +import { BaseResource } from "../BaseResource"; +import { Configuration } from "../../Configuration"; +import { + CreateWorkflowTransitionHook, + UpdateWorkflowTransitionHook, + WorkflowTransitionHook, +} from "../../models/Workflow"; + +/** + * One execution history entry for a workflow transition hook + */ +export interface WorkflowTransitionHookExecution { + id: string; + status: string; + started_at?: string; + completed_at?: string; + input_data?: unknown; + output_data?: unknown; + error_message?: string; + issue_id?: string; +} + +/** + * WorkspaceWorkflows.hooks sub-resource + * Manages hooks attached to workspace workflow transitions + */ +export class Hooks extends BaseResource { + constructor(config: Configuration) { + super(config); + } + + private basePath(workspaceSlug: string, workflowId: string, transitionId: string): string { + return `/workspaces/${workspaceSlug}/workflows/${workflowId}/state-transitions/${transitionId}/hooks`; + } + + /** + * List hooks on a workspace workflow transition + */ + async list(workspaceSlug: string, workflowId: string, transitionId: string): Promise { + const data = await this.get( + `${this.basePath(workspaceSlug, workflowId, transitionId)}/` + ); + return Array.isArray(data) ? data : data.results; + } + + /** + * 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. + */ + async create( + workspaceSlug: string, + workflowId: string, + transitionId: string, + data: CreateWorkflowTransitionHook + ): Promise { + return this.post(`${this.basePath(workspaceSlug, workflowId, transitionId)}/`, data); + } + + /** + * Retrieve a hook by ID + */ + async retrieve( + workspaceSlug: string, + workflowId: string, + transitionId: string, + hookId: string + ): Promise { + return this.get(`${this.basePath(workspaceSlug, workflowId, transitionId)}/${hookId}/`); + } + + /** + * Update a hook (`phase` and `handler_name` are immutable) + */ + async update( + workspaceSlug: string, + workflowId: string, + transitionId: string, + hookId: string, + data: UpdateWorkflowTransitionHook + ): Promise { + return this.patch( + `${this.basePath(workspaceSlug, workflowId, transitionId)}/${hookId}/`, + data + ); + } + + /** + * Delete a hook + */ + async del(workspaceSlug: string, workflowId: string, transitionId: string, hookId: string): Promise { + return this.httpDelete(`${this.basePath(workspaceSlug, workflowId, transitionId)}/${hookId}/`); + } + + /** + * Regenerate a send_webhook hook's secret. + * The new one-shot `secret_plaintext` is included in this response only. + */ + async regenerateSecret( + workspaceSlug: string, + workflowId: string, + transitionId: string, + hookId: string + ): Promise { + return this.post( + `${this.basePath(workspaceSlug, workflowId, transitionId)}/${hookId}/regenerate-webhook-secret/`, + undefined + ); + } + + /** + * List a hook's execution history entries + */ + async executions( + workspaceSlug: string, + workflowId: string, + transitionId: string, + hookId: string + ): Promise { + const data = await this.get( + `${this.basePath(workspaceSlug, workflowId, transitionId)}/${hookId}/executions/` + ); + return Array.isArray(data) ? data : data.results; + } +} diff --git a/src/api/WorkspaceWorkflows/States.ts b/src/api/WorkspaceWorkflows/States.ts new file mode 100644 index 0000000..1992160 --- /dev/null +++ b/src/api/WorkspaceWorkflows/States.ts @@ -0,0 +1,77 @@ +import { BaseResource } from "../BaseResource"; +import { Configuration } from "../../Configuration"; +import { + AddWorkspaceWorkflowStates, + RemoveWorkspaceWorkflowState, + UpdateWorkspaceWorkflowState, + WorkspaceWorkflowState, +} from "../../models/WorkspaceWorkflow"; + +/** + * WorkspaceWorkflows.states sub-resource + * Manages a workspace workflow's chain (state memberships) + */ +export class States extends BaseResource { + constructor(config: Configuration) { + super(config); + } + + /** + * Append catalog states to the workflow's chain. + * Returns the updated chain rows. + */ + async add( + workspaceSlug: string, + workflowId: string, + data: AddWorkspaceWorkflowStates + ): Promise { + const result = await this.post( + `/workspaces/${workspaceSlug}/workflows/${workflowId}/states/`, + data + ); + return Array.isArray(result) ? result : [result]; + } + + /** + * Update a chain membership row (type, allow_issue_creation, is_default). + * Changing `type` removes the row's existing transitions server-side. + */ + async update( + workspaceSlug: string, + workflowId: string, + stateId: string, + data: UpdateWorkspaceWorkflowState + ): Promise { + return this.patch( + `/workspaces/${workspaceSlug}/workflows/${workflowId}/states/${stateId}/`, + data + ); + } + + /** + * 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. + */ + async remove( + workspaceSlug: string, + workflowId: string, + stateId: string, + data?: RemoveWorkspaceWorkflowState + ): Promise { + return this.httpDelete(`/workspaces/${workspaceSlug}/workflows/${workflowId}/states/${stateId}/`, data); + } + + /** + * Mark a chain state as the workflow's default. + * Also force-enables work-item creation on that state. + */ + async markDefault(workspaceSlug: string, workflowId: string, stateId: string): Promise { + return this.post( + `/workspaces/${workspaceSlug}/workflows/${workflowId}/states/${stateId}/mark-default/`, + undefined + ); + } +} diff --git a/src/api/WorkspaceWorkflows/Transitions.ts b/src/api/WorkspaceWorkflows/Transitions.ts new file mode 100644 index 0000000..a8034de --- /dev/null +++ b/src/api/WorkspaceWorkflows/Transitions.ts @@ -0,0 +1,76 @@ +import { BaseResource } from "../BaseResource"; +import { Configuration } from "../../Configuration"; +import { + CreateWorkspaceWorkflowTransition, + UpdateWorkspaceWorkflowTransition, + WorkspaceWorkflowTransition, +} from "../../models/WorkspaceWorkflow"; + +/** + * WorkspaceWorkflows.transitions sub-resource + * Manages state transitions within a workspace workflow + */ +export class Transitions extends BaseResource { + constructor(config: Configuration) { + super(config); + } + + /** + * List all state transitions for a workspace workflow + */ + async list(workspaceSlug: string, workflowId: string): Promise { + const data = await this.get( + `/workspaces/${workspaceSlug}/workflows/${workflowId}/state-transitions/` + ); + return Array.isArray(data) ? data : data.results; + } + + /** + * Create a state transition for a workspace workflow + */ + async create( + workspaceSlug: string, + workflowId: string, + data: CreateWorkspaceWorkflowTransition + ): Promise { + return this.post( + `/workspaces/${workspaceSlug}/workflows/${workflowId}/state-transitions/`, + data + ); + } + + /** + * Retrieve a workspace workflow transition by ID + */ + async retrieve( + workspaceSlug: string, + workflowId: string, + transitionId: string + ): Promise { + return this.get( + `/workspaces/${workspaceSlug}/workflows/${workflowId}/state-transitions/${transitionId}/` + ); + } + + /** + * Update a workspace workflow transition + */ + async update( + workspaceSlug: string, + workflowId: string, + transitionId: string, + data: UpdateWorkspaceWorkflowTransition + ): Promise { + return this.patch( + `/workspaces/${workspaceSlug}/workflows/${workflowId}/state-transitions/${transitionId}/`, + data + ); + } + + /** + * Delete a workspace workflow transition + */ + async del(workspaceSlug: string, workflowId: string, transitionId: string): Promise { + return this.httpDelete(`/workspaces/${workspaceSlug}/workflows/${workflowId}/state-transitions/${transitionId}/`); + } +} diff --git a/src/api/WorkspaceWorkflows/index.ts b/src/api/WorkspaceWorkflows/index.ts new file mode 100644 index 0000000..5f7459f --- /dev/null +++ b/src/api/WorkspaceWorkflows/index.ts @@ -0,0 +1,112 @@ +import { BaseResource } from "../BaseResource"; +import { Configuration } from "../../Configuration"; +import { PaginatedResponse } from "../../models/common"; +import { WorkflowActivity } from "../../models/Workflow"; +import { + CreateWorkspaceWorkflow, + UpdateWorkspaceWorkflow, + WorkspaceWorkflow, + WorkspaceWorkflowUsage, +} from "../../models/WorkspaceWorkflow"; +import { States } from "./States"; +import { Transitions } from "./Transitions"; +import { Hooks } from "./Hooks"; + +export type ListWorkspaceWorkflowsParams = { + search?: string; + is_active?: boolean; + sort_by?: "name" | "created_at" | "updated_at"; + sort_order?: "asc" | "desc"; + cursor?: string; + per_page?: number; +}; + +export type ListWorkspaceWorkflowActivitiesParams = { + created_at__gt?: string; +}; + +/** + * WorkspaceWorkflows API resource + * Manages the workspace workflow catalog (workspace governance) and exposes + * states/transitions/hooks sub-resources. + * + * `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`. + */ +export class WorkspaceWorkflows extends BaseResource { + public states: States; + public transitions: Transitions; + public hooks: Hooks; + + constructor(config: Configuration) { + super(config); + this.states = new States(config); + this.transitions = new Transitions(config); + this.hooks = new Hooks(config); + } + + /** + * List workspace workflows + */ + async list( + workspaceSlug: string, + params?: ListWorkspaceWorkflowsParams + ): Promise> { + return this.get>(`/workspaces/${workspaceSlug}/workflows/`, params); + } + + /** + * Create a workspace workflow draft (configure its chain via `states`). + * Workflow names are workspace-unique. + */ + async create(workspaceSlug: string, data: CreateWorkspaceWorkflow): Promise { + return this.post(`/workspaces/${workspaceSlug}/workflows/`, data); + } + + /** + * Retrieve a workspace workflow with its full chain + */ + async retrieve(workspaceSlug: string, workflowId: string): Promise { + return this.get(`/workspaces/${workspaceSlug}/workflows/${workflowId}/`); + } + + /** + * Update workspace workflow metadata (name, description, is_active) + */ + async update(workspaceSlug: string, workflowId: string, data: UpdateWorkspaceWorkflow): Promise { + return this.patch(`/workspaces/${workspaceSlug}/workflows/${workflowId}/`, data); + } + + /** + * Delete a workspace workflow. + * The default workflow and workflows in use by projects cannot be deleted. + */ + async delete(workspaceSlug: string, workflowId: string): Promise { + return this.httpDelete(`/workspaces/${workspaceSlug}/workflows/${workflowId}/`); + } + + /** + * Report which projects/types resolve to this workflow, and which types + * mandate or allow it + */ + async usage(workspaceSlug: string, workflowId: string): Promise { + return this.get(`/workspaces/${workspaceSlug}/workflows/${workflowId}/usage/`); + } + + /** + * List the workflow's activity/audit entries + */ + async activities( + workspaceSlug: string, + workflowId: string, + params?: ListWorkspaceWorkflowActivitiesParams + ): Promise { + const data = await this.get( + `/workspaces/${workspaceSlug}/workflows/${workflowId}/activities/`, + params + ); + return Array.isArray(data) ? data : data.results; + } +} diff --git a/src/client/plane-client.ts b/src/client/plane-client.ts index bd5803c..a76b4df 100644 --- a/src/client/plane-client.ts +++ b/src/client/plane-client.ts @@ -31,6 +31,9 @@ import { WorkItemRelationDefinitions } from "../api/WorkItemRelationDefinitions" import { Releases } from "../api/Releases"; import { Workflows } from "../api/Workflows"; import { ProjectTemplates } from "../api/ProjectTemplates"; +import { WorkspaceStates } from "../api/WorkspaceStates"; +import { WorkspaceWorkflows } from "../api/WorkspaceWorkflows"; +import { WorkItemTypeGovernance } from "../api/WorkItemTypeGovernance"; /** * Main Plane Client class @@ -70,6 +73,9 @@ export class PlaneClient { public releases: Releases; public workflows: Workflows; public projectTemplates: ProjectTemplates; + public workspaceStates: WorkspaceStates; + public workspaceWorkflows: WorkspaceWorkflows; + public workItemTypeGovernance: WorkItemTypeGovernance; constructor(config: { baseUrl?: string; apiKey?: string; accessToken?: string; enableLogging?: boolean }) { this.config = new Configuration({ @@ -115,5 +121,8 @@ export class PlaneClient { this.releases = new Releases(this.config); this.workflows = new Workflows(this.config); this.projectTemplates = new ProjectTemplates(this.config); + this.workspaceStates = new WorkspaceStates(this.config); + this.workspaceWorkflows = new WorkspaceWorkflows(this.config); + this.workItemTypeGovernance = new WorkItemTypeGovernance(this.config); } } diff --git a/src/index.ts b/src/index.ts index 057fade..372e562 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,9 @@ export { WorkItemRelationDefinitions } from "./api/WorkItemRelationDefinitions"; export { Releases } from "./api/Releases"; export { Workflows } from "./api/Workflows"; export { ProjectTemplates } from "./api/ProjectTemplates"; +export { WorkspaceStates } from "./api/WorkspaceStates"; +export { WorkspaceWorkflows } from "./api/WorkspaceWorkflows"; +export { WorkItemTypeGovernance } from "./api/WorkItemTypeGovernance"; // Sub-resources export { Relations as WorkItemRelations } from "./api/WorkItems/Relations"; @@ -79,8 +82,14 @@ export { Links as ReleaseLinks } from "./api/Releases/Links"; export { WorkItems as ReleaseWorkItems } from "./api/Releases/WorkItems"; export { States as WorkflowStates } from "./api/Workflows/States"; export { Transitions as WorkflowTransitions } from "./api/Workflows/Transitions"; +export { Hooks as WorkflowTransitionHooks } from "./api/Workflows/Hooks"; export { WorkItems as ProjectWorkItemTemplates } from "./api/ProjectTemplates/WorkItems"; export { Pages as ProjectPageTemplates } from "./api/ProjectTemplates/Pages"; +export { States as WorkspaceWorkflowStates } from "./api/WorkspaceWorkflows/States"; +export { Transitions as WorkspaceWorkflowTransitions } from "./api/WorkspaceWorkflows/Transitions"; +export { Hooks as WorkspaceWorkflowTransitionHooks } from "./api/WorkspaceWorkflows/Hooks"; +export { Pins as WorkItemTypeWorkflowPins } from "./api/WorkItemTypeGovernance/Pins"; +export { ProjectWorkflows as ProjectTypeWorkflows } from "./api/WorkItemTypeGovernance/ProjectWorkflows"; // Models export * from "./models"; diff --git a/src/models/State.ts b/src/models/State.ts index ebb6f49..7edf20d 100644 --- a/src/models/State.ts +++ b/src/models/State.ts @@ -43,3 +43,38 @@ export type ListStatesParams = { }; export type GroupEnum = "backlog" | "unstarted" | "started" | "completed" | "cancelled" | "triage"; + +/** + * Workspace (catalog) states accept only the five lifecycle groups — the + * triage state is system-managed under workspace governance. + */ +export type CatalogGroupEnum = "backlog" | "unstarted" | "started" | "completed" | "cancelled"; + +/** + * 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. Catalog states carry no `default` flag; the workspace default lives + * on the workflow's default state. + */ +export type CreateWorkspaceState = { + name: string; + color: string; + group: CatalogGroupEnum; + description?: string; + external_source?: string; + external_id?: string; +}; + +/** + * Request model for updating a workspace (catalog) state. + * `default` is not accepted for catalog states — the API rejects it with + * code `workspace_managed`. + */ +export type UpdateWorkspaceState = Partial; + +export type ListWorkspaceStatesParams = { + cursor?: string; + per_page?: number; +}; diff --git a/src/models/WorkItemTypeGovernance.ts b/src/models/WorkItemTypeGovernance.ts new file mode 100644 index 0000000..468dda2 --- /dev/null +++ b/src/models/WorkItemTypeGovernance.ts @@ -0,0 +1,143 @@ +/** + * Minimal workflow shape for embedding (pickers, pins) + */ +export interface WorkflowLite { + id?: string; + name?: string; +} + +/** + * Minimal project reference in governance payloads + */ +export interface GovernanceProjectRef { + id?: string; + name?: string; +} + +/** + * 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. + */ +export interface GovernanceAllowlistEntry { + workflow_id?: string; + name?: string; + in_use_by_projects: GovernanceProjectRef[]; + locked?: boolean; +} + +export type TypeGovernanceMode = "any" | "constrained" | "required"; + +/** + * Governance settings for a work item type: mode, required workflow, and + * allowlist. Pins are served by the dedicated pins endpoints. + */ +export interface TypeGovernance { + mode?: TypeGovernanceMode; + required_workflow?: WorkflowLite | null; + allowlist: GovernanceAllowlistEntry[]; +} + +/** + * 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`. + */ +export interface UpdateTypeGovernance { + mode: TypeGovernanceMode; + workflow_ids?: string[]; + required_workflow_id?: string; + acknowledge?: boolean; + state_mapping?: Record; +} + +/** + * Request model for previewing a governance mode change (no writes) + */ +export interface TypeGovernancePreviewRequest { + mode: TypeGovernanceMode; + workflow_ids?: string[]; + required_workflow_id?: string; +} + +/** + * Dry-run impact report for a governance change or workflow fallback + */ +export interface GovernancePreview { + total?: number; + type_total?: number; + per_state?: Record[]; +} + +/** + * A single project-to-workflow pin for a work item type + */ +export interface WorkItemTypeWorkflowPin { + id?: string; + project?: GovernanceProjectRef; + workflow?: WorkflowLite; +} + +/** + * Request model for pinning a workflow across one or more projects + */ +export interface CreateWorkItemTypeWorkflowPins { + workflow_id: string; + project_ids: string[]; +} + +/** + * A pickable workflow option for a project's type + */ +export interface WorkflowOption { + workflow_id?: string; + name?: string; +} + +/** + * One active type's governance pill, effective workflow, and pickable + * options within a project + */ +export interface ProjectTypeWorkflow { + type_id?: string; + /** any | constrained | required | pinned */ + governance?: string; + allowlist_count?: number; + allowlist_total?: number; + effective_workflow_id?: string; + source?: string; + options: WorkflowOption[]; +} + +/** + * 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`. + */ +export interface SetProjectWorkflowPick { + workflow_id: string; + state_mapping?: Record; +} + +/** + * Response of setting a project's workflow pick: the workflow now in effect + */ +export interface ProjectWorkflowPickResult { + workflow_id?: string; +} + +/** + * 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. + */ +export interface WorkflowFallbackPreviewRequest { + type_id?: string; + new_type_id?: string; + workflow_id?: string; +} diff --git a/src/models/Workflow.ts b/src/models/Workflow.ts index 21caa10..51b977c 100644 --- a/src/models/Workflow.ts +++ b/src/models/Workflow.ts @@ -35,3 +35,74 @@ export type CreateWorkflowTransition = Required>; export type UpdateWorkflowTransition = Partial>; + +/** + * A state's membership row within a workflow chain. `id` is the membership + * row's ID and `state_id` the state's own — the inverse of + * `WorkspaceWorkflowState`, whose rows are keyed by state ID. + */ +export interface WorkflowState { + id?: string; + state_id?: string; + workflow_id?: string; + type?: string; + allow_issue_creation?: boolean; + is_default?: boolean; + created_at?: string; + updated_at?: string; +} + +export type UpdateWorkflowState = Partial>; + +/** + * One workflow activity/audit entry + */ +export interface WorkflowActivity { + id?: string; + verb?: string; + field?: string; + old_value?: unknown; + new_value?: unknown; + actor?: unknown; + created_at?: string; +} + +/** + * Request model for approving or rejecting a work item's pending workflow + * transition + */ +export interface SubmitWorkItemApproval { + type: "approve" | "reject"; +} + +/** + * Response of a work item workflow approval: the state the item moved to + */ +export interface WorkItemApprovalResult { + state_id?: string; +} + +/** + * 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. + */ +export interface WorkflowTransitionHook { + id?: string; + phase?: string; + handler_name?: string; + rule_type?: string; + execution_order?: number; + is_enabled?: boolean; + config?: Record; + secret_plaintext?: string; +} + +/** + * Request model for creating a workflow transition hook. `phase` and + * `handler_name` are immutable post-create. + */ +export type CreateWorkflowTransitionHook = Pick & + Partial>; + +export type UpdateWorkflowTransitionHook = Partial>; diff --git a/src/models/WorkspaceFeatures.ts b/src/models/WorkspaceFeatures.ts index 98a801e..ff2fd4c 100644 --- a/src/models/WorkspaceFeatures.ts +++ b/src/models/WorkspaceFeatures.ts @@ -1,13 +1,24 @@ /** * Workspace Features model interfaces + * + * All fields are optional so a caller can toggle a single feature; the update + * endpoint is a partial (PATCH) that only applies the fields that are sent. */ export interface WorkspaceFeatures { - project_grouping: boolean; - initiatives: boolean; - teams: boolean; - customers: boolean; - wiki: boolean; - pi: boolean; + project_grouping?: boolean; + initiatives?: boolean; + teams?: boolean; + customers?: boolean; + wiki?: boolean; + pi?: boolean; + work_item_types?: boolean; + releases?: boolean; + /** + * 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?: boolean; } -export type UpdateWorkspaceFeatures = Partial; +export type UpdateWorkspaceFeatures = Partial>; diff --git a/src/models/WorkspaceWorkflow.ts b/src/models/WorkspaceWorkflow.ts new file mode 100644 index 0000000..d091423 --- /dev/null +++ b/src/models/WorkspaceWorkflow.ts @@ -0,0 +1,157 @@ +import { PaginatedResponse } from "./common"; + +/** + * One state row in a workspace workflow's chain. + * + * Rows are keyed by catalog state IDs, so `id` is the state's own ID — the + * inverse of the project-scoped `WorkflowState` (see `./Workflow`), where + * `id` is the membership row's ID and `state_id` is the state's. The API + * never sends `state_id` on these rows; treat `id` as the state ID. + * + * `transitions` embeds the outgoing transitions (with approvers) when the + * payload is the full chain projection. + */ +export interface WorkspaceWorkflowState { + id?: string; + state_id?: string; + type?: string; + allow_issue_creation?: boolean; + is_default?: boolean; + sequence?: number; + transitions?: Record[]; +} + +/** + * 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`. + */ +export interface WorkspaceWorkflow { + id?: string; + name: string; + description?: string; + is_default?: boolean; + is_active?: boolean; + workspace_id?: string; + project_id?: string; + states_count?: number; + projects_count?: number; + work_item_types_count?: number; + project_ids?: string[]; + work_item_type_ids?: string[]; + states?: WorkspaceWorkflowState[]; + referenced_resources?: unknown; + created_at?: string; + updated_at?: string; + created_by?: string; + updated_by?: string; +} + +export type PaginatedWorkspaceWorkflowResponse = PaginatedResponse; + +/** + * Request model for creating a workspace workflow (a draft until its chain + * is configured via the states endpoints) + */ +export type CreateWorkspaceWorkflow = { + name: string; + description?: string; +}; + +/** + * Request model for updating workspace workflow metadata + */ +export type UpdateWorkspaceWorkflow = Partial<{ + name: string; + description: string; + is_active: boolean; +}>; + +/** + * Request model for appending catalog states to a workflow's chain + */ +export interface AddWorkspaceWorkflowStates { + state_ids: string[]; +} + +/** + * Request model for updating a chain membership row. + * Changing `type` (e.g. to/from `approval`) removes the row's existing + * transitions server-side. + */ +export type UpdateWorkspaceWorkflowState = Partial<{ + type: string; + allow_issue_creation: boolean; + is_default: boolean; +}>; + +/** + * 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`. + */ +export interface RemoveWorkspaceWorkflowState { + new_default_state_id?: string; + state_mapping?: Record; +} + +/** + * One project resolving to a workflow in the usage report + */ +export interface WorkspaceWorkflowUsageProject { + project_id?: string; + name?: string; + types: (string | null)[]; +} + +/** + * Usage report for a workspace workflow: projects/types resolving to it, plus + * the types mandating or allowing it + */ +export interface WorkspaceWorkflowUsage { + projects: WorkspaceWorkflowUsageProject[]; + types_mandating: string[]; + types_allowing: string[]; +} + +/** + * State transition within a workspace workflow + */ +export interface WorkspaceWorkflowTransition { + id?: string; + workflow_state_id?: string; + transition_state_id?: string; + rejection_state_id?: string; + required_approvals?: number; + member_ids?: string[]; + pre_hooks?: Record[]; + post_hooks?: Record[]; + created_at?: string; + updated_at?: string; +} + +/** + * 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). + */ +export type CreateWorkspaceWorkflowTransition = { + state_id: string; + transition_state_id: string; + rejection_state_id?: string; + required_approvals?: number; + member_ids?: string[]; +}; + +/** + * Request model for updating a workspace workflow transition + */ +export type UpdateWorkspaceWorkflowTransition = Partial<{ + transition_state_id: string; + rejection_state_id: string; + required_approvals: number; + member_ids: string[]; +}>; diff --git a/src/models/index.ts b/src/models/index.ts index d941638..9396393 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -38,3 +38,5 @@ export * from "./WorkItemRelationDefinition"; export * from "./Release"; export * from "./Workflow"; export * from "./ProjectTemplate"; +export * from "./WorkspaceWorkflow"; +export * from "./WorkItemTypeGovernance"; diff --git a/tests/helpers/governance.ts b/tests/helpers/governance.ts new file mode 100644 index 0000000..6bb6235 --- /dev/null +++ b/tests/helpers/governance.ts @@ -0,0 +1,24 @@ +import { HttpError } from "../../src/errors"; + +/** + * Detects the 400 `workspace_managed` rejection returned by the backend's + * `reject_when_workspace_governed` / `reject_when_workspace_types_managed` + * decorators (or an equivalent plain-text workspace-level-feature refusal), + * so tests can skip project-scoped fixture creation cleanly — with a clear + * reason — instead of failing loud when a resource is managed at the + * workspace level in the target workspace. + * + * Returns the server's reason string when it matches, or `null` when the + * error looks like a genuine failure that should still fail the test. + */ +export function workspaceManagedReason(error: unknown): string | null { + if (!(error instanceof HttpError) || error.statusCode !== 400) return null; + + const response = error.response as { code?: unknown; error?: unknown; detail?: unknown } | undefined; + const message = String(response?.error ?? response?.detail ?? ""); + + if (response?.code === "workspace_managed") return message || "workspace_managed"; + if (message.toLowerCase().includes("managed at the workspace level")) return message; + + return null; +} diff --git a/tests/unit/project-templates.test.ts b/tests/unit/project-templates.test.ts index d153ff0..5bb730e 100644 --- a/tests/unit/project-templates.test.ts +++ b/tests/unit/project-templates.test.ts @@ -3,6 +3,7 @@ import { WorkItemTemplate, PageTemplate } from "../../src/models"; import { config } from "./constants"; import { createTestClient, randomizeName } from "../helpers/test-utils"; import { describeIf as describe } from "../helpers/conditional-tests"; +import { workspaceManagedReason } from "../helpers/governance"; describe(!!(config.workspaceSlug && config.projectId), "ProjectTemplates API Tests", () => { let client: PlaneClient; @@ -11,9 +12,14 @@ describe(!!(config.workspaceSlug && config.projectId), "ProjectTemplates API Tes // Work item template under test let workItemTemplate: WorkItemTemplate; + // Governed workspaces (or ones with workspace-level work item types enabled) + // may reject project-scoped work item template creation with a 400 — skip + // gracefully in that case. + let serverBlocksWorkItemTemplates = false; // Page template under test let pageTemplate: PageTemplate; + let serverBlocksPageTemplates = false; beforeAll(async () => { client = createTestClient(); @@ -42,12 +48,22 @@ describe(!!(config.workspaceSlug && config.projectId), "ProjectTemplates API Tes // ─── Work Item Templates ───────────────────────────────────────────────────── it("should create a work item template", async () => { - // The server requires template_data carrying the seeded work item's name. - workItemTemplate = await client.projectTemplates.workItems.create(workspaceSlug, projectId, { - name: randomizeName("Test WI Template"), - short_description: "Created by test suite", - template_data: { name: randomizeName("Seed Work Item ") }, - }); + try { + // The server requires template_data carrying the seeded work item's name. + workItemTemplate = await client.projectTemplates.workItems.create(workspaceSlug, projectId, { + name: randomizeName("Test WI Template"), + short_description: "Created by test suite", + template_data: { name: randomizeName("Seed Work Item ") }, + }); + } catch (error) { + const reason = workspaceManagedReason(error); + if (reason !== null) { + serverBlocksWorkItemTemplates = true; + console.warn("Skipped: project-level work item templates are managed at the workspace level —", reason); + return; + } + throw error; + } expect(workItemTemplate).toBeDefined(); expect(workItemTemplate.id).toBeDefined(); @@ -57,6 +73,7 @@ describe(!!(config.workspaceSlug && config.projectId), "ProjectTemplates API Tes }); it("should list work item templates", async () => { + if (serverBlocksWorkItemTemplates) return; const templates = await client.projectTemplates.workItems.list(workspaceSlug, projectId); expect(templates).toBeDefined(); @@ -69,6 +86,7 @@ describe(!!(config.workspaceSlug && config.projectId), "ProjectTemplates API Tes }); it("should update a work item template", async () => { + if (serverBlocksWorkItemTemplates) return; const updated = await client.projectTemplates.workItems.update(workspaceSlug, projectId, workItemTemplate.id!, { name: randomizeName("Updated WI Template"), }); @@ -81,6 +99,7 @@ describe(!!(config.workspaceSlug && config.projectId), "ProjectTemplates API Tes }); it("should delete a work item template", async () => { + if (serverBlocksWorkItemTemplates) return; await expect( client.projectTemplates.workItems.del(workspaceSlug, projectId, workItemTemplate.id!) ).resolves.toBeUndefined(); @@ -92,10 +111,20 @@ describe(!!(config.workspaceSlug && config.projectId), "ProjectTemplates API Tes // ─── Page Templates ────────────────────────────────────────────────────────── it("should create a page template", async () => { - pageTemplate = await client.projectTemplates.pages.create(workspaceSlug, projectId, { - name: randomizeName("Test Page Template"), - short_description: "Created by test suite", - }); + try { + pageTemplate = await client.projectTemplates.pages.create(workspaceSlug, projectId, { + name: randomizeName("Test Page Template"), + short_description: "Created by test suite", + }); + } catch (error) { + const reason = workspaceManagedReason(error); + if (reason !== null) { + serverBlocksPageTemplates = true; + console.warn("Skipped: project-level page templates are managed at the workspace level —", reason); + return; + } + throw error; + } expect(pageTemplate).toBeDefined(); expect(pageTemplate.id).toBeDefined(); @@ -105,6 +134,7 @@ describe(!!(config.workspaceSlug && config.projectId), "ProjectTemplates API Tes }); it("should list page templates", async () => { + if (serverBlocksPageTemplates) return; const templates = await client.projectTemplates.pages.list(workspaceSlug, projectId); expect(templates).toBeDefined(); @@ -117,6 +147,7 @@ describe(!!(config.workspaceSlug && config.projectId), "ProjectTemplates API Tes }); it("should update a page template", async () => { + if (serverBlocksPageTemplates) return; const updated = await client.projectTemplates.pages.update(workspaceSlug, projectId, pageTemplate.id!, { name: randomizeName("Updated Page Template"), }); @@ -129,6 +160,7 @@ describe(!!(config.workspaceSlug && config.projectId), "ProjectTemplates API Tes }); it("should delete a page template", async () => { + if (serverBlocksPageTemplates) return; await expect( client.projectTemplates.pages.del(workspaceSlug, projectId, pageTemplate.id!) ).resolves.toBeUndefined(); diff --git a/tests/unit/state.test.ts b/tests/unit/state.test.ts index 37ff92a..ad1d20c 100644 --- a/tests/unit/state.test.ts +++ b/tests/unit/state.test.ts @@ -3,12 +3,16 @@ import { State } from "../../src/models/State"; import { config } from "./constants"; import { createTestClient, randomizeName } from "../helpers/test-utils"; import { describeIf as describe } from "../helpers/conditional-tests"; +import { workspaceManagedReason } from "../helpers/governance"; describe(!!(config.workspaceSlug && config.projectId), "State API Tests", () => { let client: PlaneClient; let workspaceSlug: string; let projectId: string; let state: State; + // Governed workspaces manage states at the workspace level and reject + // project-scoped state creation with a 400 — skip gracefully in that case. + let serverManagesProjectStates = false; beforeAll(async () => { client = createTestClient(); @@ -28,12 +32,22 @@ describe(!!(config.workspaceSlug && config.projectId), "State API Tests", () => }); it("should create a state", async () => { - state = await client.states.create(workspaceSlug, projectId, { - name: randomizeName("Test State"), - description: "Test State Description", - group: "started", - color: "#9AA4BC", - }); + try { + state = await client.states.create(workspaceSlug, projectId, { + name: randomizeName("Test State"), + description: "Test State Description", + group: "started", + color: "#9AA4BC", + }); + } catch (error) { + const reason = workspaceManagedReason(error); + if (reason !== null) { + serverManagesProjectStates = true; + console.warn("Skipped: project-level states are managed at the workspace level —", reason); + return; + } + throw error; + } expect(state).toBeDefined(); expect(state.id).toBeDefined(); @@ -43,6 +57,7 @@ describe(!!(config.workspaceSlug && config.projectId), "State API Tests", () => }); it("should retrieve a state", async () => { + if (serverManagesProjectStates) return; const retrievedState = await client.states.retrieve(workspaceSlug, projectId, state.id!); expect(retrievedState).toBeDefined(); @@ -52,6 +67,7 @@ describe(!!(config.workspaceSlug && config.projectId), "State API Tests", () => }); it("should update a state", async () => { + if (serverManagesProjectStates) return; const updatedState = await client.states.update(workspaceSlug, projectId, state.id!, { description: "Updated Test State Description", }); @@ -62,6 +78,7 @@ describe(!!(config.workspaceSlug && config.projectId), "State API Tests", () => }); it("should list states", async () => { + if (serverManagesProjectStates) return; const states = await client.states.list(workspaceSlug, projectId); expect(states).toBeDefined(); diff --git a/tests/unit/work-item-type-governance/work-item-type-governance.test.ts b/tests/unit/work-item-type-governance/work-item-type-governance.test.ts new file mode 100644 index 0000000..806a772 --- /dev/null +++ b/tests/unit/work-item-type-governance/work-item-type-governance.test.ts @@ -0,0 +1,69 @@ +import { PlaneClient } from "../../../src/client/plane-client"; +import { WorkItemType } from "../../../src/models/WorkItemType"; +import { config } from "../constants"; +import { createTestClient, randomizeName } from "../../helpers/test-utils"; +import { describeIf as describe } from "../../helpers/conditional-tests"; + +describe(!!config.workspaceSlug, "WorkItemTypeGovernance API Tests", () => { + let client: PlaneClient; + let workspaceSlug: string; + let governed: boolean; + let workspaceType: WorkItemType | undefined; + + beforeAll(async () => { + client = createTestClient(); + workspaceSlug = config.workspaceSlug; + + const features = await client.workspace.retrieveFeatures(workspaceSlug); + governed = !!features.states_owned_by_workspace; + + if (governed) { + workspaceType = await client.workspaceWorkItemTypes.create(workspaceSlug, { + name: randomizeName("Gov Type"), + }); + } + }); + + afterAll(async () => { + if (workspaceType?.id) { + try { + await client.workspaceWorkItemTypes.delete(workspaceSlug, workspaceType.id); + } catch (error) { + console.warn("Failed to delete workspace work item type:", error); + } + } + }); + + it("should retrieve default governance for a fresh type ('any', empty allowlist)", async () => { + if (!governed || !workspaceType?.id) return; + + const governance = await client.workItemTypeGovernance.retrieve(workspaceSlug, workspaceType.id); + expect(governance.mode).toBe("any"); + expect(governance.allowlist).toEqual([]); + }); + + it("should preview mode 'any' as a no-op", async () => { + if (!governed || !workspaceType?.id) return; + + const preview = await client.workItemTypeGovernance.preview(workspaceSlug, workspaceType.id, { mode: "any" }); + expect(preview.total).toBe(0); + }); + + it("should list no pins for a fresh type", async () => { + if (!governed || !workspaceType?.id) return; + + const pins = await client.workItemTypeGovernance.pins.list(workspaceSlug, workspaceType.id); + expect(pins).toEqual([]); + }); + + it("should list the project-side type-workflow resolution", async () => { + if (!governed || !config.projectId) return; + + const entries = await client.workItemTypeGovernance.projectWorkflows.list(workspaceSlug, config.projectId); + expect(Array.isArray(entries)).toBe(true); + for (const entry of entries) { + expect(entry.type_id).toBeDefined(); + expect(["any", "constrained", "required", "pinned"]).toContain(entry.governance); + } + }); +}); diff --git a/tests/unit/work-item-types/project-properties.test.ts b/tests/unit/work-item-types/project-properties.test.ts index 852c897..fe64f6d 100644 --- a/tests/unit/work-item-types/project-properties.test.ts +++ b/tests/unit/work-item-types/project-properties.test.ts @@ -3,6 +3,7 @@ import { WorkItemProperty, WorkItemType } from "../../../src/models"; import { config } from "../constants"; import { createTestClient, randomizeName } from "../../helpers/test-utils"; import { describeIf as describe } from "../../helpers/conditional-tests"; +import { workspaceManagedReason } from "../../helpers/governance"; describe(!!(config.workspaceSlug && config.projectId), "Project-Level Work Item Properties API Tests", () => { let client: PlaneClient; @@ -24,6 +25,12 @@ describe(!!(config.workspaceSlug && config.projectId), "Project-Level Work Item name: randomizeName("Prop Test Type "), }); } catch (error: any) { + const reason = workspaceManagedReason(error); + if (reason !== null) { + serverBlocksProjectLevel = true; + console.warn("Skipped: project-level types/properties are managed at the workspace level —", reason); + return; + } const msg = String(error?.response?.error ?? error?.response?.detail ?? ""); if (error?.statusCode === 400 && (msg.includes("work item types") || msg.includes("issue properties"))) { serverBlocksProjectLevel = true; diff --git a/tests/unit/work-item-types/properties-options.test.ts b/tests/unit/work-item-types/properties-options.test.ts index d4d91d8..78e14bb 100644 --- a/tests/unit/work-item-types/properties-options.test.ts +++ b/tests/unit/work-item-types/properties-options.test.ts @@ -3,6 +3,7 @@ import { WorkItemProperty } from "../../../src/models/WorkItemProperty"; import { config } from "../constants"; import { createTestClient, randomizeName } from "../../helpers/test-utils"; import { describeIf } from "../../helpers/conditional-tests"; +import { workspaceManagedReason } from "../../helpers/governance"; describeIf( !!(config.workspaceSlug && config.projectId && config.workItemTypeId), @@ -38,6 +39,12 @@ describeIf( }); await client.workItemProperties.delete(workspaceSlug, projectId, workItemTypeId, probe.id!); } catch (error: any) { + const reason = workspaceManagedReason(error); + if (reason !== null) { + serverBlocksTypeScoped = true; + console.warn("Skipped: type-scoped work item properties are managed at the workspace level —", reason); + return; + } const msg = String(error?.response?.error ?? error?.response?.detail ?? ""); if (error?.statusCode === 400 && msg.includes("work item propert")) { serverBlocksTypeScoped = true; diff --git a/tests/unit/work-item-types/types.test.ts b/tests/unit/work-item-types/types.test.ts index 6777774..2f5e235 100644 --- a/tests/unit/work-item-types/types.test.ts +++ b/tests/unit/work-item-types/types.test.ts @@ -3,6 +3,7 @@ import { WorkItemType } from "../../../src/models/WorkItemType"; import { config } from "../constants"; import { createTestClient, randomizeName } from "../../helpers/test-utils"; import { describeIf as describe } from "../../helpers/conditional-tests"; +import { workspaceManagedReason } from "../../helpers/governance"; describe(!!(config.workspaceSlug && config.projectId), "Work Item Types API Tests", () => { let client: PlaneClient; @@ -43,6 +44,11 @@ describe(!!(config.workspaceSlug && config.projectId), "Work Item Types API Test name: randomizeName("Test WI Type"), }); } catch (error: any) { + const reason = workspaceManagedReason(error); + if (reason !== null) { + console.warn("Skipped: project-level work item types are managed at the workspace level —", reason); + return; + } const msg = String(error?.response?.error ?? error?.response?.detail ?? ""); if (error?.statusCode === 400 && msg.includes("work item types")) { console.warn("Server blocks project-level work item types (workspace types enabled) — skipping:", msg); diff --git a/tests/unit/workflows/workflow.test.ts b/tests/unit/workflows/workflow.test.ts index 75e53e2..b966abb 100644 --- a/tests/unit/workflows/workflow.test.ts +++ b/tests/unit/workflows/workflow.test.ts @@ -4,6 +4,7 @@ import { State } from "../../../src/models/State"; import { config } from "../constants"; import { createTestClient, randomizeName } from "../../helpers/test-utils"; import { describeIf as describe } from "../../helpers/conditional-tests"; +import { workspaceManagedReason } from "../../helpers/governance"; describe(!!(config.workspaceSlug && config.projectId), "Workflow API Tests", () => { let client: PlaneClient; @@ -13,24 +14,38 @@ describe(!!(config.workspaceSlug && config.projectId), "Workflow API Tests", () let stateA: State; let stateB: State; let transition: WorkflowTransition; + // Governed workspaces manage states at the workspace level and reject + // project-scoped state creation with a 400 — skip the state/transition + // dependent tests gracefully in that case. + let statesUnavailable = false; beforeAll(async () => { client = createTestClient(); workspaceSlug = config.workspaceSlug; projectId = config.projectId; - // Create two states to use for workflow state/transition operations - stateA = await client.states.create(workspaceSlug, projectId, { - name: randomizeName("WF State A"), - group: "started", - color: "#9AA4BC", - }); + try { + // Create two states to use for workflow state/transition operations + stateA = await client.states.create(workspaceSlug, projectId, { + name: randomizeName("WF State A"), + group: "started", + color: "#9AA4BC", + }); - stateB = await client.states.create(workspaceSlug, projectId, { - name: randomizeName("WF State B"), - group: "started", - color: "#A4BC9A", - }); + stateB = await client.states.create(workspaceSlug, projectId, { + name: randomizeName("WF State B"), + group: "started", + color: "#A4BC9A", + }); + } catch (error) { + const reason = workspaceManagedReason(error); + if (reason !== null) { + statesUnavailable = true; + console.warn("Skipped: project-level states are managed at the workspace level —", reason); + return; + } + throw error; + } }); afterAll(async () => { @@ -73,6 +88,11 @@ describe(!!(config.workspaceSlug && config.projectId), "Workflow API Tests", () name: randomizeName("Test Workflow"), }); } catch (error: any) { + const reason = workspaceManagedReason(error); + if (reason !== null) { + console.warn("Skipped: project-level workflows are managed at the workspace level —", reason); + return; + } const msg = String(error?.response?.error ?? error?.response?.detail ?? ""); if (error?.statusCode === 403 && msg.includes("Workflows feature")) { console.warn("Workflows feature not enabled for this project — skipping:", msg); @@ -114,8 +134,16 @@ describe(!!(config.workspaceSlug && config.projectId), "Workflow API Tests", () workflow = updated; }); - it("should attach a state to a workflow", async () => { + it("should retrieve a workflow by ID", async () => { if (!workflow?.id) return; + const retrieved = await client.workflows.retrieve(workspaceSlug, projectId, workflow.id!); + + expect(retrieved.id).toBe(workflow.id); + expect(retrieved.name).toBe(workflow.name); + }); + + it("should attach a state to a workflow", async () => { + if (!workflow?.id || statesUnavailable) return; await expect( client.workflows.states.attach(workspaceSlug, projectId, workflow.id!, { state_ids: [stateA.id!], @@ -123,6 +151,14 @@ describe(!!(config.workspaceSlug && config.projectId), "Workflow API Tests", () ).resolves.toBeUndefined(); }); + it("should list the states attached to a workflow", async () => { + if (!workflow?.id || statesUnavailable) return; + const states = await client.workflows.states.list(workspaceSlug, projectId, workflow.id!); + + expect(Array.isArray(states)).toBe(true); + expect(states.find((s) => s.state_id === stateA.id)).toBeDefined(); + }); + it("should list transitions (initially empty for the workflow)", async () => { if (!workflow?.id) return; const transitions = await client.workflows.transitions.list(workspaceSlug, projectId, workflow.id!); @@ -132,7 +168,7 @@ describe(!!(config.workspaceSlug && config.projectId), "Workflow API Tests", () }); it("should create a workflow transition", async () => { - if (!workflow?.id) return; + if (!workflow?.id || statesUnavailable) return; const result = await client.workflows.transitions.create(workspaceSlug, projectId, workflow.id!, { state_id: stateA.id!, transition_state_id: stateB.id!, @@ -152,7 +188,7 @@ describe(!!(config.workspaceSlug && config.projectId), "Workflow API Tests", () }); it("should list transitions (find newly created)", async () => { - if (!workflow?.id) return; + if (!workflow?.id || statesUnavailable) return; const transitions = await client.workflows.transitions.list(workspaceSlug, projectId, workflow.id!); expect(transitions).toBeDefined(); @@ -163,8 +199,20 @@ describe(!!(config.workspaceSlug && config.projectId), "Workflow API Tests", () expect(found).toBeDefined(); }); + it("should retrieve a workflow transition by ID", async () => { + if (!workflow?.id || !transition?.id || statesUnavailable) return; + const retrieved = await client.workflows.transitions.retrieve( + workspaceSlug, + projectId, + workflow.id!, + transition.id! + ); + + expect(retrieved.id).toBe(transition.id); + }); + it("should update a workflow transition", async () => { - if (!workflow?.id || !transition?.id) return; + if (!workflow?.id || !transition?.id || statesUnavailable) return; const updated = await client.workflows.transitions.update(workspaceSlug, projectId, workflow.id!, transition.id!, { pre_rules: [], post_rules: [], @@ -174,17 +222,38 @@ describe(!!(config.workspaceSlug && config.projectId), "Workflow API Tests", () expect(updated.id).toBe(transition.id); }); + it("should list hooks on a transition (initially empty)", async () => { + if (!workflow?.id || !transition?.id || statesUnavailable) return; + const hooks = await client.workflows.hooks.list(workspaceSlug, projectId, workflow.id!, transition.id!); + + expect(Array.isArray(hooks)).toBe(true); + }); + it("should delete a workflow transition", async () => { - if (!workflow?.id || !transition?.id) return; + if (!workflow?.id || !transition?.id || statesUnavailable) return; await expect( client.workflows.transitions.del(workspaceSlug, projectId, workflow.id!, transition.id!) ).resolves.toBeUndefined(); }); it("should detach a state from a workflow", async () => { - if (!workflow?.id) return; + if (!workflow?.id || statesUnavailable) return; await expect( client.workflows.states.detach(workspaceSlug, projectId, workflow.id!, stateA.id!) ).resolves.toBeUndefined(); }); + + it("should list workflow activities", async () => { + if (!workflow?.id) return; + const activities = await client.workflows.activities(workspaceSlug, projectId, workflow.id!); + + expect(Array.isArray(activities)).toBe(true); + }); + + it("should delete the workflow", async () => { + if (!workflow?.id) return; + await expect(client.workflows.delete(workspaceSlug, projectId, workflow.id!)).resolves.toBeUndefined(); + // afterAll's detach is now a no-op since the workflow itself is gone. + workflow = { ...workflow, id: undefined } as unknown as Workflow; + }); }); diff --git a/tests/unit/workspace-states.test.ts b/tests/unit/workspace-states.test.ts new file mode 100644 index 0000000..04beb77 --- /dev/null +++ b/tests/unit/workspace-states.test.ts @@ -0,0 +1,81 @@ +import { PlaneClient } from "../../src/client/plane-client"; +import { State } from "../../src/models/State"; +import { config } from "./constants"; +import { createTestClient, randomizeName } from "../helpers/test-utils"; +import { describeIf as describe } from "../helpers/conditional-tests"; + +describe(!!config.workspaceSlug, "WorkspaceStates API Tests", () => { + let client: PlaneClient; + let workspaceSlug: string; + let governed: boolean; + let state: State | undefined; + + beforeAll(async () => { + client = createTestClient(); + workspaceSlug = config.workspaceSlug; + + const features = await client.workspace.retrieveFeatures(workspaceSlug); + governed = !!features.states_owned_by_workspace; + }); + + afterAll(async () => { + if (state?.id) { + try { + await client.workspaceStates.delete(workspaceSlug, state.id); + } catch (error) { + console.warn("Failed to delete workspace state:", error); + } + } + }); + + it("should list workspace states (dual-mode, always runs)", async () => { + const response = await client.workspaceStates.list(workspaceSlug); + expect(Array.isArray(response.results)).toBe(true); + }); + + it("should report the governance flag on workspace features", async () => { + const features = await client.workspace.retrieveFeatures(workspaceSlug); + expect( + typeof features.states_owned_by_workspace === "boolean" || features.states_owned_by_workspace === undefined + ).toBe(true); + }); + + it("should reject creating a catalog state when ungoverned", async () => { + if (governed) return; + await expect( + client.workspaceStates.create(workspaceSlug, { + name: randomizeName("WS Catalog State"), + color: "#FF0000", + group: "unstarted", + }) + ).rejects.toBeDefined(); + }); + + it("should create, retrieve, update, and delete a workspace (catalog) state", async () => { + if (!governed) return; + + const name = randomizeName("WS Catalog State"); + state = await client.workspaceStates.create(workspaceSlug, { + name, + color: "#FF0000", + group: "unstarted", + description: "SDK test state", + }); + + expect(state.id).toBeDefined(); + expect(state.name).toContain(name); + expect(state.project).toBeFalsy(); + + const retrieved = await client.workspaceStates.retrieve(workspaceSlug, state.id); + expect(retrieved.id).toBe(state.id); + + const updated = await client.workspaceStates.update(workspaceSlug, state.id, { + description: "Updated description", + }); + expect(updated.id).toBe(state.id); + expect(updated.description).toBe("Updated description"); + + await expect(client.workspaceStates.delete(workspaceSlug, state.id)).resolves.toBeUndefined(); + state = undefined; + }); +}); diff --git a/tests/unit/workspace-workflows/workspace-workflow.test.ts b/tests/unit/workspace-workflows/workspace-workflow.test.ts new file mode 100644 index 0000000..ddbdd89 --- /dev/null +++ b/tests/unit/workspace-workflows/workspace-workflow.test.ts @@ -0,0 +1,89 @@ +import { PlaneClient } from "../../../src/client/plane-client"; +import { State } from "../../../src/models/State"; +import { WorkspaceWorkflow } from "../../../src/models/WorkspaceWorkflow"; +import { config } from "../constants"; +import { createTestClient, randomizeName } from "../../helpers/test-utils"; +import { describeIf as describe } from "../../helpers/conditional-tests"; + +describe(!!config.workspaceSlug, "WorkspaceWorkflows API Tests", () => { + let client: PlaneClient; + let workspaceSlug: string; + let governed: boolean; + let stateA: State | undefined; + let stateB: State | undefined; + let workflow: WorkspaceWorkflow | undefined; + + beforeAll(async () => { + client = createTestClient(); + workspaceSlug = config.workspaceSlug; + + const features = await client.workspace.retrieveFeatures(workspaceSlug); + governed = !!features.states_owned_by_workspace; + }); + + afterAll(async () => { + if (workflow?.id) { + try { + await client.workspaceWorkflows.delete(workspaceSlug, workflow.id); + } catch (error) { + console.warn("Failed to delete workspace workflow:", error); + } + } + for (const state of [stateA, stateB]) { + if (state?.id) { + try { + await client.workspaceStates.delete(workspaceSlug, state.id); + } catch (error) { + console.warn("Failed to delete workspace state:", error); + } + } + } + }); + + it("should list workspace workflows (dual-mode, always runs)", async () => { + const response = await client.workspaceWorkflows.list(workspaceSlug); + expect(Array.isArray(response.results)).toBe(true); + }); + + it("should create a workflow, configure a chain, and clean up", async () => { + if (!governed) return; + + stateA = await client.workspaceStates.create(workspaceSlug, { + name: randomizeName("WS WF State A"), + color: "#FF0000", + group: "unstarted", + }); + stateB = await client.workspaceStates.create(workspaceSlug, { + name: randomizeName("WS WF State B"), + color: "#00FF00", + group: "started", + }); + + workflow = await client.workspaceWorkflows.create(workspaceSlug, { + name: randomizeName("WS Workflow"), + }); + expect(workflow.id).toBeDefined(); + + const chain = await client.workspaceWorkflows.states.add(workspaceSlug, workflow.id!, { + state_ids: [stateA.id, stateB.id], + }); + expect(Array.isArray(chain)).toBe(true); + + await client.workspaceWorkflows.states.markDefault(workspaceSlug, workflow.id!, stateA.id); + + const detail = await client.workspaceWorkflows.retrieve(workspaceSlug, workflow.id!); + expect(detail.id).toBe(workflow.id); + expect(detail.states?.length).toBeGreaterThanOrEqual(2); + + const updated = await client.workspaceWorkflows.update(workspaceSlug, workflow.id!, { + description: "SDK test workflow", + }); + expect(updated.id).toBe(workflow.id); + + const usage = await client.workspaceWorkflows.usage(workspaceSlug, workflow.id!); + expect(Array.isArray(usage.projects)).toBe(true); + + const transitions = await client.workspaceWorkflows.transitions.list(workspaceSlug, workflow.id!); + expect(Array.isArray(transitions)).toBe(true); + }); +});