diff --git a/docs/components/ServiceNow.mdx b/docs/components/ServiceNow.mdx new file mode 100644 index 000000000..b4a354cb2 --- /dev/null +++ b/docs/components/ServiceNow.mdx @@ -0,0 +1,198 @@ +--- +title: "ServiceNow" +--- + +Manage and react to incidents in ServiceNow + +## Triggers + + + + + +import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + +## Actions + + + + + +## Instructions + +Requires a ServiceNow instance with API access. The following roles are needed on your ServiceNow instance: + +**Integration account** (for Basic Auth or OAuth): +- **itil** — read/write access to the Incident table + +Optionally, enable **Web Service Access Only** on the integration account to restrict it to API-only use. + +**On Incident trigger**: Setting up the Business Rule on ServiceNow requires the **admin** role. This is a one-time setup. + + + +## On Incident + +The On Incident trigger starts a workflow execution when ServiceNow incident events are received via webhook. + +### Use Cases + +- **Incident automation**: Automate responses when incidents are created or updated +- **Notification workflows**: Send notifications when new incidents are created +- **Integration workflows**: Sync incidents with external systems +- **Escalation handling**: Handle incident updates automatically + +### Configuration + +- **Events**: Select which incident events to listen for (insert, update, delete) + +### Scoping + +Incident scoping (e.g. filtering by assignment group, category, or priority) is configured in the ServiceNow Business Rule conditions. This allows control over which incidents trigger the webhook. + +### Required Permissions + +Creating the Business Rule in ServiceNow requires the **admin** role. This is a one-time setup step. + +### Business Rule Setup + +This trigger provides a webhook URL and a ready-to-use Business Rule script: + +1. In ServiceNow, navigate to **System Definition > Business Rules** and create a new rule +2. Set the table to `incident`, set **When** to **after**, and check **insert**, **update**, and/or **delete** as needed +3. Check **Advanced** and paste the generated script into the **Script** field +4. The script uses `sn_ws.RESTMessageV2` to send incident data to the webhook URL with the secret for authentication + +### Event Data + +Each incident event includes the full incident record from ServiceNow, including: +- **sys_id**: Unique identifier +- **number**: Human-readable incident number +- **short_description**: Brief summary +- **state**: Current state +- **urgency**: Urgency level +- **impact**: Impact level +- **assignment_group**: Assigned group +- **assigned_to**: Assigned user + +### Example Data + +```json +{ + "data": { + "assigned_to": { + "display_value": "John Smith", + "value": "681ccaf9c0a8016400b98a06818d57c7" + }, + "assignment_group": { + "display_value": "Network", + "value": "d625dccec0a8016700a222a0f7900d06" + }, + "caller_id": { + "display_value": "Jane Doe", + "value": "6816f79cc0a8016401c5a33be04be441" + }, + "category": "Network", + "description": "The production web server is not responding to requests.", + "impact": "2", + "number": "INC0010001", + "opened_at": "2026-01-19 12:00:00", + "opened_by": { + "display_value": "Jane Doe", + "value": "6816f79cc0a8016401c5a33be04be441" + }, + "priority": "3", + "short_description": "Server is unresponsive", + "state": "1", + "subcategory": "DNS", + "sys_created_on": "2026-01-19 12:00:00", + "sys_id": "a1b2c3d4e5f6g7h8i9j0", + "sys_updated_on": "2026-01-19 12:00:00", + "urgency": "2" + }, + "timestamp": "2026-01-19T12:00:00Z", + "type": "servicenow.incident.insert" +} +``` + + + +## Create Incident + +The Create Incident component creates a new incident in ServiceNow using the Table API. + +### Use Cases + +- **Alert escalation**: Create incidents from monitoring alerts +- **Error tracking**: Automatically create incidents when errors are detected +- **Manual incident creation**: Create incidents from workflow events +- **Integration workflows**: Create incidents from external system events + +### Required Permissions + +The ServiceNow integration account needs: +- **itil** role — grants read/write access to the Incident table + +### Configuration + +- **Short Description**: A brief summary of the incident (required, supports expressions) +- **Description**: Detailed description of the incident (optional, supports expressions) +- **Urgency**: Incident urgency level (1-High, 2-Medium, 3-Low) +- **Impact**: Incident impact level (1-High, 2-Medium, 3-Low) +- **Category**: Incident category (select from list) +- **Subcategory**: Incident subcategory (depends on the selected category) +- **Assignment Group**: The group responsible for resolving the incident (select from list) +- **Assigned To**: The user assigned to resolve the incident (select from list) +- **Caller**: The user reporting the incident (select from list) + +### Output + +Returns the created incident object from the ServiceNow Table API, including: +- **sys_id**: Unique identifier +- **number**: Human-readable incident number (e.g. INC0010001) +- **state**: Current incident state +- **short_description**: Incident summary +- **created_on**: Creation timestamp + +### Example Output + +```json +{ + "data": { + "result": { + "assigned_to": { + "link": "https://dev12345.service-now.com/api/now/table/sys_user/681ccaf9c0a8016400b98a06818d57c7", + "value": "681ccaf9c0a8016400b98a06818d57c7" + }, + "assignment_group": { + "link": "https://dev12345.service-now.com/api/now/table/sys_user_group/d625dccec0a8016700a222a0f7900d06", + "value": "d625dccec0a8016700a222a0f7900d06" + }, + "caller_id": { + "link": "https://dev12345.service-now.com/api/now/table/sys_user/6816f79cc0a8016401c5a33be04be441", + "value": "6816f79cc0a8016401c5a33be04be441" + }, + "category": "Network", + "description": "The production web server is not responding to requests.", + "impact": "2", + "number": "INC0010001", + "opened_at": "2026-01-19 12:00:00", + "opened_by": { + "link": "https://dev12345.service-now.com/api/now/table/sys_user/6816f79cc0a8016401c5a33be04be441", + "value": "6816f79cc0a8016401c5a33be04be441" + }, + "priority": "3", + "short_description": "Server is unresponsive", + "state": "1", + "subcategory": "DNS", + "sys_created_on": "2026-01-19 12:00:00", + "sys_id": "a1b2c3d4e5f6g7h8i9j0", + "sys_updated_on": "2026-01-19 12:00:00", + "urgency": "2" + } + }, + "timestamp": "2026-01-19T12:00:00Z", + "type": "servicenow.incident" +} +``` + diff --git a/pkg/integrations/servicenow/client.go b/pkg/integrations/servicenow/client.go new file mode 100644 index 000000000..3d3a67683 --- /dev/null +++ b/pkg/integrations/servicenow/client.go @@ -0,0 +1,368 @@ +package servicenow + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/superplanehq/superplane/pkg/core" +) + +type Client struct { + AuthType string + InstanceURL string + Username string + Password string + Token string + http core.HTTPContext +} + +func NewClient(http core.HTTPContext, ctx core.IntegrationContext) (*Client, error) { + instanceURL, err := ctx.GetConfig("instanceUrl") + if err != nil { + return nil, fmt.Errorf("error getting instanceUrl: %v", err) + } + + authType, err := ctx.GetConfig("authType") + if err != nil { + return nil, fmt.Errorf("error getting authType: %v", err) + } + + switch string(authType) { + case AuthTypeBasicAuth: + username, err := ctx.GetConfig("username") + if err != nil { + return nil, fmt.Errorf("error getting username: %v", err) + } + + password, err := ctx.GetConfig("password") + if err != nil { + return nil, fmt.Errorf("error getting password: %v", err) + } + + return &Client{ + AuthType: AuthTypeBasicAuth, + InstanceURL: string(instanceURL), + Username: string(username), + Password: string(password), + http: http, + }, nil + + case AuthTypeOAuth: + secrets, err := ctx.GetSecrets() + if err != nil { + return nil, fmt.Errorf("failed to get secrets: %v", err) + } + + var accessToken string + for _, secret := range secrets { + if secret.Name == OAuthAccessToken { + accessToken = string(secret.Value) + break + } + } + + if accessToken == "" { + return nil, fmt.Errorf("OAuth access token not found") + } + + return &Client{ + AuthType: AuthTypeOAuth, + InstanceURL: string(instanceURL), + Token: accessToken, + http: http, + }, nil + } + + return nil, fmt.Errorf("unknown auth type %s", authType) +} + +func (c *Client) authHeader() string { + if c.AuthType == AuthTypeOAuth { + return fmt.Sprintf("Bearer %s", c.Token) + } + + credentials := fmt.Sprintf("%s:%s", c.Username, c.Password) + return "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials)) +} + +func (c *Client) execRequest(method, path string, body io.Reader) ([]byte, error) { + url := fmt.Sprintf("%s%s", strings.TrimRight(c.InstanceURL, "/"), path) + + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, fmt.Errorf("error building request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", c.authHeader()) + + res, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("error executing request: %v", err) + } + defer res.Body.Close() + + responseBody, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("error reading body: %v", err) + } + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return nil, fmt.Errorf("request got %d code: %s", res.StatusCode, string(responseBody)) + } + + return responseBody, nil +} + +type CreateIncidentParams struct { + ShortDescription string `json:"short_description"` + Description string `json:"description,omitempty"` + State string `json:"state,omitempty"` + Urgency string `json:"urgency,omitempty"` + Impact string `json:"impact,omitempty"` + Category string `json:"category,omitempty"` + Subcategory string `json:"subcategory,omitempty"` + AssignmentGroup string `json:"assignment_group,omitempty"` + AssignedTo string `json:"assigned_to,omitempty"` + Caller string `json:"caller_id,omitempty"` + ResolutionCode string `json:"close_code,omitempty"` + ResolutionNotes string `json:"close_notes,omitempty"` + OnHoldReason string `json:"hold_reason,omitempty"` +} + +func (c *Client) CreateIncident(params CreateIncidentParams) (map[string]any, error) { + body, err := json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("error marshaling request: %v", err) + } + + responseBody, err := c.execRequest(http.MethodPost, "/api/now/table/incident", bytes.NewReader(body)) + if err != nil { + return nil, err + } + + var response map[string]any + err = json.Unmarshal(responseBody, &response) + if err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + return response, nil +} + +func (c *Client) GetIncident(sysID string) (map[string]any, error) { + path := fmt.Sprintf("/api/now/table/incident/%s", sysID) + responseBody, err := c.execRequest(http.MethodGet, path, nil) + if err != nil { + return nil, err + } + + var response map[string]any + err = json.Unmarshal(responseBody, &response) + if err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + return response, nil +} + +func (c *Client) ValidateConnection() error { + _, err := c.execRequest(http.MethodGet, "/api/now/table/incident?sysparm_limit=1", nil) + return err +} + +func (c *Client) GetUser(sysID string) (*UserRecord, error) { + path := fmt.Sprintf("/api/now/table/sys_user/%s?sysparm_fields=sys_id,name,email", sysID) + responseBody, err := c.execRequest(http.MethodGet, path, nil) + if err != nil { + return nil, err + } + + var response struct { + Result UserRecord `json:"result"` + } + + err = json.Unmarshal(responseBody, &response) + if err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + return &response.Result, nil +} + +func (c *Client) GetAssignmentGroup(sysID string) (*AssignmentGroupRecord, error) { + path := fmt.Sprintf("/api/now/table/sys_user_group/%s?sysparm_fields=sys_id,name", sysID) + responseBody, err := c.execRequest(http.MethodGet, path, nil) + if err != nil { + return nil, err + } + + var response struct { + Result AssignmentGroupRecord `json:"result"` + } + + err = json.Unmarshal(responseBody, &response) + if err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + return &response.Result, nil +} + +type UserRecord struct { + SysID string `json:"sys_id"` + Name string `json:"name"` + Email string `json:"email"` +} + +func (c *Client) ListUsers() ([]UserRecord, error) { + path := "/api/now/table/sys_user?sysparm_query=active=true&sysparm_fields=sys_id,name,email&sysparm_limit=200" + responseBody, err := c.execRequest(http.MethodGet, path, nil) + if err != nil { + return nil, err + } + + var response struct { + Result []UserRecord `json:"result"` + } + + err = json.Unmarshal(responseBody, &response) + if err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + return response.Result, nil +} + +type AssignmentGroupRecord struct { + SysID string `json:"sys_id"` + Name string `json:"name"` +} + +func (c *Client) ListGroupMembers(groupSysID string) ([]UserRecord, error) { + path := fmt.Sprintf("/api/now/table/sys_user_grmember?sysparm_query=group=%s&sysparm_fields=user&sysparm_limit=200", groupSysID) + responseBody, err := c.execRequest(http.MethodGet, path, nil) + if err != nil { + return nil, err + } + + var response struct { + Result []struct { + User struct { + Value string `json:"value"` + } `json:"user"` + } `json:"result"` + } + + err = json.Unmarshal(responseBody, &response) + if err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + if len(response.Result) == 0 { + return []UserRecord{}, nil + } + + userIDs := make([]string, 0, len(response.Result)) + for _, member := range response.Result { + if member.User.Value != "" { + userIDs = append(userIDs, member.User.Value) + } + } + + if len(userIDs) == 0 { + return []UserRecord{}, nil + } + + query := "sys_idIN" + strings.Join(userIDs, ",") + "^active=true" + usersPath := fmt.Sprintf("/api/now/table/sys_user?sysparm_query=%s&sysparm_fields=sys_id,name,email&sysparm_limit=200", query) + usersBody, err := c.execRequest(http.MethodGet, usersPath, nil) + if err != nil { + return nil, err + } + + var usersResponse struct { + Result []UserRecord `json:"result"` + } + + err = json.Unmarshal(usersBody, &usersResponse) + if err != nil { + return nil, fmt.Errorf("error parsing users response: %v", err) + } + + return usersResponse.Result, nil +} + +func (c *Client) ListAssignmentGroups() ([]AssignmentGroupRecord, error) { + path := "/api/now/table/sys_user_group?sysparm_query=active=true&sysparm_fields=sys_id,name&sysparm_limit=200" + responseBody, err := c.execRequest(http.MethodGet, path, nil) + if err != nil { + return nil, err + } + + var response struct { + Result []AssignmentGroupRecord `json:"result"` + } + + err = json.Unmarshal(responseBody, &response) + if err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + return response.Result, nil +} + +type ChoiceRecord struct { + Label string `json:"label"` + Value string `json:"value"` +} + +func (c *Client) ListCategories() ([]ChoiceRecord, error) { + path := "/api/now/table/sys_choice?sysparm_query=name=incident^element=category&sysparm_fields=label,value&sysparm_limit=200" + responseBody, err := c.execRequest(http.MethodGet, path, nil) + if err != nil { + return nil, err + } + + var response struct { + Result []ChoiceRecord `json:"result"` + } + + err = json.Unmarshal(responseBody, &response) + if err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + return response.Result, nil +} + +func (c *Client) ListSubcategories(category string) ([]ChoiceRecord, error) { + query := "name=incident^element=subcategory" + if category != "" { + query += "^dependent_value=" + url.QueryEscape(category) + } + + path := fmt.Sprintf("/api/now/table/sys_choice?sysparm_query=%s&sysparm_fields=label,value&sysparm_limit=200", query) + responseBody, err := c.execRequest(http.MethodGet, path, nil) + if err != nil { + return nil, err + } + + var response struct { + Result []ChoiceRecord `json:"result"` + } + + err = json.Unmarshal(responseBody, &response) + if err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + return response.Result, nil +} diff --git a/pkg/integrations/servicenow/common.go b/pkg/integrations/servicenow/common.go new file mode 100644 index 000000000..94f315e90 --- /dev/null +++ b/pkg/integrations/servicenow/common.go @@ -0,0 +1,16 @@ +package servicenow + +const PayloadTypeIncident = "servicenow.incident" + +type NodeMetadata struct { + WebhookURL string `json:"webhookUrl,omitempty"` + InstanceURL string `json:"instanceUrl,omitempty"` + AssignmentGroup *ResourceInfo `json:"assignmentGroup,omitempty"` + AssignedTo *ResourceInfo `json:"assignedTo,omitempty"` + Caller *ResourceInfo `json:"caller,omitempty"` +} + +type ResourceInfo struct { + ID string `json:"id"` + Name string `json:"name"` +} diff --git a/pkg/integrations/servicenow/create_incident.go b/pkg/integrations/servicenow/create_incident.go new file mode 100644 index 000000000..5338ff70b --- /dev/null +++ b/pkg/integrations/servicenow/create_incident.go @@ -0,0 +1,443 @@ +package servicenow + +import ( + "errors" + "fmt" + "net/http" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type CreateIncident struct{} + +type CreateIncidentSpec struct { + ShortDescription string `json:"shortDescription"` + Description string `json:"description"` + State string `json:"state"` + Urgency string `json:"urgency"` + Impact string `json:"impact"` + Category string `json:"category"` + Subcategory string `json:"subcategory"` + AssignmentGroup string `json:"assignmentGroup"` + AssignedTo string `json:"assignedTo"` + Caller string `json:"caller"` + ResolutionCode *string `json:"resolutionCode,omitempty"` + ResolutionNotes *string `json:"resolutionNotes,omitempty"` + OnHoldReason *string `json:"onHoldReason,omitempty"` +} + +func (c *CreateIncident) Name() string { + return "servicenow.createIncident" +} + +func (c *CreateIncident) Label() string { + return "Create Incident" +} + +func (c *CreateIncident) Description() string { + return "Create a new incident in ServiceNow" +} + +func (c *CreateIncident) Documentation() string { + return `The Create Incident component creates a new incident in ServiceNow using the Table API. + +## Use Cases + +- **Alert escalation**: Create incidents from monitoring alerts +- **Error tracking**: Automatically create incidents when errors are detected +- **Manual incident creation**: Create incidents from workflow events +- **Integration workflows**: Create incidents from external system events + +## Required Permissions + +The ServiceNow integration account needs: +- **itil** role — grants read/write access to the Incident table + +## Configuration + +- **Short Description**: A brief summary of the incident (required, supports expressions) +- **Description**: Detailed description of the incident (optional, supports expressions) +- **Urgency**: Incident urgency level (1-High, 2-Medium, 3-Low) +- **Impact**: Incident impact level (1-High, 2-Medium, 3-Low) +- **Category**: Incident category (select from list) +- **Subcategory**: Incident subcategory (depends on the selected category) +- **Assignment Group**: The group responsible for resolving the incident (select from list) +- **Assigned To**: The user assigned to resolve the incident (select from list) +- **Caller**: The user reporting the incident (select from list) + +## Output + +Returns the created incident object from the ServiceNow Table API, including: +- **sys_id**: Unique identifier +- **number**: Human-readable incident number (e.g. INC0010001) +- **state**: Current incident state +- **short_description**: Incident summary +- **created_on**: Creation timestamp` +} + +func (c *CreateIncident) Icon() string { + return "servicenow" +} + +func (c *CreateIncident) Color() string { + return "gray" +} + +func (c *CreateIncident) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *CreateIncident) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "shortDescription", + Label: "Short Description", + Type: configuration.FieldTypeString, + Required: true, + Description: "A brief summary of the incident", + }, + { + Name: "urgency", + Label: "Urgency", + Type: configuration.FieldTypeSelect, + Required: true, + Default: "2", + Description: "How quickly the incident needs to be resolved", + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "1 - High", Value: "1"}, + {Label: "2 - Medium", Value: "2"}, + {Label: "3 - Low", Value: "3"}, + }, + }, + }, + }, + { + Name: "impact", + Label: "Impact", + Type: configuration.FieldTypeSelect, + Required: true, + Default: "2", + Description: "The extent to which the incident affects the business", + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "1 - High", Value: "1"}, + {Label: "2 - Medium", Value: "2"}, + {Label: "3 - Low", Value: "3"}, + }, + }, + }, + }, + { + Name: "description", + Label: "Description", + Type: configuration.FieldTypeText, + Required: false, + Description: "Detailed description of the incident", + }, + { + Name: "category", + Label: "Category", + Type: configuration.FieldTypeIntegrationResource, + Required: false, + Description: "The classification of the incident", + Placeholder: "Select a category", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "category", + }, + }, + }, + { + Name: "subcategory", + Label: "Subcategory", + Type: configuration.FieldTypeIntegrationResource, + Required: false, + Description: "Subcategory of the incident (depends on the selected category)", + Placeholder: "Select a subcategory", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "subcategory", + Parameters: []configuration.ParameterRef{ + { + Name: "category", + ValueFrom: &configuration.ParameterValueFrom{ + Field: "category", + }, + }, + }, + }, + }, + }, + { + Name: "assignmentGroup", + Label: "Assignment Group", + Type: configuration.FieldTypeIntegrationResource, + Required: false, + Description: "The group responsible for resolving the incident", + Placeholder: "Select an assignment group", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "assignment_group", + }, + }, + }, + { + Name: "assignedTo", + Label: "Assigned To", + Type: configuration.FieldTypeIntegrationResource, + Required: false, + Description: "The user assigned to resolve the incident", + Placeholder: "Select a user", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "user", + Parameters: []configuration.ParameterRef{ + { + Name: "assignmentGroup", + ValueFrom: &configuration.ParameterValueFrom{ + Field: "assignmentGroup", + }, + }, + }, + }, + }, + }, + { + Name: "caller", + Label: "Caller", + Type: configuration.FieldTypeIntegrationResource, + Required: false, + Description: "The user reporting the incident", + Placeholder: "Select a user", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "user", + }, + }, + }, + { + Name: "state", + Label: "State", + Type: configuration.FieldTypeSelect, + Required: false, + Description: "The current stage of the incident lifecycle", + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "New", Value: "1"}, + {Label: "In Progress", Value: "2"}, + {Label: "On Hold", Value: "3"}, + {Label: "Resolved", Value: "6"}, + {Label: "Closed", Value: "7"}, + {Label: "Canceled", Value: "8"}, + }, + }, + }, + }, + { + Name: "onHoldReason", + Label: "On Hold Reason", + Type: configuration.FieldTypeSelect, + Required: false, + Description: "Reason the incident is on hold", + VisibilityConditions: []configuration.VisibilityCondition{ + {Field: "state", Values: []string{"3"}}, + }, + RequiredConditions: []configuration.RequiredCondition{ + {Field: "state", Values: []string{"3"}}, + }, + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Awaiting Caller", Value: "1"}, + {Label: "Awaiting Change", Value: "3"}, + {Label: "Awaiting Problem", Value: "4"}, + {Label: "Awaiting Vendor", Value: "5"}, + }, + }, + }, + }, + { + Name: "resolutionCode", + Label: "Resolution Code", + Type: configuration.FieldTypeSelect, + Required: false, + Description: "How the incident was resolved", + VisibilityConditions: []configuration.VisibilityCondition{ + {Field: "state", Values: []string{"6", "7"}}, + }, + RequiredConditions: []configuration.RequiredCondition{ + {Field: "state", Values: []string{"6", "7"}}, + }, + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Duplicate", Value: "Duplicate"}, + {Label: "Known error", Value: "Known error"}, + {Label: "No resolution provided", Value: "No resolution provided"}, + {Label: "Resolved by caller", Value: "Resolved by caller"}, + {Label: "Resolved by change", Value: "Resolved by change"}, + {Label: "Resolved by problem", Value: "Resolved by problem"}, + {Label: "Resolved by request", Value: "Resolved by request"}, + {Label: "Solution provided", Value: "Solution provided"}, + {Label: "Workaround provided", Value: "Workaround provided"}, + {Label: "User error", Value: "User error"}, + }, + }, + }, + }, + { + Name: "resolutionNotes", + Label: "Resolution Notes", + Type: configuration.FieldTypeText, + Required: false, + Description: "Details about how the incident was resolved", + VisibilityConditions: []configuration.VisibilityCondition{ + {Field: "state", Values: []string{"6", "7"}}, + }, + RequiredConditions: []configuration.RequiredCondition{ + {Field: "state", Values: []string{"6", "7"}}, + }, + }, + } +} + +func (c *CreateIncident) Setup(ctx core.SetupContext) error { + spec := CreateIncidentSpec{} + err := mapstructure.Decode(ctx.Configuration, &spec) + if err != nil { + return fmt.Errorf("error decoding configuration: %v", err) + } + + if spec.ShortDescription == "" { + return errors.New("shortDescription is required") + } + + if spec.Urgency == "" { + return errors.New("urgency is required") + } + + if spec.Impact == "" { + return errors.New("impact is required") + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("error creating client: %v", err) + } + + err = client.ValidateConnection() + if err != nil { + return fmt.Errorf("error validating ServiceNow connection: %v", err) + } + + metadata := NodeMetadata{InstanceURL: client.InstanceURL} + + if spec.AssignmentGroup != "" { + group, err := client.GetAssignmentGroup(spec.AssignmentGroup) + if err != nil { + return fmt.Errorf("error verifying assignment group: %v", err) + } + + metadata.AssignmentGroup = &ResourceInfo{ID: group.SysID, Name: group.Name} + } + + if spec.AssignedTo != "" { + user, err := client.GetUser(spec.AssignedTo) + if err != nil { + return fmt.Errorf("error verifying assigned user: %v", err) + } + + metadata.AssignedTo = &ResourceInfo{ID: user.SysID, Name: user.Name} + } + + if spec.Caller != "" { + user, err := client.GetUser(spec.Caller) + if err != nil { + return fmt.Errorf("error verifying caller: %v", err) + } + + metadata.Caller = &ResourceInfo{ID: user.SysID, Name: user.Name} + } + + return ctx.Metadata.Set(metadata) +} + +func (c *CreateIncident) Execute(ctx core.ExecutionContext) error { + spec := CreateIncidentSpec{} + err := mapstructure.Decode(ctx.Configuration, &spec) + if err != nil { + return fmt.Errorf("error decoding configuration: %v", err) + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("error creating client: %v", err) + } + + params := CreateIncidentParams{ + ShortDescription: spec.ShortDescription, + Description: spec.Description, + State: spec.State, + Urgency: spec.Urgency, + Impact: spec.Impact, + Category: spec.Category, + Subcategory: spec.Subcategory, + AssignmentGroup: spec.AssignmentGroup, + AssignedTo: spec.AssignedTo, + Caller: spec.Caller, + } + + if spec.ResolutionCode != nil { + params.ResolutionCode = *spec.ResolutionCode + } + + if spec.ResolutionNotes != nil { + params.ResolutionNotes = *spec.ResolutionNotes + } + + if spec.OnHoldReason != nil { + params.OnHoldReason = *spec.OnHoldReason + } + + result, err := client.CreateIncident(params) + if err != nil { + return fmt.Errorf("failed to create incident: %v", err) + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + PayloadTypeIncident, + []any{result}, + ) +} + +func (c *CreateIncident) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *CreateIncident) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *CreateIncident) Actions() []core.Action { + return []core.Action{} +} + +func (c *CreateIncident) HandleAction(ctx core.ActionContext) error { + return nil +} + +func (c *CreateIncident) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return http.StatusOK, nil +} + +func (c *CreateIncident) Cleanup(ctx core.SetupContext) error { + return nil +} diff --git a/pkg/integrations/servicenow/create_incident_test.go b/pkg/integrations/servicenow/create_incident_test.go new file mode 100644 index 000000000..bbb8c34d8 --- /dev/null +++ b/pkg/integrations/servicenow/create_incident_test.go @@ -0,0 +1,347 @@ +package servicenow + +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__CreateIncident__Setup(t *testing.T) { + component := &CreateIncident{} + + t.Run("valid configuration with successful connection", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"result": []}`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "instanceUrl": "https://dev12345.service-now.com", + "authType": "basicAuth", + "username": "admin", + "password": "password", + }, + } + + metadataCtx := &contexts.MetadataContext{} + + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "shortDescription": "Test Incident", + "urgency": "2", + "impact": "2", + }, + HTTP: httpContext, + Integration: integrationCtx, + Metadata: metadataCtx, + }) + + require.NoError(t, err) + require.Len(t, httpContext.Requests, 1) + assert.Equal(t, "https://dev12345.service-now.com/api/now/table/incident?sysparm_limit=1", httpContext.Requests[0].URL.String()) + + metadata := metadataCtx.Metadata.(NodeMetadata) + assert.Equal(t, "https://dev12345.service-now.com", metadata.InstanceURL) + }) + + t.Run("valid configuration with resources verifies them", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + // ValidateConnection + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"result": []}`)), + }, + // GetAssignmentGroup + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"result": {"sys_id": "grp1", "name": "Network"}}`)), + }, + // GetUser (assignedTo) + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"result": {"sys_id": "user1", "name": "John Smith", "email": "john@example.com"}}`)), + }, + // GetUser (caller) + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"result": {"sys_id": "user2", "name": "Jane Doe", "email": "jane@example.com"}}`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "instanceUrl": "https://dev12345.service-now.com", + "authType": "basicAuth", + "username": "admin", + "password": "password", + }, + } + + metadataCtx := &contexts.MetadataContext{} + + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "shortDescription": "Test Incident", + "urgency": "2", + "impact": "2", + "assignmentGroup": "grp1", + "assignedTo": "user1", + "caller": "user2", + }, + HTTP: httpContext, + Integration: integrationCtx, + Metadata: metadataCtx, + }) + + require.NoError(t, err) + require.Len(t, httpContext.Requests, 4) + + metadata := metadataCtx.Metadata.(NodeMetadata) + assert.Equal(t, "https://dev12345.service-now.com", metadata.InstanceURL) + require.NotNil(t, metadata.AssignmentGroup) + assert.Equal(t, "grp1", metadata.AssignmentGroup.ID) + assert.Equal(t, "Network", metadata.AssignmentGroup.Name) + require.NotNil(t, metadata.AssignedTo) + assert.Equal(t, "user1", metadata.AssignedTo.ID) + assert.Equal(t, "John Smith", metadata.AssignedTo.Name) + require.NotNil(t, metadata.Caller) + assert.Equal(t, "user2", metadata.Caller.ID) + assert.Equal(t, "Jane Doe", metadata.Caller.Name) + }) + + t.Run("invalid assignment group returns error", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"result": []}`)), + }, + { + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader(`{"error": "not found"}`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "instanceUrl": "https://dev12345.service-now.com", + "authType": "basicAuth", + "username": "admin", + "password": "password", + }, + } + + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "shortDescription": "Test Incident", + "urgency": "2", + "impact": "2", + "assignmentGroup": "invalid-group", + }, + HTTP: httpContext, + Integration: integrationCtx, + Metadata: &contexts.MetadataContext{}, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "error verifying assignment group") + }) + + t.Run("missing shortDescription returns error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "urgency": "2", + "impact": "2", + }, + }) + + require.ErrorContains(t, err, "shortDescription is required") + }) + + t.Run("missing urgency returns error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "shortDescription": "Test Incident", + "impact": "2", + }, + }) + + require.ErrorContains(t, err, "urgency is required") + }) + + t.Run("missing impact returns error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "shortDescription": "Test Incident", + "urgency": "2", + }, + }) + + require.ErrorContains(t, err, "impact is required") + }) + + t.Run("invalid ServiceNow connection returns error", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(strings.NewReader(`{"error": "unauthorized"}`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "instanceUrl": "https://dev12345.service-now.com", + "authType": "basicAuth", + "username": "admin", + "password": "wrong", + }, + } + + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "shortDescription": "Test Incident", + "urgency": "2", + "impact": "2", + }, + HTTP: httpContext, + Integration: integrationCtx, + Metadata: &contexts.MetadataContext{}, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "error validating ServiceNow connection") + }) +} + +func Test__CreateIncident__Execute(t *testing.T) { + component := &CreateIncident{} + + t.Run("successful incident creation", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusCreated, + Body: io.NopCloser(strings.NewReader(`{ + "result": { + "sys_id": "abc123", + "number": "INC0010001", + "short_description": "Test Incident", + "state": "1", + "urgency": "2", + "impact": "2" + } + }`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "instanceUrl": "https://dev12345.service-now.com", + "authType": "basicAuth", + "username": "admin", + "password": "password", + }, + } + + executionState := &contexts.ExecutionStateContext{ + KVs: map[string]string{}, + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "shortDescription": "Test Incident", + "description": "Detailed description", + "urgency": "2", + "impact": "2", + "category": "software", + "subcategory": "email", + "assignmentGroup": "abc123def456", + "assignedTo": "789abc012def", + "caller": "def456abc123", + }, + HTTP: httpContext, + Integration: integrationCtx, + ExecutionState: executionState, + }) + + require.NoError(t, err) + assert.True(t, executionState.Passed) + assert.Equal(t, "servicenow.incident", executionState.Type) + require.Len(t, httpContext.Requests, 1) + assert.Equal(t, "https://dev12345.service-now.com/api/now/table/incident", httpContext.Requests[0].URL.String()) + + // Verify the request body contains all configured fields + reqBody, err := io.ReadAll(httpContext.Requests[0].Body) + require.NoError(t, err) + + var sentParams map[string]any + err = json.Unmarshal(reqBody, &sentParams) + require.NoError(t, err) + + assert.Equal(t, "Test Incident", sentParams["short_description"]) + assert.Equal(t, "Detailed description", sentParams["description"]) + assert.Equal(t, "2", sentParams["urgency"]) + assert.Equal(t, "2", sentParams["impact"]) + assert.Equal(t, "software", sentParams["category"]) + assert.Equal(t, "email", sentParams["subcategory"]) + assert.Equal(t, "abc123def456", sentParams["assignment_group"]) + assert.Equal(t, "789abc012def", sentParams["assigned_to"]) + assert.Equal(t, "def456abc123", sentParams["caller_id"]) + }) + + t.Run("API error returns error", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(strings.NewReader(`{"error": "unauthorized"}`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "instanceUrl": "https://dev12345.service-now.com", + "authType": "basicAuth", + "username": "admin", + "password": "wrong", + }, + } + + executionState := &contexts.ExecutionStateContext{ + KVs: map[string]string{}, + } + + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "shortDescription": "Test Incident", + "urgency": "2", + "impact": "2", + }, + HTTP: httpContext, + Integration: integrationCtx, + ExecutionState: executionState, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create incident") + }) +} diff --git a/pkg/integrations/servicenow/example.go b/pkg/integrations/servicenow/example.go new file mode 100644 index 000000000..297486f41 --- /dev/null +++ b/pkg/integrations/servicenow/example.go @@ -0,0 +1,28 @@ +package servicenow + +import ( + _ "embed" + "sync" + + "github.com/superplanehq/superplane/pkg/utils" +) + +//go:embed example_output_create_incident.json +var exampleOutputCreateIncidentBytes []byte + +var exampleOutputCreateIncidentOnce sync.Once +var exampleOutputCreateIncident map[string]any + +//go:embed example_data_on_incident.json +var exampleDataOnIncidentBytes []byte + +var exampleDataOnIncidentOnce sync.Once +var exampleDataOnIncident map[string]any + +func (c *CreateIncident) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputCreateIncidentOnce, exampleOutputCreateIncidentBytes, &exampleOutputCreateIncident) +} + +func (t *OnIncident) ExampleData() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleDataOnIncidentOnce, exampleDataOnIncidentBytes, &exampleDataOnIncident) +} diff --git a/pkg/integrations/servicenow/example_data_on_incident.json b/pkg/integrations/servicenow/example_data_on_incident.json new file mode 100644 index 000000000..bcf7f9439 --- /dev/null +++ b/pkg/integrations/servicenow/example_data_on_incident.json @@ -0,0 +1,35 @@ +{ + "type": "servicenow.incident.insert", + "data": { + "sys_id": "a1b2c3d4e5f6g7h8i9j0", + "number": "INC0010001", + "short_description": "Server is unresponsive", + "description": "The production web server is not responding to requests.", + "state": "1", + "urgency": "2", + "impact": "2", + "priority": "3", + "category": "Network", + "subcategory": "DNS", + "assignment_group": { + "display_value": "Network", + "value": "d625dccec0a8016700a222a0f7900d06" + }, + "assigned_to": { + "display_value": "John Smith", + "value": "681ccaf9c0a8016400b98a06818d57c7" + }, + "caller_id": { + "display_value": "Jane Doe", + "value": "6816f79cc0a8016401c5a33be04be441" + }, + "opened_by": { + "display_value": "Jane Doe", + "value": "6816f79cc0a8016401c5a33be04be441" + }, + "sys_created_on": "2026-01-19 12:00:00", + "sys_updated_on": "2026-01-19 12:00:00", + "opened_at": "2026-01-19 12:00:00" + }, + "timestamp": "2026-01-19T12:00:00Z" +} \ No newline at end of file diff --git a/pkg/integrations/servicenow/example_output_create_incident.json b/pkg/integrations/servicenow/example_output_create_incident.json new file mode 100644 index 000000000..dd678d015 --- /dev/null +++ b/pkg/integrations/servicenow/example_output_create_incident.json @@ -0,0 +1,37 @@ +{ + "type": "servicenow.incident", + "data": { + "result": { + "sys_id": "a1b2c3d4e5f6g7h8i9j0", + "number": "INC0010001", + "short_description": "Server is unresponsive", + "description": "The production web server is not responding to requests.", + "state": "1", + "urgency": "2", + "impact": "2", + "priority": "3", + "category": "Network", + "subcategory": "DNS", + "assignment_group": { + "link": "https://dev12345.service-now.com/api/now/table/sys_user_group/d625dccec0a8016700a222a0f7900d06", + "value": "d625dccec0a8016700a222a0f7900d06" + }, + "assigned_to": { + "link": "https://dev12345.service-now.com/api/now/table/sys_user/681ccaf9c0a8016400b98a06818d57c7", + "value": "681ccaf9c0a8016400b98a06818d57c7" + }, + "caller_id": { + "link": "https://dev12345.service-now.com/api/now/table/sys_user/6816f79cc0a8016401c5a33be04be441", + "value": "6816f79cc0a8016401c5a33be04be441" + }, + "opened_by": { + "link": "https://dev12345.service-now.com/api/now/table/sys_user/6816f79cc0a8016401c5a33be04be441", + "value": "6816f79cc0a8016401c5a33be04be441" + }, + "sys_created_on": "2026-01-19 12:00:00", + "sys_updated_on": "2026-01-19 12:00:00", + "opened_at": "2026-01-19 12:00:00" + } + }, + "timestamp": "2026-01-19T12:00:00Z" +} \ No newline at end of file diff --git a/pkg/integrations/servicenow/list_resources.go b/pkg/integrations/servicenow/list_resources.go new file mode 100644 index 000000000..312083d12 --- /dev/null +++ b/pkg/integrations/servicenow/list_resources.go @@ -0,0 +1,115 @@ +package servicenow + +import ( + "fmt" + + "github.com/superplanehq/superplane/pkg/core" +) + +func (s *ServiceNow) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { + switch resourceType { + case "user": + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, fmt.Errorf("failed to create client: %w", err) + } + + var users []UserRecord + + groupID := ctx.Parameters["assignmentGroup"] + if groupID != "" { + users, err = client.ListGroupMembers(groupID) + } else { + users, err = client.ListUsers() + } + if err != nil { + return nil, fmt.Errorf("failed to list users: %w", err) + } + + resources := make([]core.IntegrationResource, 0, len(users)) + for _, user := range users { + name := user.Name + if user.Email != "" { + name = fmt.Sprintf("%s (%s)", user.Name, user.Email) + } + + resources = append(resources, core.IntegrationResource{ + Type: resourceType, + Name: name, + ID: user.SysID, + }) + } + + return resources, nil + + case "category": + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, fmt.Errorf("failed to create client: %w", err) + } + + categories, err := client.ListCategories() + if err != nil { + return nil, fmt.Errorf("failed to list categories: %w", err) + } + + resources := make([]core.IntegrationResource, 0, len(categories)) + for _, choice := range categories { + resources = append(resources, core.IntegrationResource{ + Type: resourceType, + Name: choice.Label, + ID: choice.Value, + }) + } + + return resources, nil + + case "assignment_group": + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, fmt.Errorf("failed to create client: %w", err) + } + + groups, err := client.ListAssignmentGroups() + if err != nil { + return nil, fmt.Errorf("failed to list assignment groups: %w", err) + } + + resources := make([]core.IntegrationResource, 0, len(groups)) + for _, group := range groups { + resources = append(resources, core.IntegrationResource{ + Type: resourceType, + Name: group.Name, + ID: group.SysID, + }) + } + + return resources, nil + + case "subcategory": + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, fmt.Errorf("failed to create client: %w", err) + } + + category := ctx.Parameters["category"] + choices, err := client.ListSubcategories(category) + if err != nil { + return nil, fmt.Errorf("failed to list subcategories: %w", err) + } + + resources := make([]core.IntegrationResource, 0, len(choices)) + for _, choice := range choices { + resources = append(resources, core.IntegrationResource{ + Type: resourceType, + Name: choice.Label, + ID: choice.Value, + }) + } + + return resources, nil + + default: + return []core.IntegrationResource{}, nil + } +} diff --git a/pkg/integrations/servicenow/list_resources_test.go b/pkg/integrations/servicenow/list_resources_test.go new file mode 100644 index 000000000..950c05658 --- /dev/null +++ b/pkg/integrations/servicenow/list_resources_test.go @@ -0,0 +1,160 @@ +package servicenow + +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__ServiceNow__ListResources(t *testing.T) { + s := &ServiceNow{} + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "instanceUrl": "https://dev12345.service-now.com", + "authType": "basicAuth", + "username": "admin", + "password": "password", + }, + } + + t.Run("returns empty list for unknown resource type", func(t *testing.T) { + resources, err := s.ListResources("unknown", core.ListResourcesContext{ + Integration: integrationCtx, + }) + + require.NoError(t, err) + assert.Empty(t, resources) + }) + + t.Run("returns list of users", func(t *testing.T) { + ctx := core.ListResourcesContext{ + Integration: integrationCtx, + HTTP: &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "result": [ + {"sys_id": "user1", "name": "John Smith", "email": "john@example.com"}, + {"sys_id": "user2", "name": "Jane Doe", "email": ""} + ] + }`)), + }, + }, + }, + } + + resources, err := s.ListResources("user", ctx) + + require.NoError(t, err) + assert.Len(t, resources, 2) + assert.Equal(t, "user1", resources[0].ID) + assert.Equal(t, "John Smith (john@example.com)", resources[0].Name) + assert.Equal(t, "user", resources[0].Type) + assert.Equal(t, "user2", resources[1].ID) + assert.Equal(t, "Jane Doe", resources[1].Name) + }) + + t.Run("returns list of assignment groups", func(t *testing.T) { + ctx := core.ListResourcesContext{ + Integration: integrationCtx, + HTTP: &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "result": [ + {"sys_id": "grp1", "name": "Network"}, + {"sys_id": "grp2", "name": "Database"} + ] + }`)), + }, + }, + }, + } + + resources, err := s.ListResources("assignment_group", ctx) + + require.NoError(t, err) + assert.Len(t, resources, 2) + assert.Equal(t, "grp1", resources[0].ID) + assert.Equal(t, "Network", resources[0].Name) + assert.Equal(t, "assignment_group", resources[0].Type) + assert.Equal(t, "grp2", resources[1].ID) + assert.Equal(t, "Database", resources[1].Name) + }) + + t.Run("returns users filtered by assignment group", func(t *testing.T) { + ctx := core.ListResourcesContext{ + Integration: integrationCtx, + HTTP: &contexts.HTTPContext{ + Responses: []*http.Response{ + // First call: ListGroupMembers (sys_user_grmember) + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "result": [ + {"user": {"value": "user1"}}, + {"user": {"value": "user2"}} + ] + }`)), + }, + // Second call: fetch user details + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "result": [ + {"sys_id": "user1", "name": "John Smith", "email": "john@example.com"}, + {"sys_id": "user2", "name": "Jane Doe", "email": "jane@example.com"} + ] + }`)), + }, + }, + }, + Parameters: map[string]string{ + "assignmentGroup": "grp1", + }, + } + + resources, err := s.ListResources("user", ctx) + + require.NoError(t, err) + assert.Len(t, resources, 2) + assert.Equal(t, "user1", resources[0].ID) + assert.Equal(t, "John Smith (john@example.com)", resources[0].Name) + assert.Equal(t, "user", resources[0].Type) + assert.Equal(t, "user2", resources[1].ID) + assert.Equal(t, "Jane Doe (jane@example.com)", resources[1].Name) + }) + + t.Run("returns empty list when group has no members", func(t *testing.T) { + ctx := core.ListResourcesContext{ + Integration: integrationCtx, + HTTP: &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "result": [] + }`)), + }, + }, + }, + Parameters: map[string]string{ + "assignmentGroup": "empty-group", + }, + } + + resources, err := s.ListResources("user", ctx) + + require.NoError(t, err) + assert.Empty(t, resources) + }) +} diff --git a/pkg/integrations/servicenow/on_incident.go b/pkg/integrations/servicenow/on_incident.go new file mode 100644 index 000000000..d72b8da00 --- /dev/null +++ b/pkg/integrations/servicenow/on_incident.go @@ -0,0 +1,229 @@ +package servicenow + +import ( + "bytes" + "crypto/subtle" + "encoding/json" + "fmt" + "net/http" + "slices" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type OnIncident struct{} + +type OnIncidentConfiguration struct { + Events []string `json:"events"` +} + +func (t *OnIncident) Name() string { + return "servicenow.onIncident" +} + +func (t *OnIncident) Label() string { + return "On Incident" +} + +func (t *OnIncident) Description() string { + return "Listen to incident events from ServiceNow" +} + +func (t *OnIncident) Documentation() string { + return `The On Incident trigger starts a workflow execution when ServiceNow incident events are received via webhook. + +## Use Cases + +- **Incident automation**: Automate responses when incidents are created or updated +- **Notification workflows**: Send notifications when new incidents are created +- **Integration workflows**: Sync incidents with external systems +- **Escalation handling**: Handle incident updates automatically + +## Configuration + +- **Events**: Select which incident events to listen for (insert, update, delete) + +## Scoping + +Incident scoping (e.g. filtering by assignment group, category, or priority) is configured in the ServiceNow Business Rule conditions. This allows control over which incidents trigger the webhook. + +## Required Permissions + +Creating the Business Rule in ServiceNow requires the **admin** role. This is a one-time setup step. + +## Business Rule Setup + +This trigger provides a webhook URL and a ready-to-use Business Rule script: + +1. In ServiceNow, navigate to **System Definition > Business Rules** and create a new rule +2. Set the table to ` + "`incident`" + `, set **When** to **after**, and check **insert**, **update**, and/or **delete** as needed +3. Check **Advanced** and paste the generated script into the **Script** field +4. The script uses ` + "`sn_ws.RESTMessageV2`" + ` to send incident data to the webhook URL with the secret for authentication + +## Event Data + +Each incident event includes the full incident record from ServiceNow, including: +- **sys_id**: Unique identifier +- **number**: Human-readable incident number +- **short_description**: Brief summary +- **state**: Current state +- **urgency**: Urgency level +- **impact**: Impact level +- **assignment_group**: Assigned group +- **assigned_to**: Assigned user` +} + +func (t *OnIncident) Icon() string { + return "servicenow" +} + +func (t *OnIncident) Color() string { + return "gray" +} + +func (t *OnIncident) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "events", + Label: "Events", + Type: configuration.FieldTypeMultiSelect, + Required: true, + Default: []string{"insert"}, + Description: "Which incident events to listen for", + TypeOptions: &configuration.TypeOptions{ + MultiSelect: &configuration.MultiSelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Insert", Value: "insert"}, + {Label: "Update", Value: "update"}, + {Label: "Delete", Value: "delete"}, + }, + }, + }, + }, + } +} + +func (t *OnIncident) Setup(ctx core.TriggerContext) error { + metadata := NodeMetadata{} + err := mapstructure.Decode(ctx.Metadata.Get(), &metadata) + if err != nil { + return fmt.Errorf("failed to decode metadata: %v", err) + } + + if metadata.WebhookURL != "" { + return nil + } + + config := OnIncidentConfiguration{} + err = mapstructure.Decode(ctx.Configuration, &config) + if err != nil { + return fmt.Errorf("failed to decode configuration: %v", err) + } + + if len(config.Events) == 0 { + return fmt.Errorf("at least one event type must be chosen") + } + + webhookURL, err := ctx.Webhook.Setup() + if err != nil { + return fmt.Errorf("error setting up webhook: %v", err) + } + + return ctx.Metadata.Set(NodeMetadata{WebhookURL: webhookURL}) +} + +func (t *OnIncident) Actions() []core.Action { + return []core.Action{ + { + Name: "resetAuthentication", + Description: "Reset/regenerate webhook secret", + UserAccessible: true, + Parameters: []configuration.Field{}, + }, + } +} + +func (t *OnIncident) HandleAction(ctx core.TriggerActionContext) (map[string]any, error) { + switch ctx.Name { + case "resetAuthentication": + plainKey, _, err := ctx.Webhook.ResetSecret() + if err != nil { + return nil, fmt.Errorf("failed to reset webhook secret: %w", err) + } + + return map[string]any{"secret": string(plainKey)}, nil + } + + return nil, fmt.Errorf("action %s not supported", ctx.Name) +} + +func (t *OnIncident) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + config := OnIncidentConfiguration{} + err := mapstructure.Decode(ctx.Configuration, &config) + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("failed to decode configuration: %w", err) + } + + secretHeader := ctx.Headers.Get("X-Webhook-Secret") + if secretHeader == "" { + return http.StatusForbidden, fmt.Errorf("missing X-Webhook-Secret header") + } + + secret, err := ctx.Webhook.GetSecret() + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("error getting secret: %v", err) + } + + if subtle.ConstantTimeCompare([]byte(secretHeader), secret) != 1 { + return http.StatusForbidden, fmt.Errorf("invalid webhook secret") + } + + // Parse the webhook payload. + // ServiceNow REST Messages may include control characters (\r, \n, tabs) + // from line-wrapped content templates, which Go's JSON parser rejects. + // Use a lenient decoder that handles these cases. + var payload WebhookPayload + decoder := json.NewDecoder(bytes.NewReader( + bytes.Map(func(r rune) rune { + // Strip control characters except those valid in JSON strings + if r < 0x20 && r != '\t' { + return -1 + } + return r + }, ctx.Body), + )) + err = decoder.Decode(&payload) + if err != nil { + return http.StatusBadRequest, fmt.Errorf("error parsing request body: %v", err) + } + + if payload.EventType == "" { + return http.StatusBadRequest, fmt.Errorf("missing event_type in payload") + } + + if !slices.Contains(config.Events, payload.EventType) { + return http.StatusOK, nil + } + + err = ctx.Events.Emit( + PayloadTypeIncident+"."+payload.EventType, + payload.Incident, + ) + + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("error emitting event: %v", err) + } + + return http.StatusOK, nil +} + +type WebhookPayload struct { + EventType string `json:"event_type"` + Incident map[string]any `json:"incident"` +} + +func (t *OnIncident) Cleanup(ctx core.TriggerContext) error { + return nil +} diff --git a/pkg/integrations/servicenow/on_incident_test.go b/pkg/integrations/servicenow/on_incident_test.go new file mode 100644 index 000000000..f8583a3a5 --- /dev/null +++ b/pkg/integrations/servicenow/on_incident_test.go @@ -0,0 +1,239 @@ +package servicenow + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + contexts "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__OnIncident__HandleWebhook(t *testing.T) { + trigger := &OnIncident{} + + validConfig := map[string]any{ + "events": []string{"created"}, + } + + t.Run("missing X-Webhook-Secret -> 403", func(t *testing.T) { + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Headers: http.Header{}, + }) + + assert.Equal(t, http.StatusForbidden, code) + assert.ErrorContains(t, err, "missing X-Webhook-Secret header") + }) + + t.Run("invalid secret -> 403", func(t *testing.T) { + body := []byte(`{"event_type":"created","incident":{"sys_id":"abc123"}}`) + headers := http.Header{} + headers.Set("X-Webhook-Secret", "wrong-secret") + + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: headers, + Configuration: validConfig, + Webhook: &contexts.WebhookContext{Secret: "test-secret"}, + Events: &contexts.EventContext{}, + }) + + assert.Equal(t, http.StatusForbidden, code) + assert.ErrorContains(t, err, "invalid webhook secret") + }) + + t.Run("invalid JSON body -> 400", func(t *testing.T) { + body := []byte("invalid json") + secret := "test-secret" + + headers := http.Header{} + headers.Set("X-Webhook-Secret", secret) + + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: headers, + Configuration: validConfig, + Webhook: &contexts.WebhookContext{Secret: secret}, + Events: &contexts.EventContext{}, + }) + + assert.Equal(t, http.StatusBadRequest, code) + assert.ErrorContains(t, err, "error parsing request body") + }) + + t.Run("missing event_type -> 400", func(t *testing.T) { + body := []byte(`{"incident":{"sys_id":"abc123"}}`) + secret := "test-secret" + + headers := http.Header{} + headers.Set("X-Webhook-Secret", secret) + + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: headers, + Configuration: validConfig, + Webhook: &contexts.WebhookContext{Secret: secret}, + Events: &contexts.EventContext{}, + }) + + assert.Equal(t, http.StatusBadRequest, code) + assert.ErrorContains(t, err, "missing event_type") + }) + + t.Run("event type not configured -> no emit", func(t *testing.T) { + body := []byte(`{"event_type":"updated","incident":{"sys_id":"abc123"}}`) + secret := "test-secret" + + headers := http.Header{} + headers.Set("X-Webhook-Secret", secret) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: headers, + Configuration: validConfig, + Webhook: &contexts.WebhookContext{Secret: secret}, + Events: eventContext, + }) + + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Equal(t, 0, eventContext.Count()) + }) + + t.Run("valid secret -> event is emitted", func(t *testing.T) { + body := []byte(`{"event_type":"created","incident":{"sys_id":"abc123","number":"INC0010001","short_description":"Test incident"}}`) + secret := "test-secret" + + headers := http.Header{} + headers.Set("X-Webhook-Secret", secret) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: headers, + Configuration: validConfig, + Webhook: &contexts.WebhookContext{Secret: secret}, + Events: eventContext, + }) + + require.Equal(t, http.StatusOK, code) + require.NoError(t, err) + require.Equal(t, 1, eventContext.Count()) + + payload := eventContext.Payloads[0] + assert.Equal(t, "servicenow.incident.created", payload.Type) + assert.Equal(t, map[string]any{ + "sys_id": "abc123", + "number": "INC0010001", + "short_description": "Test incident", + }, payload.Data) + }) + + t.Run("body with control characters -> parsed successfully", func(t *testing.T) { + body := []byte("{\"event_type\":\"created\",\"incident\":{\"sys_id\":\"abc123\",\"short_description\":\"test\\r\\nincident\"}}") + secret := "test-secret" + + headers := http.Header{} + headers.Set("X-Webhook-Secret", secret) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: headers, + Configuration: validConfig, + Webhook: &contexts.WebhookContext{Secret: secret}, + Events: eventContext, + }) + + require.Equal(t, http.StatusOK, code) + require.NoError(t, err) + require.Equal(t, 1, eventContext.Count()) + }) +} + +func Test__OnIncident__HandleAction(t *testing.T) { + trigger := OnIncident{} + + t.Run("resetAuthentication returns new secret", func(t *testing.T) { + webhookCtx := &contexts.WebhookContext{Secret: "old-secret"} + + result, err := trigger.HandleAction(core.TriggerActionContext{ + Name: "resetAuthentication", + Webhook: webhookCtx, + }) + + require.NoError(t, err) + assert.NotEmpty(t, result["secret"]) + }) + + t.Run("unknown action returns error", func(t *testing.T) { + _, err := trigger.HandleAction(core.TriggerActionContext{ + Name: "unknownAction", + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "action unknownAction not supported") + }) +} + +func Test__OnIncident__Setup(t *testing.T) { + trigger := OnIncident{} + + t.Run("invalid configuration -> decode error", func(t *testing.T) { + err := trigger.Setup(core.TriggerContext{ + Integration: &contexts.IntegrationContext{}, + Metadata: &contexts.MetadataContext{}, + Configuration: "invalid-config", + }) + + require.ErrorContains(t, err, "failed to decode configuration") + }) + + t.Run("at least one event required", func(t *testing.T) { + err := trigger.Setup(core.TriggerContext{ + Integration: &contexts.IntegrationContext{}, + Metadata: &contexts.MetadataContext{}, + Webhook: &contexts.WebhookContext{}, + Configuration: OnIncidentConfiguration{Events: []string{}}, + }) + + require.ErrorContains(t, err, "at least one event type must be chosen") + }) + + t.Run("metadata already set -> no webhook setup is called", func(t *testing.T) { + metadataCtx := &contexts.MetadataContext{ + Metadata: NodeMetadata{WebhookURL: "https://example.com/webhooks/123"}, + } + + err := trigger.Setup(core.TriggerContext{ + Integration: &contexts.IntegrationContext{}, + Metadata: metadataCtx, + Configuration: OnIncidentConfiguration{Events: []string{"created"}}, + }) + + require.NoError(t, err) + + metadata, ok := metadataCtx.Metadata.(NodeMetadata) + require.True(t, ok) + assert.Equal(t, "https://example.com/webhooks/123", metadata.WebhookURL) + }) + + t.Run("successful setup creates webhook", func(t *testing.T) { + metadataCtx := &contexts.MetadataContext{} + + err := trigger.Setup(core.TriggerContext{ + Integration: &contexts.IntegrationContext{}, + Metadata: metadataCtx, + Webhook: &contexts.WebhookContext{}, + Configuration: OnIncidentConfiguration{Events: []string{"created"}}, + }) + + require.NoError(t, err) + + metadata, ok := metadataCtx.Metadata.(NodeMetadata) + require.True(t, ok) + assert.NotEmpty(t, metadata.WebhookURL) + }) +} diff --git a/pkg/integrations/servicenow/servicenow.go b/pkg/integrations/servicenow/servicenow.go new file mode 100644 index 000000000..3746bc38a --- /dev/null +++ b/pkg/integrations/servicenow/servicenow.go @@ -0,0 +1,283 @@ +package servicenow + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/registry" +) + +func init() { + registry.RegisterIntegration("servicenow", &ServiceNow{}) +} + +const ( + AuthTypeBasicAuth = "basicAuth" + AuthTypeOAuth = "oauth" + OAuthAccessToken = "accessToken" +) + +type ServiceNow struct{} + +type Configuration struct { + InstanceURL string `json:"instanceUrl"` + AuthType string `json:"authType"` + Username *string `json:"username"` + Password *string `json:"password"` + ClientID *string `json:"clientId"` + ClientSecret *string `json:"clientSecret"` +} + +func (s *ServiceNow) Name() string { + return "servicenow" +} + +func (s *ServiceNow) Label() string { + return "ServiceNow" +} + +func (s *ServiceNow) Icon() string { + return "servicenow" +} + +func (s *ServiceNow) Description() string { + return "Manage and react to incidents in ServiceNow" +} + +func (s *ServiceNow) Instructions() string { + return `Requires a ServiceNow instance with API access. The following roles are needed on your ServiceNow instance: + +**Integration account** (for Basic Auth or OAuth): +- **itil** — read/write access to the Incident table + +Optionally, enable **Web Service Access Only** on the integration account to restrict it to API-only use. + +**On Incident trigger**: Setting up the Business Rule on ServiceNow requires the **admin** role. This is a one-time setup.` +} + +func (s *ServiceNow) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "instanceUrl", + Label: "Instance URL", + Type: configuration.FieldTypeString, + Required: true, + Description: "Your ServiceNow instance URL (e.g. https://dev12345.service-now.com)", + Placeholder: "https://dev12345.service-now.com", + }, + { + Name: "authType", + Label: "Auth Type", + Type: configuration.FieldTypeSelect, + Required: true, + Default: AuthTypeBasicAuth, + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Basic Auth", Value: AuthTypeBasicAuth}, + {Label: "OAuth", Value: AuthTypeOAuth}, + }, + }, + }, + }, + { + Name: "username", + Label: "Username", + Type: configuration.FieldTypeString, + Required: true, + VisibilityConditions: []configuration.VisibilityCondition{ + {Field: "authType", Values: []string{AuthTypeBasicAuth}}, + }, + }, + { + Name: "password", + Label: "Password", + Type: configuration.FieldTypeString, + Required: true, + Sensitive: true, + VisibilityConditions: []configuration.VisibilityCondition{ + {Field: "authType", Values: []string{AuthTypeBasicAuth}}, + }, + }, + { + Name: "clientId", + Label: "Client ID", + Type: configuration.FieldTypeString, + Required: true, + Description: "OAuth Client ID from your ServiceNow instance", + VisibilityConditions: []configuration.VisibilityCondition{ + {Field: "authType", Values: []string{AuthTypeOAuth}}, + }, + }, + { + Name: "clientSecret", + Label: "Client Secret", + Type: configuration.FieldTypeString, + Sensitive: true, + Required: true, + Description: "OAuth Client Secret from your ServiceNow instance", + VisibilityConditions: []configuration.VisibilityCondition{ + {Field: "authType", Values: []string{AuthTypeOAuth}}, + }, + }, + } +} + +func (s *ServiceNow) Components() []core.Component { + return []core.Component{ + &CreateIncident{}, + } +} + +func (s *ServiceNow) Triggers() []core.Trigger { + return []core.Trigger{ + &OnIncident{}, + } +} + +func (s *ServiceNow) Cleanup(ctx core.IntegrationCleanupContext) error { + return nil +} + +func (s *ServiceNow) Sync(ctx core.SyncContext) error { + config := Configuration{} + err := mapstructure.Decode(ctx.Configuration, &config) + if err != nil { + return fmt.Errorf("failed to decode config: %v", err) + } + + if config.InstanceURL == "" { + return fmt.Errorf("instanceUrl is required") + } + + if config.AuthType == "" { + return fmt.Errorf("authType is required") + } + + if config.AuthType != AuthTypeBasicAuth && config.AuthType != AuthTypeOAuth { + return fmt.Errorf("authType %s is not supported", config.AuthType) + } + + if config.AuthType == AuthTypeOAuth { + return s.oauthSync(ctx, config) + } + + return s.basicAuthSync(ctx, config) +} + +func (s *ServiceNow) basicAuthSync(ctx core.SyncContext, config Configuration) error { + if config.Username == nil || *config.Username == "" { + return fmt.Errorf("username is required") + } + + if config.Password == nil || *config.Password == "" { + return fmt.Errorf("password is required") + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("error creating client: %v", err) + } + + err = client.ValidateConnection() + if err != nil { + return fmt.Errorf("error validating credentials: %v", err) + } + + ctx.Integration.Ready() + return nil +} + +func (s *ServiceNow) oauthSync(ctx core.SyncContext, config Configuration) error { + if config.ClientID == nil || *config.ClientID == "" { + return fmt.Errorf("clientId is required") + } + + clientSecret, err := ctx.Integration.GetConfig("clientSecret") + if err != nil { + return err + } + + data := url.Values{} + data.Set("grant_type", "client_credentials") + tokenURL := fmt.Sprintf("%s/oauth_token.do", strings.TrimRight(config.InstanceURL, "/")) + r, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(data.Encode())) + if err != nil { + return fmt.Errorf("error creating request: %v", err) + } + + r.Header.Add("Content-Type", "application/x-www-form-urlencoded") + r.SetBasicAuth(*config.ClientID, string(clientSecret)) + resp, err := ctx.HTTP.Do(r) + if err != nil { + return fmt.Errorf("error executing request: %v", err) + } + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("error reading response body: %v", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("error generating access token: request got %d: %s", resp.StatusCode, string(body)) + } + + var tokenResponse TokenResponse + err = json.Unmarshal(body, &tokenResponse) + if err != nil { + return fmt.Errorf("error unmarshaling response: %v", err) + } + + err = ctx.Integration.SetSecret(OAuthAccessToken, []byte(tokenResponse.AccessToken)) + if err != nil { + return err + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("error creating client: %v", err) + } + + err = client.ValidateConnection() + if err != nil { + return fmt.Errorf("error validating credentials: %v", err) + } + + ctx.Integration.Ready() + + return ctx.Integration.ScheduleResync(tokenResponse.GetExpiration()) +} + +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +func (r *TokenResponse) GetExpiration() time.Duration { + if r.ExpiresIn > 0 { + return time.Duration(r.ExpiresIn/2) * time.Second + } + + return time.Hour +} + +func (s *ServiceNow) HandleRequest(ctx core.HTTPRequestContext) {} + +func (s *ServiceNow) Actions() []core.Action { + return []core.Action{} +} + +func (s *ServiceNow) HandleAction(ctx core.IntegrationActionContext) error { + return nil +} diff --git a/pkg/integrations/servicenow/servicenow_test.go b/pkg/integrations/servicenow/servicenow_test.go new file mode 100644 index 000000000..105cf0e29 --- /dev/null +++ b/pkg/integrations/servicenow/servicenow_test.go @@ -0,0 +1,318 @@ +package servicenow + +import ( + "io" + "net/http" + "strings" + "testing" + "time" + + "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__ServiceNow__Sync(t *testing.T) { + s := &ServiceNow{} + + t.Run("no instanceUrl -> error", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "instanceUrl": "", + }, + } + + err := s.Sync(core.SyncContext{ + Configuration: integrationCtx.Configuration, + Integration: integrationCtx, + }) + + require.ErrorContains(t, err, "instanceUrl is required") + }) + + t.Run("no authType -> error", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "instanceUrl": "https://dev12345.service-now.com", + }, + } + + err := s.Sync(core.SyncContext{ + Configuration: integrationCtx.Configuration, + Integration: integrationCtx, + }) + + require.ErrorContains(t, err, "authType is required") + }) + + t.Run("invalid authType -> error", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "instanceUrl": "https://dev12345.service-now.com", + "authType": "nope", + }, + } + + err := s.Sync(core.SyncContext{ + Configuration: integrationCtx.Configuration, + Integration: integrationCtx, + }) + + require.ErrorContains(t, err, "authType nope is not supported") + }) + + t.Run("basic auth -> missing username -> error", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "instanceUrl": "https://dev12345.service-now.com", + "authType": AuthTypeBasicAuth, + }, + } + + err := s.Sync(core.SyncContext{ + Configuration: integrationCtx.Configuration, + Integration: integrationCtx, + }) + + require.ErrorContains(t, err, "username is required") + }) + + t.Run("basic auth -> missing password -> error", func(t *testing.T) { + username := "admin" + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "instanceUrl": "https://dev12345.service-now.com", + "authType": AuthTypeBasicAuth, + "username": username, + }, + } + + err := s.Sync(core.SyncContext{ + Configuration: integrationCtx.Configuration, + Integration: integrationCtx, + }) + + require.ErrorContains(t, err, "password is required") + }) + + t.Run("basic auth -> validation failure -> error", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(strings.NewReader(`{"error": "unauthorized"}`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "instanceUrl": "https://dev12345.service-now.com", + "authType": AuthTypeBasicAuth, + "username": "admin", + "password": "wrong", + }, + } + + err := s.Sync(core.SyncContext{ + Configuration: integrationCtx.Configuration, + HTTP: httpContext, + Integration: integrationCtx, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "error validating credentials") + assert.NotEqual(t, "ready", integrationCtx.State) + require.Len(t, httpContext.Requests, 1) + assert.Equal(t, "https://dev12345.service-now.com/api/now/table/incident?sysparm_limit=1", httpContext.Requests[0].URL.String()) + }) + + t.Run("basic auth -> success -> ready", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"result": []}`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "instanceUrl": "https://dev12345.service-now.com", + "authType": AuthTypeBasicAuth, + "username": "admin", + "password": "password123", + }, + } + + err := s.Sync(core.SyncContext{ + Configuration: integrationCtx.Configuration, + HTTP: httpContext, + Integration: integrationCtx, + }) + + require.NoError(t, err) + assert.Equal(t, "ready", integrationCtx.State) + require.Len(t, httpContext.Requests, 1) + assert.Equal(t, "https://dev12345.service-now.com/api/now/table/incident?sysparm_limit=1", httpContext.Requests[0].URL.String()) + }) + + t.Run("client credentials -> missing clientId -> error", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "instanceUrl": "https://dev12345.service-now.com", + "authType": AuthTypeOAuth, + }, + Secrets: map[string]core.IntegrationSecret{}, + } + + err := s.Sync(core.SyncContext{ + Configuration: integrationCtx.Configuration, + Integration: integrationCtx, + }) + + require.ErrorContains(t, err, "clientId is required") + }) + + t.Run("client credentials -> missing clientSecret -> error", func(t *testing.T) { + clientID := "client-123" + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "instanceUrl": "https://dev12345.service-now.com", + "authType": AuthTypeOAuth, + "clientId": clientID, + }, + } + + err := s.Sync(core.SyncContext{ + Configuration: integrationCtx.Configuration, + Integration: integrationCtx, + }) + + require.ErrorContains(t, err, "config not found: clientSecret") + }) + + t.Run("client credentials -> token exchange failure -> error", func(t *testing.T) { + clientID := "client-123" + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusBadGateway, + Body: io.NopCloser(strings.NewReader(`{}`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "instanceUrl": "https://dev12345.service-now.com", + "authType": AuthTypeOAuth, + "clientId": clientID, + "clientSecret": "secret-123", + }, + } + + err := s.Sync(core.SyncContext{ + Configuration: integrationCtx.Configuration, + HTTP: httpContext, + Integration: integrationCtx, + }) + + require.ErrorContains(t, err, "error generating access token: request got 502") + require.Len(t, httpContext.Requests, 1) + assert.Equal(t, "https://dev12345.service-now.com/oauth_token.do", httpContext.Requests[0].URL.String()) + }) + + t.Run("client credentials -> validation failure after token -> error", func(t *testing.T) { + clientID := "client-123" + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "access_token": "access-123", + "token_type": "Bearer", + "expires_in": 1800 + }`)), + }, + { + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(strings.NewReader(`{}`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Secrets: map[string]core.IntegrationSecret{}, + Configuration: map[string]any{ + "instanceUrl": "https://dev12345.service-now.com", + "authType": AuthTypeOAuth, + "clientId": clientID, + "clientSecret": "secret-123", + }, + } + + err := s.Sync(core.SyncContext{ + Configuration: integrationCtx.Configuration, + HTTP: httpContext, + Integration: integrationCtx, + }) + + require.Error(t, err) + assert.NotEqual(t, "ready", integrationCtx.State) + require.Len(t, httpContext.Requests, 2) + assert.Equal(t, "https://dev12345.service-now.com/oauth_token.do", httpContext.Requests[0].URL.String()) + assert.Equal(t, "https://dev12345.service-now.com/api/now/table/incident?sysparm_limit=1", httpContext.Requests[1].URL.String()) + }) + + t.Run("client credentials -> success -> stores token, ready, schedules resync", func(t *testing.T) { + clientID := "client-123" + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "access_token": "access-123", + "token_type": "Bearer", + "expires_in": 1800 + }`)), + }, + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"result": []}`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Secrets: map[string]core.IntegrationSecret{}, + Configuration: map[string]any{ + "instanceUrl": "https://dev12345.service-now.com", + "authType": AuthTypeOAuth, + "clientId": clientID, + "clientSecret": "secret-123", + }, + } + + err := s.Sync(core.SyncContext{ + Configuration: integrationCtx.Configuration, + HTTP: httpContext, + Integration: integrationCtx, + }) + + require.NoError(t, err) + assert.Equal(t, "ready", integrationCtx.State) + require.Len(t, httpContext.Requests, 2) + assert.Equal(t, "https://dev12345.service-now.com/oauth_token.do", httpContext.Requests[0].URL.String()) + assert.Equal(t, "https://dev12345.service-now.com/api/now/table/incident?sysparm_limit=1", httpContext.Requests[1].URL.String()) + + secret, ok := integrationCtx.Secrets[OAuthAccessToken] + require.True(t, ok) + assert.Equal(t, []byte("access-123"), secret.Value) + + require.Len(t, integrationCtx.ResyncRequests, 1) + assert.Equal(t, 900*time.Second, integrationCtx.ResyncRequests[0]) + }) +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 928671301..e42f68f42 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -52,6 +52,7 @@ import ( _ "github.com/superplanehq/superplane/pkg/integrations/rootly" _ "github.com/superplanehq/superplane/pkg/integrations/semaphore" _ "github.com/superplanehq/superplane/pkg/integrations/sendgrid" + _ "github.com/superplanehq/superplane/pkg/integrations/servicenow" _ "github.com/superplanehq/superplane/pkg/integrations/slack" _ "github.com/superplanehq/superplane/pkg/integrations/smtp" _ "github.com/superplanehq/superplane/pkg/triggers/schedule" diff --git a/web_src/src/assets/icons/integrations/servicenow.svg b/web_src/src/assets/icons/integrations/servicenow.svg new file mode 100644 index 000000000..f5e760755 --- /dev/null +++ b/web_src/src/assets/icons/integrations/servicenow.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web_src/src/pages/workflowv2/mappers/index.ts b/web_src/src/pages/workflowv2/mappers/index.ts index 129d06bb1..c9abcd211 100644 --- a/web_src/src/pages/workflowv2/mappers/index.ts +++ b/web_src/src/pages/workflowv2/mappers/index.ts @@ -123,6 +123,13 @@ import { triggerRenderers as dockerhubTriggerRenderers, eventStateRegistry as dockerhubEventStateRegistry, } from "./dockerhub"; +import { + componentMappers as servicenowComponentMappers, + customFieldRenderers as servicenowCustomFieldRenderers, + triggerRenderers as servicenowTriggerRenderers, + eventStateRegistry as servicenowEventStateRegistry, +} from "./servicenow/index"; + import { filterMapper, FILTER_STATE_REGISTRY } from "./filter"; import { sshMapper, SSH_STATE_REGISTRY } from "./ssh"; import { waitCustomFieldRenderer, waitMapper, WAIT_STATE_REGISTRY } from "./wait"; @@ -176,6 +183,7 @@ const appMappers: Record> = { prometheus: prometheusComponentMappers, cursor: cursorComponentMappers, dockerhub: dockerhubComponentMappers, + servicenow: servicenowComponentMappers, }; const appTriggerRenderers: Record> = { @@ -200,6 +208,7 @@ const appTriggerRenderers: Record> = { prometheus: prometheusTriggerRenderers, cursor: cursorTriggerRenderers, dockerhub: dockerhubTriggerRenderers, + servicenow: servicenowTriggerRenderers, }; const appEventStateRegistries: Record> = { @@ -224,6 +233,7 @@ const appEventStateRegistries: Record cursor: cursorEventStateRegistry, gitlab: gitlabEventStateRegistry, dockerhub: dockerhubEventStateRegistry, + servicenow: servicenowEventStateRegistry, }; const componentAdditionalDataBuilders: Record = { @@ -251,6 +261,7 @@ const appCustomFieldRenderers: Record 0 ? context.lastExecutions[0] : null; + const componentName = context.componentDefinition.name ?? "servicenow"; + + return { + iconSrc: snIcon, + collapsedBackground: getBackgroundColorClass(context.componentDefinition.color), + collapsed: context.node.isCollapsed, + title: context.node.name || context.componentDefinition.label || "Unnamed component", + eventSections: lastExecution ? baseEventSections(context.nodes, lastExecution, componentName) : undefined, + metadata: metadataList(context.node), + includeEmptyState: !lastExecution, + eventStateMap: getStateMap(componentName), + }; + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + return buildIncidentExecutionDetails(context.execution); + }, + + subtitle(context: SubtitleContext): string { + if (!context.execution.createdAt) return ""; + return formatTimeAgo(new Date(context.execution.createdAt)); + }, +}; + +function metadataList(node: NodeInfo): MetadataItem[] { + const metadata: MetadataItem[] = []; + const nodeMetadata = node.metadata as BaseNodeMetadata; + const configuration = node.configuration as CreateIncidentConfiguration; + + if (nodeMetadata?.instanceUrl) { + const instanceName = nodeMetadata.instanceUrl.replace(/^https?:\/\//, "").replace(/\.service-now\.com$/, ""); + metadata.push({ icon: "globe", label: instanceName }); + } + + if (configuration.urgency) { + const urgencyLabel = URGENCY_LABELS[configuration.urgency] || configuration.urgency; + metadata.push({ icon: "funnel", label: `Urgency: ${urgencyLabel}` }); + } + + return metadata; +} + +function baseEventSections(nodes: NodeInfo[], execution: ExecutionInfo, componentName: string): EventSection[] { + const rootTriggerNode = nodes.find((n) => n.id === execution.rootEvent?.nodeId); + const rootTriggerRenderer = getTriggerRenderer(rootTriggerNode?.componentName!); + const { title } = rootTriggerRenderer.getTitleAndSubtitle({ event: execution.rootEvent! }); + + return [ + { + receivedAt: new Date(execution.createdAt!), + eventTitle: title, + eventSubtitle: formatTimeAgo(new Date(execution.createdAt!)), + eventState: getState(componentName)(execution), + eventId: execution.rootEvent!.id!, + }, + ]; +} + +function getIncidentFromExecution(execution: CanvasesCanvasNodeExecution): ServiceNowIncident | null { + const outputs = execution.outputs as { default?: OutputPayload[] } | undefined; + + if (!outputs || !outputs.default || outputs.default.length === 0) { + return null; + } + + return outputs.default[0].data?.result as ServiceNowIncident; +} + +function buildIncidentExecutionDetails(execution: CanvasesCanvasNodeExecution): Record { + const details: Record = {}; + + if (execution.createdAt) { + details["Executed at"] = new Date(execution.createdAt).toLocaleString(); + } + + const incident = getIncidentFromExecution(execution); + if (incident) { + if (incident.number) details["Number"] = incident.number; + if (incident.sys_id) details["Sys ID"] = incident.sys_id; + if (incident.short_description) details["Short Description"] = incident.short_description; + if (incident.state) details["State"] = STATE_LABELS[incident.state] || incident.state; + if (incident.urgency) details["Urgency"] = URGENCY_LABELS[incident.urgency] || incident.urgency; + if (incident.impact) details["Impact"] = IMPACT_LABELS[incident.impact] || incident.impact; + if (incident.sys_created_on) details["Created On"] = incident.sys_created_on; + } + + if ( + execution.resultMessage && + (execution.resultReason === "RESULT_REASON_ERROR" || + (execution.result === "RESULT_FAILED" && execution.resultReason !== "RESULT_REASON_ERROR_RESOLVED")) + ) { + details["Error"] = { + __type: "error", + message: execution.resultMessage, + }; + } + + return details; +} diff --git a/web_src/src/pages/workflowv2/mappers/servicenow/index.ts b/web_src/src/pages/workflowv2/mappers/servicenow/index.ts new file mode 100644 index 000000000..c31b57ecd --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/servicenow/index.ts @@ -0,0 +1,20 @@ +import { ComponentBaseMapper, CustomFieldRenderer, EventStateRegistry, TriggerRenderer } from "../types"; +import { onIncidentTriggerRenderer, onIncidentCustomFieldRenderer } from "./on_incident"; +import { createIncidentMapper } from "./create_incident"; +import { buildActionStateRegistry } from "../utils"; + +export const componentMappers: Record = { + createIncident: createIncidentMapper, +}; + +export const triggerRenderers: Record = { + onIncident: onIncidentTriggerRenderer, +}; + +export const customFieldRenderers: Record = { + onIncident: onIncidentCustomFieldRenderer, +}; + +export const eventStateRegistry: Record = { + createIncident: buildActionStateRegistry("created"), +}; diff --git a/web_src/src/pages/workflowv2/mappers/servicenow/on_incident.tsx b/web_src/src/pages/workflowv2/mappers/servicenow/on_incident.tsx new file mode 100644 index 000000000..6aae8d8c9 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/servicenow/on_incident.tsx @@ -0,0 +1,268 @@ +import { useState } from "react"; +import { getBackgroundColorClass } from "@/utils/colors"; +import { CustomFieldRenderer, NodeInfo, TriggerEventContext, TriggerRenderer, TriggerRendererContext } from "../types"; +import { TriggerProps } from "@/ui/trigger"; +import { Icon } from "@/components/Icon"; +import { canvasesInvokeNodeTriggerAction } from "@/api-client"; +import { useQueryClient } from "@tanstack/react-query"; +import { useParams } from "react-router-dom"; +import { withOrganizationHeader } from "@/utils/withOrganizationHeader"; +import { canvasKeys } from "@/hooks/useCanvasData"; +import { showErrorToast } from "@/utils/toast"; +import snIcon from "@/assets/icons/integrations/servicenow.svg"; +import { OnIncidentConfiguration, ServiceNowIncident, STATE_LABELS, URGENCY_LABELS, IMPACT_LABELS } from "./types"; +import { buildSubtitle } from "../utils"; + +interface OnIncidentMetadata { + webhookUrl?: string; +} + +interface OnIncidentEventData extends ServiceNowIncident {} + +export const onIncidentTriggerRenderer: TriggerRenderer = { + getTitleAndSubtitle: (context: TriggerEventContext): { title: string; subtitle: string } => { + const incident = context.event?.data as OnIncidentEventData; + const stateLabel = incident?.state ? STATE_LABELS[incident.state] || incident.state : ""; + const urgencyLabel = incident?.urgency ? URGENCY_LABELS[incident.urgency] || incident.urgency : ""; + const contentParts = [stateLabel, urgencyLabel].filter(Boolean).join(" · "); + const subtitle = buildSubtitle(contentParts, context.event?.createdAt); + + return { + title: `${incident?.number || ""} - ${incident?.short_description || ""}`, + subtitle, + }; + }, + + getRootEventValues: (context: TriggerEventContext): Record => { + const incident = context.event?.data as OnIncidentEventData; + return getDetailsForIncident(incident); + }, + + getTriggerProps: (context: TriggerRendererContext) => { + const { node, definition, lastEvent } = context; + const configuration = node.configuration as OnIncidentConfiguration; + const metadataItems = []; + + if (configuration.events) { + metadataItems.push({ + icon: "funnel", + label: `Events: ${configuration.events.join(", ")}`, + }); + } + + const props: TriggerProps = { + title: node.name || definition.label || "Unnamed trigger", + iconSrc: snIcon, + collapsedBackground: getBackgroundColorClass(definition.color), + metadata: metadataItems, + }; + + if (lastEvent) { + const incident = lastEvent.data as OnIncidentEventData; + const stateLabel = incident?.state ? STATE_LABELS[incident.state] || incident.state : ""; + const urgencyLabel = incident?.urgency ? URGENCY_LABELS[incident.urgency] || incident.urgency : ""; + const contentParts = [stateLabel, urgencyLabel].filter(Boolean).join(" · "); + const subtitle = buildSubtitle(contentParts, lastEvent.createdAt); + + props.lastEventData = { + title: `${incident?.number || ""} - ${incident?.short_description || ""}`, + subtitle, + receivedAt: new Date(lastEvent.createdAt), + state: "triggered", + eventId: lastEvent.id, + }; + } + + return props; + }, +}; + +function buildBusinessRuleScript(webhookUrl: string, webhookSecret: string): string { + return `(function executeRule(current, previous) { + try { + var eventType = current.operation(); + + var body = { + event_type: eventType, + incident: { + sys_id: current.sys_id.toString(), + number: current.number.toString(), + short_description: current.short_description.toString(), + description: current.description.toString(), + state: current.state.toString(), + urgency: current.urgency.toString(), + impact: current.impact.toString(), + priority: current.priority.toString(), + category: current.category.toString(), + assignment_group: { display_value: current.assignment_group.getDisplayValue() }, + assigned_to: { display_value: current.assigned_to.getDisplayValue() }, + caller_id: { display_value: current.caller_id.getDisplayValue() }, + sys_created_on: current.sys_created_on.toString(), + sys_updated_on: current.sys_updated_on.toString() + } + }; + + var request = new sn_ws.RESTMessageV2(); + request.setEndpoint('${webhookUrl}'); + request.setHttpMethod('POST'); + request.setRequestHeader('Content-Type', 'application/json'); + request.setRequestHeader('X-Webhook-Secret', '${webhookSecret}'); + request.setRequestBody(JSON.stringify(body)); + request.executeAsync(); + } catch (e) { + gs.error('Superplane webhook failed: ' + e.message); + } +})(current, previous);`; +} + +function OnIncidentCustomField({ node }: { node: NodeInfo }) { + const metadata = node.metadata as OnIncidentMetadata | undefined; + const webhookUrl = metadata?.webhookUrl || "[URL GENERATED ONCE THE CANVAS IS SAVED]"; + + const [isResetting, setIsResetting] = useState(false); + const [secret, setSecret] = useState(null); + const [copied, setCopied] = useState(false); + const queryClient = useQueryClient(); + const { organizationId, canvasId } = useParams<{ organizationId: string; canvasId: string }>(); + + const scriptContent = buildBusinessRuleScript(webhookUrl, secret || "YOUR_SECRET_HERE"); + + const handleGenerateSecret = async () => { + if (!canvasId || !node.id) return; + setIsResetting(true); + try { + const response = await canvasesInvokeNodeTriggerAction( + withOrganizationHeader({ + path: { + canvasId: canvasId, + nodeId: node.id, + actionName: "resetAuthentication", + }, + body: { parameters: {} }, + }), + ); + const newSecret = response.data?.result?.secret as string | undefined; + if (newSecret) { + setSecret(newSecret); + if (organizationId) { + queryClient.invalidateQueries({ + queryKey: canvasKeys.detail(organizationId, canvasId), + }); + } + } + } catch (_error) { + showErrorToast("Failed to generate webhook secret"); + } finally { + setIsResetting(false); + } + }; + + const handleCopyScript = async () => { + try { + await navigator.clipboard.writeText(scriptContent); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (_error) { + showErrorToast("Failed to copy script"); + } + }; + + return ( +
+
+
+ ServiceNow Business Rule Setup +
+
    +
  1. + In ServiceNow, go to System Definition > Business Rules and create a new rule +
  2. +
  3. + Set the table to incident, set When to after, and check{" "} + insert, update, and/or delete as needed +
  4. +
  5. + Check Advanced and paste the script below into the Script field +
  6. +
