From b2d7d8b3580823a1b68ceb6d4812207c185363a8 Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Tue, 19 May 2026 11:13:42 +0300 Subject: [PATCH 1/8] feat: add Jira workflow components for approval and assignment ## Summary This commit introduces new components for managing Jira workflows, enhancing the integration with Jira Service Management. ## Changes - Added **Approve Workflow** component to approve or decline Jira Service Management request approvals. - Introduced **Assign Workflow To Project** component to assign existing workflow schemes to company-managed projects. - Updated documentation to include new components and their configurations. - Added tests for the new components to ensure functionality and reliability. These additions aim to streamline workflow management within Jira, providing users with more control over their project configurations. Signed-off-by: WashingtonKK --- docs/components/Jira.mdx | 216 +++++++ pkg/integrations/jira/approve_workflow.go | 270 +++++++++ .../jira/approve_workflow_test.go | 177 ++++++ .../jira/assign_workflow_to_project.go | 278 +++++++++ .../jira/assign_workflow_to_project_test.go | 149 +++++ pkg/integrations/jira/client.go | 428 +++++++++++++- pkg/integrations/jira/common.go | 35 +- pkg/integrations/jira/common_test.go | 67 +++ pkg/integrations/jira/create_issue.go | 22 - pkg/integrations/jira/create_workflow.go | 543 ++++++++++++++++++ pkg/integrations/jira/create_workflow_test.go | 215 +++++++ pkg/integrations/jira/example.go | 40 ++ .../jira/example_output_approve_workflow.json | 23 + ...ple_output_assign_workflow_to_project.json | 12 + .../jira/example_output_create_workflow.json | 12 + .../jira/example_output_transition_issue.json | 28 + pkg/integrations/jira/jira.go | 4 + pkg/integrations/jira/list_resources.go | 61 ++ pkg/integrations/jira/list_resources_test.go | 30 + pkg/integrations/jira/transition_issue.go | 228 ++++++++ .../jira/transition_issue_test.go | 138 +++++ .../mappers/jira/approve_workflow.spec.ts | 111 ++++ .../mappers/jira/approve_workflow.ts | 77 +++ .../jira/assign_workflow_to_project.spec.ts | 114 ++++ .../jira/assign_workflow_to_project.ts | 71 +++ .../mappers/jira/create_workflow.spec.ts | 121 ++++ .../mappers/jira/create_workflow.ts | 82 +++ .../pages/workflowv2/mappers/jira/index.ts | 12 + .../mappers/jira/transition_issue.spec.ts | 112 ++++ .../mappers/jira/transition_issue.ts | 73 +++ .../pages/workflowv2/mappers/jira/types.ts | 73 +++ 31 files changed, 3789 insertions(+), 33 deletions(-) create mode 100644 pkg/integrations/jira/approve_workflow.go create mode 100644 pkg/integrations/jira/approve_workflow_test.go create mode 100644 pkg/integrations/jira/assign_workflow_to_project.go create mode 100644 pkg/integrations/jira/assign_workflow_to_project_test.go create mode 100644 pkg/integrations/jira/common_test.go create mode 100644 pkg/integrations/jira/create_workflow.go create mode 100644 pkg/integrations/jira/create_workflow_test.go create mode 100644 pkg/integrations/jira/example_output_approve_workflow.json create mode 100644 pkg/integrations/jira/example_output_assign_workflow_to_project.json create mode 100644 pkg/integrations/jira/example_output_create_workflow.json create mode 100644 pkg/integrations/jira/example_output_transition_issue.json create mode 100644 pkg/integrations/jira/transition_issue.go create mode 100644 pkg/integrations/jira/transition_issue_test.go create mode 100644 web_src/src/pages/workflowv2/mappers/jira/approve_workflow.spec.ts create mode 100644 web_src/src/pages/workflowv2/mappers/jira/approve_workflow.ts create mode 100644 web_src/src/pages/workflowv2/mappers/jira/assign_workflow_to_project.spec.ts create mode 100644 web_src/src/pages/workflowv2/mappers/jira/assign_workflow_to_project.ts create mode 100644 web_src/src/pages/workflowv2/mappers/jira/create_workflow.spec.ts create mode 100644 web_src/src/pages/workflowv2/mappers/jira/create_workflow.ts create mode 100644 web_src/src/pages/workflowv2/mappers/jira/transition_issue.spec.ts create mode 100644 web_src/src/pages/workflowv2/mappers/jira/transition_issue.ts diff --git a/docs/components/Jira.mdx b/docs/components/Jira.mdx index 481491f0eb..7b3abec890 100644 --- a/docs/components/Jira.mdx +++ b/docs/components/Jira.mdx @@ -9,12 +9,16 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; ## Actions + + + + @@ -28,6 +32,108 @@ To connect Jira to SuperPlane: 4. Paste the Atlassian account **Email** that owns the API token. 5. Paste the generated **API Token**. + + +## Approve Workflow + +The Approve Workflow component approves or declines a Jira Service Management request approval. + +### Use Cases + +- **Automated approval routing**: submit a JSM approval decision after external checks pass +- **Escalation handling**: decline requests when a SuperPlane workflow detects a failed precondition +- **Audit context**: add a customer request comment before submitting the approval decision + +### Configuration + +- **Issue Key**: JSM request issue key, for example `ITSM-123`. +- **Decision**: Approve or decline. +- **Approval Selector**: Choose the latest pending approval or provide an approval id directly. +- **Approval ID**: Required when selecting by id. +- **Comment**: Optional public customer request comment posted before the approval decision. + +### Output + +Returns the updated approval payload from Jira Service Management. + +### Notes + +- Requires the API token's user to be in the approver list. +- This component only works on Jira Service Management customer requests, not standard Jira issues. + +### Example Output + +```json +{ + "data": { + "approvers": [ + { + "approver": { + "accountId": "5b10a2844c20165700ede21g", + "displayName": "Alice Example", + "emailAddress": "alice@example.com" + }, + "approverDecision": "approved" + } + ], + "completedDate": { + "iso8601": "2026-01-19T13:15:00+0000", + "jira": "2026-01-19T13:15:00.000+0000" + }, + "finalDecision": "approved", + "id": "1", + "name": "Manager approval" + }, + "timestamp": "2026-01-19T13:15:00Z", + "type": "jira.approval" +} +``` + + + +## Assign Workflow To Project + +The Assign Workflow To Project component switches a Jira project to an existing workflow scheme. + +### Use Cases + +- **Project provisioning**: apply a known workflow scheme after creating or preparing a Jira project +- **Workflow rollout**: move company-managed projects to an updated workflow scheme +- **Canvas validation**: run in dry-run mode to validate the selected project and scheme without changing Jira + +### Configuration + +- **Project**: Company-managed Jira project to update. +- **Workflow Scheme**: Existing Jira workflow scheme to assign. +- **Dry Run**: Validate inputs and emit the planned assignment without changing Jira. + +### Output + +Returns `projectId`, `workflowSchemeId`, `draftCreated`, and any Jira task metadata returned by the workflow scheme switch. + +### Notes + +- Requires Jira admin permissions (`manage:jira-configuration`). +- Workflow schemes can only be assigned to company-managed projects. Team-managed projects reject workflow scheme changes. +- Jira may start a background task when switching schemes, especially when existing issues need migration. + +### Example Output + +```json +{ + "data": { + "draftCreated": false, + "projectId": "10000", + "taskId": "3f83dg2a-ns2n-56ab-9812-42h5j1461629", + "taskSelf": "https://your-domain.atlassian.net/rest/api/3/task/3f83dg2a-ns2n-56ab-9812-42h5j1461629", + "taskStatus": "ENQUEUED", + "workflowSchemeId": "101010" + }, + "timestamp": "2026-01-19T12:00:00Z", + "type": "jira.workflowScheme.assigned" +} +``` + ## Create Incident @@ -159,6 +265,54 @@ Returns the created issue including: } ``` + + +## Create Workflow + +The Create Workflow component creates a Jira workflow with statuses and transitions. + +### Use Cases + +- **Service request lifecycle**: define a standard request workflow before assigning it through a workflow scheme +- **JSM rollout automation**: create a workflow from a SuperPlane canvas as part of project provisioning +- **Environment parity**: recreate workflow structure across Jira sites + +### Configuration + +- **Name**: Workflow name. +- **Description**: Optional workflow description. +- **Scope**: Global or project-scoped. Project-scoped workflows require a Jira project. +- **Project**: Required when scope is Project. +- **Statuses**: List of statuses with a category: TODO, IN_PROGRESS, or DONE. +- **Transitions**: List of transitions with a target status. Directed transitions use the From status list; Global transitions are available from any status. + +### Output + +Returns the created workflow's `id`, `name`, and `version`. + +### Notes + +- Requires Jira admin permissions (`manage:jira-configuration`). +- Jira creates workflows independently from projects. Use Assign Workflow To Project to apply a workflow scheme to a company-managed project. +- New issues enter the **first listed status**. SuperPlane injects the Jira-required initial transition pointing at that status; the order of the Statuses list determines the starting state. + +### Example Output + +```json +{ + "data": { + "id": "b9ff2384-d3b6-4d4e-9509-3ee19f607168", + "name": "Service request workflow", + "version": { + "id": "f010ac1b-3dd3-43a3-aa66-0ee8a447f76e", + "versionNumber": 1 + } + }, + "timestamp": "2026-01-19T12:00:00Z", + "type": "jira.workflow.created" +} +``` + ## Delete Incident @@ -334,6 +488,68 @@ Returns the Jira issue object including `id`, `key`, `self` and the full `fields } ``` + + +## Transition Issue + +The Transition Issue component moves a Jira issue through its workflow. + +### Use Cases + +- **Automated triage**: move issues into the next workflow status after a SuperPlane event +- **Cross-tool state sync**: mirror status changes from incident or deployment systems +- **Resolution automation**: close issues with a transition-scoped resolution and comment + +### Configuration + +- **Project**: Optional Jira project used to narrow the status picker. +- **Issue Key**: Jira issue key, for example `PROJ-123`. +- **Target Status**: Status to move the issue to. It must be reachable from the issue's current status. +- **Comment**: Optional transition comment. +- **Resolution**: Optional Jira resolution name to set during the transition. + +### Output + +Returns the refreshed Jira issue after the transition. + +### Notes + +- Jira does not allow direct status writes. This component finds an available transition whose target status matches the requested status. +- Workflow conditions and validators still apply. + +### Example Output + +```json +{ + "data": { + "fields": { + "project": { + "id": "10000", + "key": "PROJ", + "name": "Proj" + }, + "resolution": { + "name": "Done" + }, + "status": { + "name": "Done", + "statusCategory": { + "key": "done", + "name": "Done" + } + }, + "summary": "Investigate timeout on checkout flow", + "updated": "2026-01-19T13:00:00.000+0000" + }, + "id": "10001", + "key": "PROJ-123", + "self": "https://your-domain.atlassian.net/rest/api/3/issue/10001" + }, + "timestamp": "2026-01-19T13:00:00Z", + "type": "jira.issue" +} +``` + ## Update Issue diff --git a/pkg/integrations/jira/approve_workflow.go b/pkg/integrations/jira/approve_workflow.go new file mode 100644 index 0000000000..5a6477a823 --- /dev/null +++ b/pkg/integrations/jira/approve_workflow.go @@ -0,0 +1,270 @@ +package jira + +import ( + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +const ApproveWorkflowPayloadType = "jira.approval" + +const ( + approvalSelectorLatestPending = "latestPending" + approvalSelectorByID = "byId" +) + +type ApproveWorkflow struct{} + +type ApproveWorkflowSpec struct { + IssueKey string `json:"issueKey" mapstructure:"issueKey"` + Decision string `json:"decision" mapstructure:"decision"` + ApprovalSelector string `json:"approvalSelector" mapstructure:"approvalSelector"` + ApprovalID string `json:"approvalId" mapstructure:"approvalId"` + Comment string `json:"comment" mapstructure:"comment"` +} + +func (c *ApproveWorkflow) Name() string { + return "jira.approveWorkflow" +} + +func (c *ApproveWorkflow) Label() string { + return "Approve Workflow" +} + +func (c *ApproveWorkflow) Description() string { + return "Approve or decline a Jira Service Management request approval" +} + +func (c *ApproveWorkflow) Documentation() string { + return `The Approve Workflow component approves or declines a Jira Service Management request approval. + +## Use Cases + +- **Automated approval routing**: submit a JSM approval decision after external checks pass +- **Escalation handling**: decline requests when a SuperPlane workflow detects a failed precondition +- **Audit context**: add a customer request comment before submitting the approval decision + +## Configuration + +- **Issue Key**: JSM request issue key, for example ` + "`ITSM-123`" + `. +- **Decision**: Approve or decline. +- **Approval Selector**: Choose the latest pending approval or provide an approval id directly. +- **Approval ID**: Required when selecting by id. +- **Comment**: Optional public customer request comment posted before the approval decision. + +## Output + +Returns the updated approval payload from Jira Service Management. + +## Notes + +- Requires the API token's user to be in the approver list. +- This component only works on Jira Service Management customer requests, not standard Jira issues.` +} + +func (c *ApproveWorkflow) Icon() string { + return "jira" +} + +func (c *ApproveWorkflow) Color() string { + return "green" +} + +func (c *ApproveWorkflow) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *ApproveWorkflow) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "issueKey", + Label: "Issue Key", + Type: configuration.FieldTypeString, + Required: true, + Description: "Jira Service Management request issue key", + Placeholder: "ITSM-123", + }, + { + Name: "decision", + Label: "Decision", + Type: configuration.FieldTypeSelect, + Required: true, + Description: "Approval decision", + Default: "approve", + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Approve", Value: "approve"}, + {Label: "Decline", Value: "decline"}, + }, + }, + }, + }, + { + Name: "approvalSelector", + Label: "Approval Selector", + Type: configuration.FieldTypeSelect, + Required: true, + Description: "How to choose the approval", + Default: approvalSelectorLatestPending, + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Latest pending", Value: approvalSelectorLatestPending}, + {Label: "By ID", Value: approvalSelectorByID}, + }, + }, + }, + }, + { + Name: "approvalId", + Label: "Approval ID", + Type: configuration.FieldTypeString, + Required: false, + Description: "Approval id to decide", + VisibilityConditions: []configuration.VisibilityCondition{ + {Field: "approvalSelector", Values: []string{approvalSelectorByID}}, + }, + RequiredConditions: []configuration.RequiredCondition{ + {Field: "approvalSelector", Values: []string{approvalSelectorByID}}, + }, + }, + { + Name: "comment", + Label: "Comment", + Type: configuration.FieldTypeText, + Required: false, + Description: "Optional public customer request comment to post before the decision", + }, + } +} + +func (c *ApproveWorkflow) Setup(ctx core.SetupContext) error { + spec := ApproveWorkflowSpec{} + if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { + return fmt.Errorf("failed to decode configuration: %v", err) + } + + return validateApproveWorkflowSpec(spec) +} + +func (c *ApproveWorkflow) Execute(ctx core.ExecutionContext) error { + spec := ApproveWorkflowSpec{} + if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { + return fmt.Errorf("failed to decode configuration: %v", err) + } + if err := validateApproveWorkflowSpec(spec); err != nil { + return err + } + + issueKey := strings.TrimSpace(spec.IssueKey) + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %v", err) + } + + request, err := client.GetCustomerRequest(issueKey) + if err != nil { + if strings.Contains(err.Error(), "404") { + return fmt.Errorf("issue %s is not a Jira Service Management request; approvals only work on JSM service requests", issueKey) + } + return fmt.Errorf("failed to load JSM request: %v", err) + } + if strings.TrimSpace(request.ServiceDeskID) == "" { + return fmt.Errorf("issue %s is not a Jira Service Management request; approvals only work on JSM service requests", issueKey) + } + + approvalID, err := c.resolveApprovalID(client, issueKey, spec) + if err != nil { + return err + } + + if comment := strings.TrimSpace(spec.Comment); comment != "" { + // public=true makes the comment visible to the JSM customer alongside the decision. + if err := client.AddCustomerRequestComment(issueKey, comment, true); err != nil && ctx.Logger != nil { + ctx.Logger.Warnf("jira.approveWorkflow: failed to add request comment before approval decision: %v", err) + } + } + + approval, err := client.SubmitApprovalDecision(issueKey, approvalID, strings.TrimSpace(spec.Decision)) + if err != nil { + if strings.Contains(err.Error(), "403") { + return fmt.Errorf("approve a JSM request requires the API token's user to be in the approver list") + } + return fmt.Errorf("failed to submit approval decision: %v", err) + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + ApproveWorkflowPayloadType, + []any{approval}, + ) +} + +func validateApproveWorkflowSpec(spec ApproveWorkflowSpec) error { + if strings.TrimSpace(spec.IssueKey) == "" { + return fmt.Errorf("issueKey is required") + } + decision := strings.ToLower(strings.TrimSpace(spec.Decision)) + if decision != "approve" && decision != "decline" { + return fmt.Errorf("decision must be approve or decline") + } + selector := normalizeApprovalSelector(spec.ApprovalSelector) + if selector == approvalSelectorByID && strings.TrimSpace(spec.ApprovalID) == "" { + return fmt.Errorf("approvalId is required when approvalSelector is byId") + } + return nil +} + +func normalizeApprovalSelector(selector string) string { + if strings.TrimSpace(selector) == approvalSelectorByID { + return approvalSelectorByID + } + return approvalSelectorLatestPending +} + +func (c *ApproveWorkflow) resolveApprovalID(client *Client, issueKey string, spec ApproveWorkflowSpec) (string, error) { + if normalizeApprovalSelector(spec.ApprovalSelector) == approvalSelectorByID { + return strings.TrimSpace(spec.ApprovalID), nil + } + + approvals, err := client.ListApprovals(issueKey) + if err != nil { + return "", fmt.Errorf("failed to list approvals: %v", err) + } + for _, approval := range approvals { + if strings.EqualFold(strings.TrimSpace(approval.FinalDecision), "PENDING") { + return approval.ID.String(), nil + } + } + return "", fmt.Errorf("no pending approval found for %s", issueKey) +} + +func (c *ApproveWorkflow) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *ApproveWorkflow) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *ApproveWorkflow) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { + return http.StatusOK, nil, nil +} + +func (c *ApproveWorkflow) Cleanup(ctx core.SetupContext) error { + return nil +} + +func (c *ApproveWorkflow) Hooks() []core.Hook { + return []core.Hook{} +} + +func (c *ApproveWorkflow) HandleHook(ctx core.ActionHookContext) error { + return nil +} diff --git a/pkg/integrations/jira/approve_workflow_test.go b/pkg/integrations/jira/approve_workflow_test.go new file mode 100644 index 0000000000..323a3bd3a3 --- /dev/null +++ b/pkg/integrations/jira/approve_workflow_test.go @@ -0,0 +1,177 @@ +package jira + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__ApproveWorkflow__Setup(t *testing.T) { + component := ApproveWorkflow{} + + t.Run("missing issue key -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{"decision": "approve"}, + }) + + require.ErrorContains(t, err, "issueKey is required") + }) + + t.Run("invalid decision -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{"issueKey": "ITSM-1", "decision": "hold"}, + }) + + require.ErrorContains(t, err, "decision must be approve or decline") + }) + + t.Run("approval id is required when selector is byId", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "issueKey": "ITSM-1", + "decision": "approve", + "approvalSelector": approvalSelectorByID, + }, + }) + + require.ErrorContains(t, err, "approvalId is required") + }) +} + +func Test__ApproveWorkflow__Execute(t *testing.T) { + component := ApproveWorkflow{} + + t.Run("approves latest pending approval", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"issueKey":"ITSM-1","serviceDeskId":"1","requestTypeId":"10"}`)), + }, + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"values":[{"id":"1","name":"Old","finalDecision":"approved"},{"id":"2","name":"Manager","finalDecision":"PENDING"}],"isLastPage":true}`)), + }, + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"id":"2","name":"Manager","finalDecision":"approved"}`)), + }, + }, + } + + execCtx := &contexts.ExecutionStateContext{} + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "issueKey": "ITSM-1", + "decision": "approve", + "approvalSelector": approvalSelectorLatestPending, + }, + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + ExecutionState: execCtx, + }) + + require.NoError(t, err) + assert.True(t, execCtx.Passed) + assert.Equal(t, ApproveWorkflowPayloadType, execCtx.Type) + require.Len(t, httpContext.Requests, 3) + assert.Contains(t, httpContext.Requests[2].URL.String(), "/rest/servicedeskapi/request/ITSM-1/approval/2") + + body, err := io.ReadAll(httpContext.Requests[2].Body) + require.NoError(t, err) + var payload map[string]string + require.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, "approve", payload["decision"]) + }) + + t.Run("no pending approval -> error", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"issueKey":"ITSM-1","serviceDeskId":"1"}`)), + }, + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"values":[{"id":"1","finalDecision":"approved"}],"isLastPage":true}`)), + }, + }, + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "issueKey": "ITSM-1", + "decision": "approve", + "approvalSelector": approvalSelectorLatestPending, + }, + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + ExecutionState: &contexts.ExecutionStateContext{}, + }) + + require.ErrorContains(t, err, "no pending approval") + }) + + t.Run("permission failure explains approver requirement", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"issueKey":"ITSM-1","serviceDeskId":"1"}`)), + }, + { + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader(`{"errorMessage":"forbidden"}`)), + }, + }, + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "issueKey": "ITSM-1", + "decision": "decline", + "approvalSelector": approvalSelectorByID, + "approvalId": "2", + }, + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + ExecutionState: &contexts.ExecutionStateContext{}, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "approver list") + }) + + t.Run("standard Jira issue is rejected", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader(`{"errorMessage":"not found"}`)), + }, + }, + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "issueKey": "PROJ-1", + "decision": "approve", + "approvalSelector": approvalSelectorByID, + "approvalId": "2", + }, + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + ExecutionState: &contexts.ExecutionStateContext{}, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "Jira Service Management request") + }) +} diff --git a/pkg/integrations/jira/assign_workflow_to_project.go b/pkg/integrations/jira/assign_workflow_to_project.go new file mode 100644 index 0000000000..ee96656c85 --- /dev/null +++ b/pkg/integrations/jira/assign_workflow_to_project.go @@ -0,0 +1,278 @@ +package jira + +import ( + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +const AssignWorkflowToProjectPayloadType = "jira.workflowScheme.assigned" + +type AssignWorkflowToProject struct{} + +type AssignWorkflowToProjectSpec struct { + Project string `json:"project" mapstructure:"project"` + WorkflowScheme string `json:"workflowScheme" mapstructure:"workflowScheme"` + DryRun bool `json:"dryRun" mapstructure:"dryRun"` +} + +type WorkflowSchemeAssignmentOutput struct { + ProjectID string `json:"projectId"` + WorkflowSchemeID string `json:"workflowSchemeId"` + DraftCreated bool `json:"draftCreated"` + DryRun bool `json:"dryRun,omitempty"` + TaskID string `json:"taskId,omitempty"` + TaskStatus string `json:"taskStatus,omitempty"` + TaskSelf string `json:"taskSelf,omitempty"` +} + +func (c *AssignWorkflowToProject) Name() string { + return "jira.assignWorkflowToProject" +} + +func (c *AssignWorkflowToProject) Label() string { + return "Assign Workflow To Project" +} + +func (c *AssignWorkflowToProject) Description() string { + return "Assign a Jira workflow scheme to a company-managed project" +} + +func (c *AssignWorkflowToProject) Documentation() string { + return `The Assign Workflow To Project component switches a Jira project to an existing workflow scheme. + +## Use Cases + +- **Project provisioning**: apply a known workflow scheme after creating or preparing a Jira project +- **Workflow rollout**: move company-managed projects to an updated workflow scheme +- **Canvas validation**: run in dry-run mode to validate the selected project and scheme without changing Jira + +## Configuration + +- **Project**: Company-managed Jira project to update. +- **Workflow Scheme**: Existing Jira workflow scheme to assign. +- **Dry Run**: Validate inputs and emit the planned assignment without changing Jira. + +## Output + +Returns ` + "`projectId`" + `, ` + "`workflowSchemeId`" + `, ` + "`draftCreated`" + `, and any Jira task metadata returned by the workflow scheme switch. + +## Notes + +- Requires Jira admin permissions (` + "`manage:jira-configuration`" + `). +- Workflow schemes can only be assigned to company-managed projects. Team-managed projects reject workflow scheme changes. +- Jira may start a background task when switching schemes, especially when existing issues need migration.` +} + +func (c *AssignWorkflowToProject) Icon() string { + return "jira" +} + +func (c *AssignWorkflowToProject) Color() string { + return "blue" +} + +func (c *AssignWorkflowToProject) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *AssignWorkflowToProject) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "project", + Label: "Project", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "Company-managed Jira project", + Placeholder: "Select a project", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{Type: "project"}, + }, + }, + { + Name: "workflowScheme", + Label: "Workflow Scheme", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "Existing Jira workflow scheme", + Placeholder: "Select a workflow scheme", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{Type: "workflowScheme"}, + }, + }, + { + Name: "dryRun", + Label: "Dry Run", + Type: configuration.FieldTypeBool, + Required: false, + Description: "Validate the assignment without changing Jira", + Default: false, + }, + } +} + +func (c *AssignWorkflowToProject) Setup(ctx core.SetupContext) error { + spec := AssignWorkflowToProjectSpec{} + if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { + return fmt.Errorf("failed to decode configuration: %v", err) + } + + if strings.TrimSpace(spec.Project) == "" { + return fmt.Errorf("project is required") + } + if strings.TrimSpace(spec.WorkflowScheme) == "" { + return fmt.Errorf("workflowScheme is required") + } + + project, scheme, err := loadWorkflowSchemeAssignmentSetup(ctx.HTTP, ctx.Integration, spec.Project, spec.WorkflowScheme) + if err != nil { + return err + } + + return ctx.Metadata.Set(NodeMetadata{Project: project, WorkflowScheme: scheme}) +} + +func (c *AssignWorkflowToProject) Execute(ctx core.ExecutionContext) error { + spec := AssignWorkflowToProjectSpec{} + if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { + return fmt.Errorf("failed to decode configuration: %v", err) + } + + projectKey := strings.TrimSpace(spec.Project) + schemeID := strings.TrimSpace(spec.WorkflowScheme) + if projectKey == "" { + return fmt.Errorf("project is required") + } + if schemeID == "" { + return fmt.Errorf("workflowScheme is required") + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %v", err) + } + + project, err := client.GetProject(projectKey) + if err != nil { + return fmt.Errorf("failed to fetch project: %v", err) + } + if isTeamManagedProject(project) { + return fmt.Errorf("workflow schemes can only be assigned to company-managed projects") + } + + if spec.DryRun { + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + AssignWorkflowToProjectPayloadType, + []any{WorkflowSchemeAssignmentOutput{ + ProjectID: project.ID, + WorkflowSchemeID: schemeID, + DraftCreated: false, + DryRun: true, + }}, + ) + } + + resp, err := client.AssignWorkflowSchemeToProject(project.ID, schemeID) + if err != nil { + if strings.Contains(err.Error(), "403") { + return fmt.Errorf("failed to assign workflow scheme: %v — the API token must belong to a Jira admin", err) + } + return fmt.Errorf("failed to assign workflow scheme: %v", err) + } + + output := WorkflowSchemeAssignmentOutput{ + ProjectID: resp.ProjectID, + WorkflowSchemeID: resp.WorkflowSchemeID, + DraftCreated: false, + } + if resp.Task != nil { + output.TaskID = resp.Task.ID.String() + output.TaskStatus = resp.Task.Status + output.TaskSelf = resp.Task.Self + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + AssignWorkflowToProjectPayloadType, + []any{output}, + ) +} + +func loadWorkflowSchemeAssignmentSetup( + httpCtx core.HTTPContext, + integration core.IntegrationContext, + projectKey, + schemeID string, +) (*Project, *WorkflowScheme, error) { + projectKey = strings.TrimSpace(projectKey) + schemeID = strings.TrimSpace(schemeID) + + if httpCtx == nil { + project, err := requireProjectFromMetadata(integration, projectKey) + return project, &WorkflowScheme{ID: FlexibleString(schemeID)}, err + } + + client, err := NewClient(httpCtx, integration) + if err != nil { + return nil, nil, fmt.Errorf("failed to create client: %v", err) + } + + project, err := client.GetProject(projectKey) + if err != nil { + return nil, nil, fmt.Errorf("failed to fetch project: %v", err) + } + if isTeamManagedProject(project) { + return nil, nil, fmt.Errorf("workflow schemes can only be assigned to company-managed projects") + } + + schemes, err := client.ListWorkflowSchemes() + if err != nil { + return nil, nil, fmt.Errorf("failed to list workflow schemes: %v", err) + } + for _, scheme := range schemes { + if scheme.ID.String() == schemeID { + s := scheme + return project, &s, nil + } + } + + return nil, nil, fmt.Errorf("workflow scheme %s not found", schemeID) +} + +func isTeamManagedProject(project *Project) bool { + if project == nil { + return false + } + style := strings.ToLower(strings.TrimSpace(project.Style)) + return project.Simplified || style == "next-gen" || style == "nextgen" || style == "team-managed" +} + +func (c *AssignWorkflowToProject) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *AssignWorkflowToProject) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *AssignWorkflowToProject) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { + return http.StatusOK, nil, nil +} + +func (c *AssignWorkflowToProject) Cleanup(ctx core.SetupContext) error { + return nil +} + +func (c *AssignWorkflowToProject) Hooks() []core.Hook { + return []core.Hook{} +} + +func (c *AssignWorkflowToProject) HandleHook(ctx core.ActionHookContext) error { + return nil +} diff --git a/pkg/integrations/jira/assign_workflow_to_project_test.go b/pkg/integrations/jira/assign_workflow_to_project_test.go new file mode 100644 index 0000000000..4258eb1826 --- /dev/null +++ b/pkg/integrations/jira/assign_workflow_to_project_test.go @@ -0,0 +1,149 @@ +package jira + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__AssignWorkflowToProject__Setup(t *testing.T) { + component := AssignWorkflowToProject{} + + t.Run("missing workflow scheme -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Integration: newAuthorizedIntegration(), + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"project": "TEST"}, + }) + + require.ErrorContains(t, err, "workflowScheme is required") + }) + + t.Run("team-managed project -> clear error", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"id":"10000","key":"TEAM","name":"Team","style":"next-gen","simplified":true}`)), + }, + }, + } + + err := component.Setup(core.SetupContext{ + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"project": "TEAM", "workflowScheme": "101010"}, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "company-managed projects") + }) + + t.Run("valid setup stores workflow scheme metadata", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"id":"10000","key":"TEST","name":"Test","style":"classic","simplified":false}`)), + }, + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"isLast":true,"values":[{"id":101010,"name":"Support scheme"}]}`)), + }, + }, + } + metadataCtx := &contexts.MetadataContext{} + + err := component.Setup(core.SetupContext{ + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + Metadata: metadataCtx, + Configuration: map[string]any{"project": "TEST", "workflowScheme": "101010"}, + }) + + require.NoError(t, err) + nodeMetadata, ok := metadataCtx.Metadata.(NodeMetadata) + require.True(t, ok) + require.NotNil(t, nodeMetadata.Project) + require.NotNil(t, nodeMetadata.WorkflowScheme) + assert.Equal(t, "Support scheme", nodeMetadata.WorkflowScheme.Name) + }) +} + +func Test__AssignWorkflowToProject__Execute(t *testing.T) { + component := AssignWorkflowToProject{} + + t.Run("switches workflow scheme and emits task metadata", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"id":"10000","key":"TEST","name":"Test","style":"classic","simplified":false}`)), + }, + { + StatusCode: http.StatusSeeOther, + Body: io.NopCloser(strings.NewReader(`{"id":"task-1","status":"ENQUEUED","self":"https://test.atlassian.net/rest/api/3/task/task-1"}`)), + }, + }, + } + + execCtx := &contexts.ExecutionStateContext{} + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "project": "TEST", + "workflowScheme": "101010", + }, + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + ExecutionState: execCtx, + }) + + require.NoError(t, err) + assert.True(t, execCtx.Passed) + assert.Equal(t, AssignWorkflowToProjectPayloadType, execCtx.Type) + require.Len(t, httpContext.Requests, 2) + assert.Contains(t, httpContext.Requests[1].URL.String(), "/rest/api/3/workflowscheme/project/switch") + + body, err := io.ReadAll(httpContext.Requests[1].Body) + require.NoError(t, err) + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, "10000", payload["projectId"]) + assert.Equal(t, "101010", payload["targetSchemeId"]) + }) + + t.Run("dry run skips scheme switch", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"id":"10000","key":"TEST","name":"Test","style":"classic","simplified":false}`)), + }, + }, + } + + execCtx := &contexts.ExecutionStateContext{} + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "project": "TEST", + "workflowScheme": "101010", + "dryRun": true, + }, + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + ExecutionState: execCtx, + }) + + require.NoError(t, err) + assert.True(t, execCtx.Passed) + require.Len(t, httpContext.Requests, 1) + }) +} diff --git a/pkg/integrations/jira/client.go b/pkg/integrations/jira/client.go index 1c42bebdf0..5324184ba7 100644 --- a/pkg/integrations/jira/client.go +++ b/pkg/integrations/jira/client.go @@ -114,9 +114,11 @@ func (c *Client) GetCurrentUser() (*User, error) { } type Project struct { - ID string `json:"id"` - Key string `json:"key"` - Name string `json:"name"` + ID string `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + Style string `json:"style,omitempty"` + Simplified bool `json:"simplified,omitempty"` } func (c *Client) ListProjects() ([]Project, error) { @@ -132,6 +134,21 @@ func (c *Client) ListProjects() ([]Project, error) { return projects, nil } +func (c *Client) GetProject(projectKey string) (*Project, error) { + endpoint := c.apiURL("/rest/api/3/project/" + url.PathEscape(projectKey)) + + body, err := c.execRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var project Project + if err := json.Unmarshal(body, &project); err != nil { + return nil, fmt.Errorf("error parsing project response: %v", err) + } + return &project, nil +} + type IssueTypeMeta struct { ID string `json:"id"` Name string `json:"name"` @@ -227,14 +244,21 @@ func (c *Client) GetIssueTransitions(issueKey string) ([]Transition, error) { return resp.Transitions, nil } -type doTransitionRequest struct { - Transition transitionID `json:"transition"` -} - type transitionID struct { ID string `json:"id"` } +type DoTransitionOptions struct { + Comment string + Resolution string +} + +type doTransitionRequest struct { + Transition transitionID `json:"transition"` + Fields map[string]any `json:"fields,omitempty"` + Update map[string]any `json:"update,omitempty"` +} + // ListAssignableUsers returns the users assignable to issues in a given // project. /rest/api/3/user/assignable/search is paginated; we cap at 50 // entries, which matches the picker's practical UX. @@ -276,11 +300,54 @@ func (c *Client) ListPriorities() ([]Priority, error) { return priorities, nil } +type Resolution struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// ListResolutions returns all resolutions configured on the Jira site. +// Resolutions are instance-level, not project-scoped. +func (c *Client) ListResolutions() ([]Resolution, error) { + body, err := c.execRequest(http.MethodGet, c.apiURL("/rest/api/3/resolution"), nil) + if err != nil { + return nil, err + } + + var resolutions []Resolution + if err := json.Unmarshal(body, &resolutions); err != nil { + return nil, fmt.Errorf("error parsing resolutions response: %v", err) + } + return resolutions, nil +} + // DoTransition advances an issue along the given workflow transition. func (c *Client) DoTransition(issueKey, id string) error { + return c.DoTransitionWithOptions(issueKey, id, DoTransitionOptions{}) +} + +// DoTransitionWithOptions advances an issue and optionally applies transition-scoped fields. +func (c *Client) DoTransitionWithOptions(issueKey, id string, opts DoTransitionOptions) error { endpoint := c.apiURL("/rest/api/3/issue/" + url.PathEscape(issueKey) + "/transitions") - body, err := json.Marshal(doTransitionRequest{Transition: transitionID{ID: id}}) + req := doTransitionRequest{Transition: transitionID{ID: id}} + if resolution := strings.TrimSpace(opts.Resolution); resolution != "" { + req.Fields = map[string]any{ + "resolution": map[string]any{"name": resolution}, + } + } + if comment := strings.TrimSpace(opts.Comment); comment != "" { + req.Update = map[string]any{ + "comment": []map[string]any{ + { + "add": map[string]any{ + "body": WrapInADF(comment), + }, + }, + }, + } + } + + body, err := json.Marshal(req) if err != nil { return fmt.Errorf("error marshaling transition request: %v", err) } @@ -291,6 +358,234 @@ func (c *Client) DoTransition(issueKey, id string) error { return nil } +type FlexibleString string + +func (s *FlexibleString) UnmarshalJSON(b []byte) error { + raw := strings.TrimSpace(string(b)) + if raw == "" || raw == "null" { + *s = "" + return nil + } + + var str string + if err := json.Unmarshal(b, &str); err == nil { + *s = FlexibleString(str) + return nil + } + + *s = FlexibleString(raw) + return nil +} + +func (s FlexibleString) String() string { + return string(s) +} + +type WorkflowScope struct { + Type string `json:"type"` + Project *WorkflowScopeProjectRef `json:"project,omitempty"` +} + +type WorkflowScopeProjectRef struct { + ID string `json:"id"` +} + +type WorkflowLayout struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +type WorkflowStatusUpdate struct { + Description string `json:"description"` + Name string `json:"name"` + StatusCategory string `json:"statusCategory"` + StatusReference string `json:"statusReference"` +} + +type WorkflowCreateStatus struct { + Layout WorkflowLayout `json:"layout"` + Properties map[string]any `json:"properties"` + StatusReference string `json:"statusReference"` +} + +type WorkflowTransitionLink struct { + FromPort int `json:"fromPort"` + FromStatusReference string `json:"fromStatusReference"` + ToPort int `json:"toPort"` +} + +type WorkflowCreateTransition struct { + Actions []any `json:"actions"` + Description string `json:"description"` + ID string `json:"id"` + Links []WorkflowTransitionLink `json:"links"` + Name string `json:"name"` + Properties map[string]any `json:"properties"` + ToStatusReference string `json:"toStatusReference"` + Triggers []any `json:"triggers"` + Type string `json:"type"` + Validators []any `json:"validators"` +} + +type WorkflowCreate struct { + Description string `json:"description"` + Name string `json:"name"` + StartPointLayout WorkflowLayout `json:"startPointLayout"` + Statuses []WorkflowCreateStatus `json:"statuses"` + Transitions []WorkflowCreateTransition `json:"transitions"` +} + +type CreateWorkflowRequest struct { + Scope WorkflowScope `json:"scope"` + Statuses []WorkflowStatusUpdate `json:"statuses"` + Workflows []WorkflowCreate `json:"workflows"` +} + +type WorkflowVersion struct { + ID string `json:"id"` + VersionNumber int `json:"versionNumber"` +} + +type CreatedWorkflow struct { + Description string `json:"description,omitempty"` + ID string `json:"id"` + IsEditable bool `json:"isEditable,omitempty"` + Name string `json:"name"` + Scope WorkflowScope `json:"scope"` + Version WorkflowVersion `json:"version"` +} + +type CreateWorkflowResponse struct { + Statuses []WorkflowStatusUpdate `json:"statuses"` + Workflows []CreatedWorkflow `json:"workflows"` +} + +func (c *Client) CreateWorkflow(req *CreateWorkflowRequest) (*CreateWorkflowResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("error marshaling workflow create request: %v", err) + } + + responseBody, err := c.execRequest(http.MethodPost, c.apiURL("/rest/api/3/workflows/create"), bytes.NewReader(body)) + if err != nil { + return nil, err + } + + var response CreateWorkflowResponse + if err := json.Unmarshal(responseBody, &response); err != nil { + return nil, fmt.Errorf("error parsing workflow create response: %v", err) + } + return &response, nil +} + +type WorkflowScheme struct { + ID FlexibleString `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + DefaultWorkflow string `json:"defaultWorkflow,omitempty"` + Draft bool `json:"draft,omitempty"` + Self string `json:"self,omitempty"` +} + +type workflowSchemesPage struct { + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Total int `json:"total"` + IsLast bool `json:"isLast"` + Values []WorkflowScheme `json:"values"` +} + +func (c *Client) ListWorkflowSchemes() ([]WorkflowScheme, error) { + var out []WorkflowScheme + startAt := 0 + const pageSize = 50 + + for range 20 { + query := url.Values{} + query.Set("startAt", strconv.Itoa(startAt)) + query.Set("maxResults", strconv.Itoa(pageSize)) + endpoint := c.apiURL("/rest/api/3/workflowscheme?" + query.Encode()) + + body, err := c.execRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var page workflowSchemesPage + if err := json.Unmarshal(body, &page); err != nil { + return nil, fmt.Errorf("error parsing workflow schemes response: %v", err) + } + + out = append(out, page.Values...) + if page.IsLast || len(page.Values) == 0 { + break + } + startAt += len(page.Values) + if page.Total > 0 && startAt >= page.Total { + break + } + } + + return out, nil +} + +type WorkflowSchemeAssignmentResponse struct { + ProjectID string + WorkflowSchemeID string + Task *TaskProgress +} + +type TaskProgress struct { + ID FlexibleString `json:"id"` + Self string `json:"self,omitempty"` + Status string `json:"status,omitempty"` + Message string `json:"message,omitempty"` + Progress int `json:"progress,omitempty"` +} + +type assignWorkflowSchemeRequest struct { + ProjectID string `json:"projectId"` + TargetSchemeID string `json:"targetSchemeId"` + MappingsByIssueTypeOverrides []any `json:"mappingsByIssueTypeOverride,omitempty"` +} + +// AssignWorkflowSchemeToProject switches the workflow scheme for a classic Jira project. +func (c *Client) AssignWorkflowSchemeToProject(projectID, schemeID string) (*WorkflowSchemeAssignmentResponse, error) { + req := assignWorkflowSchemeRequest{ + ProjectID: projectID, + TargetSchemeID: schemeID, + MappingsByIssueTypeOverrides: []any{}, + } + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("error marshaling workflow scheme assignment request: %v", err) + } + + endpoint := c.apiURL("/rest/api/3/workflowscheme/project/switch") + responseBody, status, err := c.execRequestWithStatus(http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + if status < 200 || (status >= 300 && status != http.StatusSeeOther) { + return nil, fmt.Errorf("request got %d code: %s", status, string(responseBody)) + } + + out := &WorkflowSchemeAssignmentResponse{ + ProjectID: projectID, + WorkflowSchemeID: schemeID, + } + if len(strings.TrimSpace(string(responseBody))) == 0 { + return out, nil + } + + var task TaskProgress + if err := json.Unmarshal(responseBody, &task); err != nil { + return nil, fmt.Errorf("error parsing workflow scheme assignment response: %v", err) + } + out.Task = &task + return out, nil +} + type Issue struct { ID string `json:"id"` Key string `json:"key"` @@ -642,6 +937,123 @@ func (c *Client) ListCustomerRequestsByServiceDesk(serviceDeskID string, maxTota return out, nil } +// CustomerRequest is returned by GET /rest/servicedeskapi/request/{issueIdOrKey}. +type CustomerRequest struct { + IssueID string `json:"issueId,omitempty"` + IssueKey string `json:"issueKey,omitempty"` + ServiceDeskID string `json:"serviceDeskId,omitempty"` + RequestTypeID string `json:"requestTypeId,omitempty"` +} + +func (c *Client) GetCustomerRequest(issueKey string) (*CustomerRequest, error) { + base := strings.TrimSuffix(c.SiteURL, "/") + u := fmt.Sprintf("%s/rest/servicedeskapi/request/%s", base, url.PathEscape(issueKey)) + responseBody, err := c.execRequest(http.MethodGet, u, nil) + if err != nil { + return nil, err + } + + var out CustomerRequest + if err := json.Unmarshal(responseBody, &out); err != nil { + return nil, fmt.Errorf("parse customer request: %w", err) + } + return &out, nil +} + +type Approval struct { + ID FlexibleString `json:"id"` + Name string `json:"name,omitempty"` + FinalDecision string `json:"finalDecision,omitempty"` + Approvers []Approver `json:"approvers,omitempty"` + CreatedDate map[string]any `json:"createdDate,omitempty"` + CompletedDate map[string]any `json:"completedDate,omitempty"` + Links map[string]any `json:"_links,omitempty"` +} + +type Approver struct { + Approver User `json:"approver,omitempty"` + ApproverDecision string `json:"approverDecision,omitempty"` +} + +type approvalsPage struct { + Values []Approval `json:"values"` + IsLastPage bool `json:"isLastPage"` +} + +func (c *Client) ListApprovals(issueKey string) ([]Approval, error) { + base := strings.TrimSuffix(c.SiteURL, "/") + var out []Approval + start := 0 + const pageSize = 50 + + for range 20 { + query := url.Values{} + query.Set("start", strconv.Itoa(start)) + query.Set("limit", strconv.Itoa(pageSize)) + u := fmt.Sprintf("%s/rest/servicedeskapi/request/%s/approval?%s", base, url.PathEscape(issueKey), query.Encode()) + + responseBody, err := c.execRequest(http.MethodGet, u, nil) + if err != nil { + return nil, err + } + + var page approvalsPage + if err := json.Unmarshal(responseBody, &page); err != nil { + return nil, fmt.Errorf("parse approvals: %w", err) + } + + out = append(out, page.Values...) + if page.IsLastPage || len(page.Values) == 0 { + break + } + start += len(page.Values) + } + + return out, nil +} + +func (c *Client) SubmitApprovalDecision(issueKey, approvalID, decision string) (*Approval, error) { + base := strings.TrimSuffix(c.SiteURL, "/") + u := fmt.Sprintf( + "%s/rest/servicedeskapi/request/%s/approval/%s", + base, + url.PathEscape(issueKey), + url.PathEscape(approvalID), + ) + + body, err := json.Marshal(map[string]string{"decision": strings.ToLower(strings.TrimSpace(decision))}) + if err != nil { + return nil, fmt.Errorf("marshal approval decision: %w", err) + } + + responseBody, err := c.execRequest(http.MethodPost, u, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + var out Approval + if err := json.Unmarshal(responseBody, &out); err != nil { + return nil, fmt.Errorf("parse approval decision response: %w", err) + } + return &out, nil +} + +func (c *Client) AddCustomerRequestComment(issueKey, body string, public bool) error { + base := strings.TrimSuffix(c.SiteURL, "/") + u := fmt.Sprintf("%s/rest/servicedeskapi/request/%s/comment", base, url.PathEscape(issueKey)) + + requestBody, err := json.Marshal(map[string]any{ + "body": body, + "public": public, + }) + if err != nil { + return fmt.Errorf("marshal customer request comment: %w", err) + } + + _, err = c.execRequest(http.MethodPost, u, bytes.NewReader(requestBody)) + return err +} + func jqlQuotedProjectKey(projectKey string) string { escaped := strings.ReplaceAll(projectKey, `\`, `\\`) return strings.ReplaceAll(escaped, `"`, `\"`) diff --git a/pkg/integrations/jira/common.go b/pkg/integrations/jira/common.go index b7e0ba007c..f6f1d42fde 100644 --- a/pkg/integrations/jira/common.go +++ b/pkg/integrations/jira/common.go @@ -2,6 +2,7 @@ package jira import ( "fmt" + "strings" "github.com/mitchellh/mapstructure" "github.com/superplanehq/superplane/pkg/core" @@ -9,9 +10,11 @@ import ( // NodeMetadata stores metadata on action component nodes. type NodeMetadata struct { - Project *Project `json:"project,omitempty"` - IssueType string `json:"issueType,omitempty"` - Status string `json:"status,omitempty"` + Project *Project `json:"project,omitempty"` + IssueType string `json:"issueType,omitempty"` + Status string `json:"status,omitempty"` + WorkflowName string `json:"workflowName,omitempty"` + WorkflowScheme *WorkflowScheme `json:"workflowScheme,omitempty"` } func requireProject(httpCtx core.HTTPContext, integration core.IntegrationContext, projectKey string) (*Project, error) { @@ -66,3 +69,29 @@ func cloudIDFromIntegration(integration core.IntegrationContext) (string, error) } return meta.CloudID, nil } + +// applyStatus moves an issue to the requested status. It looks up available +// transitions from the issue's current state and executes the one whose target +// status name matches. Returns an error if no such transition exists. +func applyStatus(client *Client, issueKey, status string) error { + return applyStatusWithOptions(client, issueKey, status, DoTransitionOptions{}) +} + +func applyStatusWithOptions(client *Client, issueKey, status string, opts DoTransitionOptions) error { + transitions, err := client.GetIssueTransitions(issueKey) + if err != nil { + return fmt.Errorf("failed to fetch transitions: %v", err) + } + + for _, t := range transitions { + if strings.EqualFold(t.To.Name, status) { + return client.DoTransitionWithOptions(issueKey, t.ID, opts) + } + } + + available := make([]string, 0, len(transitions)) + for _, t := range transitions { + available = append(available, t.To.Name) + } + return fmt.Errorf("no transition available to status %q (available: %v)", status, available) +} diff --git a/pkg/integrations/jira/common_test.go b/pkg/integrations/jira/common_test.go new file mode 100644 index 0000000000..fa377ac1f4 --- /dev/null +++ b/pkg/integrations/jira/common_test.go @@ -0,0 +1,67 @@ +package jira + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__applyStatusWithOptions(t *testing.T) { + t.Run("posts transition body with comment and resolution", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"transitions":[{"id":"31","name":"Resolve","to":{"id":"10003","name":"Done"}}]}`)), + }, + { + StatusCode: http.StatusNoContent, + Body: io.NopCloser(strings.NewReader(``)), + }, + }, + } + client, err := NewClient(httpContext, newAuthorizedIntegration()) + require.NoError(t, err) + + err = applyStatusWithOptions(client, "TEST-1", "Done", DoTransitionOptions{ + Comment: "Ship it", + Resolution: "Done", + }) + + require.NoError(t, err) + require.Len(t, httpContext.Requests, 2) + + body, err := io.ReadAll(httpContext.Requests[1].Body) + require.NoError(t, err) + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, "31", payload["transition"].(map[string]any)["id"]) + assert.Equal(t, "Done", payload["fields"].(map[string]any)["resolution"].(map[string]any)["name"]) + assert.Contains(t, payload["update"].(map[string]any), "comment") + }) + + t.Run("returns helpful error when target status is unreachable", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"transitions":[{"id":"21","name":"Start","to":{"id":"10002","name":"In Progress"}}]}`)), + }, + }, + } + client, err := NewClient(httpContext, newAuthorizedIntegration()) + require.NoError(t, err) + + err = applyStatusWithOptions(client, "TEST-1", "Done", DoTransitionOptions{}) + + require.Error(t, err) + assert.Contains(t, err.Error(), `"Done"`) + assert.Contains(t, err.Error(), "In Progress") + }) +} diff --git a/pkg/integrations/jira/create_issue.go b/pkg/integrations/jira/create_issue.go index 283ce7ff94..533cd41b8d 100644 --- a/pkg/integrations/jira/create_issue.go +++ b/pkg/integrations/jira/create_issue.go @@ -243,28 +243,6 @@ func (c *CreateIssue) Execute(ctx core.ExecutionContext) error { ) } -// applyStatus moves an issue to the requested status. It looks up available -// transitions from the issue's current state and executes the one whose target -// status name matches. Returns an error if no such transition exists. -func applyStatus(client *Client, issueKey, status string) error { - transitions, err := client.GetIssueTransitions(issueKey) - if err != nil { - return fmt.Errorf("failed to fetch transitions: %v", err) - } - - for _, t := range transitions { - if strings.EqualFold(t.To.Name, status) { - return client.DoTransition(issueKey, t.ID) - } - } - - available := make([]string, 0, len(transitions)) - for _, t := range transitions { - available = append(available, t.To.Name) - } - return fmt.Errorf("no transition available to status %q (available: %v)", status, available) -} - func (c *CreateIssue) Cancel(ctx core.ExecutionContext) error { return nil } diff --git a/pkg/integrations/jira/create_workflow.go b/pkg/integrations/jira/create_workflow.go new file mode 100644 index 0000000000..9cf704b9e4 --- /dev/null +++ b/pkg/integrations/jira/create_workflow.go @@ -0,0 +1,543 @@ +package jira + +import ( + "fmt" + "net/http" + "slices" + "strconv" + "strings" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +const CreateWorkflowPayloadType = "jira.workflow.created" + +const ( + workflowScopeGlobal = "GLOBAL" + workflowScopeProject = "PROJECT" +) + +type CreateWorkflow struct{} + +type CreateWorkflowSpec struct { + Name string `json:"name" mapstructure:"name"` + Description string `json:"description" mapstructure:"description"` + Scope string `json:"scope" mapstructure:"scope"` + Project string `json:"project" mapstructure:"project"` + Statuses []WorkflowStatusSpec `json:"statuses" mapstructure:"statuses"` + Transitions []WorkflowTransitionSpec `json:"transitions" mapstructure:"transitions"` +} + +type WorkflowStatusSpec struct { + Name string `json:"name" mapstructure:"name"` + Category string `json:"category" mapstructure:"category"` +} + +type WorkflowTransitionSpec struct { + Name string `json:"name" mapstructure:"name"` + From []string `json:"from" mapstructure:"from"` + To string `json:"to" mapstructure:"to"` + Type string `json:"type" mapstructure:"type"` +} + +type CreateWorkflowOutput struct { + ID string `json:"id"` + Name string `json:"name"` + Version WorkflowVersion `json:"version"` +} + +func (c *CreateWorkflow) Name() string { + return "jira.createWorkflow" +} + +func (c *CreateWorkflow) Label() string { + return "Create Workflow" +} + +func (c *CreateWorkflow) Description() string { + return "Create a Jira workflow" +} + +func (c *CreateWorkflow) Documentation() string { + return `The Create Workflow component creates a Jira workflow with statuses and transitions. + +## Use Cases + +- **Service request lifecycle**: define a standard request workflow before assigning it through a workflow scheme +- **JSM rollout automation**: create a workflow from a SuperPlane canvas as part of project provisioning +- **Environment parity**: recreate workflow structure across Jira sites + +## Configuration + +- **Name**: Workflow name. +- **Description**: Optional workflow description. +- **Scope**: Global or project-scoped. Project-scoped workflows require a Jira project. +- **Project**: Required when scope is Project. +- **Statuses**: List of statuses with a category: TODO, IN_PROGRESS, or DONE. +- **Transitions**: List of transitions with a target status. Directed transitions use the From status list; Global transitions are available from any status. + +## Output + +Returns the created workflow's ` + "`id`" + `, ` + "`name`" + `, and ` + "`version`" + `. + +## Notes + +- Requires Jira admin permissions (` + "`manage:jira-configuration`" + `). +- Jira creates workflows independently from projects. Use Assign Workflow To Project to apply a workflow scheme to a company-managed project. +- New issues enter the **first listed status**. SuperPlane injects the Jira-required initial transition pointing at that status; the order of the Statuses list determines the starting state.` +} + +func (c *CreateWorkflow) Icon() string { + return "jira" +} + +func (c *CreateWorkflow) Color() string { + return "blue" +} + +func (c *CreateWorkflow) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *CreateWorkflow) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "name", + Label: "Name", + Type: configuration.FieldTypeString, + Required: true, + Description: "Workflow name", + Placeholder: "Service request workflow", + }, + { + Name: "description", + Label: "Description", + Type: configuration.FieldTypeText, + Required: false, + Description: "Optional workflow description", + }, + { + Name: "scope", + Label: "Scope", + Type: configuration.FieldTypeSelect, + Required: true, + Description: "Create the workflow globally or scoped to a project", + Default: workflowScopeGlobal, + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Global", Value: workflowScopeGlobal}, + {Label: "Project", Value: workflowScopeProject}, + }, + }, + }, + }, + { + Name: "project", + Label: "Project", + Type: configuration.FieldTypeIntegrationResource, + Required: false, + Description: "Project for a project-scoped workflow", + Placeholder: "Select a project", + VisibilityConditions: []configuration.VisibilityCondition{ + {Field: "scope", Values: []string{workflowScopeProject}}, + }, + RequiredConditions: []configuration.RequiredCondition{ + {Field: "scope", Values: []string{workflowScopeProject}}, + }, + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{Type: "project"}, + }, + }, + { + Name: "statuses", + Label: "Statuses", + Type: configuration.FieldTypeList, + Required: true, + Description: "Workflow statuses", + TypeOptions: &configuration.TypeOptions{ + List: &configuration.ListTypeOptions{ + ItemLabel: "Status", + ItemDefinition: &configuration.ListItemDefinition{ + Type: configuration.FieldTypeObject, + Schema: []configuration.Field{ + { + Name: "name", + Label: "Name", + Type: configuration.FieldTypeString, + Required: true, + Placeholder: "To Do", + }, + { + Name: "category", + Label: "Category", + Type: configuration.FieldTypeSelect, + Required: true, + Default: "TODO", + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "To do", Value: "TODO"}, + {Label: "In progress", Value: "IN_PROGRESS"}, + {Label: "Done", Value: "DONE"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + Name: "transitions", + Label: "Transitions", + Type: configuration.FieldTypeList, + Required: true, + Description: "Workflow transitions", + TypeOptions: &configuration.TypeOptions{ + List: &configuration.ListTypeOptions{ + ItemLabel: "Transition", + ItemDefinition: &configuration.ListItemDefinition{ + Type: configuration.FieldTypeObject, + Schema: []configuration.Field{ + { + Name: "name", + Label: "Name", + Type: configuration.FieldTypeString, + Required: true, + Placeholder: "Start work", + }, + { + Name: "type", + Label: "Type", + Type: configuration.FieldTypeSelect, + Required: true, + Default: "directed", + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Directed", Value: "directed"}, + {Label: "Global", Value: "global"}, + }, + }, + }, + }, + { + Name: "from", + Label: "From", + Type: configuration.FieldTypeList, + Required: false, + Description: "Source status names for directed transitions. Use any for a global transition.", + TypeOptions: &configuration.TypeOptions{ + List: &configuration.ListTypeOptions{ + ItemLabel: "Status", + ItemDefinition: &configuration.ListItemDefinition{Type: configuration.FieldTypeString}, + }, + }, + }, + { + Name: "to", + Label: "To", + Type: configuration.FieldTypeString, + Required: true, + Description: "Target status name", + Placeholder: "In Progress", + }, + }, + }, + }, + }, + }, + } +} + +func (c *CreateWorkflow) Setup(ctx core.SetupContext) error { + spec := CreateWorkflowSpec{} + if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { + return fmt.Errorf("failed to decode configuration: %v", err) + } + + if strings.TrimSpace(spec.Name) == "" { + return fmt.Errorf("name is required") + } + if err := validateWorkflowSpec(spec); err != nil { + return err + } + + meta := NodeMetadata{WorkflowName: strings.TrimSpace(spec.Name)} + if normalizeWorkflowScope(spec.Scope) == workflowScopeProject { + project, err := requireProject(ctx.HTTP, ctx.Integration, strings.TrimSpace(spec.Project)) + if err != nil { + return err + } + meta.Project = project + } + + return ctx.Metadata.Set(meta) +} + +func (c *CreateWorkflow) Execute(ctx core.ExecutionContext) error { + spec := CreateWorkflowSpec{} + if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { + return fmt.Errorf("failed to decode configuration: %v", err) + } + if strings.TrimSpace(spec.Name) == "" { + return fmt.Errorf("name is required") + } + if err := validateWorkflowSpec(spec); err != nil { + return err + } + + var project *Project + if normalizeWorkflowScope(spec.Scope) == workflowScopeProject { + var err error + project, err = requireProject(ctx.HTTP, ctx.Integration, strings.TrimSpace(spec.Project)) + if err != nil { + return err + } + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %v", err) + } + + req, err := buildCreateWorkflowRequest(spec, project) + if err != nil { + return err + } + + resp, err := client.CreateWorkflow(req) + if err != nil { + if strings.Contains(err.Error(), "403") { + return fmt.Errorf("failed to create workflow: %v — the API token must belong to a Jira admin", err) + } + return fmt.Errorf("failed to create workflow: %v", err) + } + if len(resp.Workflows) == 0 { + return fmt.Errorf("failed to create workflow: Jira returned no workflows") + } + + created := resp.Workflows[0] + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + CreateWorkflowPayloadType, + []any{CreateWorkflowOutput{ID: created.ID, Name: created.Name, Version: created.Version}}, + ) +} + +func normalizeWorkflowScope(scope string) string { + switch strings.ToUpper(strings.TrimSpace(scope)) { + case workflowScopeProject: + return workflowScopeProject + default: + return workflowScopeGlobal + } +} + +func validateWorkflowSpec(spec CreateWorkflowSpec) error { + if normalizeWorkflowScope(spec.Scope) == workflowScopeProject && strings.TrimSpace(spec.Project) == "" { + return fmt.Errorf("project is required when scope is Project") + } + if len(spec.Statuses) == 0 { + return fmt.Errorf("at least one status is required") + } + if len(spec.Transitions) == 0 { + return fmt.Errorf("at least one transition is required") + } + + statusNames := map[string]bool{} + statusDisplay := make([]string, 0, len(spec.Statuses)) + for i, status := range spec.Statuses { + name := strings.TrimSpace(status.Name) + if name == "" { + return fmt.Errorf("statuses[%d].name is required", i) + } + key := strings.ToLower(name) + if statusNames[key] { + return fmt.Errorf("duplicate status %q", name) + } + statusNames[key] = true + statusDisplay = append(statusDisplay, name) + if !slices.Contains([]string{"TODO", "IN_PROGRESS", "DONE"}, strings.ToUpper(strings.TrimSpace(status.Category))) { + return fmt.Errorf("statuses[%d].category must be TODO, IN_PROGRESS, or DONE", i) + } + } + + for i, transition := range spec.Transitions { + if strings.TrimSpace(transition.Name) == "" { + return fmt.Errorf("transitions[%d].name is required", i) + } + target := strings.TrimSpace(transition.To) + if target == "" { + return fmt.Errorf("transitions[%d].to is required", i) + } + if !statusNames[strings.ToLower(target)] { + return fmt.Errorf("transitions[%d].to references unknown status %q (available: %s)", i, target, formatAvailableStatuses(statusDisplay)) + } + if workflowTransitionType(transition) == "global" { + continue + } + if len(transition.From) == 0 { + return fmt.Errorf("transitions[%d].from is required for directed transitions", i) + } + for _, from := range transition.From { + source := strings.TrimSpace(from) + if strings.EqualFold(source, "any") { + continue + } + if !statusNames[strings.ToLower(source)] { + return fmt.Errorf("transitions[%d].from references unknown status %q (available: %s)", i, source, formatAvailableStatuses(statusDisplay)) + } + } + } + + return nil +} + +func formatAvailableStatuses(names []string) string { + if len(names) == 0 { + return "none" + } + quoted := make([]string, len(names)) + for i, name := range names { + quoted[i] = fmt.Sprintf("%q", name) + } + return strings.Join(quoted, ", ") +} + +func buildCreateWorkflowRequest(spec CreateWorkflowSpec, project *Project) (*CreateWorkflowRequest, error) { + scope := WorkflowScope{Type: normalizeWorkflowScope(spec.Scope)} + if scope.Type == workflowScopeProject { + if project == nil || strings.TrimSpace(project.ID) == "" { + return nil, fmt.Errorf("project id is required for project-scoped workflows") + } + scope.Project = &WorkflowScopeProjectRef{ID: strings.TrimSpace(project.ID)} + } + + statusRefs := map[string]string{} + statuses := make([]WorkflowStatusUpdate, 0, len(spec.Statuses)) + workflowStatuses := make([]WorkflowCreateStatus, 0, len(spec.Statuses)) + for i, status := range spec.Statuses { + name := strings.TrimSpace(status.Name) + ref := workflowStatusReference(name) + statusRefs[strings.ToLower(name)] = ref + statuses = append(statuses, WorkflowStatusUpdate{ + Description: "", + Name: name, + StatusCategory: strings.ToUpper(strings.TrimSpace(status.Category)), + StatusReference: ref, + }) + workflowStatuses = append(workflowStatuses, WorkflowCreateStatus{ + Layout: WorkflowLayout{X: 115 + float64(i*200), Y: -16}, + Properties: map[string]any{}, + StatusReference: ref, + }) + } + + transitions := []WorkflowCreateTransition{ + newWorkflowTransition("1", "Create", "INITIAL", statusRefs[strings.ToLower(strings.TrimSpace(spec.Statuses[0].Name))], nil), + } + for i, transition := range spec.Transitions { + targetRef := statusRefs[strings.ToLower(strings.TrimSpace(transition.To))] + transitionType := strings.ToUpper(workflowTransitionType(transition)) + var links []WorkflowTransitionLink + if transitionType == "DIRECTED" { + links = make([]WorkflowTransitionLink, 0, len(transition.From)) + for _, from := range transition.From { + if strings.EqualFold(strings.TrimSpace(from), "any") { + transitionType = "GLOBAL" + links = nil + break + } + links = append(links, WorkflowTransitionLink{ + FromPort: 0, + FromStatusReference: statusRefs[strings.ToLower(strings.TrimSpace(from))], + ToPort: 1, + }) + } + } + transitions = append(transitions, newWorkflowTransition( + strconv.Itoa((i+2)*10+1), + strings.TrimSpace(transition.Name), + transitionType, + targetRef, + links, + )) + } + + return &CreateWorkflowRequest{ + Scope: scope, + Statuses: statuses, + Workflows: []WorkflowCreate{ + { + Description: strings.TrimSpace(spec.Description), + Name: strings.TrimSpace(spec.Name), + StartPointLayout: WorkflowLayout{X: -100.00030899047852, Y: -153.00020599365234}, + Statuses: workflowStatuses, + Transitions: transitions, + }, + }, + }, nil +} + +// workflowStatusReference returns a deterministic id used to wire a status to +// the transitions that reference it. Jira treats statusReference as local to a +// single /workflows/create request, so name-derived UUIDs are safe — no +// cross-request stability is implied. +func workflowStatusReference(name string) string { + return uuid.NewSHA1(uuid.NameSpaceURL, []byte("superplane:jira:workflow-status:"+strings.ToLower(strings.TrimSpace(name)))).String() +} + +func workflowTransitionType(transition WorkflowTransitionSpec) string { + if strings.EqualFold(strings.TrimSpace(transition.Type), "global") { + return "global" + } + return "directed" +} + +func newWorkflowTransition(id, name, transitionType, toStatusReference string, links []WorkflowTransitionLink) WorkflowCreateTransition { + if links == nil { + links = []WorkflowTransitionLink{} + } + return WorkflowCreateTransition{ + Actions: []any{}, + Description: "", + ID: id, + Links: links, + Name: name, + Properties: map[string]any{}, + ToStatusReference: toStatusReference, + Triggers: []any{}, + Type: transitionType, + Validators: []any{}, + } +} + +func (c *CreateWorkflow) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *CreateWorkflow) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *CreateWorkflow) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { + return http.StatusOK, nil, nil +} + +func (c *CreateWorkflow) Cleanup(ctx core.SetupContext) error { + return nil +} + +func (c *CreateWorkflow) Hooks() []core.Hook { + return []core.Hook{} +} + +func (c *CreateWorkflow) HandleHook(ctx core.ActionHookContext) error { + return nil +} diff --git a/pkg/integrations/jira/create_workflow_test.go b/pkg/integrations/jira/create_workflow_test.go new file mode 100644 index 0000000000..cb312eb1e6 --- /dev/null +++ b/pkg/integrations/jira/create_workflow_test.go @@ -0,0 +1,215 @@ +package jira + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__CreateWorkflow__Setup(t *testing.T) { + component := CreateWorkflow{} + + t.Run("missing status -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Integration: newAuthorizedIntegration(), + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{ + "name": "Support workflow", + "transitions": []map[string]any{{"name": "Start", "from": []string{"To Do"}, "to": "Done"}}, + }, + }) + + require.ErrorContains(t, err, "at least one status is required") + }) + + t.Run("project scope requires project", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Integration: newAuthorizedIntegration(), + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{ + "name": "Support workflow", + "scope": workflowScopeProject, + "statuses": []map[string]any{{"name": "To Do", "category": "TODO"}}, + "transitions": []map[string]any{ + {"name": "Start", "from": []string{"To Do"}, "to": "To Do"}, + }, + }, + }) + + require.ErrorContains(t, err, "project is required") + }) + + t.Run("unknown transition status -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Integration: newAuthorizedIntegration(), + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{ + "name": "Support workflow", + "statuses": []map[string]any{{"name": "To Do", "category": "TODO"}}, + "transitions": []map[string]any{ + {"name": "Start", "from": []string{"To Do"}, "to": "Done"}, + }, + }, + }) + + require.ErrorContains(t, err, "unknown status") + }) + + t.Run("valid setup stores workflow metadata", func(t *testing.T) { + metadataCtx := &contexts.MetadataContext{} + err := component.Setup(core.SetupContext{ + Integration: newAuthorizedIntegration(), + Metadata: metadataCtx, + Configuration: map[string]any{ + "name": "Support workflow", + "statuses": []map[string]any{ + {"name": "To Do", "category": "TODO"}, + {"name": "Done", "category": "DONE"}, + }, + "transitions": []map[string]any{ + {"name": "Complete", "from": []string{"To Do"}, "to": "Done"}, + }, + }, + }) + + require.NoError(t, err) + nodeMetadata, ok := metadataCtx.Metadata.(NodeMetadata) + require.True(t, ok) + assert.Equal(t, "Support workflow", nodeMetadata.WorkflowName) + }) +} + +func Test__CreateWorkflow__Execute(t *testing.T) { + component := CreateWorkflow{} + + t.Run("creates workflow and emits first created workflow", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "workflows":[{"id":"wf-1","name":"Support workflow","version":{"id":"v-1","versionNumber":1}}], + "statuses":[] + }`)), + }, + }, + } + + execCtx := &contexts.ExecutionStateContext{} + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "name": "Support workflow", + "description": "Request lifecycle", + "statuses": []map[string]any{ + {"name": "To Do", "category": "TODO"}, + {"name": "In Progress", "category": "IN_PROGRESS"}, + {"name": "Done", "category": "DONE"}, + }, + "transitions": []map[string]any{ + {"name": "Start work", "from": []string{"To Do"}, "to": "In Progress", "type": "directed"}, + {"name": "Close", "from": []string{"any"}, "to": "Done", "type": "global"}, + }, + }, + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + ExecutionState: execCtx, + }) + + require.NoError(t, err) + assert.True(t, execCtx.Passed) + assert.Equal(t, CreateWorkflowPayloadType, execCtx.Type) + require.Len(t, execCtx.Payloads, 1) + + require.Len(t, httpContext.Requests, 1) + assert.Equal(t, http.MethodPost, httpContext.Requests[0].Method) + assert.Contains(t, httpContext.Requests[0].URL.String(), "/rest/api/3/workflows/create") + + body, err := io.ReadAll(httpContext.Requests[0].Body) + require.NoError(t, err) + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, workflowScopeGlobal, payload["scope"].(map[string]any)["type"]) + assert.Len(t, payload["statuses"].([]any), 3) + transitions := payload["workflows"].([]any)[0].(map[string]any)["transitions"].([]any) + assert.Equal(t, "INITIAL", transitions[0].(map[string]any)["type"]) + }) + + t.Run("project-scoped workflow includes project id in scope", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`[{"id":"10000","key":"TEST","name":"Test"}]`)), + }, + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "workflows":[{"id":"wf-2","name":"Scoped workflow","version":{"id":"v-2","versionNumber":1}}], + "statuses":[] + }`)), + }, + }, + } + + execCtx := &contexts.ExecutionStateContext{} + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "name": "Scoped workflow", + "scope": workflowScopeProject, + "project": "TEST", + "statuses": []map[string]any{{"name": "To Do", "category": "TODO"}}, + "transitions": []map[string]any{ + {"name": "Loop", "from": []string{"To Do"}, "to": "To Do"}, + }, + }, + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + ExecutionState: execCtx, + }) + + require.NoError(t, err) + require.Len(t, httpContext.Requests, 2) + + body, err := io.ReadAll(httpContext.Requests[1].Body) + require.NoError(t, err) + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + scope := payload["scope"].(map[string]any) + assert.Equal(t, workflowScopeProject, scope["type"]) + assert.Equal(t, "10000", scope["project"].(map[string]any)["id"]) + }) + + t.Run("permission denied surfaces admin hint", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader(`{"errorMessages":["Administer Jira required"]}`)), + }, + }, + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "name": "Support workflow", + "statuses": []map[string]any{{"name": "To Do", "category": "TODO"}}, + "transitions": []map[string]any{ + {"name": "Loop", "from": []string{"To Do"}, "to": "To Do"}, + }, + }, + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + ExecutionState: &contexts.ExecutionStateContext{}, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "Jira admin") + }) +} diff --git a/pkg/integrations/jira/example.go b/pkg/integrations/jira/example.go index 44b43ed1e2..9ad8ada884 100644 --- a/pkg/integrations/jira/example.go +++ b/pkg/integrations/jira/example.go @@ -31,6 +31,30 @@ var exampleOutputDeleteIncidentBytes []byte var exampleOutputDeleteIncidentOnce sync.Once var exampleOutputDeleteIncident map[string]any +//go:embed example_output_create_workflow.json +var exampleOutputCreateWorkflowBytes []byte + +var exampleOutputCreateWorkflowOnce sync.Once +var exampleOutputCreateWorkflow map[string]any + +//go:embed example_output_assign_workflow_to_project.json +var exampleOutputAssignWorkflowToProjectBytes []byte + +var exampleOutputAssignWorkflowToProjectOnce sync.Once +var exampleOutputAssignWorkflowToProject map[string]any + +//go:embed example_output_transition_issue.json +var exampleOutputTransitionIssueBytes []byte + +var exampleOutputTransitionIssueOnce sync.Once +var exampleOutputTransitionIssue map[string]any + +//go:embed example_output_approve_workflow.json +var exampleOutputApproveWorkflowBytes []byte + +var exampleOutputApproveWorkflowOnce sync.Once +var exampleOutputApproveWorkflow map[string]any + func (c *CreateIssue) ExampleOutput() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleOutputCreateIssueOnce, exampleOutputCreateIssueBytes, &exampleOutputCreateIssue) } @@ -76,3 +100,19 @@ func (c *GetIncident) ExampleOutput() map[string]any { func (c *DeleteIncident) ExampleOutput() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleOutputDeleteIncidentOnce, exampleOutputDeleteIncidentBytes, &exampleOutputDeleteIncident) } + +func (c *CreateWorkflow) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputCreateWorkflowOnce, exampleOutputCreateWorkflowBytes, &exampleOutputCreateWorkflow) +} + +func (c *AssignWorkflowToProject) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputAssignWorkflowToProjectOnce, exampleOutputAssignWorkflowToProjectBytes, &exampleOutputAssignWorkflowToProject) +} + +func (c *TransitionIssue) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputTransitionIssueOnce, exampleOutputTransitionIssueBytes, &exampleOutputTransitionIssue) +} + +func (c *ApproveWorkflow) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputApproveWorkflowOnce, exampleOutputApproveWorkflowBytes, &exampleOutputApproveWorkflow) +} diff --git a/pkg/integrations/jira/example_output_approve_workflow.json b/pkg/integrations/jira/example_output_approve_workflow.json new file mode 100644 index 0000000000..0710ab44a6 --- /dev/null +++ b/pkg/integrations/jira/example_output_approve_workflow.json @@ -0,0 +1,23 @@ +{ + "type": "jira.approval", + "data": { + "id": "1", + "name": "Manager approval", + "finalDecision": "approved", + "approvers": [ + { + "approver": { + "accountId": "5b10a2844c20165700ede21g", + "displayName": "Alice Example", + "emailAddress": "alice@example.com" + }, + "approverDecision": "approved" + } + ], + "completedDate": { + "iso8601": "2026-01-19T13:15:00+0000", + "jira": "2026-01-19T13:15:00.000+0000" + } + }, + "timestamp": "2026-01-19T13:15:00Z" +} diff --git a/pkg/integrations/jira/example_output_assign_workflow_to_project.json b/pkg/integrations/jira/example_output_assign_workflow_to_project.json new file mode 100644 index 0000000000..11d9bce8fe --- /dev/null +++ b/pkg/integrations/jira/example_output_assign_workflow_to_project.json @@ -0,0 +1,12 @@ +{ + "type": "jira.workflowScheme.assigned", + "data": { + "projectId": "10000", + "workflowSchemeId": "101010", + "draftCreated": false, + "taskId": "3f83dg2a-ns2n-56ab-9812-42h5j1461629", + "taskStatus": "ENQUEUED", + "taskSelf": "https://your-domain.atlassian.net/rest/api/3/task/3f83dg2a-ns2n-56ab-9812-42h5j1461629" + }, + "timestamp": "2026-01-19T12:00:00Z" +} diff --git a/pkg/integrations/jira/example_output_create_workflow.json b/pkg/integrations/jira/example_output_create_workflow.json new file mode 100644 index 0000000000..dabe97fc03 --- /dev/null +++ b/pkg/integrations/jira/example_output_create_workflow.json @@ -0,0 +1,12 @@ +{ + "type": "jira.workflow.created", + "data": { + "id": "b9ff2384-d3b6-4d4e-9509-3ee19f607168", + "name": "Service request workflow", + "version": { + "id": "f010ac1b-3dd3-43a3-aa66-0ee8a447f76e", + "versionNumber": 1 + } + }, + "timestamp": "2026-01-19T12:00:00Z" +} diff --git a/pkg/integrations/jira/example_output_transition_issue.json b/pkg/integrations/jira/example_output_transition_issue.json new file mode 100644 index 0000000000..9c2580de9e --- /dev/null +++ b/pkg/integrations/jira/example_output_transition_issue.json @@ -0,0 +1,28 @@ +{ + "type": "jira.issue", + "data": { + "id": "10001", + "key": "PROJ-123", + "self": "https://your-domain.atlassian.net/rest/api/3/issue/10001", + "fields": { + "summary": "Investigate timeout on checkout flow", + "status": { + "name": "Done", + "statusCategory": { + "key": "done", + "name": "Done" + } + }, + "resolution": { + "name": "Done" + }, + "project": { + "id": "10000", + "key": "PROJ", + "name": "Proj" + }, + "updated": "2026-01-19T13:00:00.000+0000" + } + }, + "timestamp": "2026-01-19T13:00:00Z" +} diff --git a/pkg/integrations/jira/jira.go b/pkg/integrations/jira/jira.go index f9e9f4444e..5d5dca7c15 100644 --- a/pkg/integrations/jira/jira.go +++ b/pkg/integrations/jira/jira.go @@ -88,6 +88,10 @@ func (j *Jira) Actions() []core.Action { &CreateIncident{}, &GetIncident{}, &DeleteIncident{}, + &CreateWorkflow{}, + &AssignWorkflowToProject{}, + &TransitionIssue{}, + &ApproveWorkflow{}, } } diff --git a/pkg/integrations/jira/list_resources.go b/pkg/integrations/jira/list_resources.go index 1511852e2c..17b4d3775e 100644 --- a/pkg/integrations/jira/list_resources.go +++ b/pkg/integrations/jira/list_resources.go @@ -20,6 +20,10 @@ func (j *Jira) ListResources(resourceType string, ctx core.ListResourcesContext) return listAssignees(ctx) case "priority": return listPriorities(ctx) + case "resolution": + return listResolutions(ctx) + case "workflowScheme": + return listWorkflowSchemes(ctx) case "serviceDesk": client, err := NewClient(ctx.HTTP, ctx.Integration) if err != nil { @@ -308,6 +312,63 @@ func listPriorities(ctx core.ListResourcesContext) ([]core.IntegrationResource, return resources, nil } +func listResolutions(ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { + if ctx.HTTP == nil { + return []core.IntegrationResource{}, nil + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, fmt.Errorf("failed to create client: %w", err) + } + + resolutions, err := client.ListResolutions() + if err != nil { + return nil, fmt.Errorf("failed to list resolutions: %w", err) + } + + resources := make([]core.IntegrationResource, 0, len(resolutions)) + for _, r := range resolutions { + resources = append(resources, core.IntegrationResource{ + Type: "resolution", + Name: r.Name, + ID: r.Name, + }) + } + return resources, nil +} + +func listWorkflowSchemes(ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { + if ctx.HTTP == nil { + return []core.IntegrationResource{}, nil + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, fmt.Errorf("failed to create client: %w", err) + } + + schemes, err := client.ListWorkflowSchemes() + if err != nil { + return nil, fmt.Errorf("failed to list workflow schemes: %w", err) + } + + resources := make([]core.IntegrationResource, 0, len(schemes)) + for _, scheme := range schemes { + id := scheme.ID.String() + name := strings.TrimSpace(scheme.Name) + if id != "" { + name = fmt.Sprintf("%s (%s)", name, id) + } + resources = append(resources, core.IntegrationResource{ + Type: "workflowScheme", + Name: name, + ID: id, + }) + } + return resources, nil +} + func (j *Jira) listRequestTypeFieldResources(resourceType, fieldLabel string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { deskID := strings.TrimSpace(ctx.Parameters["serviceDesk"]) reqID := strings.TrimSpace(ctx.Parameters["serviceDeskRequestType"]) diff --git a/pkg/integrations/jira/list_resources_test.go b/pkg/integrations/jira/list_resources_test.go index 263de6a637..3ec88e4be7 100644 --- a/pkg/integrations/jira/list_resources_test.go +++ b/pkg/integrations/jira/list_resources_test.go @@ -201,6 +201,36 @@ func Test__ListResources__Priority__MissingHTTPContext(t *testing.T) { assert.Empty(t, resources) } +func Test__ListResources__WorkflowScheme(t *testing.T) { + j := &Jira{} + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "isLast": true, + "values": [ + {"id":101010,"name":"Support workflow scheme"}, + {"id":"scheme-2","name":"Escalation workflow scheme"} + ] + }`)), + }, + }, + } + + resources, err := j.ListResources("workflowScheme", core.ListResourcesContext{ + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + }) + + require.NoError(t, err) + require.Len(t, resources, 2) + assert.Equal(t, "workflowScheme", resources[0].Type) + assert.Equal(t, "101010", resources[0].ID) + assert.Equal(t, "Support workflow scheme (101010)", resources[0].Name) + assert.Equal(t, "scheme-2", resources[1].ID) +} + func Test__ListResources__Unknown(t *testing.T) { j := &Jira{} appCtx := newAuthorizedIntegration() diff --git a/pkg/integrations/jira/transition_issue.go b/pkg/integrations/jira/transition_issue.go new file mode 100644 index 0000000000..39e1a5b14e --- /dev/null +++ b/pkg/integrations/jira/transition_issue.go @@ -0,0 +1,228 @@ +package jira + +import ( + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +const TransitionIssuePayloadType = "jira.issue" + +type TransitionIssue struct{} + +type TransitionIssueSpec struct { + Project string `json:"project" mapstructure:"project"` + IssueKey string `json:"issueKey" mapstructure:"issueKey"` + TargetStatus string `json:"targetStatus" mapstructure:"targetStatus"` + Comment string `json:"comment" mapstructure:"comment"` + Resolution string `json:"resolution" mapstructure:"resolution"` +} + +func (c *TransitionIssue) Name() string { + return "jira.transitionIssue" +} + +func (c *TransitionIssue) Label() string { + return "Transition Issue" +} + +func (c *TransitionIssue) Description() string { + return "Move a Jira issue to a reachable workflow status" +} + +func (c *TransitionIssue) Documentation() string { + return `The Transition Issue component moves a Jira issue through its workflow. + +## Use Cases + +- **Automated triage**: move issues into the next workflow status after a SuperPlane event +- **Cross-tool state sync**: mirror status changes from incident or deployment systems +- **Resolution automation**: close issues with a transition-scoped resolution and comment + +## Configuration + +- **Project**: Optional Jira project used to narrow the status picker. +- **Issue Key**: Jira issue key, for example ` + "`PROJ-123`" + `. +- **Target Status**: Status to move the issue to. It must be reachable from the issue's current status. +- **Comment**: Optional transition comment. +- **Resolution**: Optional Jira resolution name to set during the transition. + +## Output + +Returns the refreshed Jira issue after the transition. + +## Notes + +- Jira does not allow direct status writes. This component finds an available transition whose target status matches the requested status. +- Workflow conditions and validators still apply.` +} + +func (c *TransitionIssue) Icon() string { + return "jira" +} + +func (c *TransitionIssue) Color() string { + return "blue" +} + +func (c *TransitionIssue) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *TransitionIssue) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "project", + Label: "Project", + Type: configuration.FieldTypeIntegrationResource, + Required: false, + Description: "Optional project to narrow the status picker", + Placeholder: "Any project", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{Type: "project"}, + }, + }, + { + Name: "issueKey", + Label: "Issue Key", + Type: configuration.FieldTypeString, + Required: true, + Description: "The issue key (e.g. PROJ-123)", + Placeholder: "PROJ-123", + }, + { + Name: "targetStatus", + Label: "Target Status", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "Workflow status to transition to", + Placeholder: "Select a status", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "issueStatus", + UseNameAsValue: true, + Parameters: []configuration.ParameterRef{ + { + Name: "project", + ValueFrom: &configuration.ParameterValueFrom{Field: "project"}, + }, + }, + }, + }, + }, + { + Name: "comment", + Label: "Comment", + Type: configuration.FieldTypeText, + Required: false, + Description: "Optional comment to add during the transition", + }, + { + Name: "resolution", + Label: "Resolution", + Type: configuration.FieldTypeIntegrationResource, + Required: false, + Description: "Optional Jira resolution to set during the transition", + Placeholder: "Leave empty to keep the current resolution", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "resolution", + UseNameAsValue: true, + }, + }, + }, + } +} + +func (c *TransitionIssue) Setup(ctx core.SetupContext) error { + spec := TransitionIssueSpec{} + if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { + return fmt.Errorf("failed to decode configuration: %v", err) + } + + if strings.TrimSpace(spec.IssueKey) == "" { + return fmt.Errorf("issueKey is required") + } + if strings.TrimSpace(spec.TargetStatus) == "" { + return fmt.Errorf("targetStatus is required") + } + + meta := NodeMetadata{Status: strings.TrimSpace(spec.TargetStatus)} + if strings.TrimSpace(spec.Project) != "" { + project, err := requireProject(ctx.HTTP, ctx.Integration, strings.TrimSpace(spec.Project)) + if err != nil { + return err + } + meta.Project = project + } + + return ctx.Metadata.Set(meta) +} + +func (c *TransitionIssue) Execute(ctx core.ExecutionContext) error { + spec := TransitionIssueSpec{} + if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { + return fmt.Errorf("failed to decode configuration: %v", err) + } + + issueKey := strings.TrimSpace(spec.IssueKey) + targetStatus := strings.TrimSpace(spec.TargetStatus) + if issueKey == "" { + return fmt.Errorf("issueKey is required") + } + if targetStatus == "" { + return fmt.Errorf("targetStatus is required") + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %v", err) + } + + if err := applyStatusWithOptions(client, issueKey, targetStatus, DoTransitionOptions{ + Comment: spec.Comment, + Resolution: spec.Resolution, + }); err != nil { + return fmt.Errorf("failed to transition issue: %v", err) + } + + issue, err := client.GetIssue(issueKey) + if err != nil { + return fmt.Errorf("failed to fetch transitioned issue: %v", err) + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + TransitionIssuePayloadType, + []any{issue}, + ) +} + +func (c *TransitionIssue) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *TransitionIssue) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *TransitionIssue) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { + return http.StatusOK, nil, nil +} + +func (c *TransitionIssue) Cleanup(ctx core.SetupContext) error { + return nil +} + +func (c *TransitionIssue) Hooks() []core.Hook { + return []core.Hook{} +} + +func (c *TransitionIssue) HandleHook(ctx core.ActionHookContext) error { + return nil +} diff --git a/pkg/integrations/jira/transition_issue_test.go b/pkg/integrations/jira/transition_issue_test.go new file mode 100644 index 0000000000..4433bd2d53 --- /dev/null +++ b/pkg/integrations/jira/transition_issue_test.go @@ -0,0 +1,138 @@ +package jira + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__TransitionIssue__Setup(t *testing.T) { + component := TransitionIssue{} + + t.Run("missing issue key -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Integration: newAuthorizedIntegration(), + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"targetStatus": "Done"}, + }) + + require.ErrorContains(t, err, "issueKey is required") + }) + + t.Run("missing target status -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Integration: newAuthorizedIntegration(), + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"issueKey": "TEST-1"}, + }) + + require.ErrorContains(t, err, "targetStatus is required") + }) + + t.Run("valid setup stores project and status metadata", func(t *testing.T) { + metadataCtx := &contexts.MetadataContext{} + err := component.Setup(core.SetupContext{ + Integration: newAuthorizedIntegrationWithMetadata(Metadata{ + Projects: []Project{{ID: "10000", Key: "TEST", Name: "Test Project"}}, + }), + Metadata: metadataCtx, + Configuration: map[string]any{ + "project": "TEST", + "issueKey": "TEST-1", + "targetStatus": "Done", + }, + }) + + require.NoError(t, err) + nodeMetadata, ok := metadataCtx.Metadata.(NodeMetadata) + require.True(t, ok) + assert.Equal(t, "Done", nodeMetadata.Status) + require.NotNil(t, nodeMetadata.Project) + assert.Equal(t, "TEST", nodeMetadata.Project.Key) + }) +} + +func Test__TransitionIssue__Execute(t *testing.T) { + component := TransitionIssue{} + + t.Run("transitions issue with comment and resolution", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"transitions":[{"id":"31","name":"Resolve","to":{"id":"10003","name":"Done"}}]}`)), + }, + { + StatusCode: http.StatusNoContent, + Body: io.NopCloser(strings.NewReader(``)), + }, + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"id":"10001","key":"TEST-1","fields":{"summary":"Done issue","status":{"name":"Done"}}}`)), + }, + }, + } + + execCtx := &contexts.ExecutionStateContext{} + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "issueKey": "TEST-1", + "targetStatus": "Done", + "comment": "Ship it", + "resolution": "Done", + }, + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + ExecutionState: execCtx, + }) + + require.NoError(t, err) + assert.True(t, execCtx.Passed) + assert.Equal(t, TransitionIssuePayloadType, execCtx.Type) + + require.Len(t, httpContext.Requests, 3) + assert.Equal(t, http.MethodPost, httpContext.Requests[1].Method) + assert.Contains(t, httpContext.Requests[1].URL.String(), "/rest/api/3/issue/TEST-1/transitions") + + body, err := io.ReadAll(httpContext.Requests[1].Body) + require.NoError(t, err) + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + fields := payload["fields"].(map[string]any) + assert.Equal(t, "Done", fields["resolution"].(map[string]any)["name"]) + update := payload["update"].(map[string]any) + assert.Contains(t, update, "comment") + }) + + t.Run("unreachable status returns available transitions", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"transitions":[{"id":"21","name":"Start","to":{"id":"10002","name":"In Progress"}}]}`)), + }, + }, + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "issueKey": "TEST-1", + "targetStatus": "Done", + }, + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + ExecutionState: &contexts.ExecutionStateContext{}, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "Done") + assert.Contains(t, err.Error(), "In Progress") + }) +} diff --git a/web_src/src/pages/workflowv2/mappers/jira/approve_workflow.spec.ts b/web_src/src/pages/workflowv2/mappers/jira/approve_workflow.spec.ts new file mode 100644 index 0000000000..b4cff05c50 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/jira/approve_workflow.spec.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; + +import { approveWorkflowMapper } from "./approve_workflow"; +import { eventStateRegistry } from "./index"; +import type { ComponentBaseContext, ExecutionDetailsContext, ExecutionInfo, NodeInfo } from "../types"; + +function node(overrides?: Partial): NodeInfo { + return { + id: "node-1", + name: "Approve workflow", + componentName: "jira.approveWorkflow", + isCollapsed: false, + configuration: {}, + metadata: {}, + ...overrides, + }; +} + +function execution(overrides?: Partial): ExecutionInfo { + return { + id: "exec-1", + createdAt: "2026-01-19T12:00:00Z", + updatedAt: "2026-01-19T12:00:00Z", + state: "STATE_FINISHED", + result: "RESULT_PASSED", + resultReason: "RESULT_REASON_OK", + resultMessage: "", + metadata: {}, + configuration: {}, + rootEvent: undefined, + ...overrides, + }; +} + +function detailsCtx(overrides?: { + node?: Partial; + execution?: Partial; +}): ExecutionDetailsContext { + const n = node(overrides?.node); + return { nodes: [n], node: n, execution: execution(overrides?.execution) }; +} + +function componentCtx(overrides?: { node?: Partial }): ComponentBaseContext { + const n = node(overrides?.node); + return { + nodes: [n], + node: n, + componentDefinition: { + name: "jira.approveWorkflow", + label: "Approve Workflow", + description: "", + icon: "jira", + color: "green", + }, + lastExecutions: [], + currentUser: undefined, + actions: { invokeNodeExecutionHook: async () => {} }, + }; +} + +describe("approveWorkflowMapper", () => { + it("extracts approval details", () => { + const details = approveWorkflowMapper.getExecutionDetails( + detailsCtx({ + node: { configuration: { issueKey: "ITSM-1", decision: "approve" } }, + execution: { + outputs: { + default: [ + { + type: "jira.approval", + timestamp: "2026-01-19T12:00:00Z", + data: { + id: "2", + name: "Manager", + finalDecision: "approved", + approvers: [{ approver: { displayName: "Alice" } }], + }, + }, + ], + }, + }, + }), + ); + + expect(details["Approval ID"]).toBe("2"); + expect(details.Name).toBe("Manager"); + expect(details.Decision).toBe("approved"); + expect(details.Approvers).toBe("Alice"); + expect(details["Issue Key"]).toBe("ITSM-1"); + }); + + it("renders issue, decision, and approval id metadata", () => { + const props = approveWorkflowMapper.props( + componentCtx({ + node: { + configuration: { issueKey: "ITSM-1", decision: "decline", approvalSelector: "byId", approvalId: "2" }, + }, + }), + ); + + expect(props.metadata).toEqual([ + { icon: "hash", label: "ITSM-1" }, + { icon: "circle-x", label: "decline" }, + { icon: "badge-check", label: "2" }, + ]); + }); + + it("maps finished success to decided", () => { + expect(eventStateRegistry.approveWorkflow.getState(execution())).toBe("decided"); + }); +}); diff --git a/web_src/src/pages/workflowv2/mappers/jira/approve_workflow.ts b/web_src/src/pages/workflowv2/mappers/jira/approve_workflow.ts new file mode 100644 index 0000000000..d12dc153c0 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/jira/approve_workflow.ts @@ -0,0 +1,77 @@ +import type { ComponentBaseProps } from "@/ui/componentBase"; +import type React from "react"; +import type { + ComponentBaseContext, + ComponentBaseMapper, + ExecutionDetailsContext, + NodeInfo, + OutputPayload, + SubtitleContext, +} from "../types"; +import type { MetadataItem } from "@/ui/metadataList"; +import { renderTimeAgo } from "@/components/TimeAgo"; +import { jiraComponentBaseProps } from "./base"; +import { addDetail, addIssueKeyMetadata } from "./utils"; +import type { ApproveWorkflowConfiguration, JiraApproval } from "./types"; + +export const approveWorkflowMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + return jiraComponentBaseProps(context, metadataList(context.node)); + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + const details: Record = { + "Executed At": context.execution.createdAt ? new Date(context.execution.createdAt).toLocaleString() : "-", + }; + + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + const approval = outputs?.default?.[0]?.data as JiraApproval | undefined; + if (approval) { + addDetail(details, "Approval ID", approval.id); + addDetail(details, "Name", approval.name); + addDetail(details, "Decision", approval.finalDecision); + if (approval.approvers?.length) { + details["Approvers"] = approval.approvers + .map((entry) => entry.approver?.displayName) + .filter(Boolean) + .join(", "); + } + } + + const configuration = context.node.configuration as ApproveWorkflowConfiguration | undefined; + addDetail(details, "Issue Key", configuration?.issueKey); + addDetail(details, "Configured Decision", configuration?.decision); + + return details; + }, + + subtitle(context: SubtitleContext): string | React.ReactNode { + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + const approval = outputs?.default?.[0]?.data as JiraApproval | undefined; + if (approval?.finalDecision) return approval.finalDecision; + if (context.execution.createdAt) { + return renderTimeAgo(new Date(context.execution.createdAt)); + } + return ""; + }, +}; + +function metadataList(node: NodeInfo): MetadataItem[] { + const metadata: MetadataItem[] = []; + const configuration = node.configuration as ApproveWorkflowConfiguration | undefined; + + addIssueKeyMetadata(metadata, "hash", configuration?.issueKey); + + if (configuration?.decision) { + metadata.push({ + icon: configuration.decision === "approve" ? "circle-check" : "circle-x", + label: configuration.decision, + }); + } + + if (configuration?.approvalSelector === "byId" && configuration.approvalId) { + metadata.push({ icon: "badge-check", label: configuration.approvalId }); + } + + return metadata; +} diff --git a/web_src/src/pages/workflowv2/mappers/jira/assign_workflow_to_project.spec.ts b/web_src/src/pages/workflowv2/mappers/jira/assign_workflow_to_project.spec.ts new file mode 100644 index 0000000000..8949226293 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/jira/assign_workflow_to_project.spec.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; + +import { assignWorkflowToProjectMapper } from "./assign_workflow_to_project"; +import { eventStateRegistry } from "./index"; +import type { ComponentBaseContext, ExecutionDetailsContext, ExecutionInfo, NodeInfo } from "../types"; + +function node(overrides?: Partial): NodeInfo { + return { + id: "node-1", + name: "Assign scheme", + componentName: "jira.assignWorkflowToProject", + isCollapsed: false, + configuration: {}, + metadata: {}, + ...overrides, + }; +} + +function execution(overrides?: Partial): ExecutionInfo { + return { + id: "exec-1", + createdAt: "2026-01-19T12:00:00Z", + updatedAt: "2026-01-19T12:00:00Z", + state: "STATE_FINISHED", + result: "RESULT_PASSED", + resultReason: "RESULT_REASON_OK", + resultMessage: "", + metadata: {}, + configuration: {}, + rootEvent: undefined, + ...overrides, + }; +} + +function detailsCtx(overrides?: { + node?: Partial; + execution?: Partial; +}): ExecutionDetailsContext { + const n = node(overrides?.node); + return { nodes: [n], node: n, execution: execution(overrides?.execution) }; +} + +function componentCtx(overrides?: { node?: Partial }): ComponentBaseContext { + const n = node(overrides?.node); + return { + nodes: [n], + node: n, + componentDefinition: { + name: "jira.assignWorkflowToProject", + label: "Assign Workflow To Project", + description: "", + icon: "jira", + color: "blue", + }, + lastExecutions: [], + currentUser: undefined, + actions: { invokeNodeExecutionHook: async () => {} }, + }; +} + +describe("assignWorkflowToProjectMapper", () => { + it("extracts assignment details", () => { + const details = assignWorkflowToProjectMapper.getExecutionDetails( + detailsCtx({ + execution: { + outputs: { + default: [ + { + type: "jira.workflowScheme.assigned", + timestamp: "2026-01-19T12:00:00Z", + data: { + projectId: "10000", + workflowSchemeId: "101010", + draftCreated: false, + taskId: "task-1", + taskStatus: "ENQUEUED", + }, + }, + ], + }, + }, + }), + ); + + expect(details["Project ID"]).toBe("10000"); + expect(details["Workflow Scheme ID"]).toBe("101010"); + expect(details["Task ID"]).toBe("task-1"); + expect(details["Task Status"]).toBe("ENQUEUED"); + }); + + it("renders project and scheme metadata", () => { + const props = assignWorkflowToProjectMapper.props( + componentCtx({ + node: { + configuration: { project: "TEST", workflowScheme: "101010", dryRun: true }, + metadata: { + project: { key: "TEST", name: "Test Project" }, + workflowScheme: { id: "101010", name: "Support scheme" }, + }, + }, + }), + ); + + expect(props.metadata).toEqual([ + { icon: "folder", label: "Test Project" }, + { icon: "workflow", label: "Support scheme" }, + { icon: "search-check", label: "Dry run" }, + ]); + }); + + it("maps finished success to assigned", () => { + expect(eventStateRegistry.assignWorkflowToProject.getState(execution())).toBe("assigned"); + }); +}); diff --git a/web_src/src/pages/workflowv2/mappers/jira/assign_workflow_to_project.ts b/web_src/src/pages/workflowv2/mappers/jira/assign_workflow_to_project.ts new file mode 100644 index 0000000000..49a5b57339 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/jira/assign_workflow_to_project.ts @@ -0,0 +1,71 @@ +import type { ComponentBaseProps } from "@/ui/componentBase"; +import type React from "react"; +import type { + ComponentBaseContext, + ComponentBaseMapper, + ExecutionDetailsContext, + NodeInfo, + OutputPayload, + SubtitleContext, +} from "../types"; +import type { MetadataItem } from "@/ui/metadataList"; +import { renderTimeAgo } from "@/components/TimeAgo"; +import { jiraComponentBaseProps } from "./base"; +import { addDetail, addProjectMetadata } from "./utils"; +import type { AssignWorkflowToProjectConfiguration, JiraNodeMetadata, JiraWorkflowSchemeAssignment } from "./types"; + +export const assignWorkflowToProjectMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + return jiraComponentBaseProps(context, metadataList(context.node)); + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + const details: Record = { + "Executed At": context.execution.createdAt ? new Date(context.execution.createdAt).toLocaleString() : "-", + }; + + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + const assignment = outputs?.default?.[0]?.data as JiraWorkflowSchemeAssignment | undefined; + if (assignment) { + addDetail(details, "Project ID", assignment.projectId); + addDetail(details, "Workflow Scheme ID", assignment.workflowSchemeId); + details["Draft Created"] = assignment.draftCreated ? "Yes" : "No"; + if (assignment.dryRun) details["Dry Run"] = "Yes"; + addDetail(details, "Task ID", assignment.taskId); + addDetail(details, "Task Status", assignment.taskStatus); + addDetail(details, "Task URL", assignment.taskSelf); + } + + return details; + }, + + subtitle(context: SubtitleContext): string | React.ReactNode { + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + const assignment = outputs?.default?.[0]?.data as JiraWorkflowSchemeAssignment | undefined; + if (assignment?.taskStatus) return assignment.taskStatus; + if (assignment?.dryRun) return "Dry run"; + if (context.execution.createdAt) { + return renderTimeAgo(new Date(context.execution.createdAt)); + } + return ""; + }, +}; + +function metadataList(node: NodeInfo): MetadataItem[] { + const metadata: MetadataItem[] = []; + const nodeMetadata = node.metadata as JiraNodeMetadata | undefined; + const configuration = node.configuration as AssignWorkflowToProjectConfiguration | undefined; + + addProjectMetadata(metadata, nodeMetadata?.project, configuration?.project); + + const schemeLabel = nodeMetadata?.workflowScheme?.name || configuration?.workflowScheme; + if (schemeLabel) { + metadata.push({ icon: "workflow", label: schemeLabel }); + } + + if (configuration?.dryRun) { + metadata.push({ icon: "search-check", label: "Dry run" }); + } + + return metadata; +} diff --git a/web_src/src/pages/workflowv2/mappers/jira/create_workflow.spec.ts b/web_src/src/pages/workflowv2/mappers/jira/create_workflow.spec.ts new file mode 100644 index 0000000000..0bdb3bd33d --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/jira/create_workflow.spec.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; + +import { createWorkflowMapper } from "./create_workflow"; +import { eventStateRegistry } from "./index"; +import type { ComponentBaseContext, ExecutionDetailsContext, ExecutionInfo, NodeInfo, SubtitleContext } from "../types"; + +function node(overrides?: Partial): NodeInfo { + return { + id: "node-1", + name: "Create workflow", + componentName: "jira.createWorkflow", + isCollapsed: false, + configuration: {}, + metadata: {}, + ...overrides, + }; +} + +function execution(overrides?: Partial): ExecutionInfo { + return { + id: "exec-1", + createdAt: "2026-01-19T12:00:00Z", + updatedAt: "2026-01-19T12:00:00Z", + state: "STATE_FINISHED", + result: "RESULT_PASSED", + resultReason: "RESULT_REASON_OK", + resultMessage: "", + metadata: {}, + configuration: {}, + rootEvent: undefined, + ...overrides, + }; +} + +function detailsCtx(overrides?: { + node?: Partial; + execution?: Partial; +}): ExecutionDetailsContext { + const n = node(overrides?.node); + return { nodes: [n], node: n, execution: execution(overrides?.execution) }; +} + +function componentCtx(overrides?: { node?: Partial }): ComponentBaseContext { + const n = node(overrides?.node); + return { + nodes: [n], + node: n, + componentDefinition: { + name: "jira.createWorkflow", + label: "Create Workflow", + description: "", + icon: "jira", + color: "blue", + }, + lastExecutions: [], + currentUser: undefined, + actions: { invokeNodeExecutionHook: async () => {} }, + }; +} + +describe("createWorkflowMapper", () => { + it("extracts workflow output details", () => { + const details = createWorkflowMapper.getExecutionDetails( + detailsCtx({ + node: { configuration: { scope: "GLOBAL", statuses: [{ name: "To Do" }], transitions: [{ name: "Done" }] } }, + execution: { + outputs: { + default: [ + { + type: "jira.workflow.created", + timestamp: "2026-01-19T12:00:00Z", + data: { id: "wf-1", name: "Support", version: { versionNumber: 1 } }, + }, + ], + }, + }, + }), + ); + + expect(details["Workflow ID"]).toBe("wf-1"); + expect(details.Name).toBe("Support"); + expect(details.Version).toBe("1"); + expect(details.Statuses).toBe("1"); + expect(details.Transitions).toBe("1"); + }); + + it("renders workflow metadata", () => { + const props = createWorkflowMapper.props( + componentCtx({ + node: { + configuration: { name: "Support", scope: "PROJECT", project: "TEST", statuses: [{ name: "To Do" }] }, + metadata: { project: { key: "TEST", name: "Test Project" }, workflowName: "Support" }, + }, + }), + ); + + expect(props.metadata).toEqual([ + { icon: "workflow", label: "Support" }, + { icon: "globe", label: "Project scoped" }, + { icon: "folder", label: "Test Project" }, + { icon: "list", label: "1 statuses" }, + ]); + }); + + it("uses workflow name as subtitle", () => { + const result = createWorkflowMapper.subtitle({ + node: node(), + execution: execution({ + outputs: { + default: [{ type: "jira.workflow.created", timestamp: "2026-01-19T12:00:00Z", data: { name: "Support" } }], + }, + }), + } as SubtitleContext); + + expect(result).toBe("Support"); + }); + + it("maps finished success to created", () => { + expect(eventStateRegistry.createWorkflow.getState(execution())).toBe("created"); + }); +}); diff --git a/web_src/src/pages/workflowv2/mappers/jira/create_workflow.ts b/web_src/src/pages/workflowv2/mappers/jira/create_workflow.ts new file mode 100644 index 0000000000..4581d99c7a --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/jira/create_workflow.ts @@ -0,0 +1,82 @@ +import type { ComponentBaseProps } from "@/ui/componentBase"; +import type React from "react"; +import type { + ComponentBaseContext, + ComponentBaseMapper, + ExecutionDetailsContext, + NodeInfo, + OutputPayload, + SubtitleContext, +} from "../types"; +import type { MetadataItem } from "@/ui/metadataList"; +import { renderTimeAgo } from "@/components/TimeAgo"; +import { jiraComponentBaseProps } from "./base"; +import { addDetail, addProjectMetadata } from "./utils"; +import type { CreateWorkflowConfiguration, JiraNodeMetadata, JiraWorkflow } from "./types"; + +export const createWorkflowMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + return jiraComponentBaseProps(context, metadataList(context.node)); + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + const details: Record = { + "Executed At": context.execution.createdAt ? new Date(context.execution.createdAt).toLocaleString() : "-", + }; + + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + const workflow = outputs?.default?.[0]?.data as JiraWorkflow | undefined; + if (workflow) { + addDetail(details, "Workflow ID", workflow.id); + addDetail(details, "Name", workflow.name); + if (workflow.version?.versionNumber !== undefined) { + details["Version"] = String(workflow.version.versionNumber); + } + } + + const configuration = context.node.configuration as CreateWorkflowConfiguration | undefined; + if (configuration?.scope) { + details["Scope"] = configuration.scope; + } + if (configuration?.statuses?.length) { + details["Statuses"] = String(configuration.statuses.length); + } + if (configuration?.transitions?.length) { + details["Transitions"] = String(configuration.transitions.length); + } + + return details; + }, + + subtitle(context: SubtitleContext): string | React.ReactNode { + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + const workflow = outputs?.default?.[0]?.data as JiraWorkflow | undefined; + if (workflow?.name) return workflow.name; + if (context.execution.createdAt) { + return renderTimeAgo(new Date(context.execution.createdAt)); + } + return ""; + }, +}; + +function metadataList(node: NodeInfo): MetadataItem[] { + const metadata: MetadataItem[] = []; + const nodeMetadata = node.metadata as JiraNodeMetadata | undefined; + const configuration = node.configuration as CreateWorkflowConfiguration | undefined; + + const name = nodeMetadata?.workflowName || configuration?.name; + if (name) { + metadata.push({ icon: "workflow", label: name }); + } + + const scope = configuration?.scope || "GLOBAL"; + metadata.push({ icon: "globe", label: scope === "PROJECT" ? "Project scoped" : "Global" }); + + addProjectMetadata(metadata, nodeMetadata?.project, configuration?.project); + + if (configuration?.statuses?.length) { + metadata.push({ icon: "list", label: `${configuration.statuses.length} statuses` }); + } + + return metadata.slice(0, 4); +} diff --git a/web_src/src/pages/workflowv2/mappers/jira/index.ts b/web_src/src/pages/workflowv2/mappers/jira/index.ts index fb32e8909c..0bb550d824 100644 --- a/web_src/src/pages/workflowv2/mappers/jira/index.ts +++ b/web_src/src/pages/workflowv2/mappers/jira/index.ts @@ -7,6 +7,10 @@ import { updateIssueMapper } from "./update_issue"; import { createIncidentMapper } from "./create_incident"; import { getIncidentMapper } from "./get_incident"; import { deleteIncidentMapper } from "./delete_incident"; +import { createWorkflowMapper } from "./create_workflow"; +import { assignWorkflowToProjectMapper } from "./assign_workflow_to_project"; +import { transitionIssueMapper } from "./transition_issue"; +import { approveWorkflowMapper } from "./approve_workflow"; export const componentMappers: Record = { createIssue: createIssueMapper, @@ -16,6 +20,10 @@ export const componentMappers: Record = { createIncident: createIncidentMapper, getIncident: getIncidentMapper, deleteIncident: deleteIncidentMapper, + createWorkflow: createWorkflowMapper, + assignWorkflowToProject: assignWorkflowToProjectMapper, + transitionIssue: transitionIssueMapper, + approveWorkflow: approveWorkflowMapper, }; export const triggerRenderers: Record = {}; @@ -28,4 +36,8 @@ export const eventStateRegistry: Record = { createIncident: buildActionStateRegistry("created"), getIncident: buildActionStateRegistry("fetched"), deleteIncident: buildActionStateRegistry("deleted"), + createWorkflow: buildActionStateRegistry("created"), + assignWorkflowToProject: buildActionStateRegistry("assigned"), + transitionIssue: buildActionStateRegistry("transitioned"), + approveWorkflow: buildActionStateRegistry("decided"), }; diff --git a/web_src/src/pages/workflowv2/mappers/jira/transition_issue.spec.ts b/web_src/src/pages/workflowv2/mappers/jira/transition_issue.spec.ts new file mode 100644 index 0000000000..73a7e58176 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/jira/transition_issue.spec.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; + +import { transitionIssueMapper } from "./transition_issue"; +import { eventStateRegistry } from "./index"; +import type { ComponentBaseContext, ExecutionDetailsContext, ExecutionInfo, NodeInfo } from "../types"; + +function node(overrides?: Partial): NodeInfo { + return { + id: "node-1", + name: "Transition issue", + componentName: "jira.transitionIssue", + isCollapsed: false, + configuration: {}, + metadata: {}, + ...overrides, + }; +} + +function execution(overrides?: Partial): ExecutionInfo { + return { + id: "exec-1", + createdAt: "2026-01-19T12:00:00Z", + updatedAt: "2026-01-19T12:00:00Z", + state: "STATE_FINISHED", + result: "RESULT_PASSED", + resultReason: "RESULT_REASON_OK", + resultMessage: "", + metadata: {}, + configuration: {}, + rootEvent: undefined, + ...overrides, + }; +} + +function detailsCtx(overrides?: { + node?: Partial; + execution?: Partial; +}): ExecutionDetailsContext { + const n = node(overrides?.node); + return { nodes: [n], node: n, execution: execution(overrides?.execution) }; +} + +function componentCtx(overrides?: { node?: Partial }): ComponentBaseContext { + const n = node(overrides?.node); + return { + nodes: [n], + node: n, + componentDefinition: { + name: "jira.transitionIssue", + label: "Transition Issue", + description: "", + icon: "jira", + color: "blue", + }, + lastExecutions: [], + currentUser: undefined, + actions: { invokeNodeExecutionHook: async () => {} }, + }; +} + +describe("transitionIssueMapper", () => { + it("extracts transitioned issue details", () => { + const details = transitionIssueMapper.getExecutionDetails( + detailsCtx({ + node: { configuration: { targetStatus: "Done", resolution: "Done" } }, + execution: { + outputs: { + default: [ + { + type: "jira.issue", + timestamp: "2026-01-19T12:00:00Z", + data: { + key: "TEST-1", + self: "https://test.atlassian.net/rest/api/3/issue/10001", + fields: { summary: "Ship", status: { name: "Done" } }, + }, + }, + ], + }, + }, + }), + ); + + expect(details.Key).toBe("TEST-1"); + expect(details["Issue URL"]).toBe("https://test.atlassian.net/browse/TEST-1"); + expect(details.Status).toBe("Done"); + expect(details["Target Status"]).toBe("Done"); + expect(details.Resolution).toBe("Done"); + }); + + it("renders project, key, status, and resolution metadata", () => { + const props = transitionIssueMapper.props( + componentCtx({ + node: { + configuration: { project: "TEST", issueKey: "TEST-1", targetStatus: "Done", resolution: "Done" }, + metadata: { project: { key: "TEST", name: "Test Project" }, status: "Done" }, + }, + }), + ); + + expect(props.metadata).toEqual([ + { icon: "folder", label: "Test Project" }, + { icon: "hash", label: "TEST-1" }, + { icon: "flag", label: "Done" }, + { icon: "circle-check", label: "Done" }, + ]); + }); + + it("maps finished success to transitioned", () => { + expect(eventStateRegistry.transitionIssue.getState(execution())).toBe("transitioned"); + }); +}); diff --git a/web_src/src/pages/workflowv2/mappers/jira/transition_issue.ts b/web_src/src/pages/workflowv2/mappers/jira/transition_issue.ts new file mode 100644 index 0000000000..40754f1e56 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/jira/transition_issue.ts @@ -0,0 +1,73 @@ +import type { ComponentBaseProps } from "@/ui/componentBase"; +import type React from "react"; +import type { + ComponentBaseContext, + ComponentBaseMapper, + ExecutionDetailsContext, + NodeInfo, + OutputPayload, + SubtitleContext, +} from "../types"; +import type { MetadataItem } from "@/ui/metadataList"; +import { renderTimeAgo } from "@/components/TimeAgo"; +import { jiraComponentBaseProps } from "./base"; +import { addDetail, addIssueKeyMetadata, addProjectMetadata, getIssueLabel, getIssueUrl } from "./utils"; +import type { JiraIssue, JiraNodeMetadata, TransitionIssueConfiguration } from "./types"; + +export const transitionIssueMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + return jiraComponentBaseProps(context, metadataList(context.node)); + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + const details: Record = { + "Executed At": context.execution.createdAt ? new Date(context.execution.createdAt).toLocaleString() : "-", + }; + + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + const issue = outputs?.default?.[0]?.data as JiraIssue | undefined; + if (issue) { + addDetail(details, "Key", issue.key); + addDetail(details, "Issue URL", getIssueUrl(issue)); + addDetail(details, "Summary", issue.fields?.summary); + addDetail(details, "Status", issue.fields?.status?.name); + } + + const configuration = context.node.configuration as TransitionIssueConfiguration | undefined; + addDetail(details, "Target Status", configuration?.targetStatus); + addDetail(details, "Resolution", configuration?.resolution); + + return details; + }, + + subtitle(context: SubtitleContext): string | React.ReactNode { + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + const issue = outputs?.default?.[0]?.data as JiraIssue | undefined; + const label = getIssueLabel(issue); + if (label) return label; + if (context.execution.createdAt) { + return renderTimeAgo(new Date(context.execution.createdAt)); + } + return ""; + }, +}; + +function metadataList(node: NodeInfo): MetadataItem[] { + const metadata: MetadataItem[] = []; + const nodeMetadata = node.metadata as JiraNodeMetadata | undefined; + const configuration = node.configuration as TransitionIssueConfiguration | undefined; + + addProjectMetadata(metadata, nodeMetadata?.project, configuration?.project); + addIssueKeyMetadata(metadata, "hash", configuration?.issueKey); + + const status = nodeMetadata?.status || configuration?.targetStatus; + if (status) { + metadata.push({ icon: "flag", label: status }); + } + + if (configuration?.resolution) { + metadata.push({ icon: "circle-check", label: configuration.resolution }); + } + + return metadata; +} diff --git a/web_src/src/pages/workflowv2/mappers/jira/types.ts b/web_src/src/pages/workflowv2/mappers/jira/types.ts index b8b25600ae..374572aab0 100644 --- a/web_src/src/pages/workflowv2/mappers/jira/types.ts +++ b/web_src/src/pages/workflowv2/mappers/jira/types.ts @@ -2,6 +2,8 @@ export interface JiraProject { id?: string; key?: string; name?: string; + style?: string; + simplified?: boolean; } export interface JiraStatus { @@ -45,6 +47,46 @@ export interface JiraNodeMetadata { project?: JiraProject; issueType?: string; status?: string; + workflowName?: string; + workflowScheme?: JiraWorkflowScheme; +} + +export interface JiraWorkflowVersion { + id?: string; + versionNumber?: number; +} + +export interface JiraWorkflow { + id?: string; + name?: string; + version?: JiraWorkflowVersion; +} + +export interface JiraWorkflowScheme { + id?: string; + name?: string; + description?: string; + self?: string; +} + +export interface JiraWorkflowSchemeAssignment { + projectId?: string; + workflowSchemeId?: string; + draftCreated?: boolean; + dryRun?: boolean; + taskId?: string; + taskStatus?: string; + taskSelf?: string; +} + +export interface JiraApproval { + id?: string; + name?: string; + finalDecision?: string; + approvers?: Array<{ + approver?: JiraUser; + approverDecision?: string; + }>; } export interface CreateIssueConfiguration { @@ -79,3 +121,34 @@ export interface DeleteIssueConfiguration { issueKey?: string; deleteSubtasks?: boolean; } + +export interface CreateWorkflowConfiguration { + name?: string; + description?: string; + scope?: string; + project?: string; + statuses?: Array<{ name?: string; category?: string }>; + transitions?: Array<{ name?: string; from?: string[]; to?: string; type?: string }>; +} + +export interface AssignWorkflowToProjectConfiguration { + project?: string; + workflowScheme?: string; + dryRun?: boolean; +} + +export interface TransitionIssueConfiguration { + project?: string; + issueKey?: string; + targetStatus?: string; + comment?: string; + resolution?: string; +} + +export interface ApproveWorkflowConfiguration { + issueKey?: string; + decision?: string; + approvalSelector?: string; + approvalId?: string; + comment?: string; +} From 17c970a18b3343ab50a3eaa571b74ee6e92b2d96 Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Tue, 19 May 2026 17:40:02 +0300 Subject: [PATCH 2/8] refactor: update Jira documentation and remove unused components Signed-off-by: WashingtonKK --- docs/components/Jira.mdx | 178 +++--- .../jira/assign_workflow_to_project.go | 278 --------- .../jira/assign_workflow_to_project_test.go | 149 ----- pkg/integrations/jira/client.go | 420 ++++++++------ pkg/integrations/jira/common.go | 57 +- pkg/integrations/jira/common_test.go | 77 ++- pkg/integrations/jira/create_workflow.go | 543 ------------------ pkg/integrations/jira/create_workflow_test.go | 215 ------- pkg/integrations/jira/example.go | 30 +- ...ple_output_assign_workflow_to_project.json | 12 - .../jira/example_output_create_workflow.json | 12 - .../jira/example_output_get_workflow.json | 46 ++ pkg/integrations/jira/get_workflow.go | 337 +++++++++++ pkg/integrations/jira/get_workflow_test.go | 219 +++++++ pkg/integrations/jira/jira.go | 3 +- pkg/integrations/jira/list_resources.go | 54 +- pkg/integrations/jira/list_resources_test.go | 101 +++- .../jira/transition_issue_test.go | 2 +- .../jira/assign_workflow_to_project.ts | 71 --- .../mappers/jira/create_workflow.spec.ts | 121 ---- ...o_project.spec.ts => get_workflow.spec.ts} | 65 ++- .../{create_workflow.ts => get_workflow.ts} | 51 +- .../pages/workflowv2/mappers/jira/index.ts | 9 +- .../pages/workflowv2/mappers/jira/types.ts | 49 +- 24 files changed, 1232 insertions(+), 1867 deletions(-) delete mode 100644 pkg/integrations/jira/assign_workflow_to_project.go delete mode 100644 pkg/integrations/jira/assign_workflow_to_project_test.go delete mode 100644 pkg/integrations/jira/create_workflow.go delete mode 100644 pkg/integrations/jira/create_workflow_test.go delete mode 100644 pkg/integrations/jira/example_output_assign_workflow_to_project.json delete mode 100644 pkg/integrations/jira/example_output_create_workflow.json create mode 100644 pkg/integrations/jira/example_output_get_workflow.json create mode 100644 pkg/integrations/jira/get_workflow.go create mode 100644 pkg/integrations/jira/get_workflow_test.go delete mode 100644 web_src/src/pages/workflowv2/mappers/jira/assign_workflow_to_project.ts delete mode 100644 web_src/src/pages/workflowv2/mappers/jira/create_workflow.spec.ts rename web_src/src/pages/workflowv2/mappers/jira/{assign_workflow_to_project.spec.ts => get_workflow.spec.ts} (51%) rename web_src/src/pages/workflowv2/mappers/jira/{create_workflow.ts => get_workflow.ts} (53%) diff --git a/docs/components/Jira.mdx b/docs/components/Jira.mdx index 7b3abec890..477771a946 100644 --- a/docs/components/Jira.mdx +++ b/docs/components/Jira.mdx @@ -10,14 +10,13 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; - - + @@ -89,51 +88,6 @@ Returns the updated approval payload from Jira Service Management. } ``` - - -## Assign Workflow To Project - -The Assign Workflow To Project component switches a Jira project to an existing workflow scheme. - -### Use Cases - -- **Project provisioning**: apply a known workflow scheme after creating or preparing a Jira project -- **Workflow rollout**: move company-managed projects to an updated workflow scheme -- **Canvas validation**: run in dry-run mode to validate the selected project and scheme without changing Jira - -### Configuration - -- **Project**: Company-managed Jira project to update. -- **Workflow Scheme**: Existing Jira workflow scheme to assign. -- **Dry Run**: Validate inputs and emit the planned assignment without changing Jira. - -### Output - -Returns `projectId`, `workflowSchemeId`, `draftCreated`, and any Jira task metadata returned by the workflow scheme switch. - -### Notes - -- Requires Jira admin permissions (`manage:jira-configuration`). -- Workflow schemes can only be assigned to company-managed projects. Team-managed projects reject workflow scheme changes. -- Jira may start a background task when switching schemes, especially when existing issues need migration. - -### Example Output - -```json -{ - "data": { - "draftCreated": false, - "projectId": "10000", - "taskId": "3f83dg2a-ns2n-56ab-9812-42h5j1461629", - "taskSelf": "https://your-domain.atlassian.net/rest/api/3/task/3f83dg2a-ns2n-56ab-9812-42h5j1461629", - "taskStatus": "ENQUEUED", - "workflowSchemeId": "101010" - }, - "timestamp": "2026-01-19T12:00:00Z", - "type": "jira.workflowScheme.assigned" -} -``` - ## Create Incident @@ -265,54 +219,6 @@ Returns the created issue including: } ``` - - -## Create Workflow - -The Create Workflow component creates a Jira workflow with statuses and transitions. - -### Use Cases - -- **Service request lifecycle**: define a standard request workflow before assigning it through a workflow scheme -- **JSM rollout automation**: create a workflow from a SuperPlane canvas as part of project provisioning -- **Environment parity**: recreate workflow structure across Jira sites - -### Configuration - -- **Name**: Workflow name. -- **Description**: Optional workflow description. -- **Scope**: Global or project-scoped. Project-scoped workflows require a Jira project. -- **Project**: Required when scope is Project. -- **Statuses**: List of statuses with a category: TODO, IN_PROGRESS, or DONE. -- **Transitions**: List of transitions with a target status. Directed transitions use the From status list; Global transitions are available from any status. - -### Output - -Returns the created workflow's `id`, `name`, and `version`. - -### Notes - -- Requires Jira admin permissions (`manage:jira-configuration`). -- Jira creates workflows independently from projects. Use Assign Workflow To Project to apply a workflow scheme to a company-managed project. -- New issues enter the **first listed status**. SuperPlane injects the Jira-required initial transition pointing at that status; the order of the Statuses list determines the starting state. - -### Example Output - -```json -{ - "data": { - "id": "b9ff2384-d3b6-4d4e-9509-3ee19f607168", - "name": "Service request workflow", - "version": { - "id": "f010ac1b-3dd3-43a3-aa66-0ee8a447f76e", - "versionNumber": 1 - } - }, - "timestamp": "2026-01-19T12:00:00Z", - "type": "jira.workflow.created" -} -``` - ## Delete Incident @@ -488,6 +394,88 @@ Returns the Jira issue object including `id`, `key`, `self` and the full `fields } ``` + + +## Get Workflow + +The Get Workflow component returns the Jira workflow that governs a given issue. + +### Use Cases + +- **State-machine introspection**: see every status in the workflow plus where the issue is right now +- **Routing decisions**: branch on which transitions are currently reachable before running `transitionIssue` +- **Operator dashboards**: render the workflow as a graph next to the issue + +### Configuration + +- **Project**: The Jira project the issue belongs to. +- **Issue Key**: Jira issue key, for example `PROJ-123`. + +### Output + +Returns: + +- `workflowName` and `workflowSchemeName` — the workflow scheme assigned to the project and the workflow it routes the issue's type to. +- `currentStatus` / `currentStatusId` — where the issue is now. +- `statuses` — every status the workflow defines (with `isCurrent` set on the current one). +- `availableTransitions` — transitions reachable from the issue's current state, each with the transition id, name, and target status. + +### Notes + +- Resolving the bound workflow goes `issue → project + issue type → workflow scheme → workflow`. Team-managed (next-gen) projects don't expose a workflow scheme; in that case `workflowName` and `statuses` are empty but `currentStatus` and `availableTransitions` are still populated. +- The `availableTransitions` list reflects workflow rules, conditions, and the calling user's permissions — it is exactly what Jira would offer in the issue view. + +### Example Output + +```json +{ + "data": { + "availableTransitions": [ + { + "id": "21", + "name": "Stop progress", + "toStatus": "To Do", + "toStatusId": "10001" + }, + { + "id": "31", + "name": "Resolve", + "toStatus": "Done", + "toStatusId": "10003" + } + ], + "currentStatus": "In Progress", + "currentStatusId": "10002", + "issueKey": "PROJ-123", + "issueType": "Task", + "projectKey": "PROJ", + "statuses": [ + { + "category": "TODO", + "id": "10001", + "name": "To Do" + }, + { + "category": "IN_PROGRESS", + "id": "10002", + "isCurrent": true, + "name": "In Progress" + }, + { + "category": "DONE", + "id": "10003", + "name": "Done" + } + ], + "workflowName": "Software Simplified Workflow", + "workflowSchemeId": "101010", + "workflowSchemeName": "Default workflow scheme" + }, + "timestamp": "2026-01-19T12:00:00Z", + "type": "jira.workflow" +} +``` + ## Transition Issue diff --git a/pkg/integrations/jira/assign_workflow_to_project.go b/pkg/integrations/jira/assign_workflow_to_project.go deleted file mode 100644 index ee96656c85..0000000000 --- a/pkg/integrations/jira/assign_workflow_to_project.go +++ /dev/null @@ -1,278 +0,0 @@ -package jira - -import ( - "fmt" - "net/http" - "strings" - - "github.com/google/uuid" - "github.com/mitchellh/mapstructure" - "github.com/superplanehq/superplane/pkg/configuration" - "github.com/superplanehq/superplane/pkg/core" -) - -const AssignWorkflowToProjectPayloadType = "jira.workflowScheme.assigned" - -type AssignWorkflowToProject struct{} - -type AssignWorkflowToProjectSpec struct { - Project string `json:"project" mapstructure:"project"` - WorkflowScheme string `json:"workflowScheme" mapstructure:"workflowScheme"` - DryRun bool `json:"dryRun" mapstructure:"dryRun"` -} - -type WorkflowSchemeAssignmentOutput struct { - ProjectID string `json:"projectId"` - WorkflowSchemeID string `json:"workflowSchemeId"` - DraftCreated bool `json:"draftCreated"` - DryRun bool `json:"dryRun,omitempty"` - TaskID string `json:"taskId,omitempty"` - TaskStatus string `json:"taskStatus,omitempty"` - TaskSelf string `json:"taskSelf,omitempty"` -} - -func (c *AssignWorkflowToProject) Name() string { - return "jira.assignWorkflowToProject" -} - -func (c *AssignWorkflowToProject) Label() string { - return "Assign Workflow To Project" -} - -func (c *AssignWorkflowToProject) Description() string { - return "Assign a Jira workflow scheme to a company-managed project" -} - -func (c *AssignWorkflowToProject) Documentation() string { - return `The Assign Workflow To Project component switches a Jira project to an existing workflow scheme. - -## Use Cases - -- **Project provisioning**: apply a known workflow scheme after creating or preparing a Jira project -- **Workflow rollout**: move company-managed projects to an updated workflow scheme -- **Canvas validation**: run in dry-run mode to validate the selected project and scheme without changing Jira - -## Configuration - -- **Project**: Company-managed Jira project to update. -- **Workflow Scheme**: Existing Jira workflow scheme to assign. -- **Dry Run**: Validate inputs and emit the planned assignment without changing Jira. - -## Output - -Returns ` + "`projectId`" + `, ` + "`workflowSchemeId`" + `, ` + "`draftCreated`" + `, and any Jira task metadata returned by the workflow scheme switch. - -## Notes - -- Requires Jira admin permissions (` + "`manage:jira-configuration`" + `). -- Workflow schemes can only be assigned to company-managed projects. Team-managed projects reject workflow scheme changes. -- Jira may start a background task when switching schemes, especially when existing issues need migration.` -} - -func (c *AssignWorkflowToProject) Icon() string { - return "jira" -} - -func (c *AssignWorkflowToProject) Color() string { - return "blue" -} - -func (c *AssignWorkflowToProject) OutputChannels(configuration any) []core.OutputChannel { - return []core.OutputChannel{core.DefaultOutputChannel} -} - -func (c *AssignWorkflowToProject) Configuration() []configuration.Field { - return []configuration.Field{ - { - Name: "project", - Label: "Project", - Type: configuration.FieldTypeIntegrationResource, - Required: true, - Description: "Company-managed Jira project", - Placeholder: "Select a project", - TypeOptions: &configuration.TypeOptions{ - Resource: &configuration.ResourceTypeOptions{Type: "project"}, - }, - }, - { - Name: "workflowScheme", - Label: "Workflow Scheme", - Type: configuration.FieldTypeIntegrationResource, - Required: true, - Description: "Existing Jira workflow scheme", - Placeholder: "Select a workflow scheme", - TypeOptions: &configuration.TypeOptions{ - Resource: &configuration.ResourceTypeOptions{Type: "workflowScheme"}, - }, - }, - { - Name: "dryRun", - Label: "Dry Run", - Type: configuration.FieldTypeBool, - Required: false, - Description: "Validate the assignment without changing Jira", - Default: false, - }, - } -} - -func (c *AssignWorkflowToProject) Setup(ctx core.SetupContext) error { - spec := AssignWorkflowToProjectSpec{} - if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { - return fmt.Errorf("failed to decode configuration: %v", err) - } - - if strings.TrimSpace(spec.Project) == "" { - return fmt.Errorf("project is required") - } - if strings.TrimSpace(spec.WorkflowScheme) == "" { - return fmt.Errorf("workflowScheme is required") - } - - project, scheme, err := loadWorkflowSchemeAssignmentSetup(ctx.HTTP, ctx.Integration, spec.Project, spec.WorkflowScheme) - if err != nil { - return err - } - - return ctx.Metadata.Set(NodeMetadata{Project: project, WorkflowScheme: scheme}) -} - -func (c *AssignWorkflowToProject) Execute(ctx core.ExecutionContext) error { - spec := AssignWorkflowToProjectSpec{} - if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { - return fmt.Errorf("failed to decode configuration: %v", err) - } - - projectKey := strings.TrimSpace(spec.Project) - schemeID := strings.TrimSpace(spec.WorkflowScheme) - if projectKey == "" { - return fmt.Errorf("project is required") - } - if schemeID == "" { - return fmt.Errorf("workflowScheme is required") - } - - client, err := NewClient(ctx.HTTP, ctx.Integration) - if err != nil { - return fmt.Errorf("failed to create client: %v", err) - } - - project, err := client.GetProject(projectKey) - if err != nil { - return fmt.Errorf("failed to fetch project: %v", err) - } - if isTeamManagedProject(project) { - return fmt.Errorf("workflow schemes can only be assigned to company-managed projects") - } - - if spec.DryRun { - return ctx.ExecutionState.Emit( - core.DefaultOutputChannel.Name, - AssignWorkflowToProjectPayloadType, - []any{WorkflowSchemeAssignmentOutput{ - ProjectID: project.ID, - WorkflowSchemeID: schemeID, - DraftCreated: false, - DryRun: true, - }}, - ) - } - - resp, err := client.AssignWorkflowSchemeToProject(project.ID, schemeID) - if err != nil { - if strings.Contains(err.Error(), "403") { - return fmt.Errorf("failed to assign workflow scheme: %v — the API token must belong to a Jira admin", err) - } - return fmt.Errorf("failed to assign workflow scheme: %v", err) - } - - output := WorkflowSchemeAssignmentOutput{ - ProjectID: resp.ProjectID, - WorkflowSchemeID: resp.WorkflowSchemeID, - DraftCreated: false, - } - if resp.Task != nil { - output.TaskID = resp.Task.ID.String() - output.TaskStatus = resp.Task.Status - output.TaskSelf = resp.Task.Self - } - - return ctx.ExecutionState.Emit( - core.DefaultOutputChannel.Name, - AssignWorkflowToProjectPayloadType, - []any{output}, - ) -} - -func loadWorkflowSchemeAssignmentSetup( - httpCtx core.HTTPContext, - integration core.IntegrationContext, - projectKey, - schemeID string, -) (*Project, *WorkflowScheme, error) { - projectKey = strings.TrimSpace(projectKey) - schemeID = strings.TrimSpace(schemeID) - - if httpCtx == nil { - project, err := requireProjectFromMetadata(integration, projectKey) - return project, &WorkflowScheme{ID: FlexibleString(schemeID)}, err - } - - client, err := NewClient(httpCtx, integration) - if err != nil { - return nil, nil, fmt.Errorf("failed to create client: %v", err) - } - - project, err := client.GetProject(projectKey) - if err != nil { - return nil, nil, fmt.Errorf("failed to fetch project: %v", err) - } - if isTeamManagedProject(project) { - return nil, nil, fmt.Errorf("workflow schemes can only be assigned to company-managed projects") - } - - schemes, err := client.ListWorkflowSchemes() - if err != nil { - return nil, nil, fmt.Errorf("failed to list workflow schemes: %v", err) - } - for _, scheme := range schemes { - if scheme.ID.String() == schemeID { - s := scheme - return project, &s, nil - } - } - - return nil, nil, fmt.Errorf("workflow scheme %s not found", schemeID) -} - -func isTeamManagedProject(project *Project) bool { - if project == nil { - return false - } - style := strings.ToLower(strings.TrimSpace(project.Style)) - return project.Simplified || style == "next-gen" || style == "nextgen" || style == "team-managed" -} - -func (c *AssignWorkflowToProject) Cancel(ctx core.ExecutionContext) error { - return nil -} - -func (c *AssignWorkflowToProject) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { - return ctx.DefaultProcessing() -} - -func (c *AssignWorkflowToProject) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { - return http.StatusOK, nil, nil -} - -func (c *AssignWorkflowToProject) Cleanup(ctx core.SetupContext) error { - return nil -} - -func (c *AssignWorkflowToProject) Hooks() []core.Hook { - return []core.Hook{} -} - -func (c *AssignWorkflowToProject) HandleHook(ctx core.ActionHookContext) error { - return nil -} diff --git a/pkg/integrations/jira/assign_workflow_to_project_test.go b/pkg/integrations/jira/assign_workflow_to_project_test.go deleted file mode 100644 index 4258eb1826..0000000000 --- a/pkg/integrations/jira/assign_workflow_to_project_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package jira - -import ( - "encoding/json" - "io" - "net/http" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/superplanehq/superplane/pkg/core" - "github.com/superplanehq/superplane/test/support/contexts" -) - -func Test__AssignWorkflowToProject__Setup(t *testing.T) { - component := AssignWorkflowToProject{} - - t.Run("missing workflow scheme -> error", func(t *testing.T) { - err := component.Setup(core.SetupContext{ - Integration: newAuthorizedIntegration(), - Metadata: &contexts.MetadataContext{}, - Configuration: map[string]any{"project": "TEST"}, - }) - - require.ErrorContains(t, err, "workflowScheme is required") - }) - - t.Run("team-managed project -> clear error", func(t *testing.T) { - httpContext := &contexts.HTTPContext{ - Responses: []*http.Response{ - { - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{"id":"10000","key":"TEAM","name":"Team","style":"next-gen","simplified":true}`)), - }, - }, - } - - err := component.Setup(core.SetupContext{ - HTTP: httpContext, - Integration: newAuthorizedIntegration(), - Metadata: &contexts.MetadataContext{}, - Configuration: map[string]any{"project": "TEAM", "workflowScheme": "101010"}, - }) - - require.Error(t, err) - assert.Contains(t, err.Error(), "company-managed projects") - }) - - t.Run("valid setup stores workflow scheme metadata", func(t *testing.T) { - httpContext := &contexts.HTTPContext{ - Responses: []*http.Response{ - { - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{"id":"10000","key":"TEST","name":"Test","style":"classic","simplified":false}`)), - }, - { - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{"isLast":true,"values":[{"id":101010,"name":"Support scheme"}]}`)), - }, - }, - } - metadataCtx := &contexts.MetadataContext{} - - err := component.Setup(core.SetupContext{ - HTTP: httpContext, - Integration: newAuthorizedIntegration(), - Metadata: metadataCtx, - Configuration: map[string]any{"project": "TEST", "workflowScheme": "101010"}, - }) - - require.NoError(t, err) - nodeMetadata, ok := metadataCtx.Metadata.(NodeMetadata) - require.True(t, ok) - require.NotNil(t, nodeMetadata.Project) - require.NotNil(t, nodeMetadata.WorkflowScheme) - assert.Equal(t, "Support scheme", nodeMetadata.WorkflowScheme.Name) - }) -} - -func Test__AssignWorkflowToProject__Execute(t *testing.T) { - component := AssignWorkflowToProject{} - - t.Run("switches workflow scheme and emits task metadata", func(t *testing.T) { - httpContext := &contexts.HTTPContext{ - Responses: []*http.Response{ - { - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{"id":"10000","key":"TEST","name":"Test","style":"classic","simplified":false}`)), - }, - { - StatusCode: http.StatusSeeOther, - Body: io.NopCloser(strings.NewReader(`{"id":"task-1","status":"ENQUEUED","self":"https://test.atlassian.net/rest/api/3/task/task-1"}`)), - }, - }, - } - - execCtx := &contexts.ExecutionStateContext{} - err := component.Execute(core.ExecutionContext{ - Configuration: map[string]any{ - "project": "TEST", - "workflowScheme": "101010", - }, - HTTP: httpContext, - Integration: newAuthorizedIntegration(), - ExecutionState: execCtx, - }) - - require.NoError(t, err) - assert.True(t, execCtx.Passed) - assert.Equal(t, AssignWorkflowToProjectPayloadType, execCtx.Type) - require.Len(t, httpContext.Requests, 2) - assert.Contains(t, httpContext.Requests[1].URL.String(), "/rest/api/3/workflowscheme/project/switch") - - body, err := io.ReadAll(httpContext.Requests[1].Body) - require.NoError(t, err) - var payload map[string]any - require.NoError(t, json.Unmarshal(body, &payload)) - assert.Equal(t, "10000", payload["projectId"]) - assert.Equal(t, "101010", payload["targetSchemeId"]) - }) - - t.Run("dry run skips scheme switch", func(t *testing.T) { - httpContext := &contexts.HTTPContext{ - Responses: []*http.Response{ - { - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{"id":"10000","key":"TEST","name":"Test","style":"classic","simplified":false}`)), - }, - }, - } - - execCtx := &contexts.ExecutionStateContext{} - err := component.Execute(core.ExecutionContext{ - Configuration: map[string]any{ - "project": "TEST", - "workflowScheme": "101010", - "dryRun": true, - }, - HTTP: httpContext, - Integration: newAuthorizedIntegration(), - ExecutionState: execCtx, - }) - - require.NoError(t, err) - assert.True(t, execCtx.Passed) - require.Len(t, httpContext.Requests, 1) - }) -} diff --git a/pkg/integrations/jira/client.go b/pkg/integrations/jira/client.go index 5324184ba7..5ca98325df 100644 --- a/pkg/integrations/jira/client.go +++ b/pkg/integrations/jira/client.go @@ -177,13 +177,29 @@ func (c *Client) GetProjectIssueTypes(projectKey string) ([]IssueTypeMeta, error return resp.IssueTypes, nil } +// Status represents a Jira workflow status. Category is the normalized +// statusCategory value used by Jira's workflow APIs: "TODO", +// "IN_PROGRESS", "DONE", or "UNDEFINED". It is populated from either the +// flat string returned by /rest/api/3/statuses/search or the nested +// statusCategory.key returned by /rest/api/3/project/{key}/statuses. type Status struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id"` + Name string `json:"name"` + Category string `json:"-"` +} + +type projectStatusCategory struct { + Key string `json:"key"` +} + +type projectStatus struct { + ID string `json:"id"` + Name string `json:"name"` + StatusCategory projectStatusCategory `json:"statusCategory"` } type projectStatusesIssueType struct { - Statuses []Status `json:"statuses"` + Statuses []projectStatus `json:"statuses"` } // GetProjectStatuses returns the unique set of statuses across all issue @@ -211,16 +227,123 @@ func (c *Client) GetProjectStatuses(projectKey string) ([]Status, error) { continue } seen[s.Name] = true - statuses = append(statuses, s) + statuses = append(statuses, Status{ + ID: s.ID, + Name: s.Name, + Category: normalizeStatusCategoryKey(s.StatusCategory.Key), + }) + } + } + return statuses, nil +} + +type globalStatusesPage struct { + IsLast bool `json:"isLast"` + NextPage string `json:"nextPage"` + Values []globalStatus `json:"values"` +} + +type globalStatus struct { + ID string `json:"id"` + Name string `json:"name"` + StatusCategory string `json:"statusCategory"` +} + +// ListGlobalStatuses returns every workflow status visible to the caller via +// /rest/api/3/statuses/search. Used by the issueStatus resource picker when +// no project context is available (e.g. when defining a global workflow) and +// to look up status categories at workflow-create time. +func (c *Client) ListGlobalStatuses() ([]Status, error) { + endpoint := c.apiURL("/rest/api/3/statuses/search?maxResults=200") + seen := map[string]bool{} + statuses := []Status{} + + for endpoint != "" { + body, err := c.execRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + var page globalStatusesPage + if err := json.Unmarshal(body, &page); err != nil { + return nil, fmt.Errorf("error parsing statuses search response: %v", err) + } + + for _, s := range page.Values { + if seen[s.Name] { + continue + } + seen[s.Name] = true + statuses = append(statuses, Status{ + ID: s.ID, + Name: s.Name, + Category: normalizeStatusCategoryName(s.StatusCategory), + }) } + + if page.IsLast || page.NextPage == "" { + break + } + endpoint = page.NextPage } + return statuses, nil } +// normalizeStatusCategoryKey converts the lowercase "key" values returned by +// /rest/api/3/project/{key}/statuses (new/indeterminate/done/undefined) into +// the upper-case category names accepted by /rest/api/3/workflows/create. +func normalizeStatusCategoryKey(key string) string { + switch strings.ToLower(strings.TrimSpace(key)) { + case "new": + return "TODO" + case "indeterminate": + return "IN_PROGRESS" + case "done": + return "DONE" + default: + return "UNDEFINED" + } +} + +// normalizeStatusCategoryName accepts the category names returned by +// /rest/api/3/statuses/search (already TODO/IN_PROGRESS/DONE/UNDEFINED) and +// returns the canonical value used by workflow create requests. +func normalizeStatusCategoryName(name string) string { + switch strings.ToUpper(strings.TrimSpace(name)) { + case "TODO": + return "TODO" + case "IN_PROGRESS": + return "IN_PROGRESS" + case "DONE": + return "DONE" + default: + return "UNDEFINED" + } +} + type Transition struct { ID string `json:"id"` Name string `json:"name"` To Status `json:"to"` + // Fields lists the fields that are present on this transition's screen, + // keyed by Jira field id (for example "resolution", "customfield_10010"). + // Populated by GetIssueTransitions because we always pass + // expand=transitions.fields — needed to know whether a transition supports + // setting fields like resolution. Empty when no screen is configured. + Fields map[string]any `json:"fields,omitempty"` +} + +// HasField reports whether this transition's screen includes the named Jira +// field id. Used to avoid the "Field 'X' cannot be set. It is not on the +// appropriate screen" error from Jira when the user supplies a field that +// the chosen transition doesn't actually accept. +func (t Transition) HasField(fieldID string) bool { + if t.Fields == nil { + return false + } + _, ok := t.Fields[strings.TrimSpace(fieldID)] + return ok } type transitionsResponse struct { @@ -228,9 +351,13 @@ type transitionsResponse struct { } // GetIssueTransitions returns the transitions available from an issue's -// current workflow state. +// current workflow state, expanded with each transition's per-screen fields +// so callers can decide whether a given field (for example resolution) can +// be set during the transition. func (c *Client) GetIssueTransitions(issueKey string) ([]Transition, error) { - endpoint := c.apiURL("/rest/api/3/issue/" + url.PathEscape(issueKey) + "/transitions") + query := url.Values{} + query.Set("expand", "transitions.fields") + endpoint := c.apiURL("/rest/api/3/issue/" + url.PathEscape(issueKey) + "/transitions?" + query.Encode()) body, err := c.execRequest(http.MethodGet, endpoint, nil) if err != nil { @@ -325,7 +452,12 @@ func (c *Client) DoTransition(issueKey, id string) error { return c.DoTransitionWithOptions(issueKey, id, DoTransitionOptions{}) } -// DoTransitionWithOptions advances an issue and optionally applies transition-scoped fields. +// DoTransitionWithOptions advances an issue and optionally applies +// transition-scoped fields. The caller is responsible for ensuring that any +// fields it sets are actually on the chosen transition's screen — Jira +// returns a 400 with "Field 'X' cannot be set. It is not on the appropriate +// screen, or unknown." otherwise. applyStatusWithOptions handles that +// pre-check. func (c *Client) DoTransitionWithOptions(issueKey, id string, opts DoTransitionOptions) error { endpoint := c.apiURL("/rest/api/3/issue/" + url.PathEscape(issueKey) + "/transitions") @@ -381,208 +513,148 @@ func (s FlexibleString) String() string { return string(s) } -type WorkflowScope struct { - Type string `json:"type"` - Project *WorkflowScopeProjectRef `json:"project,omitempty"` -} - -type WorkflowScopeProjectRef struct { - ID string `json:"id"` -} - -type WorkflowLayout struct { - X float64 `json:"x"` - Y float64 `json:"y"` -} - -type WorkflowStatusUpdate struct { - Description string `json:"description"` - Name string `json:"name"` - StatusCategory string `json:"statusCategory"` - StatusReference string `json:"statusReference"` -} - -type WorkflowCreateStatus struct { - Layout WorkflowLayout `json:"layout"` - Properties map[string]any `json:"properties"` - StatusReference string `json:"statusReference"` -} - -type WorkflowTransitionLink struct { - FromPort int `json:"fromPort"` - FromStatusReference string `json:"fromStatusReference"` - ToPort int `json:"toPort"` -} - -type WorkflowCreateTransition struct { - Actions []any `json:"actions"` - Description string `json:"description"` - ID string `json:"id"` - Links []WorkflowTransitionLink `json:"links"` - Name string `json:"name"` - Properties map[string]any `json:"properties"` - ToStatusReference string `json:"toStatusReference"` - Triggers []any `json:"triggers"` - Type string `json:"type"` - Validators []any `json:"validators"` -} - -type WorkflowCreate struct { - Description string `json:"description"` - Name string `json:"name"` - StartPointLayout WorkflowLayout `json:"startPointLayout"` - Statuses []WorkflowCreateStatus `json:"statuses"` - Transitions []WorkflowCreateTransition `json:"transitions"` +// WorkflowSchemeDetail is returned by GET /rest/api/3/workflowscheme/{id}. It +// describes which workflow is used per issue type. Used to resolve the +// workflow bound to an issue (issue type ID -> workflow name). +type WorkflowSchemeDetail struct { + ID FlexibleString `json:"id"` + Name string `json:"name"` + DefaultWorkflow string `json:"defaultWorkflow"` + IssueTypeMappings map[string]string `json:"issueTypeMappings"` } -type CreateWorkflowRequest struct { - Scope WorkflowScope `json:"scope"` - Statuses []WorkflowStatusUpdate `json:"statuses"` - Workflows []WorkflowCreate `json:"workflows"` -} - -type WorkflowVersion struct { - ID string `json:"id"` - VersionNumber int `json:"versionNumber"` -} - -type CreatedWorkflow struct { - Description string `json:"description,omitempty"` - ID string `json:"id"` - IsEditable bool `json:"isEditable,omitempty"` - Name string `json:"name"` - Scope WorkflowScope `json:"scope"` - Version WorkflowVersion `json:"version"` -} - -type CreateWorkflowResponse struct { - Statuses []WorkflowStatusUpdate `json:"statuses"` - Workflows []CreatedWorkflow `json:"workflows"` -} - -func (c *Client) CreateWorkflow(req *CreateWorkflowRequest) (*CreateWorkflowResponse, error) { - body, err := json.Marshal(req) - if err != nil { - return nil, fmt.Errorf("error marshaling workflow create request: %v", err) - } - - responseBody, err := c.execRequest(http.MethodPost, c.apiURL("/rest/api/3/workflows/create"), bytes.NewReader(body)) +// GetWorkflowScheme returns details for one workflow scheme. +func (c *Client) GetWorkflowScheme(schemeID string) (*WorkflowSchemeDetail, error) { + endpoint := c.apiURL("/rest/api/3/workflowscheme/" + url.PathEscape(schemeID)) + body, err := c.execRequest(http.MethodGet, endpoint, nil) if err != nil { return nil, err } - - var response CreateWorkflowResponse - if err := json.Unmarshal(responseBody, &response); err != nil { - return nil, fmt.Errorf("error parsing workflow create response: %v", err) + var out WorkflowSchemeDetail + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("error parsing workflow scheme response: %v", err) } - return &response, nil + if out.IssueTypeMappings == nil { + out.IssueTypeMappings = map[string]string{} + } + return &out, nil } -type WorkflowScheme struct { - ID FlexibleString `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - DefaultWorkflow string `json:"defaultWorkflow,omitempty"` - Draft bool `json:"draft,omitempty"` - Self string `json:"self,omitempty"` +// projectWorkflowSchemeAssignment captures one entry in the response of +// /rest/api/3/workflowscheme/project — the assignment of a workflow scheme +// (which can be inlined as workflowScheme) to a project. +type projectWorkflowSchemeAssignment struct { + ProjectIDs []string `json:"projectIds"` + WorkflowScheme struct { + ID FlexibleString `json:"id"` + Name string `json:"name"` + DefaultWorkflow string `json:"defaultWorkflow,omitempty"` + } `json:"workflowScheme"` } -type workflowSchemesPage struct { - StartAt int `json:"startAt"` - MaxResults int `json:"maxResults"` - Total int `json:"total"` - IsLast bool `json:"isLast"` - Values []WorkflowScheme `json:"values"` +type projectWorkflowSchemesResponse struct { + Values []projectWorkflowSchemeAssignment `json:"values"` } -func (c *Client) ListWorkflowSchemes() ([]WorkflowScheme, error) { - var out []WorkflowScheme - startAt := 0 - const pageSize = 50 - - for range 20 { - query := url.Values{} - query.Set("startAt", strconv.Itoa(startAt)) - query.Set("maxResults", strconv.Itoa(pageSize)) - endpoint := c.apiURL("/rest/api/3/workflowscheme?" + query.Encode()) - - body, err := c.execRequest(http.MethodGet, endpoint, nil) - if err != nil { - return nil, err - } +// GetWorkflowSchemeForProject returns the workflow scheme assigned to a +// company-managed project. For team-managed projects Jira may return an empty +// list (their workflow lives directly on the project), so callers should +// handle a nil result. +func (c *Client) GetWorkflowSchemeForProject(projectID string) (*WorkflowSchemeDetail, error) { + query := url.Values{} + query.Set("projectId", projectID) + endpoint := c.apiURL("/rest/api/3/workflowscheme/project?" + query.Encode()) - var page workflowSchemesPage - if err := json.Unmarshal(body, &page); err != nil { - return nil, fmt.Errorf("error parsing workflow schemes response: %v", err) - } + body, err := c.execRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + var resp projectWorkflowSchemesResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("error parsing project workflow scheme response: %v", err) + } - out = append(out, page.Values...) - if page.IsLast || len(page.Values) == 0 { - break - } - startAt += len(page.Values) - if page.Total > 0 && startAt >= page.Total { - break + for _, assignment := range resp.Values { + schemeID := strings.TrimSpace(assignment.WorkflowScheme.ID.String()) + if schemeID == "" { + continue } + // Resolve the full scheme details (the inlined version omits issueTypeMappings). + return c.GetWorkflowScheme(schemeID) } - return out, nil + return nil, nil } -type WorkflowSchemeAssignmentResponse struct { - ProjectID string - WorkflowSchemeID string - Task *TaskProgress +type workflowSearchEntry struct { + ID struct { + Name string `json:"name"` + } `json:"id"` + Statuses []Status `json:"statuses"` } -type TaskProgress struct { - ID FlexibleString `json:"id"` - Self string `json:"self,omitempty"` - Status string `json:"status,omitempty"` - Message string `json:"message,omitempty"` - Progress int `json:"progress,omitempty"` +type workflowSearchResponse struct { + Values []workflowSearchEntry `json:"values"` } -type assignWorkflowSchemeRequest struct { - ProjectID string `json:"projectId"` - TargetSchemeID string `json:"targetSchemeId"` - MappingsByIssueTypeOverrides []any `json:"mappingsByIssueTypeOverride,omitempty"` -} +// GetWorkflowStatusesByName returns the statuses of a workflow looked up by +// name. Used at scheme-switch time to compute which existing issue statuses +// don't exist in the target workflow. +func (c *Client) GetWorkflowStatusesByName(workflowName string) ([]Status, error) { + query := url.Values{} + query.Set("workflowName", workflowName) + query.Set("expand", "statuses") + endpoint := c.apiURL("/rest/api/3/workflow/search?" + query.Encode()) -// AssignWorkflowSchemeToProject switches the workflow scheme for a classic Jira project. -func (c *Client) AssignWorkflowSchemeToProject(projectID, schemeID string) (*WorkflowSchemeAssignmentResponse, error) { - req := assignWorkflowSchemeRequest{ - ProjectID: projectID, - TargetSchemeID: schemeID, - MappingsByIssueTypeOverrides: []any{}, - } - body, err := json.Marshal(req) + body, err := c.execRequest(http.MethodGet, endpoint, nil) if err != nil { - return nil, fmt.Errorf("error marshaling workflow scheme assignment request: %v", err) + return nil, err + } + var out workflowSearchResponse + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("error parsing workflow search response: %v", err) + } + for _, entry := range out.Values { + if entry.ID.Name == workflowName { + return entry.Statuses, nil + } + } + if len(out.Values) > 0 { + return out.Values[0].Statuses, nil } + return nil, fmt.Errorf("workflow %q not found", workflowName) +} - endpoint := c.apiURL("/rest/api/3/workflowscheme/project/switch") - responseBody, status, err := c.execRequestWithStatus(http.MethodPost, endpoint, bytes.NewReader(body)) +// GetProjectIssueTypeStatuses returns each issue type's current status list +// for a project. Unlike GetProjectStatuses (which dedupes across issue types), +// this preserves the per-issue-type grouping needed to plan a scheme switch. +func (c *Client) GetProjectIssueTypeStatuses(projectKey string) (map[string][]Status, error) { + endpoint := c.apiURL("/rest/api/3/project/" + url.PathEscape(projectKey) + "/statuses") + body, err := c.execRequest(http.MethodGet, endpoint, nil) if err != nil { return nil, err } - if status < 200 || (status >= 300 && status != http.StatusSeeOther) { - return nil, fmt.Errorf("request got %d code: %s", status, string(responseBody)) - } - out := &WorkflowSchemeAssignmentResponse{ - ProjectID: projectID, - WorkflowSchemeID: schemeID, + var raw []struct { + ID string `json:"id"` + Statuses []projectStatus `json:"statuses"` } - if len(strings.TrimSpace(string(responseBody))) == 0 { - return out, nil + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("error parsing project issue type statuses: %v", err) } - var task TaskProgress - if err := json.Unmarshal(responseBody, &task); err != nil { - return nil, fmt.Errorf("error parsing workflow scheme assignment response: %v", err) + out := map[string][]Status{} + for _, it := range raw { + converted := make([]Status, 0, len(it.Statuses)) + for _, s := range it.Statuses { + converted = append(converted, Status{ + ID: s.ID, + Name: s.Name, + Category: normalizeStatusCategoryKey(s.StatusCategory.Key), + }) + } + out[it.ID] = converted } - out.Task = &task return out, nil } diff --git a/pkg/integrations/jira/common.go b/pkg/integrations/jira/common.go index f6f1d42fde..fddc11fe2c 100644 --- a/pkg/integrations/jira/common.go +++ b/pkg/integrations/jira/common.go @@ -10,11 +10,9 @@ import ( // NodeMetadata stores metadata on action component nodes. type NodeMetadata struct { - Project *Project `json:"project,omitempty"` - IssueType string `json:"issueType,omitempty"` - Status string `json:"status,omitempty"` - WorkflowName string `json:"workflowName,omitempty"` - WorkflowScheme *WorkflowScheme `json:"workflowScheme,omitempty"` + Project *Project `json:"project,omitempty"` + IssueType string `json:"issueType,omitempty"` + Status string `json:"status,omitempty"` } func requireProject(httpCtx core.HTTPContext, integration core.IntegrationContext, projectKey string) (*Project, error) { @@ -77,21 +75,60 @@ func applyStatus(client *Client, issueKey, status string) error { return applyStatusWithOptions(client, issueKey, status, DoTransitionOptions{}) } +// applyStatusWithOptions looks up the transitions reachable from the issue's +// current state, picks the best one whose target status matches, and runs it. +// +// When a Resolution is requested, the picker prefers a transition whose +// screen actually exposes the resolution field. Jira returns +// +// {"errors":{"resolution":"Field 'resolution' cannot be set. It is not on the appropriate screen, or unknown."}} +// +// when you set `fields.resolution` on a transition whose screen has no +// resolution field. Pre-filtering against transition.Fields avoids that 400. +// If no matching transition has resolution on its screen, return a clear +// error so the user can either drop the resolution or configure the +// workflow's transition screen. func applyStatusWithOptions(client *Client, issueKey, status string, opts DoTransitionOptions) error { transitions, err := client.GetIssueTransitions(issueKey) if err != nil { return fmt.Errorf("failed to fetch transitions: %v", err) } + wantsResolution := strings.TrimSpace(opts.Resolution) != "" + + var matches []Transition for _, t := range transitions { if strings.EqualFold(t.To.Name, status) { - return client.DoTransitionWithOptions(issueKey, t.ID, opts) + matches = append(matches, t) } } - available := make([]string, 0, len(transitions)) - for _, t := range transitions { - available = append(available, t.To.Name) + if len(matches) == 0 { + available := make([]string, 0, len(transitions)) + for _, t := range transitions { + available = append(available, t.To.Name) + } + return fmt.Errorf("no transition available to status %q (available: %v)", status, available) } - return fmt.Errorf("no transition available to status %q (available: %v)", status, available) + + if wantsResolution { + for _, t := range matches { + if t.HasField("resolution") { + return client.DoTransitionWithOptions(issueKey, t.ID, opts) + } + } + // Resolution requested but no matching transition's screen accepts it. + // Surface a clear error instead of letting Jira's confusing "not on the + // appropriate screen" message bubble up. + names := make([]string, 0, len(matches)) + for _, t := range matches { + names = append(names, t.Name) + } + return fmt.Errorf( + "transition to %q does not allow setting a resolution; configure the resolution field on the transition screen for %v in Jira, or leave Resolution empty", + status, names, + ) + } + + return client.DoTransitionWithOptions(issueKey, matches[0].ID, opts) } diff --git a/pkg/integrations/jira/common_test.go b/pkg/integrations/jira/common_test.go index fa377ac1f4..654df9bf83 100644 --- a/pkg/integrations/jira/common_test.go +++ b/pkg/integrations/jira/common_test.go @@ -13,12 +13,12 @@ import ( ) func Test__applyStatusWithOptions(t *testing.T) { - t.Run("posts transition body with comment and resolution", func(t *testing.T) { + t.Run("posts transition body with comment and resolution when resolution is on screen", func(t *testing.T) { httpContext := &contexts.HTTPContext{ Responses: []*http.Response{ { StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{"transitions":[{"id":"31","name":"Resolve","to":{"id":"10003","name":"Done"}}]}`)), + Body: io.NopCloser(strings.NewReader(`{"transitions":[{"id":"31","name":"Resolve","to":{"id":"10003","name":"Done"},"fields":{"resolution":{"required":false}}}]}`)), }, { StatusCode: http.StatusNoContent, @@ -36,6 +36,8 @@ func Test__applyStatusWithOptions(t *testing.T) { require.NoError(t, err) require.Len(t, httpContext.Requests, 2) + // Confirms we request transitions.fields so the resolution check has data to work with. + assert.Contains(t, httpContext.Requests[0].URL.String(), "expand=transitions.fields") body, err := io.ReadAll(httpContext.Requests[1].Body) require.NoError(t, err) @@ -46,6 +48,77 @@ func Test__applyStatusWithOptions(t *testing.T) { assert.Contains(t, payload["update"].(map[string]any), "comment") }) + t.Run("prefers a transition whose screen exposes resolution when several reach the same status", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"transitions":[ + {"id":"41","name":"Close","to":{"id":"10003","name":"Done"}}, + {"id":"42","name":"Resolve","to":{"id":"10003","name":"Done"},"fields":{"resolution":{"required":false}}} + ]}`)), + }, + { + StatusCode: http.StatusNoContent, + Body: io.NopCloser(strings.NewReader(``)), + }, + }, + } + client, err := NewClient(httpContext, newAuthorizedIntegration()) + require.NoError(t, err) + + err = applyStatusWithOptions(client, "TEST-1", "Done", DoTransitionOptions{Resolution: "Done"}) + require.NoError(t, err) + + require.Len(t, httpContext.Requests, 2) + body, err := io.ReadAll(httpContext.Requests[1].Body) + require.NoError(t, err) + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, "42", payload["transition"].(map[string]any)["id"]) + }) + + t.Run("returns a clear error when resolution is requested but no transition exposes it", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"transitions":[{"id":"31","name":"Close","to":{"id":"10003","name":"Done"},"fields":{"summary":{"required":false}}}]}`)), + }, + }, + } + client, err := NewClient(httpContext, newAuthorizedIntegration()) + require.NoError(t, err) + + err = applyStatusWithOptions(client, "TEST-1", "Done", DoTransitionOptions{Resolution: "Done"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "transition to \"Done\" does not allow setting a resolution") + assert.Contains(t, err.Error(), "Close") + // Important: we did not call POST /transitions when the precheck fails — only the GET. + require.Len(t, httpContext.Requests, 1) + }) + + t.Run("uses the first matching transition when no resolution is requested", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"transitions":[{"id":"31","name":"Close","to":{"id":"10003","name":"Done"}}]}`)), + }, + { + StatusCode: http.StatusNoContent, + Body: io.NopCloser(strings.NewReader(``)), + }, + }, + } + client, err := NewClient(httpContext, newAuthorizedIntegration()) + require.NoError(t, err) + + err = applyStatusWithOptions(client, "TEST-1", "Done", DoTransitionOptions{Comment: "Closing"}) + require.NoError(t, err) + }) + t.Run("returns helpful error when target status is unreachable", func(t *testing.T) { httpContext := &contexts.HTTPContext{ Responses: []*http.Response{ diff --git a/pkg/integrations/jira/create_workflow.go b/pkg/integrations/jira/create_workflow.go deleted file mode 100644 index 9cf704b9e4..0000000000 --- a/pkg/integrations/jira/create_workflow.go +++ /dev/null @@ -1,543 +0,0 @@ -package jira - -import ( - "fmt" - "net/http" - "slices" - "strconv" - "strings" - - "github.com/google/uuid" - "github.com/mitchellh/mapstructure" - "github.com/superplanehq/superplane/pkg/configuration" - "github.com/superplanehq/superplane/pkg/core" -) - -const CreateWorkflowPayloadType = "jira.workflow.created" - -const ( - workflowScopeGlobal = "GLOBAL" - workflowScopeProject = "PROJECT" -) - -type CreateWorkflow struct{} - -type CreateWorkflowSpec struct { - Name string `json:"name" mapstructure:"name"` - Description string `json:"description" mapstructure:"description"` - Scope string `json:"scope" mapstructure:"scope"` - Project string `json:"project" mapstructure:"project"` - Statuses []WorkflowStatusSpec `json:"statuses" mapstructure:"statuses"` - Transitions []WorkflowTransitionSpec `json:"transitions" mapstructure:"transitions"` -} - -type WorkflowStatusSpec struct { - Name string `json:"name" mapstructure:"name"` - Category string `json:"category" mapstructure:"category"` -} - -type WorkflowTransitionSpec struct { - Name string `json:"name" mapstructure:"name"` - From []string `json:"from" mapstructure:"from"` - To string `json:"to" mapstructure:"to"` - Type string `json:"type" mapstructure:"type"` -} - -type CreateWorkflowOutput struct { - ID string `json:"id"` - Name string `json:"name"` - Version WorkflowVersion `json:"version"` -} - -func (c *CreateWorkflow) Name() string { - return "jira.createWorkflow" -} - -func (c *CreateWorkflow) Label() string { - return "Create Workflow" -} - -func (c *CreateWorkflow) Description() string { - return "Create a Jira workflow" -} - -func (c *CreateWorkflow) Documentation() string { - return `The Create Workflow component creates a Jira workflow with statuses and transitions. - -## Use Cases - -- **Service request lifecycle**: define a standard request workflow before assigning it through a workflow scheme -- **JSM rollout automation**: create a workflow from a SuperPlane canvas as part of project provisioning -- **Environment parity**: recreate workflow structure across Jira sites - -## Configuration - -- **Name**: Workflow name. -- **Description**: Optional workflow description. -- **Scope**: Global or project-scoped. Project-scoped workflows require a Jira project. -- **Project**: Required when scope is Project. -- **Statuses**: List of statuses with a category: TODO, IN_PROGRESS, or DONE. -- **Transitions**: List of transitions with a target status. Directed transitions use the From status list; Global transitions are available from any status. - -## Output - -Returns the created workflow's ` + "`id`" + `, ` + "`name`" + `, and ` + "`version`" + `. - -## Notes - -- Requires Jira admin permissions (` + "`manage:jira-configuration`" + `). -- Jira creates workflows independently from projects. Use Assign Workflow To Project to apply a workflow scheme to a company-managed project. -- New issues enter the **first listed status**. SuperPlane injects the Jira-required initial transition pointing at that status; the order of the Statuses list determines the starting state.` -} - -func (c *CreateWorkflow) Icon() string { - return "jira" -} - -func (c *CreateWorkflow) Color() string { - return "blue" -} - -func (c *CreateWorkflow) OutputChannels(configuration any) []core.OutputChannel { - return []core.OutputChannel{core.DefaultOutputChannel} -} - -func (c *CreateWorkflow) Configuration() []configuration.Field { - return []configuration.Field{ - { - Name: "name", - Label: "Name", - Type: configuration.FieldTypeString, - Required: true, - Description: "Workflow name", - Placeholder: "Service request workflow", - }, - { - Name: "description", - Label: "Description", - Type: configuration.FieldTypeText, - Required: false, - Description: "Optional workflow description", - }, - { - Name: "scope", - Label: "Scope", - Type: configuration.FieldTypeSelect, - Required: true, - Description: "Create the workflow globally or scoped to a project", - Default: workflowScopeGlobal, - TypeOptions: &configuration.TypeOptions{ - Select: &configuration.SelectTypeOptions{ - Options: []configuration.FieldOption{ - {Label: "Global", Value: workflowScopeGlobal}, - {Label: "Project", Value: workflowScopeProject}, - }, - }, - }, - }, - { - Name: "project", - Label: "Project", - Type: configuration.FieldTypeIntegrationResource, - Required: false, - Description: "Project for a project-scoped workflow", - Placeholder: "Select a project", - VisibilityConditions: []configuration.VisibilityCondition{ - {Field: "scope", Values: []string{workflowScopeProject}}, - }, - RequiredConditions: []configuration.RequiredCondition{ - {Field: "scope", Values: []string{workflowScopeProject}}, - }, - TypeOptions: &configuration.TypeOptions{ - Resource: &configuration.ResourceTypeOptions{Type: "project"}, - }, - }, - { - Name: "statuses", - Label: "Statuses", - Type: configuration.FieldTypeList, - Required: true, - Description: "Workflow statuses", - TypeOptions: &configuration.TypeOptions{ - List: &configuration.ListTypeOptions{ - ItemLabel: "Status", - ItemDefinition: &configuration.ListItemDefinition{ - Type: configuration.FieldTypeObject, - Schema: []configuration.Field{ - { - Name: "name", - Label: "Name", - Type: configuration.FieldTypeString, - Required: true, - Placeholder: "To Do", - }, - { - Name: "category", - Label: "Category", - Type: configuration.FieldTypeSelect, - Required: true, - Default: "TODO", - TypeOptions: &configuration.TypeOptions{ - Select: &configuration.SelectTypeOptions{ - Options: []configuration.FieldOption{ - {Label: "To do", Value: "TODO"}, - {Label: "In progress", Value: "IN_PROGRESS"}, - {Label: "Done", Value: "DONE"}, - }, - }, - }, - }, - }, - }, - }, - }, - }, - { - Name: "transitions", - Label: "Transitions", - Type: configuration.FieldTypeList, - Required: true, - Description: "Workflow transitions", - TypeOptions: &configuration.TypeOptions{ - List: &configuration.ListTypeOptions{ - ItemLabel: "Transition", - ItemDefinition: &configuration.ListItemDefinition{ - Type: configuration.FieldTypeObject, - Schema: []configuration.Field{ - { - Name: "name", - Label: "Name", - Type: configuration.FieldTypeString, - Required: true, - Placeholder: "Start work", - }, - { - Name: "type", - Label: "Type", - Type: configuration.FieldTypeSelect, - Required: true, - Default: "directed", - TypeOptions: &configuration.TypeOptions{ - Select: &configuration.SelectTypeOptions{ - Options: []configuration.FieldOption{ - {Label: "Directed", Value: "directed"}, - {Label: "Global", Value: "global"}, - }, - }, - }, - }, - { - Name: "from", - Label: "From", - Type: configuration.FieldTypeList, - Required: false, - Description: "Source status names for directed transitions. Use any for a global transition.", - TypeOptions: &configuration.TypeOptions{ - List: &configuration.ListTypeOptions{ - ItemLabel: "Status", - ItemDefinition: &configuration.ListItemDefinition{Type: configuration.FieldTypeString}, - }, - }, - }, - { - Name: "to", - Label: "To", - Type: configuration.FieldTypeString, - Required: true, - Description: "Target status name", - Placeholder: "In Progress", - }, - }, - }, - }, - }, - }, - } -} - -func (c *CreateWorkflow) Setup(ctx core.SetupContext) error { - spec := CreateWorkflowSpec{} - if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { - return fmt.Errorf("failed to decode configuration: %v", err) - } - - if strings.TrimSpace(spec.Name) == "" { - return fmt.Errorf("name is required") - } - if err := validateWorkflowSpec(spec); err != nil { - return err - } - - meta := NodeMetadata{WorkflowName: strings.TrimSpace(spec.Name)} - if normalizeWorkflowScope(spec.Scope) == workflowScopeProject { - project, err := requireProject(ctx.HTTP, ctx.Integration, strings.TrimSpace(spec.Project)) - if err != nil { - return err - } - meta.Project = project - } - - return ctx.Metadata.Set(meta) -} - -func (c *CreateWorkflow) Execute(ctx core.ExecutionContext) error { - spec := CreateWorkflowSpec{} - if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { - return fmt.Errorf("failed to decode configuration: %v", err) - } - if strings.TrimSpace(spec.Name) == "" { - return fmt.Errorf("name is required") - } - if err := validateWorkflowSpec(spec); err != nil { - return err - } - - var project *Project - if normalizeWorkflowScope(spec.Scope) == workflowScopeProject { - var err error - project, err = requireProject(ctx.HTTP, ctx.Integration, strings.TrimSpace(spec.Project)) - if err != nil { - return err - } - } - - client, err := NewClient(ctx.HTTP, ctx.Integration) - if err != nil { - return fmt.Errorf("failed to create client: %v", err) - } - - req, err := buildCreateWorkflowRequest(spec, project) - if err != nil { - return err - } - - resp, err := client.CreateWorkflow(req) - if err != nil { - if strings.Contains(err.Error(), "403") { - return fmt.Errorf("failed to create workflow: %v — the API token must belong to a Jira admin", err) - } - return fmt.Errorf("failed to create workflow: %v", err) - } - if len(resp.Workflows) == 0 { - return fmt.Errorf("failed to create workflow: Jira returned no workflows") - } - - created := resp.Workflows[0] - return ctx.ExecutionState.Emit( - core.DefaultOutputChannel.Name, - CreateWorkflowPayloadType, - []any{CreateWorkflowOutput{ID: created.ID, Name: created.Name, Version: created.Version}}, - ) -} - -func normalizeWorkflowScope(scope string) string { - switch strings.ToUpper(strings.TrimSpace(scope)) { - case workflowScopeProject: - return workflowScopeProject - default: - return workflowScopeGlobal - } -} - -func validateWorkflowSpec(spec CreateWorkflowSpec) error { - if normalizeWorkflowScope(spec.Scope) == workflowScopeProject && strings.TrimSpace(spec.Project) == "" { - return fmt.Errorf("project is required when scope is Project") - } - if len(spec.Statuses) == 0 { - return fmt.Errorf("at least one status is required") - } - if len(spec.Transitions) == 0 { - return fmt.Errorf("at least one transition is required") - } - - statusNames := map[string]bool{} - statusDisplay := make([]string, 0, len(spec.Statuses)) - for i, status := range spec.Statuses { - name := strings.TrimSpace(status.Name) - if name == "" { - return fmt.Errorf("statuses[%d].name is required", i) - } - key := strings.ToLower(name) - if statusNames[key] { - return fmt.Errorf("duplicate status %q", name) - } - statusNames[key] = true - statusDisplay = append(statusDisplay, name) - if !slices.Contains([]string{"TODO", "IN_PROGRESS", "DONE"}, strings.ToUpper(strings.TrimSpace(status.Category))) { - return fmt.Errorf("statuses[%d].category must be TODO, IN_PROGRESS, or DONE", i) - } - } - - for i, transition := range spec.Transitions { - if strings.TrimSpace(transition.Name) == "" { - return fmt.Errorf("transitions[%d].name is required", i) - } - target := strings.TrimSpace(transition.To) - if target == "" { - return fmt.Errorf("transitions[%d].to is required", i) - } - if !statusNames[strings.ToLower(target)] { - return fmt.Errorf("transitions[%d].to references unknown status %q (available: %s)", i, target, formatAvailableStatuses(statusDisplay)) - } - if workflowTransitionType(transition) == "global" { - continue - } - if len(transition.From) == 0 { - return fmt.Errorf("transitions[%d].from is required for directed transitions", i) - } - for _, from := range transition.From { - source := strings.TrimSpace(from) - if strings.EqualFold(source, "any") { - continue - } - if !statusNames[strings.ToLower(source)] { - return fmt.Errorf("transitions[%d].from references unknown status %q (available: %s)", i, source, formatAvailableStatuses(statusDisplay)) - } - } - } - - return nil -} - -func formatAvailableStatuses(names []string) string { - if len(names) == 0 { - return "none" - } - quoted := make([]string, len(names)) - for i, name := range names { - quoted[i] = fmt.Sprintf("%q", name) - } - return strings.Join(quoted, ", ") -} - -func buildCreateWorkflowRequest(spec CreateWorkflowSpec, project *Project) (*CreateWorkflowRequest, error) { - scope := WorkflowScope{Type: normalizeWorkflowScope(spec.Scope)} - if scope.Type == workflowScopeProject { - if project == nil || strings.TrimSpace(project.ID) == "" { - return nil, fmt.Errorf("project id is required for project-scoped workflows") - } - scope.Project = &WorkflowScopeProjectRef{ID: strings.TrimSpace(project.ID)} - } - - statusRefs := map[string]string{} - statuses := make([]WorkflowStatusUpdate, 0, len(spec.Statuses)) - workflowStatuses := make([]WorkflowCreateStatus, 0, len(spec.Statuses)) - for i, status := range spec.Statuses { - name := strings.TrimSpace(status.Name) - ref := workflowStatusReference(name) - statusRefs[strings.ToLower(name)] = ref - statuses = append(statuses, WorkflowStatusUpdate{ - Description: "", - Name: name, - StatusCategory: strings.ToUpper(strings.TrimSpace(status.Category)), - StatusReference: ref, - }) - workflowStatuses = append(workflowStatuses, WorkflowCreateStatus{ - Layout: WorkflowLayout{X: 115 + float64(i*200), Y: -16}, - Properties: map[string]any{}, - StatusReference: ref, - }) - } - - transitions := []WorkflowCreateTransition{ - newWorkflowTransition("1", "Create", "INITIAL", statusRefs[strings.ToLower(strings.TrimSpace(spec.Statuses[0].Name))], nil), - } - for i, transition := range spec.Transitions { - targetRef := statusRefs[strings.ToLower(strings.TrimSpace(transition.To))] - transitionType := strings.ToUpper(workflowTransitionType(transition)) - var links []WorkflowTransitionLink - if transitionType == "DIRECTED" { - links = make([]WorkflowTransitionLink, 0, len(transition.From)) - for _, from := range transition.From { - if strings.EqualFold(strings.TrimSpace(from), "any") { - transitionType = "GLOBAL" - links = nil - break - } - links = append(links, WorkflowTransitionLink{ - FromPort: 0, - FromStatusReference: statusRefs[strings.ToLower(strings.TrimSpace(from))], - ToPort: 1, - }) - } - } - transitions = append(transitions, newWorkflowTransition( - strconv.Itoa((i+2)*10+1), - strings.TrimSpace(transition.Name), - transitionType, - targetRef, - links, - )) - } - - return &CreateWorkflowRequest{ - Scope: scope, - Statuses: statuses, - Workflows: []WorkflowCreate{ - { - Description: strings.TrimSpace(spec.Description), - Name: strings.TrimSpace(spec.Name), - StartPointLayout: WorkflowLayout{X: -100.00030899047852, Y: -153.00020599365234}, - Statuses: workflowStatuses, - Transitions: transitions, - }, - }, - }, nil -} - -// workflowStatusReference returns a deterministic id used to wire a status to -// the transitions that reference it. Jira treats statusReference as local to a -// single /workflows/create request, so name-derived UUIDs are safe — no -// cross-request stability is implied. -func workflowStatusReference(name string) string { - return uuid.NewSHA1(uuid.NameSpaceURL, []byte("superplane:jira:workflow-status:"+strings.ToLower(strings.TrimSpace(name)))).String() -} - -func workflowTransitionType(transition WorkflowTransitionSpec) string { - if strings.EqualFold(strings.TrimSpace(transition.Type), "global") { - return "global" - } - return "directed" -} - -func newWorkflowTransition(id, name, transitionType, toStatusReference string, links []WorkflowTransitionLink) WorkflowCreateTransition { - if links == nil { - links = []WorkflowTransitionLink{} - } - return WorkflowCreateTransition{ - Actions: []any{}, - Description: "", - ID: id, - Links: links, - Name: name, - Properties: map[string]any{}, - ToStatusReference: toStatusReference, - Triggers: []any{}, - Type: transitionType, - Validators: []any{}, - } -} - -func (c *CreateWorkflow) Cancel(ctx core.ExecutionContext) error { - return nil -} - -func (c *CreateWorkflow) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { - return ctx.DefaultProcessing() -} - -func (c *CreateWorkflow) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { - return http.StatusOK, nil, nil -} - -func (c *CreateWorkflow) Cleanup(ctx core.SetupContext) error { - return nil -} - -func (c *CreateWorkflow) Hooks() []core.Hook { - return []core.Hook{} -} - -func (c *CreateWorkflow) HandleHook(ctx core.ActionHookContext) error { - return nil -} diff --git a/pkg/integrations/jira/create_workflow_test.go b/pkg/integrations/jira/create_workflow_test.go deleted file mode 100644 index cb312eb1e6..0000000000 --- a/pkg/integrations/jira/create_workflow_test.go +++ /dev/null @@ -1,215 +0,0 @@ -package jira - -import ( - "encoding/json" - "io" - "net/http" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/superplanehq/superplane/pkg/core" - "github.com/superplanehq/superplane/test/support/contexts" -) - -func Test__CreateWorkflow__Setup(t *testing.T) { - component := CreateWorkflow{} - - t.Run("missing status -> error", func(t *testing.T) { - err := component.Setup(core.SetupContext{ - Integration: newAuthorizedIntegration(), - Metadata: &contexts.MetadataContext{}, - Configuration: map[string]any{ - "name": "Support workflow", - "transitions": []map[string]any{{"name": "Start", "from": []string{"To Do"}, "to": "Done"}}, - }, - }) - - require.ErrorContains(t, err, "at least one status is required") - }) - - t.Run("project scope requires project", func(t *testing.T) { - err := component.Setup(core.SetupContext{ - Integration: newAuthorizedIntegration(), - Metadata: &contexts.MetadataContext{}, - Configuration: map[string]any{ - "name": "Support workflow", - "scope": workflowScopeProject, - "statuses": []map[string]any{{"name": "To Do", "category": "TODO"}}, - "transitions": []map[string]any{ - {"name": "Start", "from": []string{"To Do"}, "to": "To Do"}, - }, - }, - }) - - require.ErrorContains(t, err, "project is required") - }) - - t.Run("unknown transition status -> error", func(t *testing.T) { - err := component.Setup(core.SetupContext{ - Integration: newAuthorizedIntegration(), - Metadata: &contexts.MetadataContext{}, - Configuration: map[string]any{ - "name": "Support workflow", - "statuses": []map[string]any{{"name": "To Do", "category": "TODO"}}, - "transitions": []map[string]any{ - {"name": "Start", "from": []string{"To Do"}, "to": "Done"}, - }, - }, - }) - - require.ErrorContains(t, err, "unknown status") - }) - - t.Run("valid setup stores workflow metadata", func(t *testing.T) { - metadataCtx := &contexts.MetadataContext{} - err := component.Setup(core.SetupContext{ - Integration: newAuthorizedIntegration(), - Metadata: metadataCtx, - Configuration: map[string]any{ - "name": "Support workflow", - "statuses": []map[string]any{ - {"name": "To Do", "category": "TODO"}, - {"name": "Done", "category": "DONE"}, - }, - "transitions": []map[string]any{ - {"name": "Complete", "from": []string{"To Do"}, "to": "Done"}, - }, - }, - }) - - require.NoError(t, err) - nodeMetadata, ok := metadataCtx.Metadata.(NodeMetadata) - require.True(t, ok) - assert.Equal(t, "Support workflow", nodeMetadata.WorkflowName) - }) -} - -func Test__CreateWorkflow__Execute(t *testing.T) { - component := CreateWorkflow{} - - t.Run("creates workflow and emits first created workflow", func(t *testing.T) { - httpContext := &contexts.HTTPContext{ - Responses: []*http.Response{ - { - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{ - "workflows":[{"id":"wf-1","name":"Support workflow","version":{"id":"v-1","versionNumber":1}}], - "statuses":[] - }`)), - }, - }, - } - - execCtx := &contexts.ExecutionStateContext{} - err := component.Execute(core.ExecutionContext{ - Configuration: map[string]any{ - "name": "Support workflow", - "description": "Request lifecycle", - "statuses": []map[string]any{ - {"name": "To Do", "category": "TODO"}, - {"name": "In Progress", "category": "IN_PROGRESS"}, - {"name": "Done", "category": "DONE"}, - }, - "transitions": []map[string]any{ - {"name": "Start work", "from": []string{"To Do"}, "to": "In Progress", "type": "directed"}, - {"name": "Close", "from": []string{"any"}, "to": "Done", "type": "global"}, - }, - }, - HTTP: httpContext, - Integration: newAuthorizedIntegration(), - ExecutionState: execCtx, - }) - - require.NoError(t, err) - assert.True(t, execCtx.Passed) - assert.Equal(t, CreateWorkflowPayloadType, execCtx.Type) - require.Len(t, execCtx.Payloads, 1) - - require.Len(t, httpContext.Requests, 1) - assert.Equal(t, http.MethodPost, httpContext.Requests[0].Method) - assert.Contains(t, httpContext.Requests[0].URL.String(), "/rest/api/3/workflows/create") - - body, err := io.ReadAll(httpContext.Requests[0].Body) - require.NoError(t, err) - var payload map[string]any - require.NoError(t, json.Unmarshal(body, &payload)) - assert.Equal(t, workflowScopeGlobal, payload["scope"].(map[string]any)["type"]) - assert.Len(t, payload["statuses"].([]any), 3) - transitions := payload["workflows"].([]any)[0].(map[string]any)["transitions"].([]any) - assert.Equal(t, "INITIAL", transitions[0].(map[string]any)["type"]) - }) - - t.Run("project-scoped workflow includes project id in scope", func(t *testing.T) { - httpContext := &contexts.HTTPContext{ - Responses: []*http.Response{ - { - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`[{"id":"10000","key":"TEST","name":"Test"}]`)), - }, - { - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{ - "workflows":[{"id":"wf-2","name":"Scoped workflow","version":{"id":"v-2","versionNumber":1}}], - "statuses":[] - }`)), - }, - }, - } - - execCtx := &contexts.ExecutionStateContext{} - err := component.Execute(core.ExecutionContext{ - Configuration: map[string]any{ - "name": "Scoped workflow", - "scope": workflowScopeProject, - "project": "TEST", - "statuses": []map[string]any{{"name": "To Do", "category": "TODO"}}, - "transitions": []map[string]any{ - {"name": "Loop", "from": []string{"To Do"}, "to": "To Do"}, - }, - }, - HTTP: httpContext, - Integration: newAuthorizedIntegration(), - ExecutionState: execCtx, - }) - - require.NoError(t, err) - require.Len(t, httpContext.Requests, 2) - - body, err := io.ReadAll(httpContext.Requests[1].Body) - require.NoError(t, err) - var payload map[string]any - require.NoError(t, json.Unmarshal(body, &payload)) - scope := payload["scope"].(map[string]any) - assert.Equal(t, workflowScopeProject, scope["type"]) - assert.Equal(t, "10000", scope["project"].(map[string]any)["id"]) - }) - - t.Run("permission denied surfaces admin hint", func(t *testing.T) { - httpContext := &contexts.HTTPContext{ - Responses: []*http.Response{ - { - StatusCode: http.StatusForbidden, - Body: io.NopCloser(strings.NewReader(`{"errorMessages":["Administer Jira required"]}`)), - }, - }, - } - - err := component.Execute(core.ExecutionContext{ - Configuration: map[string]any{ - "name": "Support workflow", - "statuses": []map[string]any{{"name": "To Do", "category": "TODO"}}, - "transitions": []map[string]any{ - {"name": "Loop", "from": []string{"To Do"}, "to": "To Do"}, - }, - }, - HTTP: httpContext, - Integration: newAuthorizedIntegration(), - ExecutionState: &contexts.ExecutionStateContext{}, - }) - - require.Error(t, err) - assert.Contains(t, err.Error(), "Jira admin") - }) -} diff --git a/pkg/integrations/jira/example.go b/pkg/integrations/jira/example.go index 9ad8ada884..ff41ddd5e6 100644 --- a/pkg/integrations/jira/example.go +++ b/pkg/integrations/jira/example.go @@ -31,18 +31,6 @@ var exampleOutputDeleteIncidentBytes []byte var exampleOutputDeleteIncidentOnce sync.Once var exampleOutputDeleteIncident map[string]any -//go:embed example_output_create_workflow.json -var exampleOutputCreateWorkflowBytes []byte - -var exampleOutputCreateWorkflowOnce sync.Once -var exampleOutputCreateWorkflow map[string]any - -//go:embed example_output_assign_workflow_to_project.json -var exampleOutputAssignWorkflowToProjectBytes []byte - -var exampleOutputAssignWorkflowToProjectOnce sync.Once -var exampleOutputAssignWorkflowToProject map[string]any - //go:embed example_output_transition_issue.json var exampleOutputTransitionIssueBytes []byte @@ -55,6 +43,12 @@ var exampleOutputApproveWorkflowBytes []byte var exampleOutputApproveWorkflowOnce sync.Once var exampleOutputApproveWorkflow map[string]any +//go:embed example_output_get_workflow.json +var exampleOutputGetWorkflowBytes []byte + +var exampleOutputGetWorkflowOnce sync.Once +var exampleOutputGetWorkflow map[string]any + func (c *CreateIssue) ExampleOutput() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleOutputCreateIssueOnce, exampleOutputCreateIssueBytes, &exampleOutputCreateIssue) } @@ -101,14 +95,6 @@ func (c *DeleteIncident) ExampleOutput() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleOutputDeleteIncidentOnce, exampleOutputDeleteIncidentBytes, &exampleOutputDeleteIncident) } -func (c *CreateWorkflow) ExampleOutput() map[string]any { - return utils.UnmarshalEmbeddedJSON(&exampleOutputCreateWorkflowOnce, exampleOutputCreateWorkflowBytes, &exampleOutputCreateWorkflow) -} - -func (c *AssignWorkflowToProject) ExampleOutput() map[string]any { - return utils.UnmarshalEmbeddedJSON(&exampleOutputAssignWorkflowToProjectOnce, exampleOutputAssignWorkflowToProjectBytes, &exampleOutputAssignWorkflowToProject) -} - func (c *TransitionIssue) ExampleOutput() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleOutputTransitionIssueOnce, exampleOutputTransitionIssueBytes, &exampleOutputTransitionIssue) } @@ -116,3 +102,7 @@ func (c *TransitionIssue) ExampleOutput() map[string]any { func (c *ApproveWorkflow) ExampleOutput() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleOutputApproveWorkflowOnce, exampleOutputApproveWorkflowBytes, &exampleOutputApproveWorkflow) } + +func (c *GetWorkflow) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputGetWorkflowOnce, exampleOutputGetWorkflowBytes, &exampleOutputGetWorkflow) +} diff --git a/pkg/integrations/jira/example_output_assign_workflow_to_project.json b/pkg/integrations/jira/example_output_assign_workflow_to_project.json deleted file mode 100644 index 11d9bce8fe..0000000000 --- a/pkg/integrations/jira/example_output_assign_workflow_to_project.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "type": "jira.workflowScheme.assigned", - "data": { - "projectId": "10000", - "workflowSchemeId": "101010", - "draftCreated": false, - "taskId": "3f83dg2a-ns2n-56ab-9812-42h5j1461629", - "taskStatus": "ENQUEUED", - "taskSelf": "https://your-domain.atlassian.net/rest/api/3/task/3f83dg2a-ns2n-56ab-9812-42h5j1461629" - }, - "timestamp": "2026-01-19T12:00:00Z" -} diff --git a/pkg/integrations/jira/example_output_create_workflow.json b/pkg/integrations/jira/example_output_create_workflow.json deleted file mode 100644 index dabe97fc03..0000000000 --- a/pkg/integrations/jira/example_output_create_workflow.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "type": "jira.workflow.created", - "data": { - "id": "b9ff2384-d3b6-4d4e-9509-3ee19f607168", - "name": "Service request workflow", - "version": { - "id": "f010ac1b-3dd3-43a3-aa66-0ee8a447f76e", - "versionNumber": 1 - } - }, - "timestamp": "2026-01-19T12:00:00Z" -} diff --git a/pkg/integrations/jira/example_output_get_workflow.json b/pkg/integrations/jira/example_output_get_workflow.json new file mode 100644 index 0000000000..6503b7ba22 --- /dev/null +++ b/pkg/integrations/jira/example_output_get_workflow.json @@ -0,0 +1,46 @@ +{ + "type": "jira.workflow", + "timestamp": "2026-01-19T12:00:00Z", + "data": { + "issueKey": "PROJ-123", + "issueType": "Task", + "projectKey": "PROJ", + "workflowName": "Software Simplified Workflow", + "workflowSchemeId": "101010", + "workflowSchemeName": "Default workflow scheme", + "currentStatus": "In Progress", + "currentStatusId": "10002", + "statuses": [ + { + "id": "10001", + "name": "To Do", + "category": "TODO" + }, + { + "id": "10002", + "name": "In Progress", + "category": "IN_PROGRESS", + "isCurrent": true + }, + { + "id": "10003", + "name": "Done", + "category": "DONE" + } + ], + "availableTransitions": [ + { + "id": "21", + "name": "Stop progress", + "toStatusId": "10001", + "toStatus": "To Do" + }, + { + "id": "31", + "name": "Resolve", + "toStatusId": "10003", + "toStatus": "Done" + } + ] + } +} diff --git a/pkg/integrations/jira/get_workflow.go b/pkg/integrations/jira/get_workflow.go new file mode 100644 index 0000000000..a0e13817ad --- /dev/null +++ b/pkg/integrations/jira/get_workflow.go @@ -0,0 +1,337 @@ +package jira + +import ( + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +const GetWorkflowPayloadType = "jira.workflow" + +type GetWorkflow struct{} + +type GetWorkflowSpec struct { + Project string `json:"project" mapstructure:"project"` + IssueKey string `json:"issueKey" mapstructure:"issueKey"` +} + +// WorkflowStatus is a status inside a workflow definition. Includes whether +// it's the issue's current status so the canvas can render the workflow as +// a state machine with the current location highlighted. +type WorkflowStatus struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Category string `json:"category,omitempty"` + IsCurrent bool `json:"isCurrent,omitempty"` +} + +// WorkflowAvailableTransition is one transition the issue can take right now +// from its current status. +type WorkflowAvailableTransition struct { + ID string `json:"id"` + Name string `json:"name"` + ToStatusID string `json:"toStatusId,omitempty"` + ToStatus string `json:"toStatus"` +} + +// GetWorkflowOutput summarizes the workflow currently bound to an issue: +// where the issue is now, every status the workflow defines, and every +// transition it can take from the current state. +type GetWorkflowOutput struct { + IssueKey string `json:"issueKey"` + IssueType string `json:"issueType,omitempty"` + ProjectKey string `json:"projectKey,omitempty"` + WorkflowName string `json:"workflowName,omitempty"` + WorkflowSchemeID string `json:"workflowSchemeId,omitempty"` + WorkflowSchemeName string `json:"workflowSchemeName,omitempty"` + CurrentStatus string `json:"currentStatus,omitempty"` + CurrentStatusID string `json:"currentStatusId,omitempty"` + Statuses []WorkflowStatus `json:"statuses,omitempty"` + AvailableTransitions []WorkflowAvailableTransition `json:"availableTransitions,omitempty"` +} + +func (c *GetWorkflow) Name() string { + return "jira.getWorkflow" +} + +func (c *GetWorkflow) Label() string { + return "Get Workflow" +} + +func (c *GetWorkflow) Description() string { + return "Get the Jira workflow bound to an issue, including its current status and reachable transitions" +} + +func (c *GetWorkflow) Documentation() string { + return `The Get Workflow component returns the Jira workflow that governs a given issue. + +## Use Cases + +- **State-machine introspection**: see every status in the workflow plus where the issue is right now +- **Routing decisions**: branch on which transitions are currently reachable before running ` + "`transitionIssue`" + ` +- **Operator dashboards**: render the workflow as a graph next to the issue + +## Configuration + +- **Project**: The Jira project the issue belongs to. +- **Issue Key**: Jira issue key, for example ` + "`PROJ-123`" + `. + +## Output + +Returns: + +- ` + "`workflowName`" + ` and ` + "`workflowSchemeName`" + ` — the workflow scheme assigned to the project and the workflow it routes the issue's type to. +- ` + "`currentStatus`" + ` / ` + "`currentStatusId`" + ` — where the issue is now. +- ` + "`statuses`" + ` — every status the workflow defines (with ` + "`isCurrent`" + ` set on the current one). +- ` + "`availableTransitions`" + ` — transitions reachable from the issue's current state, each with the transition id, name, and target status. + +## Notes + +- Resolving the bound workflow goes ` + "`issue → project + issue type → workflow scheme → workflow`" + `. Team-managed (next-gen) projects don't expose a workflow scheme; in that case ` + "`workflowName`" + ` and ` + "`statuses`" + ` are empty but ` + "`currentStatus`" + ` and ` + "`availableTransitions`" + ` are still populated. +- The ` + "`availableTransitions`" + ` list reflects workflow rules, conditions, and the calling user's permissions — it is exactly what Jira would offer in the issue view.` +} + +func (c *GetWorkflow) Icon() string { + return "jira" +} + +func (c *GetWorkflow) Color() string { + return "blue" +} + +func (c *GetWorkflow) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *GetWorkflow) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "project", + Label: "Project", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "The Jira project the issue belongs to", + Placeholder: "Select a project", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{Type: "project"}, + }, + }, + { + Name: "issueKey", + Label: "Issue Key", + Type: configuration.FieldTypeString, + Required: true, + Description: "The issue key (e.g. PROJ-123)", + Placeholder: "PROJ-123", + }, + } +} + +func (c *GetWorkflow) Setup(ctx core.SetupContext) error { + spec := GetWorkflowSpec{} + if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { + return fmt.Errorf("failed to decode configuration: %v", err) + } + + if strings.TrimSpace(spec.Project) == "" { + return fmt.Errorf("project is required") + } + if strings.TrimSpace(spec.IssueKey) == "" { + return fmt.Errorf("issueKey is required") + } + + project, err := requireProject(ctx.HTTP, ctx.Integration, spec.Project) + if err != nil { + return err + } + + return ctx.Metadata.Set(NodeMetadata{Project: project}) +} + +func (c *GetWorkflow) Execute(ctx core.ExecutionContext) error { + spec := GetWorkflowSpec{} + if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { + return fmt.Errorf("failed to decode configuration: %v", err) + } + + issueKey := strings.TrimSpace(spec.IssueKey) + if issueKey == "" { + return fmt.Errorf("issueKey is required") + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %v", err) + } + + issue, err := client.GetIssue(issueKey) + if err != nil { + return fmt.Errorf("failed to fetch issue: %v", err) + } + + output := GetWorkflowOutput{IssueKey: issueKey} + currentStatusID, currentStatusName := extractIssueStatus(issue) + output.CurrentStatus = currentStatusName + output.CurrentStatusID = currentStatusID + + issueTypeName, projectID, projectKey := extractIssueTypeAndProject(issue) + output.IssueType = issueTypeName + output.ProjectKey = projectKey + + transitions, err := client.GetIssueTransitions(issueKey) + if err != nil { + return fmt.Errorf("failed to fetch transitions: %v", err) + } + output.AvailableTransitions = make([]WorkflowAvailableTransition, 0, len(transitions)) + for _, t := range transitions { + output.AvailableTransitions = append(output.AvailableTransitions, WorkflowAvailableTransition{ + ID: t.ID, + Name: t.Name, + ToStatusID: t.To.ID, + ToStatus: t.To.Name, + }) + } + + // Resolving the workflow itself (statuses + scheme) requires a company-managed + // project. Team-managed projects bind workflows differently and Jira's public + // scheme APIs return nothing for them — we degrade gracefully and still emit + // the current status + available transitions, which is the most useful part. + if projectID != "" { + scheme, err := client.GetWorkflowSchemeForProject(projectID) + if err != nil && ctx.Logger != nil { + ctx.Logger.Warnf("jira.getWorkflow: failed to fetch workflow scheme for project %s: %v", projectID, err) + } + if scheme != nil { + output.WorkflowSchemeID = scheme.ID.String() + output.WorkflowSchemeName = scheme.Name + + workflowName := resolveWorkflowForIssueType(client, scheme, projectKey, issueTypeName) + output.WorkflowName = workflowName + if workflowName != "" { + statuses, err := client.GetWorkflowStatusesByName(workflowName) + if err != nil && ctx.Logger != nil { + ctx.Logger.Warnf("jira.getWorkflow: failed to load statuses for workflow %q: %v", workflowName, err) + } + output.Statuses = make([]WorkflowStatus, 0, len(statuses)) + for _, s := range statuses { + output.Statuses = append(output.Statuses, WorkflowStatus{ + ID: s.ID, + Name: s.Name, + Category: s.Category, + IsCurrent: statusMatches(s, currentStatusID, currentStatusName), + }) + } + } + } + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + GetWorkflowPayloadType, + []any{output}, + ) +} + +// extractIssueStatus pulls the issue's current status id and name out of the +// loosely-typed fields map returned by GetIssue. +func extractIssueStatus(issue *Issue) (id, name string) { + if issue == nil { + return "", "" + } + status, ok := issue.Fields["status"].(map[string]any) + if !ok { + return "", "" + } + if v, ok := status["id"].(string); ok { + id = v + } + if v, ok := status["name"].(string); ok { + name = v + } + return id, name +} + +// extractIssueTypeAndProject pulls the issue's issue type name and the +// project's id + key from the loosely-typed fields map. +func extractIssueTypeAndProject(issue *Issue) (issueType, projectID, projectKey string) { + if issue == nil { + return "", "", "" + } + if it, ok := issue.Fields["issuetype"].(map[string]any); ok { + if v, ok := it["name"].(string); ok { + issueType = v + } + } + if p, ok := issue.Fields["project"].(map[string]any); ok { + if v, ok := p["id"].(string); ok { + projectID = v + } + if v, ok := p["key"].(string); ok { + projectKey = v + } + } + return issueType, projectID, projectKey +} + +// resolveWorkflowForIssueType maps an issue type name to the workflow that +// the scheme routes it through. Jira keys the scheme's issueTypeMappings by +// issue type id, so we look up the id via the project's issue types and fall +// back to the default workflow if there's no specific mapping. +func resolveWorkflowForIssueType(client *Client, scheme *WorkflowSchemeDetail, projectKey, issueTypeName string) string { + if scheme == nil { + return "" + } + if strings.TrimSpace(projectKey) != "" && strings.TrimSpace(issueTypeName) != "" { + issueTypes, err := client.GetProjectIssueTypes(projectKey) + if err == nil { + for _, it := range issueTypes { + if strings.EqualFold(it.Name, issueTypeName) { + if wf := strings.TrimSpace(scheme.IssueTypeMappings[it.ID]); wf != "" { + return wf + } + break + } + } + } + } + return strings.TrimSpace(scheme.DefaultWorkflow) +} + +func statusMatches(s Status, currentID, currentName string) bool { + if currentID != "" && strings.EqualFold(strings.TrimSpace(s.ID), currentID) { + return true + } + if currentName != "" && strings.EqualFold(strings.TrimSpace(s.Name), strings.TrimSpace(currentName)) { + return true + } + return false +} + +func (c *GetWorkflow) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *GetWorkflow) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *GetWorkflow) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { + return http.StatusOK, nil, nil +} + +func (c *GetWorkflow) Cleanup(ctx core.SetupContext) error { + return nil +} + +func (c *GetWorkflow) Hooks() []core.Hook { + return []core.Hook{} +} + +func (c *GetWorkflow) HandleHook(ctx core.ActionHookContext) error { + return nil +} diff --git a/pkg/integrations/jira/get_workflow_test.go b/pkg/integrations/jira/get_workflow_test.go new file mode 100644 index 0000000000..709aa21f0c --- /dev/null +++ b/pkg/integrations/jira/get_workflow_test.go @@ -0,0 +1,219 @@ +package jira + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__GetWorkflow__Setup(t *testing.T) { + component := GetWorkflow{} + + t.Run("missing project -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Integration: newAuthorizedIntegration(), + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"issueKey": "TEST-1"}, + }) + + require.ErrorContains(t, err, "project is required") + }) + + t.Run("missing issue key -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Integration: newAuthorizedIntegration(), + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"project": "TEST"}, + }) + + require.ErrorContains(t, err, "issueKey is required") + }) + + t.Run("valid setup stores project metadata", func(t *testing.T) { + metadataCtx := &contexts.MetadataContext{} + err := component.Setup(core.SetupContext{ + Integration: newAuthorizedIntegrationWithMetadata(Metadata{ + Projects: []Project{{ID: "10000", Key: "TEST", Name: "Test Project"}}, + }), + Metadata: metadataCtx, + Configuration: map[string]any{ + "project": "TEST", + "issueKey": "TEST-1", + }, + }) + + require.NoError(t, err) + nodeMetadata, ok := metadataCtx.Metadata.(NodeMetadata) + require.True(t, ok) + require.NotNil(t, nodeMetadata.Project) + assert.Equal(t, "TEST", nodeMetadata.Project.Key) + }) +} + +func Test__GetWorkflow__Execute(t *testing.T) { + component := GetWorkflow{} + + const issueResponse = `{ + "id":"10001","key":"TEST-1","self":"https://test.atlassian.net/rest/api/3/issue/10001", + "fields":{ + "status":{"id":"10002","name":"In Progress"}, + "issuetype":{"name":"Task"}, + "project":{"id":"10000","key":"TEST"} + } + }` + const transitionsResponse = `{"transitions":[ + {"id":"31","name":"Resolve","to":{"id":"10003","name":"Done"}}, + {"id":"21","name":"Back to To Do","to":{"id":"10001","name":"To Do"}} + ]}` + const projectSchemeResponse = `{"values":[{"projectIds":["10000"],"workflowScheme":{"id":"101010","name":"Default scheme"}}]}` + const schemeDetailResponse = `{"id":101010,"name":"Default scheme","defaultWorkflow":"wf","issueTypeMappings":{"10100":"task-workflow"}}` + const issueTypesResponse = `{"issueTypes":[{"id":"10100","name":"Task"}]}` + const workflowStatusesResponse = `{"values":[{"id":{"name":"task-workflow"},"statuses":[ + {"id":"10001","name":"To Do"}, + {"id":"10002","name":"In Progress"}, + {"id":"10003","name":"Done"} + ]}]}` + + t.Run("returns workflow + current status + transitions for a company-managed project", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(issueResponse))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(transitionsResponse))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(projectSchemeResponse))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(schemeDetailResponse))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(issueTypesResponse))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(workflowStatusesResponse))}, + }, + } + + execCtx := &contexts.ExecutionStateContext{} + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "project": "TEST", + "issueKey": "TEST-1", + }, + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + ExecutionState: execCtx, + Logger: newLogger(), + }) + + require.NoError(t, err) + assert.True(t, execCtx.Passed) + assert.Equal(t, GetWorkflowPayloadType, execCtx.Type) + require.Len(t, execCtx.Payloads, 1) + + output := unwrapGetWorkflowPayload(t, execCtx.Payloads[0]) + + assert.Equal(t, "TEST-1", output.IssueKey) + assert.Equal(t, "Task", output.IssueType) + assert.Equal(t, "TEST", output.ProjectKey) + assert.Equal(t, "In Progress", output.CurrentStatus) + assert.Equal(t, "10002", output.CurrentStatusID) + assert.Equal(t, "101010", output.WorkflowSchemeID) + assert.Equal(t, "Default scheme", output.WorkflowSchemeName) + assert.Equal(t, "task-workflow", output.WorkflowName) + + require.Len(t, output.Statuses, 3) + var foundCurrent bool + for _, s := range output.Statuses { + if s.Name == "In Progress" { + assert.True(t, s.IsCurrent, "current status should be flagged") + foundCurrent = true + } else { + assert.False(t, s.IsCurrent) + } + } + assert.True(t, foundCurrent) + + require.Len(t, output.AvailableTransitions, 2) + assert.Equal(t, "Resolve", output.AvailableTransitions[0].Name) + assert.Equal(t, "Done", output.AvailableTransitions[0].ToStatus) + + // transitions endpoint must request fields so the resolution-check works downstream. + assert.Contains(t, httpContext.Requests[1].URL.String(), "expand=transitions.fields") + }) + + t.Run("team-managed project (no scheme) -> still emits current status + transitions", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(issueResponse))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(transitionsResponse))}, + // /workflowscheme/project returns no values for team-managed projects. + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"values":[]}`))}, + }, + } + + execCtx := &contexts.ExecutionStateContext{} + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "project": "TEST", + "issueKey": "TEST-1", + }, + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + ExecutionState: execCtx, + Logger: newLogger(), + }) + + require.NoError(t, err) + output := unwrapGetWorkflowPayload(t, execCtx.Payloads[0]) + assert.Equal(t, "In Progress", output.CurrentStatus) + assert.Equal(t, "", output.WorkflowName) + assert.Empty(t, output.Statuses) + require.Len(t, output.AvailableTransitions, 2) + }) + + t.Run("falls back to default workflow when issue type is not in the scheme mappings", func(t *testing.T) { + schemeWithoutMapping := `{"id":101010,"name":"Default scheme","defaultWorkflow":"jira-default","issueTypeMappings":{}}` + defaultWorkflowStatuses := `{"values":[{"id":{"name":"jira-default"},"statuses":[ + {"id":"10001","name":"To Do"}, + {"id":"10002","name":"In Progress"}, + {"id":"10003","name":"Done"} + ]}]}` + + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(issueResponse))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(transitionsResponse))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(projectSchemeResponse))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(schemeWithoutMapping))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(issueTypesResponse))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(defaultWorkflowStatuses))}, + }, + } + + execCtx := &contexts.ExecutionStateContext{} + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "project": "TEST", + "issueKey": "TEST-1", + }, + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + ExecutionState: execCtx, + Logger: newLogger(), + }) + + require.NoError(t, err) + output := unwrapGetWorkflowPayload(t, execCtx.Payloads[0]) + assert.Equal(t, "jira-default", output.WorkflowName) + }) +} + +// unwrapGetWorkflowPayload extracts the GetWorkflowOutput from the wrapped +// `{type, timestamp, data}` envelope that ExecutionStateContext.Emit produces. +func unwrapGetWorkflowPayload(t *testing.T, payload any) GetWorkflowOutput { + t.Helper() + wrapped, ok := payload.(map[string]any) + require.True(t, ok, "expected wrapped payload map, got %T", payload) + out, ok := wrapped["data"].(GetWorkflowOutput) + require.True(t, ok, "expected data to be GetWorkflowOutput, got %T", wrapped["data"]) + return out +} diff --git a/pkg/integrations/jira/jira.go b/pkg/integrations/jira/jira.go index 5d5dca7c15..c47071db65 100644 --- a/pkg/integrations/jira/jira.go +++ b/pkg/integrations/jira/jira.go @@ -88,8 +88,7 @@ func (j *Jira) Actions() []core.Action { &CreateIncident{}, &GetIncident{}, &DeleteIncident{}, - &CreateWorkflow{}, - &AssignWorkflowToProject{}, + &GetWorkflow{}, &TransitionIssue{}, &ApproveWorkflow{}, } diff --git a/pkg/integrations/jira/list_resources.go b/pkg/integrations/jira/list_resources.go index 17b4d3775e..57e862de04 100644 --- a/pkg/integrations/jira/list_resources.go +++ b/pkg/integrations/jira/list_resources.go @@ -22,8 +22,6 @@ func (j *Jira) ListResources(resourceType string, ctx core.ListResourcesContext) return listPriorities(ctx) case "resolution": return listResolutions(ctx) - case "workflowScheme": - return listWorkflowSchemes(ctx) case "serviceDesk": client, err := NewClient(ctx.HTTP, ctx.Integration) if err != nil { @@ -221,11 +219,6 @@ func listIssueTypes(ctx core.ListResourcesContext) ([]core.IntegrationResource, } func listIssueStatuses(ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { - projectKey := ctx.Parameters["project"] - if projectKey == "" || strings.Contains(projectKey, "{{") { - return []core.IntegrationResource{}, nil - } - if ctx.HTTP == nil { return []core.IntegrationResource{}, nil } @@ -235,11 +228,23 @@ func listIssueStatuses(ctx core.ListResourcesContext) ([]core.IntegrationResourc return nil, fmt.Errorf("failed to create client: %w", err) } - statuses, err := client.GetProjectStatuses(projectKey) + projectKey := strings.TrimSpace(ctx.Parameters["project"]) + if projectKey != "" && !strings.Contains(projectKey, "{{") { + statuses, err := client.GetProjectStatuses(projectKey) + if err != nil { + return nil, fmt.Errorf("failed to list issue statuses: %w", err) + } + return issueStatusResources(statuses), nil + } + + statuses, err := client.ListGlobalStatuses() if err != nil { return nil, fmt.Errorf("failed to list issue statuses: %w", err) } + return issueStatusResources(statuses), nil +} +func issueStatusResources(statuses []Status) []core.IntegrationResource { resources := make([]core.IntegrationResource, 0, len(statuses)) for _, s := range statuses { resources = append(resources, core.IntegrationResource{ @@ -248,7 +253,7 @@ func listIssueStatuses(ctx core.ListResourcesContext) ([]core.IntegrationResourc ID: s.Name, }) } - return resources, nil + return resources } func listAssignees(ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { @@ -338,37 +343,6 @@ func listResolutions(ctx core.ListResourcesContext) ([]core.IntegrationResource, return resources, nil } -func listWorkflowSchemes(ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { - if ctx.HTTP == nil { - return []core.IntegrationResource{}, nil - } - - client, err := NewClient(ctx.HTTP, ctx.Integration) - if err != nil { - return nil, fmt.Errorf("failed to create client: %w", err) - } - - schemes, err := client.ListWorkflowSchemes() - if err != nil { - return nil, fmt.Errorf("failed to list workflow schemes: %w", err) - } - - resources := make([]core.IntegrationResource, 0, len(schemes)) - for _, scheme := range schemes { - id := scheme.ID.String() - name := strings.TrimSpace(scheme.Name) - if id != "" { - name = fmt.Sprintf("%s (%s)", name, id) - } - resources = append(resources, core.IntegrationResource{ - Type: "workflowScheme", - Name: name, - ID: id, - }) - } - return resources, nil -} - func (j *Jira) listRequestTypeFieldResources(resourceType, fieldLabel string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { deskID := strings.TrimSpace(ctx.Parameters["serviceDesk"]) reqID := strings.TrimSpace(ctx.Parameters["serviceDeskRequestType"]) diff --git a/pkg/integrations/jira/list_resources_test.go b/pkg/integrations/jira/list_resources_test.go index 3ec88e4be7..31c21ab401 100644 --- a/pkg/integrations/jira/list_resources_test.go +++ b/pkg/integrations/jira/list_resources_test.go @@ -12,6 +12,15 @@ import ( "github.com/superplanehq/superplane/test/support/contexts" ) +const globalStatusesResponse = `{ + "isLast": true, + "values": [ + {"id":"1","name":"To Do","statusCategory":"TODO"}, + {"id":"2","name":"In Progress","statusCategory":"IN_PROGRESS"}, + {"id":"3","name":"Done","statusCategory":"DONE"} + ] +}` + func Test__ListResources__Project(t *testing.T) { j := &Jira{} appCtx := newAuthorizedIntegrationWithMetadata(Metadata{ @@ -201,34 +210,80 @@ func Test__ListResources__Priority__MissingHTTPContext(t *testing.T) { assert.Empty(t, resources) } -func Test__ListResources__WorkflowScheme(t *testing.T) { +func Test__ListResources__IssueStatus(t *testing.T) { j := &Jira{} - httpContext := &contexts.HTTPContext{ - Responses: []*http.Response{ - { - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{ - "isLast": true, - "values": [ - {"id":101010,"name":"Support workflow scheme"}, - {"id":"scheme-2","name":"Escalation workflow scheme"} - ] - }`)), + + t.Run("with project parameter -> uses project statuses endpoint", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`[ + {"name":"Task","statuses":[ + {"id":"1","name":"To Do","statusCategory":{"key":"new"}}, + {"id":"2","name":"Done","statusCategory":{"key":"done"}} + ]} + ]`)), + }, }, - }, - } + } - resources, err := j.ListResources("workflowScheme", core.ListResourcesContext{ - HTTP: httpContext, - Integration: newAuthorizedIntegration(), + resources, err := j.ListResources("issueStatus", core.ListResourcesContext{ + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + Parameters: map[string]string{"project": "TEST"}, + }) + + require.NoError(t, err) + require.Len(t, resources, 2) + assert.Equal(t, "issueStatus", resources[0].Type) + assert.Equal(t, "To Do", resources[0].Name) + assert.Contains(t, httpContext.Requests[0].URL.String(), "/rest/api/3/project/TEST/statuses") }) - require.NoError(t, err) - require.Len(t, resources, 2) - assert.Equal(t, "workflowScheme", resources[0].Type) - assert.Equal(t, "101010", resources[0].ID) - assert.Equal(t, "Support workflow scheme (101010)", resources[0].Name) - assert.Equal(t, "scheme-2", resources[1].ID) + t.Run("without project parameter -> falls back to global statuses", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(globalStatusesResponse)), + }, + }, + } + + resources, err := j.ListResources("issueStatus", core.ListResourcesContext{ + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + }) + + require.NoError(t, err) + require.Len(t, resources, 3) + assert.Equal(t, "To Do", resources[0].Name) + assert.Equal(t, "In Progress", resources[1].Name) + assert.Equal(t, "Done", resources[2].Name) + assert.Contains(t, httpContext.Requests[0].URL.String(), "/rest/api/3/statuses/search") + }) + + t.Run("unresolved expression project parameter -> falls back to global statuses", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(globalStatusesResponse)), + }, + }, + } + + resources, err := j.ListResources("issueStatus", core.ListResourcesContext{ + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + Parameters: map[string]string{"project": "{{ trigger.project }}"}, + }) + + require.NoError(t, err) + require.Len(t, resources, 3) + assert.Contains(t, httpContext.Requests[0].URL.String(), "/rest/api/3/statuses/search") + }) } func Test__ListResources__Unknown(t *testing.T) { diff --git a/pkg/integrations/jira/transition_issue_test.go b/pkg/integrations/jira/transition_issue_test.go index 4433bd2d53..a82247f5c6 100644 --- a/pkg/integrations/jira/transition_issue_test.go +++ b/pkg/integrations/jira/transition_issue_test.go @@ -67,7 +67,7 @@ func Test__TransitionIssue__Execute(t *testing.T) { Responses: []*http.Response{ { StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{"transitions":[{"id":"31","name":"Resolve","to":{"id":"10003","name":"Done"}}]}`)), + Body: io.NopCloser(strings.NewReader(`{"transitions":[{"id":"31","name":"Resolve","to":{"id":"10003","name":"Done"},"fields":{"resolution":{"required":false}}}]}`)), }, { StatusCode: http.StatusNoContent, diff --git a/web_src/src/pages/workflowv2/mappers/jira/assign_workflow_to_project.ts b/web_src/src/pages/workflowv2/mappers/jira/assign_workflow_to_project.ts deleted file mode 100644 index 49a5b57339..0000000000 --- a/web_src/src/pages/workflowv2/mappers/jira/assign_workflow_to_project.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { ComponentBaseProps } from "@/ui/componentBase"; -import type React from "react"; -import type { - ComponentBaseContext, - ComponentBaseMapper, - ExecutionDetailsContext, - NodeInfo, - OutputPayload, - SubtitleContext, -} from "../types"; -import type { MetadataItem } from "@/ui/metadataList"; -import { renderTimeAgo } from "@/components/TimeAgo"; -import { jiraComponentBaseProps } from "./base"; -import { addDetail, addProjectMetadata } from "./utils"; -import type { AssignWorkflowToProjectConfiguration, JiraNodeMetadata, JiraWorkflowSchemeAssignment } from "./types"; - -export const assignWorkflowToProjectMapper: ComponentBaseMapper = { - props(context: ComponentBaseContext): ComponentBaseProps { - return jiraComponentBaseProps(context, metadataList(context.node)); - }, - - getExecutionDetails(context: ExecutionDetailsContext): Record { - const details: Record = { - "Executed At": context.execution.createdAt ? new Date(context.execution.createdAt).toLocaleString() : "-", - }; - - const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; - const assignment = outputs?.default?.[0]?.data as JiraWorkflowSchemeAssignment | undefined; - if (assignment) { - addDetail(details, "Project ID", assignment.projectId); - addDetail(details, "Workflow Scheme ID", assignment.workflowSchemeId); - details["Draft Created"] = assignment.draftCreated ? "Yes" : "No"; - if (assignment.dryRun) details["Dry Run"] = "Yes"; - addDetail(details, "Task ID", assignment.taskId); - addDetail(details, "Task Status", assignment.taskStatus); - addDetail(details, "Task URL", assignment.taskSelf); - } - - return details; - }, - - subtitle(context: SubtitleContext): string | React.ReactNode { - const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; - const assignment = outputs?.default?.[0]?.data as JiraWorkflowSchemeAssignment | undefined; - if (assignment?.taskStatus) return assignment.taskStatus; - if (assignment?.dryRun) return "Dry run"; - if (context.execution.createdAt) { - return renderTimeAgo(new Date(context.execution.createdAt)); - } - return ""; - }, -}; - -function metadataList(node: NodeInfo): MetadataItem[] { - const metadata: MetadataItem[] = []; - const nodeMetadata = node.metadata as JiraNodeMetadata | undefined; - const configuration = node.configuration as AssignWorkflowToProjectConfiguration | undefined; - - addProjectMetadata(metadata, nodeMetadata?.project, configuration?.project); - - const schemeLabel = nodeMetadata?.workflowScheme?.name || configuration?.workflowScheme; - if (schemeLabel) { - metadata.push({ icon: "workflow", label: schemeLabel }); - } - - if (configuration?.dryRun) { - metadata.push({ icon: "search-check", label: "Dry run" }); - } - - return metadata; -} diff --git a/web_src/src/pages/workflowv2/mappers/jira/create_workflow.spec.ts b/web_src/src/pages/workflowv2/mappers/jira/create_workflow.spec.ts deleted file mode 100644 index 0bdb3bd33d..0000000000 --- a/web_src/src/pages/workflowv2/mappers/jira/create_workflow.spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { createWorkflowMapper } from "./create_workflow"; -import { eventStateRegistry } from "./index"; -import type { ComponentBaseContext, ExecutionDetailsContext, ExecutionInfo, NodeInfo, SubtitleContext } from "../types"; - -function node(overrides?: Partial): NodeInfo { - return { - id: "node-1", - name: "Create workflow", - componentName: "jira.createWorkflow", - isCollapsed: false, - configuration: {}, - metadata: {}, - ...overrides, - }; -} - -function execution(overrides?: Partial): ExecutionInfo { - return { - id: "exec-1", - createdAt: "2026-01-19T12:00:00Z", - updatedAt: "2026-01-19T12:00:00Z", - state: "STATE_FINISHED", - result: "RESULT_PASSED", - resultReason: "RESULT_REASON_OK", - resultMessage: "", - metadata: {}, - configuration: {}, - rootEvent: undefined, - ...overrides, - }; -} - -function detailsCtx(overrides?: { - node?: Partial; - execution?: Partial; -}): ExecutionDetailsContext { - const n = node(overrides?.node); - return { nodes: [n], node: n, execution: execution(overrides?.execution) }; -} - -function componentCtx(overrides?: { node?: Partial }): ComponentBaseContext { - const n = node(overrides?.node); - return { - nodes: [n], - node: n, - componentDefinition: { - name: "jira.createWorkflow", - label: "Create Workflow", - description: "", - icon: "jira", - color: "blue", - }, - lastExecutions: [], - currentUser: undefined, - actions: { invokeNodeExecutionHook: async () => {} }, - }; -} - -describe("createWorkflowMapper", () => { - it("extracts workflow output details", () => { - const details = createWorkflowMapper.getExecutionDetails( - detailsCtx({ - node: { configuration: { scope: "GLOBAL", statuses: [{ name: "To Do" }], transitions: [{ name: "Done" }] } }, - execution: { - outputs: { - default: [ - { - type: "jira.workflow.created", - timestamp: "2026-01-19T12:00:00Z", - data: { id: "wf-1", name: "Support", version: { versionNumber: 1 } }, - }, - ], - }, - }, - }), - ); - - expect(details["Workflow ID"]).toBe("wf-1"); - expect(details.Name).toBe("Support"); - expect(details.Version).toBe("1"); - expect(details.Statuses).toBe("1"); - expect(details.Transitions).toBe("1"); - }); - - it("renders workflow metadata", () => { - const props = createWorkflowMapper.props( - componentCtx({ - node: { - configuration: { name: "Support", scope: "PROJECT", project: "TEST", statuses: [{ name: "To Do" }] }, - metadata: { project: { key: "TEST", name: "Test Project" }, workflowName: "Support" }, - }, - }), - ); - - expect(props.metadata).toEqual([ - { icon: "workflow", label: "Support" }, - { icon: "globe", label: "Project scoped" }, - { icon: "folder", label: "Test Project" }, - { icon: "list", label: "1 statuses" }, - ]); - }); - - it("uses workflow name as subtitle", () => { - const result = createWorkflowMapper.subtitle({ - node: node(), - execution: execution({ - outputs: { - default: [{ type: "jira.workflow.created", timestamp: "2026-01-19T12:00:00Z", data: { name: "Support" } }], - }, - }), - } as SubtitleContext); - - expect(result).toBe("Support"); - }); - - it("maps finished success to created", () => { - expect(eventStateRegistry.createWorkflow.getState(execution())).toBe("created"); - }); -}); diff --git a/web_src/src/pages/workflowv2/mappers/jira/assign_workflow_to_project.spec.ts b/web_src/src/pages/workflowv2/mappers/jira/get_workflow.spec.ts similarity index 51% rename from web_src/src/pages/workflowv2/mappers/jira/assign_workflow_to_project.spec.ts rename to web_src/src/pages/workflowv2/mappers/jira/get_workflow.spec.ts index 8949226293..289b8ee048 100644 --- a/web_src/src/pages/workflowv2/mappers/jira/assign_workflow_to_project.spec.ts +++ b/web_src/src/pages/workflowv2/mappers/jira/get_workflow.spec.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from "vitest"; -import { assignWorkflowToProjectMapper } from "./assign_workflow_to_project"; +import { getWorkflowMapper } from "./get_workflow"; import { eventStateRegistry } from "./index"; import type { ComponentBaseContext, ExecutionDetailsContext, ExecutionInfo, NodeInfo } from "../types"; function node(overrides?: Partial): NodeInfo { return { id: "node-1", - name: "Assign scheme", - componentName: "jira.assignWorkflowToProject", + name: "Get workflow", + componentName: "jira.getWorkflow", isCollapsed: false, configuration: {}, metadata: {}, @@ -46,8 +46,8 @@ function componentCtx(overrides?: { node?: Partial }): ComponentBaseCo nodes: [n], node: n, componentDefinition: { - name: "jira.assignWorkflowToProject", - label: "Assign Workflow To Project", + name: "jira.getWorkflow", + label: "Get Workflow", description: "", icon: "jira", color: "blue", @@ -58,22 +58,26 @@ function componentCtx(overrides?: { node?: Partial }): ComponentBaseCo }; } -describe("assignWorkflowToProjectMapper", () => { - it("extracts assignment details", () => { - const details = assignWorkflowToProjectMapper.getExecutionDetails( +describe("getWorkflowMapper", () => { + it("extracts workflow + current status + transitions from the payload", () => { + const details = getWorkflowMapper.getExecutionDetails( detailsCtx({ execution: { outputs: { default: [ { - type: "jira.workflowScheme.assigned", + type: "jira.workflow", timestamp: "2026-01-19T12:00:00Z", data: { - projectId: "10000", - workflowSchemeId: "101010", - draftCreated: false, - taskId: "task-1", - taskStatus: "ENQUEUED", + issueKey: "TEST-1", + issueType: "Task", + workflowName: "Software Simplified", + workflowSchemeName: "Default scheme", + currentStatus: "In Progress", + availableTransitions: [ + { id: "21", name: "Stop progress", toStatus: "To Do" }, + { id: "31", name: "Resolve", toStatus: "Done" }, + ], }, }, ], @@ -82,33 +86,36 @@ describe("assignWorkflowToProjectMapper", () => { }), ); - expect(details["Project ID"]).toBe("10000"); - expect(details["Workflow Scheme ID"]).toBe("101010"); - expect(details["Task ID"]).toBe("task-1"); - expect(details["Task Status"]).toBe("ENQUEUED"); + expect(details.Issue).toBe("TEST-1"); + expect(details["Issue Type"]).toBe("Task"); + expect(details.Workflow).toBe("Software Simplified"); + expect(details["Current Status"]).toBe("In Progress"); + expect(details["Available Transitions"]).toBe("To Do, Done"); }); - it("renders project and scheme metadata", () => { - const props = assignWorkflowToProjectMapper.props( + it("falls back gracefully when no execution data is present", () => { + const details = getWorkflowMapper.getExecutionDetails(detailsCtx()); + expect(details["Executed At"]).toBeDefined(); + expect(details.Issue).toBeUndefined(); + }); + + it("renders project + issue key in metadata", () => { + const props = getWorkflowMapper.props( componentCtx({ node: { - configuration: { project: "TEST", workflowScheme: "101010", dryRun: true }, - metadata: { - project: { key: "TEST", name: "Test Project" }, - workflowScheme: { id: "101010", name: "Support scheme" }, - }, + configuration: { project: "TEST", issueKey: "TEST-1" }, + metadata: { project: { key: "TEST", name: "Test Project" } }, }, }), ); expect(props.metadata).toEqual([ { icon: "folder", label: "Test Project" }, - { icon: "workflow", label: "Support scheme" }, - { icon: "search-check", label: "Dry run" }, + { icon: "hash", label: "TEST-1" }, ]); }); - it("maps finished success to assigned", () => { - expect(eventStateRegistry.assignWorkflowToProject.getState(execution())).toBe("assigned"); + it("maps finished success to retrieved", () => { + expect(eventStateRegistry.getWorkflow.getState(execution())).toBe("retrieved"); }); }); diff --git a/web_src/src/pages/workflowv2/mappers/jira/create_workflow.ts b/web_src/src/pages/workflowv2/mappers/jira/get_workflow.ts similarity index 53% rename from web_src/src/pages/workflowv2/mappers/jira/create_workflow.ts rename to web_src/src/pages/workflowv2/mappers/jira/get_workflow.ts index 4581d99c7a..18075e105d 100644 --- a/web_src/src/pages/workflowv2/mappers/jira/create_workflow.ts +++ b/web_src/src/pages/workflowv2/mappers/jira/get_workflow.ts @@ -11,10 +11,10 @@ import type { import type { MetadataItem } from "@/ui/metadataList"; import { renderTimeAgo } from "@/components/TimeAgo"; import { jiraComponentBaseProps } from "./base"; -import { addDetail, addProjectMetadata } from "./utils"; -import type { CreateWorkflowConfiguration, JiraNodeMetadata, JiraWorkflow } from "./types"; +import { addDetail, addIssueKeyMetadata, addProjectMetadata } from "./utils"; +import type { GetWorkflowConfiguration, JiraNodeMetadata, JiraWorkflow } from "./types"; -export const createWorkflowMapper: ComponentBaseMapper = { +export const getWorkflowMapper: ComponentBaseMapper = { props(context: ComponentBaseContext): ComponentBaseProps { return jiraComponentBaseProps(context, metadataList(context.node)); }, @@ -27,31 +27,27 @@ export const createWorkflowMapper: ComponentBaseMapper = { const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; const workflow = outputs?.default?.[0]?.data as JiraWorkflow | undefined; if (workflow) { - addDetail(details, "Workflow ID", workflow.id); - addDetail(details, "Name", workflow.name); - if (workflow.version?.versionNumber !== undefined) { - details["Version"] = String(workflow.version.versionNumber); + addDetail(details, "Issue", workflow.issueKey); + addDetail(details, "Issue Type", workflow.issueType); + addDetail(details, "Current Status", workflow.currentStatus); + addDetail(details, "Workflow", workflow.workflowName); + addDetail(details, "Workflow Scheme", workflow.workflowSchemeName); + if (workflow.availableTransitions?.length) { + details["Available Transitions"] = workflow.availableTransitions + .map((t) => t.toStatus || t.name) + .filter(Boolean) + .join(", "); } } - const configuration = context.node.configuration as CreateWorkflowConfiguration | undefined; - if (configuration?.scope) { - details["Scope"] = configuration.scope; - } - if (configuration?.statuses?.length) { - details["Statuses"] = String(configuration.statuses.length); - } - if (configuration?.transitions?.length) { - details["Transitions"] = String(configuration.transitions.length); - } - return details; }, subtitle(context: SubtitleContext): string | React.ReactNode { const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; const workflow = outputs?.default?.[0]?.data as JiraWorkflow | undefined; - if (workflow?.name) return workflow.name; + if (workflow?.workflowName) return workflow.workflowName; + if (workflow?.currentStatus) return workflow.currentStatus; if (context.execution.createdAt) { return renderTimeAgo(new Date(context.execution.createdAt)); } @@ -62,21 +58,10 @@ export const createWorkflowMapper: ComponentBaseMapper = { function metadataList(node: NodeInfo): MetadataItem[] { const metadata: MetadataItem[] = []; const nodeMetadata = node.metadata as JiraNodeMetadata | undefined; - const configuration = node.configuration as CreateWorkflowConfiguration | undefined; - - const name = nodeMetadata?.workflowName || configuration?.name; - if (name) { - metadata.push({ icon: "workflow", label: name }); - } - - const scope = configuration?.scope || "GLOBAL"; - metadata.push({ icon: "globe", label: scope === "PROJECT" ? "Project scoped" : "Global" }); + const configuration = node.configuration as GetWorkflowConfiguration | undefined; addProjectMetadata(metadata, nodeMetadata?.project, configuration?.project); + addIssueKeyMetadata(metadata, "hash", configuration?.issueKey); - if (configuration?.statuses?.length) { - metadata.push({ icon: "list", label: `${configuration.statuses.length} statuses` }); - } - - return metadata.slice(0, 4); + return metadata; } diff --git a/web_src/src/pages/workflowv2/mappers/jira/index.ts b/web_src/src/pages/workflowv2/mappers/jira/index.ts index 0bb550d824..c33b0b4ab5 100644 --- a/web_src/src/pages/workflowv2/mappers/jira/index.ts +++ b/web_src/src/pages/workflowv2/mappers/jira/index.ts @@ -7,8 +7,7 @@ import { updateIssueMapper } from "./update_issue"; import { createIncidentMapper } from "./create_incident"; import { getIncidentMapper } from "./get_incident"; import { deleteIncidentMapper } from "./delete_incident"; -import { createWorkflowMapper } from "./create_workflow"; -import { assignWorkflowToProjectMapper } from "./assign_workflow_to_project"; +import { getWorkflowMapper } from "./get_workflow"; import { transitionIssueMapper } from "./transition_issue"; import { approveWorkflowMapper } from "./approve_workflow"; @@ -20,8 +19,7 @@ export const componentMappers: Record = { createIncident: createIncidentMapper, getIncident: getIncidentMapper, deleteIncident: deleteIncidentMapper, - createWorkflow: createWorkflowMapper, - assignWorkflowToProject: assignWorkflowToProjectMapper, + getWorkflow: getWorkflowMapper, transitionIssue: transitionIssueMapper, approveWorkflow: approveWorkflowMapper, }; @@ -36,8 +34,7 @@ export const eventStateRegistry: Record = { createIncident: buildActionStateRegistry("created"), getIncident: buildActionStateRegistry("fetched"), deleteIncident: buildActionStateRegistry("deleted"), - createWorkflow: buildActionStateRegistry("created"), - assignWorkflowToProject: buildActionStateRegistry("assigned"), + getWorkflow: buildActionStateRegistry("retrieved"), transitionIssue: buildActionStateRegistry("transitioned"), approveWorkflow: buildActionStateRegistry("decided"), }; diff --git a/web_src/src/pages/workflowv2/mappers/jira/types.ts b/web_src/src/pages/workflowv2/mappers/jira/types.ts index 374572aab0..f5931ad41b 100644 --- a/web_src/src/pages/workflowv2/mappers/jira/types.ts +++ b/web_src/src/pages/workflowv2/mappers/jira/types.ts @@ -47,36 +47,33 @@ export interface JiraNodeMetadata { project?: JiraProject; issueType?: string; status?: string; - workflowName?: string; - workflowScheme?: JiraWorkflowScheme; } -export interface JiraWorkflowVersion { - id?: string; - versionNumber?: number; -} - -export interface JiraWorkflow { +export interface JiraWorkflowStatus { id?: string; name?: string; - version?: JiraWorkflowVersion; + category?: string; + isCurrent?: boolean; } -export interface JiraWorkflowScheme { +export interface JiraWorkflowAvailableTransition { id?: string; name?: string; - description?: string; - self?: string; + toStatusId?: string; + toStatus?: string; } -export interface JiraWorkflowSchemeAssignment { - projectId?: string; +export interface JiraWorkflow { + issueKey?: string; + issueType?: string; + projectKey?: string; + workflowName?: string; workflowSchemeId?: string; - draftCreated?: boolean; - dryRun?: boolean; - taskId?: string; - taskStatus?: string; - taskSelf?: string; + workflowSchemeName?: string; + currentStatus?: string; + currentStatusId?: string; + statuses?: JiraWorkflowStatus[]; + availableTransitions?: JiraWorkflowAvailableTransition[]; } export interface JiraApproval { @@ -122,19 +119,9 @@ export interface DeleteIssueConfiguration { deleteSubtasks?: boolean; } -export interface CreateWorkflowConfiguration { - name?: string; - description?: string; - scope?: string; - project?: string; - statuses?: Array<{ name?: string; category?: string }>; - transitions?: Array<{ name?: string; from?: string[]; to?: string; type?: string }>; -} - -export interface AssignWorkflowToProjectConfiguration { +export interface GetWorkflowConfiguration { project?: string; - workflowScheme?: string; - dryRun?: boolean; + issueKey?: string; } export interface TransitionIssueConfiguration { From 8b847966e26d5301cca1c521dfa65a8633dc95fe Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Tue, 19 May 2026 18:07:29 +0300 Subject: [PATCH 3/8] feat: enhance Jira workflow status retrieval and testing Signed-off-by: WashingtonKK --- pkg/integrations/jira/client.go | 18 +++++++++++++--- pkg/integrations/jira/client_test.go | 25 ++++++++++++++++++++++ pkg/integrations/jira/get_workflow_test.go | 17 +++++++++------ 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/pkg/integrations/jira/client.go b/pkg/integrations/jira/client.go index 5ca98325df..3d89b85633 100644 --- a/pkg/integrations/jira/client.go +++ b/pkg/integrations/jira/client.go @@ -590,7 +590,7 @@ type workflowSearchEntry struct { ID struct { Name string `json:"name"` } `json:"id"` - Statuses []Status `json:"statuses"` + Statuses []globalStatus `json:"statuses"` } type workflowSearchResponse struct { @@ -616,15 +616,27 @@ func (c *Client) GetWorkflowStatusesByName(workflowName string) ([]Status, error } for _, entry := range out.Values { if entry.ID.Name == workflowName { - return entry.Statuses, nil + return statusesFromGlobal(entry.Statuses), nil } } if len(out.Values) > 0 { - return out.Values[0].Statuses, nil + return statusesFromGlobal(out.Values[0].Statuses), nil } return nil, fmt.Errorf("workflow %q not found", workflowName) } +func statusesFromGlobal(raw []globalStatus) []Status { + statuses := make([]Status, 0, len(raw)) + for _, s := range raw { + statuses = append(statuses, Status{ + ID: s.ID, + Name: s.Name, + Category: normalizeStatusCategoryName(s.StatusCategory), + }) + } + return statuses +} + // GetProjectIssueTypeStatuses returns each issue type's current status list // for a project. Unlike GetProjectStatuses (which dedupes across issue types), // this preserves the per-issue-type grouping needed to plan a scheme switch. diff --git a/pkg/integrations/jira/client_test.go b/pkg/integrations/jira/client_test.go index 23a0c4ef1d..e41304d79c 100644 --- a/pkg/integrations/jira/client_test.go +++ b/pkg/integrations/jira/client_test.go @@ -680,3 +680,28 @@ func Test__Client__ResolveNumericIssueID(t *testing.T) { assert.Equal(t, "999", id) }) } + +func Test__GetWorkflowStatusesByName(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"values":[{"id":{"name":"task-workflow"},"statuses":[ + {"id":"10001","name":"To Do","statusCategory":"TODO"}, + {"id":"10002","name":"In Progress","statusCategory":"IN_PROGRESS"}, + {"id":"10003","name":"Done","statusCategory":"DONE"} + ]}]}`)), + }, + }, + } + + client, err := NewClient(httpContext, newAuthorizedIntegration()) + require.NoError(t, err) + + statuses, err := client.GetWorkflowStatusesByName("task-workflow") + require.NoError(t, err) + require.Len(t, statuses, 3) + assert.Equal(t, Status{ID: "10001", Name: "To Do", Category: "TODO"}, statuses[0]) + assert.Equal(t, Status{ID: "10002", Name: "In Progress", Category: "IN_PROGRESS"}, statuses[1]) + assert.Equal(t, Status{ID: "10003", Name: "Done", Category: "DONE"}, statuses[2]) +} diff --git a/pkg/integrations/jira/get_workflow_test.go b/pkg/integrations/jira/get_workflow_test.go index 709aa21f0c..683d5db1f4 100644 --- a/pkg/integrations/jira/get_workflow_test.go +++ b/pkg/integrations/jira/get_workflow_test.go @@ -75,9 +75,9 @@ func Test__GetWorkflow__Execute(t *testing.T) { const schemeDetailResponse = `{"id":101010,"name":"Default scheme","defaultWorkflow":"wf","issueTypeMappings":{"10100":"task-workflow"}}` const issueTypesResponse = `{"issueTypes":[{"id":"10100","name":"Task"}]}` const workflowStatusesResponse = `{"values":[{"id":{"name":"task-workflow"},"statuses":[ - {"id":"10001","name":"To Do"}, - {"id":"10002","name":"In Progress"}, - {"id":"10003","name":"Done"} + {"id":"10001","name":"To Do","statusCategory":"TODO"}, + {"id":"10002","name":"In Progress","statusCategory":"IN_PROGRESS"}, + {"id":"10003","name":"Done","statusCategory":"DONE"} ]}]}` t.Run("returns workflow + current status + transitions for a company-managed project", func(t *testing.T) { @@ -121,8 +121,10 @@ func Test__GetWorkflow__Execute(t *testing.T) { assert.Equal(t, "task-workflow", output.WorkflowName) require.Len(t, output.Statuses, 3) + statusCategories := map[string]string{} var foundCurrent bool for _, s := range output.Statuses { + statusCategories[s.Name] = s.Category if s.Name == "In Progress" { assert.True(t, s.IsCurrent, "current status should be flagged") foundCurrent = true @@ -130,6 +132,9 @@ func Test__GetWorkflow__Execute(t *testing.T) { assert.False(t, s.IsCurrent) } } + assert.Equal(t, "TODO", statusCategories["To Do"]) + assert.Equal(t, "IN_PROGRESS", statusCategories["In Progress"]) + assert.Equal(t, "DONE", statusCategories["Done"]) assert.True(t, foundCurrent) require.Len(t, output.AvailableTransitions, 2) @@ -173,9 +178,9 @@ func Test__GetWorkflow__Execute(t *testing.T) { t.Run("falls back to default workflow when issue type is not in the scheme mappings", func(t *testing.T) { schemeWithoutMapping := `{"id":101010,"name":"Default scheme","defaultWorkflow":"jira-default","issueTypeMappings":{}}` defaultWorkflowStatuses := `{"values":[{"id":{"name":"jira-default"},"statuses":[ - {"id":"10001","name":"To Do"}, - {"id":"10002","name":"In Progress"}, - {"id":"10003","name":"Done"} + {"id":"10001","name":"To Do","statusCategory":"TODO"}, + {"id":"10002","name":"In Progress","statusCategory":"IN_PROGRESS"}, + {"id":"10003","name":"Done","statusCategory":"DONE"} ]}]}` httpContext := &contexts.HTTPContext{ From 95a95b93b45103524de165b45f6fa79cbcf7a38d Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Tue, 19 May 2026 18:07:50 +0300 Subject: [PATCH 4/8] refactor: improve error handling and testing for Jira workflow status retrieval This commit enhances the `GetWorkflowStatusesByName` method to ensure it only returns statuses for workflows with an exact name match, preventing incorrect status associations. It also updates the test cases to cover scenarios where workflows are not found, ensuring that errors are surfaced correctly. Additionally, the error handling in the workflow execution process is improved to provide clearer feedback when fetching workflow schemes and statuses fails. Signed-off-by: WashingtonKK --- pkg/integrations/jira/client.go | 12 +-- pkg/integrations/jira/client_test.go | 64 ++++++++---- pkg/integrations/jira/get_workflow.go | 47 +++++---- pkg/integrations/jira/get_workflow_test.go | 110 +++++++++++++++++++++ 4 files changed, 189 insertions(+), 44 deletions(-) diff --git a/pkg/integrations/jira/client.go b/pkg/integrations/jira/client.go index 3d89b85633..45e4fcde59 100644 --- a/pkg/integrations/jira/client.go +++ b/pkg/integrations/jira/client.go @@ -597,9 +597,12 @@ type workflowSearchResponse struct { Values []workflowSearchEntry `json:"values"` } -// GetWorkflowStatusesByName returns the statuses of a workflow looked up by -// name. Used at scheme-switch time to compute which existing issue statuses -// don't exist in the target workflow. +// GetWorkflowStatusesByName returns the statuses of the workflow with the +// given exact name. Jira's /rest/api/3/workflow/search?workflowName=... does +// a prefix-style match server-side and can return multiple workflows, so we +// filter for an exact name match here and refuse to guess if none of the +// returned workflows match — returning a different workflow's statuses +// would silently mis-describe the issue's state machine. func (c *Client) GetWorkflowStatusesByName(workflowName string) ([]Status, error) { query := url.Values{} query.Set("workflowName", workflowName) @@ -619,9 +622,6 @@ func (c *Client) GetWorkflowStatusesByName(workflowName string) ([]Status, error return statusesFromGlobal(entry.Statuses), nil } } - if len(out.Values) > 0 { - return statusesFromGlobal(out.Values[0].Statuses), nil - } return nil, fmt.Errorf("workflow %q not found", workflowName) } diff --git a/pkg/integrations/jira/client_test.go b/pkg/integrations/jira/client_test.go index e41304d79c..a1ba7ec648 100644 --- a/pkg/integrations/jira/client_test.go +++ b/pkg/integrations/jira/client_test.go @@ -682,26 +682,52 @@ func Test__Client__ResolveNumericIssueID(t *testing.T) { } func Test__GetWorkflowStatusesByName(t *testing.T) { - httpContext := &contexts.HTTPContext{ - Responses: []*http.Response{ - { - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{"values":[{"id":{"name":"task-workflow"},"statuses":[ - {"id":"10001","name":"To Do","statusCategory":"TODO"}, - {"id":"10002","name":"In Progress","statusCategory":"IN_PROGRESS"}, - {"id":"10003","name":"Done","statusCategory":"DONE"} - ]}]}`)), + t.Run("returns statuses for an exact-name match", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"values":[{"id":{"name":"task-workflow"},"statuses":[ + {"id":"10001","name":"To Do","statusCategory":"TODO"}, + {"id":"10002","name":"In Progress","statusCategory":"IN_PROGRESS"}, + {"id":"10003","name":"Done","statusCategory":"DONE"} + ]}]}`)), + }, }, - }, - } + } + + client, err := NewClient(httpContext, newAuthorizedIntegration()) + require.NoError(t, err) + + statuses, err := client.GetWorkflowStatusesByName("task-workflow") + require.NoError(t, err) + require.Len(t, statuses, 3) + assert.Equal(t, Status{ID: "10001", Name: "To Do", Category: "TODO"}, statuses[0]) + assert.Equal(t, Status{ID: "10002", Name: "In Progress", Category: "IN_PROGRESS"}, statuses[1]) + assert.Equal(t, Status{ID: "10003", Name: "Done", Category: "DONE"}, statuses[2]) + }) - client, err := NewClient(httpContext, newAuthorizedIntegration()) - require.NoError(t, err) + t.Run("filters out workflows whose name does not match exactly", func(t *testing.T) { + // Jira's workflow/search does a prefix match, so a query for + // "task" can return "task-workflow-old" too. We must not return + // that one's statuses as if they belonged to the requested + // workflow. + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"values":[{"id":{"name":"task-workflow-old"},"statuses":[ + {"id":"99","name":"Stale","statusCategory":"TODO"} + ]}]}`)), + }, + }, + } + + client, err := NewClient(httpContext, newAuthorizedIntegration()) + require.NoError(t, err) - statuses, err := client.GetWorkflowStatusesByName("task-workflow") - require.NoError(t, err) - require.Len(t, statuses, 3) - assert.Equal(t, Status{ID: "10001", Name: "To Do", Category: "TODO"}, statuses[0]) - assert.Equal(t, Status{ID: "10002", Name: "In Progress", Category: "IN_PROGRESS"}, statuses[1]) - assert.Equal(t, Status{ID: "10003", Name: "Done", Category: "DONE"}, statuses[2]) + _, err = client.GetWorkflowStatusesByName("task-workflow") + require.Error(t, err) + assert.Contains(t, err.Error(), `workflow "task-workflow" not found`) + }) } diff --git a/pkg/integrations/jira/get_workflow.go b/pkg/integrations/jira/get_workflow.go index a0e13817ad..aebe64db14 100644 --- a/pkg/integrations/jira/get_workflow.go +++ b/pkg/integrations/jira/get_workflow.go @@ -198,24 +198,29 @@ func (c *GetWorkflow) Execute(ctx core.ExecutionContext) error { } // Resolving the workflow itself (statuses + scheme) requires a company-managed - // project. Team-managed projects bind workflows differently and Jira's public - // scheme APIs return nothing for them — we degrade gracefully and still emit - // the current status + available transitions, which is the most useful part. + // project. Team-managed projects bind workflows differently and Jira's scheme + // APIs return an empty list (not an error) for them — we degrade gracefully + // in that case and still emit current status + available transitions. Any + // other failure is surfaced so callers don't get partial output that looks + // successful. if projectID != "" { scheme, err := client.GetWorkflowSchemeForProject(projectID) - if err != nil && ctx.Logger != nil { - ctx.Logger.Warnf("jira.getWorkflow: failed to fetch workflow scheme for project %s: %v", projectID, err) + if err != nil { + return fmt.Errorf("failed to fetch workflow scheme for project %s: %v", projectID, err) } if scheme != nil { output.WorkflowSchemeID = scheme.ID.String() output.WorkflowSchemeName = scheme.Name - workflowName := resolveWorkflowForIssueType(client, scheme, projectKey, issueTypeName) + workflowName, err := resolveWorkflowForIssueType(client, scheme, projectKey, issueTypeName) + if err != nil { + return fmt.Errorf("failed to resolve workflow for issue type %q: %v", issueTypeName, err) + } output.WorkflowName = workflowName if workflowName != "" { statuses, err := client.GetWorkflowStatusesByName(workflowName) - if err != nil && ctx.Logger != nil { - ctx.Logger.Warnf("jira.getWorkflow: failed to load statuses for workflow %q: %v", workflowName, err) + if err != nil { + return fmt.Errorf("failed to load statuses for workflow %q: %v", workflowName, err) } output.Statuses = make([]WorkflowStatus, 0, len(statuses)) for _, s := range statuses { @@ -281,25 +286,29 @@ func extractIssueTypeAndProject(issue *Issue) (issueType, projectID, projectKey // resolveWorkflowForIssueType maps an issue type name to the workflow that // the scheme routes it through. Jira keys the scheme's issueTypeMappings by // issue type id, so we look up the id via the project's issue types and fall -// back to the default workflow if there's no specific mapping. -func resolveWorkflowForIssueType(client *Client, scheme *WorkflowSchemeDetail, projectKey, issueTypeName string) string { +// back to the scheme's default workflow when there is no specific mapping. +// If the project's issue type list can't be fetched we return the error +// instead of silently falling back to the default workflow — that +// fallback could otherwise hide the issue type's real workflow. +func resolveWorkflowForIssueType(client *Client, scheme *WorkflowSchemeDetail, projectKey, issueTypeName string) (string, error) { if scheme == nil { - return "" + return "", nil } if strings.TrimSpace(projectKey) != "" && strings.TrimSpace(issueTypeName) != "" { issueTypes, err := client.GetProjectIssueTypes(projectKey) - if err == nil { - for _, it := range issueTypes { - if strings.EqualFold(it.Name, issueTypeName) { - if wf := strings.TrimSpace(scheme.IssueTypeMappings[it.ID]); wf != "" { - return wf - } - break + if err != nil { + return "", err + } + for _, it := range issueTypes { + if strings.EqualFold(it.Name, issueTypeName) { + if wf := strings.TrimSpace(scheme.IssueTypeMappings[it.ID]); wf != "" { + return wf, nil } + break } } } - return strings.TrimSpace(scheme.DefaultWorkflow) + return strings.TrimSpace(scheme.DefaultWorkflow), nil } func statusMatches(s Status, currentID, currentName string) bool { diff --git a/pkg/integrations/jira/get_workflow_test.go b/pkg/integrations/jira/get_workflow_test.go index 683d5db1f4..5bcd6300a4 100644 --- a/pkg/integrations/jira/get_workflow_test.go +++ b/pkg/integrations/jira/get_workflow_test.go @@ -175,6 +175,116 @@ func Test__GetWorkflow__Execute(t *testing.T) { require.Len(t, output.AvailableTransitions, 2) }) + t.Run("workflow scheme fetch failure is surfaced as a hard error", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(issueResponse))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(transitionsResponse))}, + {StatusCode: http.StatusInternalServerError, Body: io.NopCloser(strings.NewReader(`{"errorMessage":"boom"}`))}, + }, + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "project": "TEST", + "issueKey": "TEST-1", + }, + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + ExecutionState: &contexts.ExecutionStateContext{}, + Logger: newLogger(), + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to fetch workflow scheme") + }) + + t.Run("workflow status fetch failure is surfaced as a hard error", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(issueResponse))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(transitionsResponse))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(projectSchemeResponse))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(schemeDetailResponse))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(issueTypesResponse))}, + {StatusCode: http.StatusInternalServerError, Body: io.NopCloser(strings.NewReader(`{"errorMessage":"boom"}`))}, + }, + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "project": "TEST", + "issueKey": "TEST-1", + }, + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + ExecutionState: &contexts.ExecutionStateContext{}, + Logger: newLogger(), + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to load statuses") + }) + + t.Run("project issue type fetch failure does not silently fall back to default workflow", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(issueResponse))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(transitionsResponse))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(projectSchemeResponse))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(schemeDetailResponse))}, + {StatusCode: http.StatusInternalServerError, Body: io.NopCloser(strings.NewReader(`{"errorMessage":"boom"}`))}, + }, + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "project": "TEST", + "issueKey": "TEST-1", + }, + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + ExecutionState: &contexts.ExecutionStateContext{}, + Logger: newLogger(), + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to resolve workflow") + }) + + t.Run("workflow/search prefix match for a different workflow returns an error", func(t *testing.T) { + // scheme routes Task to "task-workflow", but workflow/search returns + // the older "task-workflow-old" only. We must not pretend its + // statuses belong to "task-workflow". + prefixMatchOnly := `{"values":[{"id":{"name":"task-workflow-old"},"statuses":[ + {"id":"99","name":"Stale","statusCategory":"TODO"} + ]}]}` + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(issueResponse))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(transitionsResponse))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(projectSchemeResponse))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(schemeDetailResponse))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(issueTypesResponse))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(prefixMatchOnly))}, + }, + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "project": "TEST", + "issueKey": "TEST-1", + }, + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + ExecutionState: &contexts.ExecutionStateContext{}, + Logger: newLogger(), + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), `workflow "task-workflow" not found`) + }) + t.Run("falls back to default workflow when issue type is not in the scheme mappings", func(t *testing.T) { schemeWithoutMapping := `{"id":101010,"name":"Default scheme","defaultWorkflow":"jira-default","issueTypeMappings":{}}` defaultWorkflowStatuses := `{"values":[{"id":{"name":"jira-default"},"statuses":[ From 85375777e230c89f153d89dd894d2aa8f2d3084a Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Tue, 19 May 2026 18:47:46 +0300 Subject: [PATCH 5/8] feat: implement latest pending approval selection logic in Jira workflow This commit introduces a new function, `latestPendingApprovalID`, to determine the most recently created pending approval from a list of approvals. It enhances the `resolveApprovalID` method to utilize this new logic, improving the accuracy of approval selection. Additionally, new test cases are added to ensure the functionality works as expected under various scenarios, including cases with multiple pending approvals and no pending approvals. Signed-off-by: WashingtonKK --- pkg/integrations/jira/approve_workflow.go | 81 ++++++++++++++++++- .../jira/approve_workflow_test.go | 73 +++++++++++++++++ 2 files changed, 150 insertions(+), 4 deletions(-) diff --git a/pkg/integrations/jira/approve_workflow.go b/pkg/integrations/jira/approve_workflow.go index 5a6477a823..e3f79c0c0a 100644 --- a/pkg/integrations/jira/approve_workflow.go +++ b/pkg/integrations/jira/approve_workflow.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "strings" + "time" "github.com/google/uuid" "github.com/mitchellh/mapstructure" @@ -237,12 +238,84 @@ func (c *ApproveWorkflow) resolveApprovalID(client *Client, issueKey string, spe if err != nil { return "", fmt.Errorf("failed to list approvals: %v", err) } - for _, approval := range approvals { - if strings.EqualFold(strings.TrimSpace(approval.FinalDecision), "PENDING") { - return approval.ID.String(), nil + approvalID, ok := latestPendingApprovalID(approvals) + if !ok { + return "", fmt.Errorf("no pending approval found for %s", issueKey) + } + return approvalID, nil +} + +func isPendingApproval(approval Approval) bool { + return strings.EqualFold(strings.TrimSpace(approval.FinalDecision), "PENDING") +} + +// latestPendingApprovalID returns the most recently created pending approval. +// Jira lists approvals oldest-first; when createdDate is missing we use list +// position (last pending wins) as a fallback. +func latestPendingApprovalID(approvals []Approval) (string, bool) { + var ( + bestID string + bestTime time.Time + bestIndex = -1 + hasTime bool + ) + + for i, approval := range approvals { + if !isPendingApproval(approval) { + continue + } + id := approval.ID.String() + if t, ok := approvalCreatedTime(approval); ok { + if !hasTime || t.After(bestTime) || (t.Equal(bestTime) && i > bestIndex) { + bestTime = t + bestID = id + bestIndex = i + hasTime = true + } + continue + } + if !hasTime && i > bestIndex { + bestID = id + bestIndex = i + } + } + + if bestIndex < 0 { + return "", false + } + return bestID, true +} + +func approvalCreatedTime(approval Approval) (time.Time, bool) { + if approval.CreatedDate == nil { + return time.Time{}, false + } + for _, key := range []string{"iso8601", "jira"} { + raw, ok := approval.CreatedDate[key].(string) + if !ok { + continue + } + if t, ok := parseJiraDateTime(raw); ok { + return t, true + } + } + return time.Time{}, false +} + +func parseJiraDateTime(raw string) (time.Time, bool) { + raw = strings.TrimSpace(raw) + layouts := []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02T15:04:05.000-0700", + "2006-01-02T15:04:05-0700", + } + for _, layout := range layouts { + if t, err := time.Parse(layout, raw); err == nil { + return t, true } } - return "", fmt.Errorf("no pending approval found for %s", issueKey) + return time.Time{}, false } func (c *ApproveWorkflow) Cancel(ctx core.ExecutionContext) error { diff --git a/pkg/integrations/jira/approve_workflow_test.go b/pkg/integrations/jira/approve_workflow_test.go index 323a3bd3a3..a1674f40ef 100644 --- a/pkg/integrations/jira/approve_workflow_test.go +++ b/pkg/integrations/jira/approve_workflow_test.go @@ -45,9 +45,82 @@ func Test__ApproveWorkflow__Setup(t *testing.T) { }) } +func TestLatestPendingApprovalID(t *testing.T) { + t.Run("picks last pending when listed oldest first", func(t *testing.T) { + id, ok := latestPendingApprovalID([]Approval{ + {ID: "1", FinalDecision: "PENDING"}, + {ID: "2", FinalDecision: "approved"}, + {ID: "3", FinalDecision: "PENDING"}, + }) + require.True(t, ok) + assert.Equal(t, "3", id) + }) + + t.Run("picks pending with latest createdDate", func(t *testing.T) { + id, ok := latestPendingApprovalID([]Approval{ + { + ID: "1", + FinalDecision: "PENDING", + CreatedDate: map[string]any{"iso8601": "2026-01-01T10:00:00+0000"}, + }, + { + ID: "2", + FinalDecision: "PENDING", + CreatedDate: map[string]any{"iso8601": "2026-01-02T10:00:00+0000"}, + }, + }) + require.True(t, ok) + assert.Equal(t, "2", id) + }) + + t.Run("no pending approvals", func(t *testing.T) { + _, ok := latestPendingApprovalID([]Approval{ + {ID: "1", FinalDecision: "approved"}, + }) + assert.False(t, ok) + }) +} + func Test__ApproveWorkflow__Execute(t *testing.T) { component := ApproveWorkflow{} + t.Run("approves latest pending approval when multiple are pending", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"issueKey":"ITSM-1","serviceDeskId":"1","requestTypeId":"10"}`)), + }, + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"values":[ + {"id":"1","name":"Stage 1","finalDecision":"PENDING","createdDate":{"iso8601":"2026-01-01T10:00:00+0000"}}, + {"id":"2","name":"Stage 2","finalDecision":"PENDING","createdDate":{"iso8601":"2026-01-02T10:00:00+0000"}} + ],"isLastPage":true}`)), + }, + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"id":"2","name":"Stage 2","finalDecision":"approved"}`)), + }, + }, + } + + execCtx := &contexts.ExecutionStateContext{} + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "issueKey": "ITSM-1", + "decision": "approve", + "approvalSelector": approvalSelectorLatestPending, + }, + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + ExecutionState: execCtx, + }) + + require.NoError(t, err) + assert.Contains(t, httpContext.Requests[2].URL.String(), "/approval/2") + }) + t.Run("approves latest pending approval", func(t *testing.T) { httpContext := &contexts.HTTPContext{ Responses: []*http.Response{ From 4b5292696d742b9687543d973affceac8696d84a Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Wed, 20 May 2026 07:12:43 +0300 Subject: [PATCH 6/8] refactor: remove unused GetProjectIssueTypeStatuses function from Jira client This commit removes the `GetProjectIssueTypeStatuses` function from the Jira client, as it is no longer needed. This cleanup helps streamline the codebase and improve maintainability. Signed-off-by: WashingtonKK --- pkg/integrations/jira/client.go | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/pkg/integrations/jira/client.go b/pkg/integrations/jira/client.go index 45e4fcde59..94d946bc93 100644 --- a/pkg/integrations/jira/client.go +++ b/pkg/integrations/jira/client.go @@ -637,39 +637,6 @@ func statusesFromGlobal(raw []globalStatus) []Status { return statuses } -// GetProjectIssueTypeStatuses returns each issue type's current status list -// for a project. Unlike GetProjectStatuses (which dedupes across issue types), -// this preserves the per-issue-type grouping needed to plan a scheme switch. -func (c *Client) GetProjectIssueTypeStatuses(projectKey string) (map[string][]Status, error) { - endpoint := c.apiURL("/rest/api/3/project/" + url.PathEscape(projectKey) + "/statuses") - body, err := c.execRequest(http.MethodGet, endpoint, nil) - if err != nil { - return nil, err - } - - var raw []struct { - ID string `json:"id"` - Statuses []projectStatus `json:"statuses"` - } - if err := json.Unmarshal(body, &raw); err != nil { - return nil, fmt.Errorf("error parsing project issue type statuses: %v", err) - } - - out := map[string][]Status{} - for _, it := range raw { - converted := make([]Status, 0, len(it.Statuses)) - for _, s := range it.Statuses { - converted = append(converted, Status{ - ID: s.ID, - Name: s.Name, - Category: normalizeStatusCategoryKey(s.StatusCategory.Key), - }) - } - out[it.ID] = converted - } - return out, nil -} - type Issue struct { ID string `json:"id"` Key string `json:"key"` From 7265cf0e98cf75a8ad68407474f725cff57f7023 Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Wed, 20 May 2026 10:08:38 +0300 Subject: [PATCH 7/8] feat: add JSM approval listing functionality to Jira integration This commit introduces a new feature to list pending approvals for Jira Service Management (JSM) requests. The `ListResources` method is updated to include a case for "jsmApproval", which calls a new helper function `listJSMApprovals`. This function filters and returns only the pending approvals associated with a given issue key. Additionally, comprehensive test cases are added to ensure the correct functionality of this new feature, including scenarios for valid and invalid issue keys. Signed-off-by: WashingtonKK --- docs/components/Jira.mdx | 4 +- pkg/integrations/jira/approve_workflow.go | 24 +++++-- pkg/integrations/jira/get_workflow.go | 52 ++++++--------- pkg/integrations/jira/get_workflow_test.go | 69 ++++++++++++++++---- pkg/integrations/jira/list_resources.go | 48 ++++++++++++++ pkg/integrations/jira/list_resources_test.go | 64 ++++++++++++++++++ 6 files changed, 207 insertions(+), 54 deletions(-) diff --git a/docs/components/Jira.mdx b/docs/components/Jira.mdx index 477771a946..437ba11f6c 100644 --- a/docs/components/Jira.mdx +++ b/docs/components/Jira.mdx @@ -47,8 +47,8 @@ The Approve Workflow component approves or declines a Jira Service Management re - **Issue Key**: JSM request issue key, for example `ITSM-123`. - **Decision**: Approve or decline. -- **Approval Selector**: Choose the latest pending approval or provide an approval id directly. -- **Approval ID**: Required when selecting by id. +- **Approval Selector**: Choose the latest pending approval or pick a specific one from the list. +- **Approval**: The pending approval to decide. Required when picking a specific approval. - **Comment**: Optional public customer request comment posted before the approval decision. ### Output diff --git a/pkg/integrations/jira/approve_workflow.go b/pkg/integrations/jira/approve_workflow.go index e3f79c0c0a..244afa4264 100644 --- a/pkg/integrations/jira/approve_workflow.go +++ b/pkg/integrations/jira/approve_workflow.go @@ -54,8 +54,8 @@ func (c *ApproveWorkflow) Documentation() string { - **Issue Key**: JSM request issue key, for example ` + "`ITSM-123`" + `. - **Decision**: Approve or decline. -- **Approval Selector**: Choose the latest pending approval or provide an approval id directly. -- **Approval ID**: Required when selecting by id. +- **Approval Selector**: Choose the latest pending approval or pick a specific one from the list. +- **Approval**: The pending approval to decide. Required when picking a specific approval. - **Comment**: Optional public customer request comment posted before the approval decision. ## Output @@ -117,17 +117,29 @@ func (c *ApproveWorkflow) Configuration() []configuration.Field { Select: &configuration.SelectTypeOptions{ Options: []configuration.FieldOption{ {Label: "Latest pending", Value: approvalSelectorLatestPending}, - {Label: "By ID", Value: approvalSelectorByID}, + {Label: "Pick approval", Value: approvalSelectorByID}, }, }, }, }, { Name: "approvalId", - Label: "Approval ID", - Type: configuration.FieldTypeString, + Label: "Approval", + Type: configuration.FieldTypeIntegrationResource, Required: false, - Description: "Approval id to decide", + Description: "Pending approval to decide", + Placeholder: "Select an approval", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "jsmApproval", + Parameters: []configuration.ParameterRef{ + { + Name: "issueKey", + ValueFrom: &configuration.ParameterValueFrom{Field: "issueKey"}, + }, + }, + }, + }, VisibilityConditions: []configuration.VisibilityCondition{ {Field: "approvalSelector", Values: []string{approvalSelectorByID}}, }, diff --git a/pkg/integrations/jira/get_workflow.go b/pkg/integrations/jira/get_workflow.go index aebe64db14..715129595a 100644 --- a/pkg/integrations/jira/get_workflow.go +++ b/pkg/integrations/jira/get_workflow.go @@ -179,7 +179,7 @@ func (c *GetWorkflow) Execute(ctx core.ExecutionContext) error { output.CurrentStatus = currentStatusName output.CurrentStatusID = currentStatusID - issueTypeName, projectID, projectKey := extractIssueTypeAndProject(issue) + issueTypeName, issueTypeID, projectID, projectKey := extractIssueTypeAndProject(issue) output.IssueType = issueTypeName output.ProjectKey = projectKey @@ -212,10 +212,7 @@ func (c *GetWorkflow) Execute(ctx core.ExecutionContext) error { output.WorkflowSchemeID = scheme.ID.String() output.WorkflowSchemeName = scheme.Name - workflowName, err := resolveWorkflowForIssueType(client, scheme, projectKey, issueTypeName) - if err != nil { - return fmt.Errorf("failed to resolve workflow for issue type %q: %v", issueTypeName, err) - } + workflowName := resolveWorkflowForIssueType(scheme, issueTypeID) output.WorkflowName = workflowName if workflowName != "" { statuses, err := client.GetWorkflowStatusesByName(workflowName) @@ -261,13 +258,16 @@ func extractIssueStatus(issue *Issue) (id, name string) { return id, name } -// extractIssueTypeAndProject pulls the issue's issue type name and the -// project's id + key from the loosely-typed fields map. -func extractIssueTypeAndProject(issue *Issue) (issueType, projectID, projectKey string) { +// extractIssueTypeAndProject pulls the issue's issue type id + name and the +// project's id + key from the loosely-typed fields map returned by GetIssue. +func extractIssueTypeAndProject(issue *Issue) (issueType, issueTypeID, projectID, projectKey string) { if issue == nil { - return "", "", "" + return "", "", "", "" } if it, ok := issue.Fields["issuetype"].(map[string]any); ok { + if v, ok := it["id"].(string); ok { + issueTypeID = v + } if v, ok := it["name"].(string); ok { issueType = v } @@ -280,35 +280,23 @@ func extractIssueTypeAndProject(issue *Issue) (issueType, projectID, projectKey projectKey = v } } - return issueType, projectID, projectKey + return issueType, issueTypeID, projectID, projectKey } -// resolveWorkflowForIssueType maps an issue type name to the workflow that -// the scheme routes it through. Jira keys the scheme's issueTypeMappings by -// issue type id, so we look up the id via the project's issue types and fall -// back to the scheme's default workflow when there is no specific mapping. -// If the project's issue type list can't be fetched we return the error -// instead of silently falling back to the default workflow — that -// fallback could otherwise hide the issue type's real workflow. -func resolveWorkflowForIssueType(client *Client, scheme *WorkflowSchemeDetail, projectKey, issueTypeName string) (string, error) { +// resolveWorkflowForIssueType maps an issue type id to the workflow that the +// scheme routes it through. Jira keys issueTypeMappings by issue type id; we +// read the id from the issue itself rather than create-metadata, which only +// lists types the caller can create (sub-tasks, epics, etc. are often omitted). +func resolveWorkflowForIssueType(scheme *WorkflowSchemeDetail, issueTypeID string) string { if scheme == nil { - return "", nil + return "" } - if strings.TrimSpace(projectKey) != "" && strings.TrimSpace(issueTypeName) != "" { - issueTypes, err := client.GetProjectIssueTypes(projectKey) - if err != nil { - return "", err - } - for _, it := range issueTypes { - if strings.EqualFold(it.Name, issueTypeName) { - if wf := strings.TrimSpace(scheme.IssueTypeMappings[it.ID]); wf != "" { - return wf, nil - } - break - } + if id := strings.TrimSpace(issueTypeID); id != "" { + if wf := strings.TrimSpace(scheme.IssueTypeMappings[id]); wf != "" { + return wf } } - return strings.TrimSpace(scheme.DefaultWorkflow), nil + return strings.TrimSpace(scheme.DefaultWorkflow) } func statusMatches(s Status, currentID, currentName string) bool { diff --git a/pkg/integrations/jira/get_workflow_test.go b/pkg/integrations/jira/get_workflow_test.go index 5bcd6300a4..a044bec73e 100644 --- a/pkg/integrations/jira/get_workflow_test.go +++ b/pkg/integrations/jira/get_workflow_test.go @@ -63,7 +63,7 @@ func Test__GetWorkflow__Execute(t *testing.T) { "id":"10001","key":"TEST-1","self":"https://test.atlassian.net/rest/api/3/issue/10001", "fields":{ "status":{"id":"10002","name":"In Progress"}, - "issuetype":{"name":"Task"}, + "issuetype":{"id":"10100","name":"Task"}, "project":{"id":"10000","key":"TEST"} } }` @@ -73,7 +73,6 @@ func Test__GetWorkflow__Execute(t *testing.T) { ]}` const projectSchemeResponse = `{"values":[{"projectIds":["10000"],"workflowScheme":{"id":"101010","name":"Default scheme"}}]}` const schemeDetailResponse = `{"id":101010,"name":"Default scheme","defaultWorkflow":"wf","issueTypeMappings":{"10100":"task-workflow"}}` - const issueTypesResponse = `{"issueTypes":[{"id":"10100","name":"Task"}]}` const workflowStatusesResponse = `{"values":[{"id":{"name":"task-workflow"},"statuses":[ {"id":"10001","name":"To Do","statusCategory":"TODO"}, {"id":"10002","name":"In Progress","statusCategory":"IN_PROGRESS"}, @@ -87,7 +86,6 @@ func Test__GetWorkflow__Execute(t *testing.T) { {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(transitionsResponse))}, {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(projectSchemeResponse))}, {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(schemeDetailResponse))}, - {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(issueTypesResponse))}, {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(workflowStatusesResponse))}, }, } @@ -206,7 +204,6 @@ func Test__GetWorkflow__Execute(t *testing.T) { {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(transitionsResponse))}, {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(projectSchemeResponse))}, {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(schemeDetailResponse))}, - {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(issueTypesResponse))}, {StatusCode: http.StatusInternalServerError, Body: io.NopCloser(strings.NewReader(`{"errorMessage":"boom"}`))}, }, } @@ -226,30 +223,51 @@ func Test__GetWorkflow__Execute(t *testing.T) { assert.Contains(t, err.Error(), "failed to load statuses") }) - t.Run("project issue type fetch failure does not silently fall back to default workflow", func(t *testing.T) { + t.Run("sub-task resolves workflow via issue type id without create-metadata lookup", func(t *testing.T) { + subtaskIssue := `{ + "id":"10002","key":"TEST-2","self":"https://test.atlassian.net/rest/api/3/issue/10002", + "fields":{ + "status":{"id":"10002","name":"In Progress"}, + "issuetype":{"id":"10200","name":"Sub-task"}, + "project":{"id":"10000","key":"TEST"} + } + }` + subtaskScheme := `{"id":101010,"name":"Default scheme","defaultWorkflow":"jira-default","issueTypeMappings":{"10200":"subtask-workflow"}}` + subtaskWorkflowStatuses := `{"values":[{"id":{"name":"subtask-workflow"},"statuses":[ + {"id":"10001","name":"To Do","statusCategory":"TODO"}, + {"id":"10002","name":"In Progress","statusCategory":"IN_PROGRESS"} + ]}]}` + httpContext := &contexts.HTTPContext{ Responses: []*http.Response{ - {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(issueResponse))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(subtaskIssue))}, {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(transitionsResponse))}, {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(projectSchemeResponse))}, - {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(schemeDetailResponse))}, - {StatusCode: http.StatusInternalServerError, Body: io.NopCloser(strings.NewReader(`{"errorMessage":"boom"}`))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(subtaskScheme))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(subtaskWorkflowStatuses))}, }, } + execCtx := &contexts.ExecutionStateContext{} err := component.Execute(core.ExecutionContext{ Configuration: map[string]any{ "project": "TEST", - "issueKey": "TEST-1", + "issueKey": "TEST-2", }, HTTP: httpContext, Integration: newAuthorizedIntegration(), - ExecutionState: &contexts.ExecutionStateContext{}, + ExecutionState: execCtx, Logger: newLogger(), }) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to resolve workflow") + require.NoError(t, err) + output := unwrapGetWorkflowPayload(t, execCtx.Payloads[0]) + assert.Equal(t, "Sub-task", output.IssueType) + assert.Equal(t, "subtask-workflow", output.WorkflowName) + require.Len(t, httpContext.Requests, 5) + for _, req := range httpContext.Requests { + assert.NotContains(t, req.URL.String(), "/issue/createmeta/") + } }) t.Run("workflow/search prefix match for a different workflow returns an error", func(t *testing.T) { @@ -265,7 +283,6 @@ func Test__GetWorkflow__Execute(t *testing.T) { {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(transitionsResponse))}, {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(projectSchemeResponse))}, {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(schemeDetailResponse))}, - {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(issueTypesResponse))}, {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(prefixMatchOnly))}, }, } @@ -299,7 +316,6 @@ func Test__GetWorkflow__Execute(t *testing.T) { {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(transitionsResponse))}, {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(projectSchemeResponse))}, {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(schemeWithoutMapping))}, - {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(issueTypesResponse))}, {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(defaultWorkflowStatuses))}, }, } @@ -322,6 +338,31 @@ func Test__GetWorkflow__Execute(t *testing.T) { }) } +func TestResolveWorkflowForIssueType(t *testing.T) { + scheme := &WorkflowSchemeDetail{ + DefaultWorkflow: "default-wf", + IssueTypeMappings: map[string]string{ + "10200": "subtask-workflow", + }, + } + + t.Run("maps by issue type id from the issue", func(t *testing.T) { + assert.Equal(t, "subtask-workflow", resolveWorkflowForIssueType(scheme, "10200")) + }) + + t.Run("falls back to default when id has no mapping", func(t *testing.T) { + assert.Equal(t, "default-wf", resolveWorkflowForIssueType(scheme, "10100")) + }) + + t.Run("falls back to default when id is empty", func(t *testing.T) { + assert.Equal(t, "default-wf", resolveWorkflowForIssueType(scheme, "")) + }) + + t.Run("nil scheme returns empty", func(t *testing.T) { + assert.Equal(t, "", resolveWorkflowForIssueType(nil, "10200")) + }) +} + // unwrapGetWorkflowPayload extracts the GetWorkflowOutput from the wrapped // `{type, timestamp, data}` envelope that ExecutionStateContext.Emit produces. func unwrapGetWorkflowPayload(t *testing.T, payload any) GetWorkflowOutput { diff --git a/pkg/integrations/jira/list_resources.go b/pkg/integrations/jira/list_resources.go index 57e862de04..f4c5ed87de 100644 --- a/pkg/integrations/jira/list_resources.go +++ b/pkg/integrations/jira/list_resources.go @@ -22,6 +22,8 @@ func (j *Jira) ListResources(resourceType string, ctx core.ListResourcesContext) return listPriorities(ctx) case "resolution": return listResolutions(ctx) + case "jsmApproval": + return listJSMApprovals(ctx) case "serviceDesk": client, err := NewClient(ctx.HTTP, ctx.Integration) if err != nil { @@ -317,6 +319,52 @@ func listPriorities(ctx core.ListResourcesContext) ([]core.IntegrationResource, return resources, nil } +// listJSMApprovals returns the pending approvals for a JSM customer request, +// so the Approve Workflow component can offer a picker instead of asking the +// user to copy an approval id by hand. Non-pending approvals are filtered out +// because they are not actionable. +func listJSMApprovals(ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { + issueKey := strings.TrimSpace(ctx.Parameters["issueKey"]) + if issueKey == "" || strings.Contains(issueKey, "{{") { + return []core.IntegrationResource{}, nil + } + + if ctx.HTTP == nil { + return []core.IntegrationResource{}, nil + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, fmt.Errorf("failed to create client: %w", err) + } + + approvals, err := client.ListApprovals(issueKey) + if err != nil { + return nil, fmt.Errorf("failed to list approvals: %w", err) + } + + resources := make([]core.IntegrationResource, 0, len(approvals)) + for _, approval := range approvals { + if !isPendingApproval(approval) { + continue + } + id := approval.ID.String() + if id == "" { + continue + } + name := strings.TrimSpace(approval.Name) + if name == "" { + name = fmt.Sprintf("Approval %s", id) + } + resources = append(resources, core.IntegrationResource{ + Type: "jsmApproval", + Name: name, + ID: id, + }) + } + return resources, nil +} + func listResolutions(ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { if ctx.HTTP == nil { return []core.IntegrationResource{}, nil diff --git a/pkg/integrations/jira/list_resources_test.go b/pkg/integrations/jira/list_resources_test.go index 31c21ab401..6af0bacbdb 100644 --- a/pkg/integrations/jira/list_resources_test.go +++ b/pkg/integrations/jira/list_resources_test.go @@ -171,6 +171,70 @@ func Test__ListResources__Assignee(t *testing.T) { }) } +func Test__ListResources__JSMApproval(t *testing.T) { + j := &Jira{} + + t.Run("returns only pending approvals for the issueKey parameter", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "values": [ + {"id":"1","name":"Manager","finalDecision":"approved"}, + {"id":"2","name":"Director","finalDecision":"PENDING"}, + {"id":"3","finalDecision":"PENDING"} + ], + "isLastPage": true + }`)), + }, + }, + } + + resources, err := j.ListResources("jsmApproval", core.ListResourcesContext{ + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + Parameters: map[string]string{"issueKey": "ITSM-1"}, + }) + + require.NoError(t, err) + require.Len(t, resources, 2) + assert.Equal(t, "jsmApproval", resources[0].Type) + assert.Equal(t, "2", resources[0].ID) + assert.Equal(t, "Director", resources[0].Name) + assert.Equal(t, "3", resources[1].ID) + assert.Equal(t, "Approval 3", resources[1].Name) + assert.Contains(t, httpContext.Requests[0].URL.String(), "/rest/servicedeskapi/request/ITSM-1/approval") + }) + + t.Run("missing issueKey -> empty list, no API call", func(t *testing.T) { + httpContext := &contexts.HTTPContext{} + + resources, err := j.ListResources("jsmApproval", core.ListResourcesContext{ + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + }) + + require.NoError(t, err) + assert.Empty(t, resources) + assert.Empty(t, httpContext.Requests) + }) + + t.Run("unresolved expression issueKey -> empty list, no API call", func(t *testing.T) { + httpContext := &contexts.HTTPContext{} + + resources, err := j.ListResources("jsmApproval", core.ListResourcesContext{ + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + Parameters: map[string]string{"issueKey": "{{ trigger.issueKey }}"}, + }) + + require.NoError(t, err) + assert.Empty(t, resources) + assert.Empty(t, httpContext.Requests) + }) +} + func Test__ListResources__Priority(t *testing.T) { j := &Jira{} From 10c941508bca30e6d00f8f2cdaea3eb0dce98767 Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Thu, 21 May 2026 10:05:52 +0300 Subject: [PATCH 8/8] refactor: simplify subtitle logic in Jira workflow mapper This commit refactors the subtitle method in the Jira workflow mapper to streamline the logic for displaying the subtitle. It replaces multiple checks for workflow properties with a single timestamp check, improving code clarity and maintainability. Signed-off-by: WashingtonKK --- .../src/pages/workflowv2/mappers/jira/get_workflow.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/web_src/src/pages/workflowv2/mappers/jira/get_workflow.ts b/web_src/src/pages/workflowv2/mappers/jira/get_workflow.ts index 18075e105d..7ca4bb2c35 100644 --- a/web_src/src/pages/workflowv2/mappers/jira/get_workflow.ts +++ b/web_src/src/pages/workflowv2/mappers/jira/get_workflow.ts @@ -44,14 +44,8 @@ export const getWorkflowMapper: ComponentBaseMapper = { }, subtitle(context: SubtitleContext): string | React.ReactNode { - const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; - const workflow = outputs?.default?.[0]?.data as JiraWorkflow | undefined; - if (workflow?.workflowName) return workflow.workflowName; - if (workflow?.currentStatus) return workflow.currentStatus; - if (context.execution.createdAt) { - return renderTimeAgo(new Date(context.execution.createdAt)); - } - return ""; + const timestamp = context.execution.updatedAt || context.execution.createdAt; + return timestamp ? renderTimeAgo(new Date(timestamp)) : ""; }, };