diff --git a/docs/components/Jira.mdx b/docs/components/Jira.mdx index 57fa298b77..0888e23255 100644 --- a/docs/components/Jira.mdx +++ b/docs/components/Jira.mdx @@ -9,6 +9,7 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; ## Actions + @@ -20,7 +21,9 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + + @@ -36,6 +39,65 @@ To connect Jira to SuperPlane: 4. Paste the Atlassian account **Email** that owns the API token. 5. Paste the generated **API Token**. + + +## Approve Workflow + +**Component key:** `jira.approveWorkflow` + +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 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 + +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" +} +``` + ## Create Alert @@ -605,6 +667,90 @@ Returns the Jira issue object including `id`, `key`, `self` and the full `fields } ``` + + +## Get Workflow + +**Component key:** `jira.getWorkflow` + +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" +} +``` + ## Ping Heartbeat @@ -645,6 +791,70 @@ Returns the API **message** (for example "PONG - Heartbeat received"). } ``` + + +## Transition Issue + +**Component key:** `jira.transitionIssue` + +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 Alert diff --git a/pkg/integrations/jira/approve_workflow.go b/pkg/integrations/jira/approve_workflow.go new file mode 100644 index 0000000000..308523a9c5 --- /dev/null +++ b/pkg/integrations/jira/approve_workflow.go @@ -0,0 +1,361 @@ +package jira + +import ( + "fmt" + "net/http" + "strings" + "time" + + "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 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 + +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: "Pick approval", Value: approvalSelectorByID}, + }, + }, + }, + }, + { + Name: "approvalId", + Label: "Approval", + Type: configuration.FieldTypeIntegrationResource, + Required: false, + 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}}, + }, + 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) + } + approvalID, ok := latestPendingApprovalID(approvals) + if !ok { + return "", fmt.Errorf("no pending approval found for %s", issueKey) + } + return approvalID, nil +} + +func isPendingApproval(approval Approval) bool { + // JSM marks open approvals with finalDecision "pending", but for some + // workflows it leaves the field empty until a decision is made. A completed + // approval always carries a concrete decision (approved/declined), so treat + // both "pending" and an empty decision as still pending — otherwise open + // approvals are skipped and resolution reports "no pending approval". + decision := strings.TrimSpace(approval.FinalDecision) + return decision == "" || strings.EqualFold(decision, "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 time.Time{}, false +} + +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..294445f4aa --- /dev/null +++ b/pkg/integrations/jira/approve_workflow_test.go @@ -0,0 +1,261 @@ +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 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) + }) + + t.Run("treats empty finalDecision as pending", func(t *testing.T) { + // JSM leaves finalDecision empty for some open approvals; they must + // still be selectable rather than skipped as if already decided. + id, ok := latestPendingApprovalID([]Approval{ + {ID: "1", FinalDecision: "approved"}, + {ID: "2", FinalDecision: ""}, + }) + require.True(t, ok) + assert.Equal(t, "2", id) + }) +} + +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{ + { + 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/client.go b/pkg/integrations/jira/client.go index 01db56788f..3d46d6388d 100644 --- a/pkg/integrations/jira/client.go +++ b/pkg/integrations/jira/client.go @@ -115,9 +115,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) { @@ -133,6 +135,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"` @@ -161,13 +178,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 @@ -195,16 +228,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 { @@ -212,9 +352,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 { @@ -228,14 +372,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. @@ -277,11 +428,59 @@ 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. 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") - 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) } @@ -292,6 +491,169 @@ 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) +} + +// 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"` +} + +// 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 out WorkflowSchemeDetail + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("error parsing workflow scheme response: %v", err) + } + if out.IssueTypeMappings == nil { + out.IssueTypeMappings = map[string]string{} + } + return &out, nil +} + +// 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"` + IssueTypeMappings map[string]string `json:"issueTypeMappings,omitempty"` + } `json:"workflowScheme"` +} + +type projectWorkflowSchemesResponse struct { + Values []projectWorkflowSchemeAssignment `json:"values"` +} + +// 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()) + + 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) + } + + for _, assignment := range resp.Values { + schemeID := strings.TrimSpace(assignment.WorkflowScheme.ID.String()) + if schemeID != "" { + // Resolve the full scheme details (the inlined version omits issueTypeMappings). + return c.GetWorkflowScheme(schemeID) + } + // Jira omits the scheme id for the built-in Default Workflow Scheme + // (common for company-managed projects that never customized it). The + // inlined object still carries the default workflow and any per-issue-type + // mappings, so fall back to it instead of dropping the workflow entirely. + if defaultWorkflow := strings.TrimSpace(assignment.WorkflowScheme.DefaultWorkflow); defaultWorkflow != "" { + mappings := assignment.WorkflowScheme.IssueTypeMappings + if mappings == nil { + mappings = map[string]string{} + } + return &WorkflowSchemeDetail{ + ID: assignment.WorkflowScheme.ID, + Name: assignment.WorkflowScheme.Name, + DefaultWorkflow: defaultWorkflow, + IssueTypeMappings: mappings, + }, nil + } + } + + return nil, nil +} + +type workflowSearchEntry struct { + ID struct { + Name string `json:"name"` + } `json:"id"` + Statuses []globalStatus `json:"statuses"` +} + +type workflowSearchResponse struct { + Values []workflowSearchEntry `json:"values"` +} + +// 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) + query.Set("expand", "statuses") + endpoint := c.apiURL("/rest/api/3/workflow/search?" + query.Encode()) + + body, err := c.execRequest(http.MethodGet, endpoint, nil) + if err != nil { + 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 statusesFromGlobal(entry.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 +} + type Issue struct { ID string `json:"id"` Key string `json:"key"` @@ -643,6 +1005,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/client_test.go b/pkg/integrations/jira/client_test.go index 0f82df59e3..edd8e7a6aa 100644 --- a/pkg/integrations/jira/client_test.go +++ b/pkg/integrations/jira/client_test.go @@ -407,6 +407,88 @@ func Test__Client__GetProjectIssueTypes(t *testing.T) { }) } +func Test__Client__GetWorkflowSchemeForProject(t *testing.T) { + t.Run("custom scheme with id resolves full details", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "values": [ + {"projectIds":["10000"],"workflowScheme":{"id":"42","name":"Custom Scheme","defaultWorkflow":"Custom WF"}} + ] + }`)), + }, + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "id":"42","name":"Custom Scheme","defaultWorkflow":"Custom WF", + "issueTypeMappings":{"10001":"Bug WF"} + }`)), + }, + }, + } + + client, err := NewClient(httpContext, newAuthorizedIntegration()) + require.NoError(t, err) + + scheme, err := client.GetWorkflowSchemeForProject("10000") + require.NoError(t, err) + require.NotNil(t, scheme) + assert.Equal(t, "Custom Scheme", scheme.Name) + assert.Equal(t, "Bug WF", scheme.IssueTypeMappings["10001"]) + // Both endpoints were hit: project assignment, then full scheme by id. + assert.Contains(t, httpContext.Requests[1].URL.String(), "/rest/api/3/workflowscheme/42") + }) + + t.Run("default scheme without id falls back to inlined default workflow", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "values": [ + {"projectIds":["10000"],"workflowScheme":{"name":"Default Workflow Scheme","defaultWorkflow":"jira","issueTypeMappings":{"10001":"Bug WF"}}} + ] + }`)), + }, + }, + } + + client, err := NewClient(httpContext, newAuthorizedIntegration()) + require.NoError(t, err) + + scheme, err := client.GetWorkflowSchemeForProject("10000") + require.NoError(t, err) + require.NotNil(t, scheme) + assert.Equal(t, "Default Workflow Scheme", scheme.Name) + assert.Equal(t, "jira", scheme.DefaultWorkflow) + // Per-issue-type mappings inlined in the project response are preserved, + // not discarded in favour of the default workflow. + assert.Equal(t, "Bug WF", scheme.IssueTypeMappings["10001"]) + // No id means no second request to resolve full details. + assert.Len(t, httpContext.Requests, 1) + }) + + t.Run("team-managed project with empty list returns nil", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"values":[]}`)), + }, + }, + } + + client, err := NewClient(httpContext, newAuthorizedIntegration()) + require.NoError(t, err) + + scheme, err := client.GetWorkflowSchemeForProject("10000") + require.NoError(t, err) + assert.Nil(t, scheme) + }) +} + func Test__WrapInADF(t *testing.T) { t.Run("wraps text in ADF format", func(t *testing.T) { result := WrapInADF("Hello world") @@ -888,6 +970,57 @@ func Test__Client__ResolveNumericIssueID(t *testing.T) { }) } +func Test__GetWorkflowStatusesByName(t *testing.T) { + 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]) + }) + + 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) + + _, err = client.GetWorkflowStatusesByName("task-workflow") + require.Error(t, err) + assert.Contains(t, err.Error(), `workflow "task-workflow" not found`) + }) +} + func Test__Client__OpsAlertsAPI(t *testing.T) { cloudID := "35273b54-3f06-40d2-880f-dd28cf6daafa" appCtx := &contexts.IntegrationContext{ diff --git a/pkg/integrations/jira/common.go b/pkg/integrations/jira/common.go index 1b05942dee..bc5a17f3e9 100644 --- a/pkg/integrations/jira/common.go +++ b/pkg/integrations/jira/common.go @@ -80,6 +80,86 @@ 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{}) +} + +// 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) + } + + var matches []Transition + for _, t := range transitions { + if strings.EqualFold(t.To.Name, status) { + matches = append(matches, t) + } + } + + 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) + } + + // Resolution and comment differ in how strictly Jira gates them: + // - Resolution is sent via `fields`, which Jira rejects outright unless + // the field is on the transition's screen — so it's a hard requirement. + // - A comment is sent via `update.comment`, which Jira generally accepts + // even when the screen metadata doesn't list a comment field — so it's + // only a soft preference; requiring it would block otherwise-valid moves. + wantsResolution := strings.TrimSpace(opts.Resolution) != "" + wantsComment := strings.TrimSpace(opts.Comment) != "" + + // First choice: a transition whose screen exposes everything we want to set, + // so the comment lands atomically with the transition when possible. + for _, t := range matches { + if (!wantsResolution || t.HasField("resolution")) && (!wantsComment || t.HasField("comment")) { + return client.DoTransitionWithOptions(issueKey, t.ID, opts) + } + } + + // Otherwise fall back to any transition that accepts the resolution (the + // hard requirement); the comment is still attached and Jira usually accepts + // it even without a dedicated comment field on the screen. + for _, t := range matches { + if !wantsResolution || t.HasField("resolution") { + return client.DoTransitionWithOptions(issueKey, t.ID, opts) + } + } + + // No matching transition exposes the resolution field. 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, + ) +} + // resolveCloudID returns the Atlassian cloud id from integration metadata, or fetches it from // the site tenant_info endpoint when metadata was not populated (e.g. integrations connected // before cloud id was stored during sync). diff --git a/pkg/integrations/jira/common_test.go b/pkg/integrations/jira/common_test.go new file mode 100644 index 0000000000..6ea9f573c4 --- /dev/null +++ b/pkg/integrations/jira/common_test.go @@ -0,0 +1,201 @@ +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 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"},"fields":{"resolution":{"required":false},"comment":{"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{ + Comment: "Ship it", + Resolution: "Done", + }) + + 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) + 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("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("prefers a transition whose screen exposes comment 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":"Comment & Close","to":{"id":"10003","name":"Done"},"fields":{"comment":{"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{Comment: "Closing"}) + 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"]) + assert.Contains(t, payload["update"].(map[string]any), "comment") + }) + + t.Run("attaches the comment optimistically when no transition screen lists it", func(t *testing.T) { + // Jira accepts update.comment on most transitions even when the screen + // metadata omits a comment field, so a comment must not block the move. + 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}}}]}`)), + }, + { + 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) + + 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.Contains(t, payload["update"].(map[string]any), "comment") + }) + + t.Run("uses the first matching transition when no fields are 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{}) + require.NoError(t, err) + }) + + 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/example.go b/pkg/integrations/jira/example.go index aac732caec..83c58cfdeb 100644 --- a/pkg/integrations/jira/example.go +++ b/pkg/integrations/jira/example.go @@ -31,6 +31,24 @@ var exampleOutputDeleteIncidentBytes []byte var exampleOutputDeleteIncidentOnce sync.Once var exampleOutputDeleteIncident 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 + +//go:embed example_output_get_workflow.json +var exampleOutputGetWorkflowBytes []byte + +var exampleOutputGetWorkflowOnce sync.Once +var exampleOutputGetWorkflow map[string]any + //go:embed example_output_create_alert.json var exampleOutputCreateAlertBytes []byte @@ -101,6 +119,18 @@ func (c *DeleteIncident) ExampleOutput() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleOutputDeleteIncidentOnce, exampleOutputDeleteIncidentBytes, &exampleOutputDeleteIncident) } +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) +} + +func (c *GetWorkflow) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputGetWorkflowOnce, exampleOutputGetWorkflowBytes, &exampleOutputGetWorkflow) +} + //go:embed example_output_create_heartbeat.json var exampleOutputCreateHeartbeatBytes []byte 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_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/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/get_workflow.go b/pkg/integrations/jira/get_workflow.go new file mode 100644 index 0000000000..715129595a --- /dev/null +++ b/pkg/integrations/jira/get_workflow.go @@ -0,0 +1,334 @@ +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, issueTypeID, 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 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 { + 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(scheme, issueTypeID) + output.WorkflowName = workflowName + if workflowName != "" { + statuses, err := client.GetWorkflowStatusesByName(workflowName) + 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 { + 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 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 "", "", "", "" + } + 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 + } + } + 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, issueTypeID, projectID, projectKey +} + +// 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 "" + } + if id := strings.TrimSpace(issueTypeID); id != "" { + if wf := strings.TrimSpace(scheme.IssueTypeMappings[id]); wf != "" { + return wf + } + } + 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..a044bec73e --- /dev/null +++ b/pkg/integrations/jira/get_workflow_test.go @@ -0,0 +1,375 @@ +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":{"id":"10100","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 workflowStatusesResponse = `{"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 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(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) + 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 + } else { + 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) + 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("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.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("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(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(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-2", + }, + HTTP: httpContext, + Integration: newAuthorizedIntegration(), + ExecutionState: execCtx, + Logger: newLogger(), + }) + + 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) { + // 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(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":[ + {"id":"10001","name":"To Do","statusCategory":"TODO"}, + {"id":"10002","name":"In Progress","statusCategory":"IN_PROGRESS"}, + {"id":"10003","name":"Done","statusCategory":"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(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) + }) +} + +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 { + 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 f627694746..d39d3aba98 100644 --- a/pkg/integrations/jira/jira.go +++ b/pkg/integrations/jira/jira.go @@ -88,6 +88,9 @@ func (j *Jira) Actions() []core.Action { &CreateIncident{}, &GetIncident{}, &DeleteIncident{}, + &GetWorkflow{}, + &TransitionIssue{}, + &ApproveWorkflow{}, &CreateHeartbeat{}, &PingHeartbeat{}, &UpdateHeartbeat{}, diff --git a/pkg/integrations/jira/list_resources.go b/pkg/integrations/jira/list_resources.go index 8a9518c492..ee47dbfc72 100644 --- a/pkg/integrations/jira/list_resources.go +++ b/pkg/integrations/jira/list_resources.go @@ -23,6 +23,10 @@ func (j *Jira) ListResources(resourceType string, ctx core.ListResourcesContext) return listAssignees(ctx) case "priority": return listPriorities(ctx) + case "resolution": + return listResolutions(ctx) + case "jsmApproval": + return listJSMApprovals(ctx) case "serviceDesk": return listServiceDesks(ctx) case "serviceDeskRequestType": @@ -317,11 +321,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 } @@ -331,11 +330,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{ @@ -344,7 +355,7 @@ func listIssueStatuses(ctx core.ListResourcesContext) ([]core.IntegrationResourc ID: s.Name, }) } - return resources, nil + return resources } func listAssignees(ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { @@ -408,6 +419,78 @@ 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 + } + + 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 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 5e937c3bb1..78b9bbb966 100644 --- a/pkg/integrations/jira/list_resources_test.go +++ b/pkg/integrations/jira/list_resources_test.go @@ -13,6 +13,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{ @@ -187,6 +196,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{} @@ -226,6 +299,82 @@ func Test__ListResources__Priority__MissingHTTPContext(t *testing.T) { assert.Empty(t, resources) } +func Test__ListResources__IssueStatus(t *testing.T) { + j := &Jira{} + + 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("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") + }) + + 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) { 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..a82247f5c6 --- /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"},"fields":{"resolution":{"required":false}}}]}`)), + }, + { + 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/app/mappers/jira/approve_workflow.spec.ts b/web_src/src/pages/app/mappers/jira/approve_workflow.spec.ts new file mode 100644 index 0000000000..b4cff05c50 --- /dev/null +++ b/web_src/src/pages/app/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/app/mappers/jira/approve_workflow.ts b/web_src/src/pages/app/mappers/jira/approve_workflow.ts new file mode 100644 index 0000000000..d12dc153c0 --- /dev/null +++ b/web_src/src/pages/app/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/app/mappers/jira/get_workflow.spec.ts b/web_src/src/pages/app/mappers/jira/get_workflow.spec.ts new file mode 100644 index 0000000000..289b8ee048 --- /dev/null +++ b/web_src/src/pages/app/mappers/jira/get_workflow.spec.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; + +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: "Get workflow", + componentName: "jira.getWorkflow", + 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.getWorkflow", + label: "Get Workflow", + description: "", + icon: "jira", + color: "blue", + }, + lastExecutions: [], + currentUser: undefined, + actions: { invokeNodeExecutionHook: async () => {} }, + }; +} + +describe("getWorkflowMapper", () => { + it("extracts workflow + current status + transitions from the payload", () => { + const details = getWorkflowMapper.getExecutionDetails( + detailsCtx({ + execution: { + outputs: { + default: [ + { + type: "jira.workflow", + timestamp: "2026-01-19T12:00:00Z", + data: { + 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" }, + ], + }, + }, + ], + }, + }, + }), + ); + + 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("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", issueKey: "TEST-1" }, + metadata: { project: { key: "TEST", name: "Test Project" } }, + }, + }), + ); + + expect(props.metadata).toEqual([ + { icon: "folder", label: "Test Project" }, + { icon: "hash", label: "TEST-1" }, + ]); + }); + + it("maps finished success to retrieved", () => { + expect(eventStateRegistry.getWorkflow.getState(execution())).toBe("retrieved"); + }); +}); diff --git a/web_src/src/pages/app/mappers/jira/get_workflow.ts b/web_src/src/pages/app/mappers/jira/get_workflow.ts new file mode 100644 index 0000000000..7ca4bb2c35 --- /dev/null +++ b/web_src/src/pages/app/mappers/jira/get_workflow.ts @@ -0,0 +1,61 @@ +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 } from "./utils"; +import type { GetWorkflowConfiguration, JiraNodeMetadata, JiraWorkflow } from "./types"; + +export const getWorkflowMapper: 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, "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(", "); + } + } + + return details; + }, + + subtitle(context: SubtitleContext): string | React.ReactNode { + const timestamp = context.execution.updatedAt || context.execution.createdAt; + return timestamp ? renderTimeAgo(new Date(timestamp)) : ""; + }, +}; + +function metadataList(node: NodeInfo): MetadataItem[] { + const metadata: MetadataItem[] = []; + const nodeMetadata = node.metadata as JiraNodeMetadata | undefined; + const configuration = node.configuration as GetWorkflowConfiguration | undefined; + + addProjectMetadata(metadata, nodeMetadata?.project, configuration?.project); + addIssueKeyMetadata(metadata, "hash", configuration?.issueKey); + + return metadata; +} diff --git a/web_src/src/pages/app/mappers/jira/index.ts b/web_src/src/pages/app/mappers/jira/index.ts index aa38b5e274..17506c0164 100644 --- a/web_src/src/pages/app/mappers/jira/index.ts +++ b/web_src/src/pages/app/mappers/jira/index.ts @@ -7,6 +7,9 @@ import { updateIssueMapper } from "./update_issue"; import { createIncidentMapper } from "./create_incident"; import { getIncidentMapper } from "./get_incident"; import { deleteIncidentMapper } from "./delete_incident"; +import { getWorkflowMapper } from "./get_workflow"; +import { transitionIssueMapper } from "./transition_issue"; +import { approveWorkflowMapper } from "./approve_workflow"; import { createHeartbeatMapper } from "./create_heartbeat"; import { pingHeartbeatMapper } from "./ping_heartbeat"; import { updateHeartbeatMapper } from "./update_heartbeat"; @@ -24,6 +27,9 @@ export const componentMappers: Record = { createIncident: createIncidentMapper, getIncident: getIncidentMapper, deleteIncident: deleteIncidentMapper, + getWorkflow: getWorkflowMapper, + transitionIssue: transitionIssueMapper, + approveWorkflow: approveWorkflowMapper, createHeartbeat: createHeartbeatMapper, pingHeartbeat: pingHeartbeatMapper, updateHeartbeat: updateHeartbeatMapper, @@ -44,6 +50,9 @@ export const eventStateRegistry: Record = { createIncident: buildActionStateRegistry("created"), getIncident: buildActionStateRegistry("fetched"), deleteIncident: buildActionStateRegistry("deleted"), + getWorkflow: buildActionStateRegistry("retrieved"), + transitionIssue: buildActionStateRegistry("transitioned"), + approveWorkflow: buildActionStateRegistry("decided"), createHeartbeat: buildActionStateRegistry("created"), pingHeartbeat: buildActionStateRegistry("pinged"), updateHeartbeat: buildActionStateRegistry("updated"), diff --git a/web_src/src/pages/app/mappers/jira/transition_issue.spec.ts b/web_src/src/pages/app/mappers/jira/transition_issue.spec.ts new file mode 100644 index 0000000000..73a7e58176 --- /dev/null +++ b/web_src/src/pages/app/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/app/mappers/jira/transition_issue.ts b/web_src/src/pages/app/mappers/jira/transition_issue.ts new file mode 100644 index 0000000000..40754f1e56 --- /dev/null +++ b/web_src/src/pages/app/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/app/mappers/jira/types.ts b/web_src/src/pages/app/mappers/jira/types.ts index b8b25600ae..f5931ad41b 100644 --- a/web_src/src/pages/app/mappers/jira/types.ts +++ b/web_src/src/pages/app/mappers/jira/types.ts @@ -2,6 +2,8 @@ export interface JiraProject { id?: string; key?: string; name?: string; + style?: string; + simplified?: boolean; } export interface JiraStatus { @@ -47,6 +49,43 @@ export interface JiraNodeMetadata { status?: string; } +export interface JiraWorkflowStatus { + id?: string; + name?: string; + category?: string; + isCurrent?: boolean; +} + +export interface JiraWorkflowAvailableTransition { + id?: string; + name?: string; + toStatusId?: string; + toStatus?: string; +} + +export interface JiraWorkflow { + issueKey?: string; + issueType?: string; + projectKey?: string; + workflowName?: string; + workflowSchemeId?: string; + workflowSchemeName?: string; + currentStatus?: string; + currentStatusId?: string; + statuses?: JiraWorkflowStatus[]; + availableTransitions?: JiraWorkflowAvailableTransition[]; +} + +export interface JiraApproval { + id?: string; + name?: string; + finalDecision?: string; + approvers?: Array<{ + approver?: JiraUser; + approverDecision?: string; + }>; +} + export interface CreateIssueConfiguration { project?: string; issueType?: string; @@ -79,3 +118,24 @@ export interface DeleteIssueConfiguration { issueKey?: string; deleteSubtasks?: boolean; } + +export interface GetWorkflowConfiguration { + project?: string; + issueKey?: string; +} + +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; +}