+ +
+ {metadata?.webhookUrl ? ( + <> + + {secret && ( +
+
+ +

+ Secret: {secret} — copy it now, it won't be shown + again. +

+
+
+ )} + + ) : ( +

+ Save to generate the webhook URL and secret. +

+ )} +
+ +
+
+ Business Rule Script + +
+
+
+                  {scriptContent}
+                
+
+
+
+
+
+
+ ); +} + +export const onIncidentCustomFieldRenderer: CustomFieldRenderer = { + render: (node: NodeInfo) => { + return ; + }, +}; + +function getDetailsForIncident(incident?: OnIncidentEventData): Record { + const details: Record = {}; + + if (incident?.number) details["Number"] = incident.number; + if (incident?.short_description) details["Short Description"] = incident.short_description; + if (incident?.state) details["State"] = STATE_LABELS[incident.state] || incident.state; + if (incident?.urgency) details["Urgency"] = URGENCY_LABELS[incident.urgency] || incident.urgency; + if (incident?.impact) details["Impact"] = IMPACT_LABELS[incident.impact] || incident.impact; + if (incident?.priority) details["Priority"] = incident.priority; + if (incident?.category) details["Category"] = incident.category; + if (incident?.assignment_group?.display_value) details["Assignment Group"] = incident.assignment_group.display_value; + if (incident?.assigned_to?.display_value) details["Assigned To"] = incident.assigned_to.display_value; + if (incident?.sys_created_on) details["Created On"] = incident.sys_created_on; + + return details; +} diff --git a/web_src/src/pages/workflowv2/mappers/servicenow/types.ts b/web_src/src/pages/workflowv2/mappers/servicenow/types.ts new file mode 100644 index 000000000..3add72f28 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/servicenow/types.ts @@ -0,0 +1,76 @@ +export interface ServiceNowIncident { + sys_id?: string; + number?: string; + short_description?: string; + description?: string; + state?: string; + urgency?: string; + impact?: string; + priority?: string; + category?: string; + subcategory?: string; + assignment_group?: ServiceNowReference; + assigned_to?: ServiceNowReference; + caller_id?: ServiceNowReference; + opened_by?: ServiceNowReference; + sys_created_on?: string; + sys_updated_on?: string; + opened_at?: string; + closed_at?: string; + resolved_at?: string; +} + +export interface ServiceNowReference { + display_value?: string; + value?: string; + link?: string; +} + +export interface BaseNodeMetadata { + instanceUrl?: string; + webhookUrl?: string; + assignmentGroup?: { id: string; name: string }; + assignedTo?: { id: string; name: string }; + caller?: { id: string; name: string }; +} + +export interface CreateIncidentConfiguration { + shortDescription?: string; + urgency?: string; + impact?: string; + description?: string; + category?: string; + subcategory?: string; + assignmentGroup?: string; + assignedTo?: string; + caller?: string; + state?: string; + onHoldReason?: string; + resolutionCode?: string; + resolutionNotes?: string; +} + +export interface OnIncidentConfiguration { + events?: string[]; +} + +export const STATE_LABELS: Record = { + "1": "New", + "2": "In Progress", + "3": "On Hold", + "6": "Resolved", + "7": "Closed", + "8": "Canceled", +}; + +export const URGENCY_LABELS: Record = { + "1": "High", + "2": "Medium", + "3": "Low", +}; + +export const IMPACT_LABELS: Record = { + "1": "High", + "2": "Medium", + "3": "Low", +}; diff --git a/web_src/src/ui/BuildingBlocksSidebar/index.tsx b/web_src/src/ui/BuildingBlocksSidebar/index.tsx index 38d42dd31..5daff1bd2 100644 --- a/web_src/src/ui/BuildingBlocksSidebar/index.tsx +++ b/web_src/src/ui/BuildingBlocksSidebar/index.tsx @@ -38,6 +38,7 @@ import sendgridIcon from "@/assets/icons/integrations/sendgrid.svg"; import prometheusIcon from "@/assets/icons/integrations/prometheus.svg"; import renderIcon from "@/assets/icons/integrations/render.svg"; import dockerIcon from "@/assets/icons/integrations/docker.svg"; +import servicenowIcon from "@/assets/icons/integrations/servicenow.svg"; export interface BuildingBlock { name: string; @@ -418,6 +419,7 @@ function CategorySection({ prometheus: prometheusIcon, render: renderIcon, dockerhub: dockerIcon, + servicenow: servicenowIcon, aws: { codeArtifact: awsIcon, cloudwatch: awsCloudwatchIcon, @@ -495,6 +497,7 @@ function CategorySection({ prometheus: prometheusIcon, render: renderIcon, dockerhub: dockerIcon, + servicenow: servicenowIcon, aws: { codeArtifact: awsCodeArtifactIcon, cloudwatch: awsCloudwatchIcon, diff --git a/web_src/src/ui/componentSidebar/integrationIcons.tsx b/web_src/src/ui/componentSidebar/integrationIcons.tsx index fbfa0360e..41b265bc1 100644 --- a/web_src/src/ui/componentSidebar/integrationIcons.tsx +++ b/web_src/src/ui/componentSidebar/integrationIcons.tsx @@ -24,6 +24,7 @@ import sendgridIcon from "@/assets/icons/integrations/sendgrid.svg"; import prometheusIcon from "@/assets/icons/integrations/prometheus.svg"; import renderIcon from "@/assets/icons/integrations/render.svg"; import dockerIcon from "@/assets/icons/integrations/docker.svg"; +import servicenowIcon from "@/assets/icons/integrations/servicenow.svg"; /** Integration type name (e.g. "github") → logo src. Used for Settings tab and header. */ export const INTEGRATION_APP_LOGO_MAP: Record = { @@ -50,6 +51,7 @@ export const INTEGRATION_APP_LOGO_MAP: Record = { prometheus: prometheusIcon, render: renderIcon, dockerhub: dockerIcon, + servicenow: servicenowIcon, }; /** Block name first part (e.g. "github") or compound (e.g. aws.lambda) → logo src for header. */ @@ -75,6 +77,7 @@ export const APP_LOGO_MAP: Record> = { prometheus: prometheusIcon, render: renderIcon, dockerhub: dockerIcon, + servicenow: servicenowIcon, aws: { cloudwatch: awsCloudwatchIcon, lambda: awsLambdaIcon,