diff --git a/docs/components/Jira.mdx b/docs/components/Jira.mdx index 449e0c4c5d..aebc9d3824 100644 --- a/docs/components/Jira.mdx +++ b/docs/components/Jira.mdx @@ -4,6 +4,12 @@ title: "Jira" Manage and react to issues in Jira +## Triggers + + + + + import { CardGrid, LinkCard } from "@astrojs/starlight/components"; ## Actions @@ -12,6 +18,112 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; +## Instructions + +Jira supports two authentication methods. Choose the one that fits your use case: + +## API Token + +Use this method for simple setups that only need issue management (no webhooks). + +1. Go to **Atlassian API Tokens** (https://id.atlassian.com/manage-profile/security/api-tokens) +2. Click **Create API token** and copy the token +3. Select **API Token** as auth type below and enter your Jira base URL, email, and token + +## OAuth 2.0 + +Use this method if you need webhook support (e.g. On Issue Created trigger). + +1. Go to **Atlassian Developer Console** (https://developer.atlassian.com/console/myapps/) +2. Click **Create** > **OAuth 2.0 integration**, name your app and agree to the terms +3. Go to **Permissions** and add these scopes under **Jira API**: + - read:jira-work - Read issues and projects + - write:jira-work - Create issues + - read:jira-user - Read user info + - manage:jira-webhook - Register and delete webhooks +4. Go to **Distribution**, click **Edit**, and set the status to **Sharing** to allow other users in your organization to connect +5. Go to **Settings** to find the **Client ID** and create a **Secret** +6. Select **OAuth 2.0** as auth type below and enter the Client ID and Client Secret +7. After creating the integration, go to the Atlassian app **Authorization** > **OAuth 2.0 (3LO)** and set the callback URL to your SuperPlane integration callback URL +8. Click **Connect** to authorize with Atlassian + + + +## On Issue Created + +The On Issue Created trigger starts a workflow execution when a new issue is created in a Jira project. + +### Use Cases + +- **Issue automation**: Automate responses to new issues +- **Notification workflows**: Send notifications when issues are created +- **Task management**: Sync issues with external task management systems +- **Triage automation**: Automatically categorize or assign new issues + +### Configuration + +- **Project**: Select the Jira project to monitor +- **Issue Types**: Optionally filter by issue type (Task, Bug, Story, etc.) + +### Event Data + +Each issue created event includes: +- **webhookEvent**: The event type (jira:issue_created) +- **issue**: Complete issue information including key, summary, fields +- **user**: User who created the issue + +### Webhook Setup + +This trigger automatically sets up a Jira webhook when configured. The webhook is managed by SuperPlane and will be cleaned up when the trigger is removed. + +### Example Data + +```json +{ + "data": { + "issue": { + "fields": { + "creator": { + "displayName": "John Doe" + }, + "issuetype": { + "id": "10001", + "name": "Task" + }, + "priority": { + "name": "Medium" + }, + "project": { + "id": "10000", + "key": "PROJ", + "name": "My Project" + }, + "reporter": { + "displayName": "John Doe" + }, + "status": { + "name": "To Do" + }, + "summary": "Implement new feature" + }, + "id": "10001", + "key": "PROJ-123", + "self": "https://your-domain.atlassian.net/rest/api/3/issue/10001" + }, + "issue_event_type_name": "issue_created", + "timestamp": 1612345678901, + "user": { + "accountId": "557058:abcd1234-5678-efgh-ijkl-mnopqrstuvwx", + "displayName": "John Doe", + "emailAddress": "john.doe@example.com" + }, + "webhookEvent": "jira:issue_created" + }, + "timestamp": "2026-01-19T12:00:00Z", + "type": "jira.issueCreated" +} +``` + ## Create Issue diff --git a/pkg/integrations/jira/client.go b/pkg/integrations/jira/client.go index 55a504e2f2..664ada1166 100644 --- a/pkg/integrations/jira/client.go +++ b/pkg/integrations/jira/client.go @@ -12,13 +12,29 @@ import ( ) type Client struct { - Email string - Token string + AuthType string + // For API Token auth + Email string + Token string + // For OAuth auth + AccessToken string + CloudID string + // Common BaseURL string http core.HTTPContext } func NewClient(httpCtx core.HTTPContext, ctx core.IntegrationContext) (*Client, error) { + authType, _ := ctx.GetConfig("authType") + + if string(authType) == AuthTypeOAuth { + return newOAuthClient(httpCtx, ctx) + } + + return newAPITokenClient(httpCtx, ctx) +} + +func newAPITokenClient(httpCtx core.HTTPContext, ctx core.IntegrationContext) (*Client, error) { baseURL, err := ctx.GetConfig("baseUrl") if err != nil { return nil, fmt.Errorf("error getting baseUrl: %v", err) @@ -35,18 +51,61 @@ func NewClient(httpCtx core.HTTPContext, ctx core.IntegrationContext) (*Client, } return &Client{ - Email: string(email), - Token: string(apiToken), - BaseURL: string(baseURL), - http: httpCtx, + AuthType: AuthTypeAPIToken, + Email: string(email), + Token: string(apiToken), + BaseURL: string(baseURL), + http: httpCtx, + }, nil +} + +func newOAuthClient(httpCtx core.HTTPContext, ctx core.IntegrationContext) (*Client, error) { + accessToken, err := findOAuthSecret(ctx, OAuthAccessToken) + if err != nil { + return nil, fmt.Errorf("error getting access token: %v", err) + } + + if accessToken == "" { + return nil, fmt.Errorf("OAuth access token not found") + } + + metadata := ctx.GetMetadata() + metadataMap, ok := metadata.(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid metadata format") + } + + cloudID, _ := metadataMap["cloudId"].(string) + if cloudID == "" { + return nil, fmt.Errorf("cloud ID not found in metadata") + } + + return &Client{ + AuthType: AuthTypeOAuth, + AccessToken: accessToken, + CloudID: cloudID, + http: httpCtx, }, nil } func (c *Client) authHeader() string { + if c.AuthType == AuthTypeOAuth { + return fmt.Sprintf("Bearer %s", c.AccessToken) + } credentials := fmt.Sprintf("%s:%s", c.Email, c.Token) return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(credentials))) } +func (c *Client) apiURL(path string) string { + var url string + if c.AuthType == AuthTypeOAuth { + url = fmt.Sprintf("https://api.atlassian.com/ex/jira/%s%s", c.CloudID, path) + } else { + url = fmt.Sprintf("%s%s", c.BaseURL, path) + } + return url +} + func (c *Client) execRequest(method, url string, body io.Reader) ([]byte, error) { req, err := http.NewRequest(method, url, body) if err != nil { @@ -84,7 +143,7 @@ type User struct { // GetCurrentUser verifies credentials by fetching the authenticated user. func (c *Client) GetCurrentUser() (*User, error) { - url := fmt.Sprintf("%s/rest/api/3/myself", c.BaseURL) + url := c.apiURL("/rest/api/3/myself") responseBody, err := c.execRequest(http.MethodGet, url, nil) if err != nil { return nil, err @@ -107,7 +166,7 @@ type Project struct { // ListProjects returns all projects accessible to the authenticated user. func (c *Client) ListProjects() ([]Project, error) { - url := fmt.Sprintf("%s/rest/api/3/project", c.BaseURL) + url := c.apiURL("/rest/api/3/project") responseBody, err := c.execRequest(http.MethodGet, url, nil) if err != nil { return nil, err @@ -131,7 +190,7 @@ type Issue struct { // GetIssue fetches a single issue by its key. func (c *Client) GetIssue(issueKey string) (*Issue, error) { - url := fmt.Sprintf("%s/rest/api/3/issue/%s", c.BaseURL, issueKey) + url := c.apiURL(fmt.Sprintf("/rest/api/3/issue/%s", issueKey)) responseBody, err := c.execRequest(http.MethodGet, url, nil) if err != nil { return nil, err @@ -215,7 +274,7 @@ type CreateIssueResponse struct { // CreateIssue creates a new issue in Jira. func (c *Client) CreateIssue(req *CreateIssueRequest) (*CreateIssueResponse, error) { - url := fmt.Sprintf("%s/rest/api/3/issue", c.BaseURL) + url := c.apiURL("/rest/api/3/issue") body, err := json.Marshal(req) if err != nil { @@ -234,3 +293,114 @@ func (c *Client) CreateIssue(req *CreateIssueRequest) (*CreateIssueResponse, err return &response, nil } + +// WebhookRegistrationRequest is the request body for registering webhooks. +type WebhookRegistrationRequest struct { + URL string `json:"url"` + Webhooks []WebhookSpec `json:"webhooks"` +} + +// WebhookSpec defines a single webhook configuration. +type WebhookSpec struct { + JQLFilter string `json:"jqlFilter"` + Events []string `json:"events"` +} + +// WebhookRegistrationResponse is the response from registering webhooks. +type WebhookRegistrationResponse struct { + WebhookRegistrationResult []WebhookRegistrationResult `json:"webhookRegistrationResult"` +} + +// WebhookRegistrationResult contains the result of a single webhook registration. +type WebhookRegistrationResult struct { + CreatedWebhookID int64 `json:"createdWebhookId"` + Errors []string `json:"errors,omitempty"` +} + +// RegisterWebhook registers a new webhook in Jira. +func (c *Client) RegisterWebhook(webhookURL, jqlFilter string, events []string) (*WebhookRegistrationResponse, error) { + url := c.apiURL("/rest/api/3/webhook") + + req := WebhookRegistrationRequest{ + URL: webhookURL, + Webhooks: []WebhookSpec{ + { + JQLFilter: jqlFilter, + Events: events, + }, + }, + } + + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("error marshaling request: %v", err) + } + + responseBody, err := c.execRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + var response WebhookRegistrationResponse + if err := json.Unmarshal(responseBody, &response); err != nil { + return nil, fmt.Errorf("error parsing webhook registration response: %v", err) + } + + return &response, nil +} + +// FailedWebhookResponse is the response from the failed webhooks endpoint. +type FailedWebhookResponse struct { + Values []FailedWebhook `json:"values"` +} + +// FailedWebhook contains information about a failed webhook delivery. +type FailedWebhook struct { + ID string `json:"id"` + Body string `json:"body"` + URL string `json:"url"` + FailureReason string `json:"failureReason"` + LatestFailureTime string `json:"latestFailureTime"` +} + +// GetFailedWebhooks returns webhooks that failed to be delivered in the last 72 hours. +func (c *Client) GetFailedWebhooks() (*FailedWebhookResponse, error) { + url := c.apiURL("/rest/api/3/webhook/failed") + responseBody, err := c.execRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + var response FailedWebhookResponse + if err := json.Unmarshal(responseBody, &response); err != nil { + return nil, fmt.Errorf("error parsing failed webhooks response: %v", err) + } + + return &response, nil +} + +// WebhookDeletionRequest is the request body for deleting webhooks. +type WebhookDeletionRequest struct { + WebhookIDs []int64 `json:"webhookIds"` +} + +// DeleteWebhook removes webhooks from Jira. +func (c *Client) DeleteWebhook(webhookIDs []int64) error { + url := c.apiURL("/rest/api/3/webhook") + + req := WebhookDeletionRequest{ + WebhookIDs: webhookIDs, + } + + body, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("error marshaling request: %v", err) + } + + _, err = c.execRequest(http.MethodDelete, url, bytes.NewReader(body)) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/integrations/jira/common.go b/pkg/integrations/jira/common.go index 6461311ece..f587620400 100644 --- a/pkg/integrations/jira/common.go +++ b/pkg/integrations/jira/common.go @@ -1,6 +1,131 @@ package jira +import ( + "fmt" + "net/http" + "slices" + "strings" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/crypto" +) + // NodeMetadata stores metadata on trigger/component nodes. type NodeMetadata struct { Project *Project `json:"project,omitempty"` } + +// ensureProjectInMetadata validates project exists and sets node metadata. +func ensureProjectInMetadata(ctx core.MetadataContext, app core.IntegrationContext, configuration any) error { + var nodeMetadata NodeMetadata + err := mapstructure.Decode(ctx.Get(), &nodeMetadata) + if err != nil { + return fmt.Errorf("failed to decode node metadata: %w", err) + } + + project := getProjectFromConfiguration(configuration) + if project == "" { + return fmt.Errorf("project is required") + } + + var appMetadata Metadata + if err := mapstructure.Decode(app.GetMetadata(), &appMetadata); err != nil { + return fmt.Errorf("failed to decode application metadata: %w", err) + } + + projectIndex := slices.IndexFunc(appMetadata.Projects, func(p Project) bool { + return p.Key == project + }) + + if projectIndex == -1 { + return fmt.Errorf("project %s is not accessible", project) + } + + if nodeMetadata.Project != nil && nodeMetadata.Project.Key == project { + return nil + } + + return ctx.Set(NodeMetadata{ + Project: &appMetadata.Projects[projectIndex], + }) +} + +// getProjectFromConfiguration extracts project from config map. +func getProjectFromConfiguration(c any) string { + configMap, ok := c.(map[string]any) + if !ok { + return "" + } + + p, ok := configMap["project"] + if !ok { + return "" + } + + project, ok := p.(string) + if !ok { + return "" + } + + return project +} + +// verifyJiraSignature verifies HMAC-SHA256 signature from Jira webhook. +// OAuth dynamic webhooks do not include a signature header, so we skip +// verification for those. The trust model relies on the webhook URL +// being known only to Jira. +func verifyJiraSignature(ctx core.WebhookRequestContext) (int, error) { + signature := ctx.Headers.Get("X-Hub-Signature") + if signature == "" { + // OAuth dynamic webhooks do not send a signature header. + return http.StatusOK, nil + } + + signature = strings.TrimPrefix(signature, "sha256=") + if signature == "" { + return http.StatusForbidden, fmt.Errorf("invalid signature format") + } + + secret, err := ctx.Webhook.GetSecret() + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("error authenticating request") + } + + err = crypto.VerifySignature(secret, ctx.Body, signature) + if err != nil { + return http.StatusForbidden, err + } + + return http.StatusOK, nil +} + +// whitelistedIssueType checks if issue type is in allowed list. +// If issueTypes is empty, all issue types are allowed. +func whitelistedIssueType(data map[string]any, issueTypes []string) bool { + if len(issueTypes) == 0 { + return true + } + + issue, ok := data["issue"].(map[string]any) + if !ok { + return false + } + + fields, ok := issue["fields"].(map[string]any) + if !ok { + return false + } + + issuetype, ok := fields["issuetype"].(map[string]any) + if !ok { + return false + } + + name, ok := issuetype["name"].(string) + if !ok { + return false + } + + return slices.Contains(issueTypes, name) +} diff --git a/pkg/integrations/jira/example.go b/pkg/integrations/jira/example.go index 8b8a9bb23c..f28a8c5f58 100644 --- a/pkg/integrations/jira/example.go +++ b/pkg/integrations/jira/example.go @@ -10,9 +10,19 @@ import ( //go:embed example_output_create_issue.json var exampleOutputCreateIssueBytes []byte +//go:embed example_data_on_issue_created.json +var exampleDataOnIssueCreatedBytes []byte + var exampleOutputCreateIssueOnce sync.Once var exampleOutputCreateIssue map[string]any +var exampleDataOnIssueCreatedOnce sync.Once +var exampleDataOnIssueCreated map[string]any + func (c *CreateIssue) ExampleOutput() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleOutputCreateIssueOnce, exampleOutputCreateIssueBytes, &exampleOutputCreateIssue) } + +func (t *OnIssueCreated) ExampleData() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleDataOnIssueCreatedOnce, exampleDataOnIssueCreatedBytes, &exampleDataOnIssueCreated) +} diff --git a/pkg/integrations/jira/example_data_on_issue_created.json b/pkg/integrations/jira/example_data_on_issue_created.json new file mode 100644 index 0000000000..ba2ea9605e --- /dev/null +++ b/pkg/integrations/jira/example_data_on_issue_created.json @@ -0,0 +1,43 @@ +{ + "data": { + "webhookEvent": "jira:issue_created", + "issue_event_type_name": "issue_created", + "timestamp": 1612345678901, + "user": { + "accountId": "557058:abcd1234-5678-efgh-ijkl-mnopqrstuvwx", + "displayName": "John Doe", + "emailAddress": "john.doe@example.com" + }, + "issue": { + "id": "10001", + "key": "PROJ-123", + "self": "https://your-domain.atlassian.net/rest/api/3/issue/10001", + "fields": { + "summary": "Implement new feature", + "issuetype": { + "name": "Task", + "id": "10001" + }, + "project": { + "key": "PROJ", + "name": "My Project", + "id": "10000" + }, + "status": { + "name": "To Do" + }, + "priority": { + "name": "Medium" + }, + "creator": { + "displayName": "John Doe" + }, + "reporter": { + "displayName": "John Doe" + } + } + } + }, + "timestamp": "2026-01-19T12:00:00Z", + "type": "jira.issueCreated" +} diff --git a/pkg/integrations/jira/jira.go b/pkg/integrations/jira/jira.go index 374a506271..eaa8ae42c2 100644 --- a/pkg/integrations/jira/jira.go +++ b/pkg/integrations/jira/jira.go @@ -2,27 +2,50 @@ package jira import ( "fmt" + "net/http" + "strings" + "time" "github.com/mitchellh/mapstructure" "github.com/superplanehq/superplane/pkg/configuration" "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/crypto" "github.com/superplanehq/superplane/pkg/registry" ) func init() { - registry.RegisterIntegration("jira", &Jira{}) + registry.RegisterIntegrationWithWebhookHandler("jira", &Jira{}, &JiraWebhookHandler{}) } type Jira struct{} type Configuration struct { + AuthType string `json:"authType"` + // API Token fields BaseURL string `json:"baseUrl"` Email string `json:"email"` APIToken string `json:"apiToken"` + // OAuth fields + ClientID *string `json:"clientId"` + ClientSecret *string `json:"clientSecret"` } type Metadata struct { Projects []Project `json:"projects"` + // OAuth fields + State string `json:"state,omitempty"` + CloudID string `json:"cloudId,omitempty"` +} + +// WebhookConfiguration represents the configuration for a Jira webhook. +type WebhookConfiguration struct { + EventType string `json:"eventType"` + Project string `json:"project"` +} + +// WebhookMetadata stores the webhook ID for cleanup. +type WebhookMetadata struct { + ID int64 `json:"id"` } func (j *Jira) Name() string { @@ -42,17 +65,60 @@ func (j *Jira) Description() string { } func (j *Jira) Instructions() string { - return "" + return `Jira supports two authentication methods. Choose the one that fits your use case: + +## API Token + +Use this method for simple setups that only need issue management (no webhooks). + +1. Go to **Atlassian API Tokens** (https://id.atlassian.com/manage-profile/security/api-tokens) +2. Click **Create API token** and copy the token +3. Select **API Token** as auth type below and enter your Jira base URL, email, and token + +## OAuth 2.0 + +Use this method if you need webhook support (e.g. On Issue Created trigger). + +1. Go to **Atlassian Developer Console** (https://developer.atlassian.com/console/myapps/) +2. Click **Create** > **OAuth 2.0 integration**, name your app and agree to the terms +3. Go to **Permissions** and add these scopes under **Jira API**: + - read:jira-work - Read issues and projects + - write:jira-work - Create issues + - read:jira-user - Read user info + - manage:jira-webhook - Register and delete webhooks +4. Go to **Distribution**, click **Edit**, and set the status to **Sharing** to allow other users in your organization to connect +5. Go to **Settings** to find the **Client ID** and create a **Secret** +6. Select **OAuth 2.0** as auth type below and enter the Client ID and Client Secret +7. After creating the integration, go to the Atlassian app **Authorization** > **OAuth 2.0 (3LO)** and set the callback URL to your SuperPlane integration callback URL +8. Click **Connect** to authorize with Atlassian` } func (j *Jira) Configuration() []configuration.Field { return []configuration.Field{ + { + Name: "authType", + Label: "Auth Type", + Type: configuration.FieldTypeSelect, + Required: true, + Default: AuthTypeAPIToken, + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "API Token", Value: AuthTypeAPIToken}, + {Label: "OAuth 2.0", Value: AuthTypeOAuth}, + }, + }, + }, + }, { Name: "baseUrl", Label: "Base URL", Type: configuration.FieldTypeString, Required: true, Description: "Jira Cloud instance URL (e.g. https://your-domain.atlassian.net)", + VisibilityConditions: []configuration.VisibilityCondition{ + {Field: "authType", Values: []string{AuthTypeAPIToken}}, + }, }, { Name: "email", @@ -60,6 +126,9 @@ func (j *Jira) Configuration() []configuration.Field { Type: configuration.FieldTypeString, Required: true, Description: "Email address for API authentication", + VisibilityConditions: []configuration.VisibilityCondition{ + {Field: "authType", Values: []string{AuthTypeAPIToken}}, + }, }, { Name: "apiToken", @@ -68,6 +137,30 @@ func (j *Jira) Configuration() []configuration.Field { Required: true, Sensitive: true, Description: "Jira API token", + VisibilityConditions: []configuration.VisibilityCondition{ + {Field: "authType", Values: []string{AuthTypeAPIToken}}, + }, + }, + { + Name: "clientId", + Label: "Client ID", + Type: configuration.FieldTypeString, + Required: true, + Description: "OAuth 2.0 Client ID from Atlassian Developer Console", + VisibilityConditions: []configuration.VisibilityCondition{ + {Field: "authType", Values: []string{AuthTypeOAuth}}, + }, + }, + { + Name: "clientSecret", + Label: "Client Secret", + Type: configuration.FieldTypeString, + Required: true, + Sensitive: true, + Description: "OAuth 2.0 Client Secret from Atlassian Developer Console", + VisibilityConditions: []configuration.VisibilityCondition{ + {Field: "authType", Values: []string{AuthTypeOAuth}}, + }, }, } } @@ -79,7 +172,9 @@ func (j *Jira) Components() []core.Component { } func (j *Jira) Triggers() []core.Trigger { - return []core.Trigger{} + return []core.Trigger{ + &OnIssueCreated{}, + } } func (j *Jira) Cleanup(ctx core.IntegrationCleanupContext) error { @@ -93,6 +188,14 @@ func (j *Jira) Sync(ctx core.SyncContext) error { return fmt.Errorf("failed to decode config: %v", err) } + if config.AuthType == AuthTypeOAuth { + return j.oauthSync(ctx, config) + } + + return j.apiTokenSync(ctx, config) +} + +func (j *Jira) apiTokenSync(ctx core.SyncContext, config Configuration) error { if config.BaseURL == "" { return fmt.Errorf("baseUrl is required") } @@ -125,14 +228,258 @@ func (j *Jira) Sync(ctx core.SyncContext) error { return nil } +func (j *Jira) oauthSync(ctx core.SyncContext, config Configuration) error { + if config.ClientID == nil || *config.ClientID == "" { + return fmt.Errorf("clientId is required") + } + + if config.ClientSecret == nil || *config.ClientSecret == "" { + return fmt.Errorf("clientSecret is required") + } + + metadata := Metadata{} + _ = mapstructure.Decode(ctx.Integration.GetMetadata(), &metadata) + + accessToken, _ := findOAuthSecret(ctx.Integration, OAuthAccessToken) + + if accessToken != "" && metadata.CloudID != "" { + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err == nil { + _, err = client.GetCurrentUser() + if err == nil { + projects, err := client.ListProjects() + if err == nil { + metadata.Projects = projects + ctx.Integration.SetMetadata(metadata) + ctx.Integration.Ready() + return ctx.Integration.ScheduleResync(30 * time.Minute) + } + ctx.Logger.Errorf("oauthSync: failed to list projects: %v", err) + } else { + ctx.Logger.Errorf("oauthSync: failed to get current user: %v", err) + } + } else { + ctx.Logger.Errorf("oauthSync: failed to create client: %v", err) + } + + // Tokens invalid, try to refresh + refreshToken, _ := findOAuthSecret(ctx.Integration, OAuthRefreshToken) + if refreshToken != "" { + clientSecret, err := ctx.Integration.GetConfig("clientSecret") + if err == nil { + tokenResponse, err := refreshOAuthToken(ctx.HTTP, *config.ClientID, string(clientSecret), refreshToken) + if err == nil { + // Store new tokens + _ = ctx.Integration.SetSecret(OAuthAccessToken, []byte(tokenResponse.AccessToken)) + if tokenResponse.RefreshToken != "" { + _ = ctx.Integration.SetSecret(OAuthRefreshToken, []byte(tokenResponse.RefreshToken)) + } + + // Retry with new tokens + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err == nil { + projects, err := client.ListProjects() + if err == nil { + metadata.Projects = projects + ctx.Integration.SetMetadata(metadata) + ctx.Integration.Ready() + return ctx.Integration.ScheduleResync(tokenResponse.GetExpiration()) + } + } + } + } + } + } + + // No valid tokens, need to authorize. + // Reuse existing state if an OAuth flow is already in progress to avoid + // invalidating a callback the user has not completed yet. + state := metadata.State + if state == "" { + var err error + state, err = crypto.Base64String(32) + if err != nil { + return fmt.Errorf("failed to generate state: %v", err) + } + + metadata.State = state + ctx.Integration.SetMetadata(metadata) + } + + redirectURI := fmt.Sprintf("%s/api/v1/integrations/%s/callback", ctx.WebhooksBaseURL, ctx.Integration.ID().String()) + authURL := buildAuthorizationURL(*config.ClientID, redirectURI, state) + + ctx.Integration.NewBrowserAction(core.BrowserAction{ + Description: "Authorize with Atlassian", + URL: authURL, + Method: "GET", + }) + + return nil +} + func (j *Jira) HandleRequest(ctx core.HTTPRequestContext) { - // no-op + if strings.HasSuffix(ctx.Request.URL.Path, "/callback") { + j.handleOAuthCallback(ctx) + return + } + + http.NotFound(ctx.Response, ctx.Request) +} + +func (j *Jira) handleOAuthCallback(ctx core.HTTPRequestContext) { + code := ctx.Request.URL.Query().Get("code") + state := ctx.Request.URL.Query().Get("state") + + if code == "" || state == "" { + ctx.Logger.Errorf("missing code or state") + http.Error(ctx.Response, "missing code or state", http.StatusBadRequest) + return + } + + // Validate state + metadata := Metadata{} + if err := mapstructure.Decode(ctx.Integration.GetMetadata(), &metadata); err != nil { + ctx.Logger.Errorf("failed to decode metadata: %v", err) + http.Error(ctx.Response, "internal server error", http.StatusInternalServerError) + return + } + + if state != metadata.State { + ctx.Logger.Errorf("invalid state") + http.Error(ctx.Response, "invalid state", http.StatusBadRequest) + return + } + + // Get client credentials + clientID, err := ctx.Integration.GetConfig("clientId") + if err != nil { + ctx.Logger.Errorf("failed to get clientId: %v", err) + http.Error(ctx.Response, "internal server error", http.StatusInternalServerError) + return + } + + clientSecret, err := ctx.Integration.GetConfig("clientSecret") + if err != nil { + ctx.Logger.Errorf("failed to get clientSecret: %v", err) + http.Error(ctx.Response, "internal server error", http.StatusInternalServerError) + return + } + + redirectURI := fmt.Sprintf("%s/api/v1/integrations/%s/callback", ctx.WebhooksBaseURL, ctx.Integration.ID().String()) + + // Exchange code for tokens + tokenResponse, err := exchangeCodeForTokens(ctx.HTTP, string(clientID), string(clientSecret), code, redirectURI) + if err != nil { + ctx.Logger.Errorf("failed to exchange code for tokens: %v", err) + http.Error(ctx.Response, "failed to exchange code for tokens", http.StatusInternalServerError) + return + } + + // Get accessible resources to find cloud ID + resources, err := getAccessibleResources(ctx.HTTP, tokenResponse.AccessToken) + if err != nil { + ctx.Logger.Errorf("failed to get accessible resources: %v", err) + http.Error(ctx.Response, "failed to get accessible resources", http.StatusInternalServerError) + return + } + + if len(resources) == 0 { + ctx.Logger.Errorf("no accessible Jira resources found") + http.Error(ctx.Response, "no accessible Jira resources found", http.StatusBadRequest) + return + } + + if len(resources) > 1 { + names := make([]string, len(resources)) + for i, r := range resources { + names[i] = fmt.Sprintf("%s (%s)", r.Name, r.URL) + } + ctx.Logger.Errorf("multiple Jira sites found: %v", names) + http.Error(ctx.Response, + fmt.Sprintf("multiple Jira Cloud sites found: %v -- restrict the OAuth app to a single site", names), + http.StatusBadRequest, + ) + return + } + + cloudID := resources[0].ID + + // Store tokens as secrets + if err := ctx.Integration.SetSecret(OAuthAccessToken, []byte(tokenResponse.AccessToken)); err != nil { + ctx.Logger.Errorf("failed to store access token: %v", err) + http.Error(ctx.Response, "internal server error", http.StatusInternalServerError) + return + } + + if tokenResponse.RefreshToken != "" { + if err := ctx.Integration.SetSecret(OAuthRefreshToken, []byte(tokenResponse.RefreshToken)); err != nil { + ctx.Logger.Errorf("failed to store refresh token: %v", err) + http.Error(ctx.Response, "internal server error", http.StatusInternalServerError) + return + } + } + + // Update metadata with cloud ID and clear state + metadata.CloudID = cloudID + metadata.State = "" + ctx.Integration.SetMetadata(metadata) + + // Load projects and mark integration as ready so the user does not + // have to wait for the next sync cycle. + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + ctx.Logger.Errorf("failed to create client after OAuth: %v", err) + } else { + projects, err := client.ListProjects() + if err != nil { + ctx.Logger.Errorf("failed to list projects after OAuth: %v", err) + } else { + metadata.Projects = projects + ctx.Integration.SetMetadata(metadata) + } + } + + ctx.Integration.Ready() + _ = ctx.Integration.ScheduleResync(tokenResponse.GetExpiration()) + + // Remove browser action + ctx.Integration.RemoveBrowserAction() + + // Redirect to integration settings page + http.Redirect( + ctx.Response, + ctx.Request, + fmt.Sprintf("%s/%s/settings/integrations/%s", ctx.BaseURL, ctx.OrganizationID, ctx.Integration.ID().String()), + http.StatusSeeOther, + ) } func (j *Jira) Actions() []core.Action { - return []core.Action{} + return []core.Action{ + { + Name: "getFailedWebhooks", + Description: "Get webhooks that failed to be delivered in the last 72 hours", + UserAccessible: true, + }, + } } func (j *Jira) HandleAction(ctx core.IntegrationActionContext) error { - return nil + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("error creating client: %v", err) + } + + switch ctx.Name { + case "getFailedWebhooks": + _, err := client.GetFailedWebhooks() + if err != nil { + return fmt.Errorf("error getting failed webhooks: %v", err) + } + return nil + + default: + return fmt.Errorf("unknown action: %s", ctx.Name) + } } diff --git a/pkg/integrations/jira/jira_test.go b/pkg/integrations/jira/jira_test.go index 21e5aa49d7..db4bcb7102 100644 --- a/pkg/integrations/jira/jira_test.go +++ b/pkg/integrations/jira/jira_test.go @@ -126,3 +126,201 @@ func Test__Jira__Sync(t *testing.T) { assert.NotEqual(t, "ready", appCtx.State) }) } + +func Test__Jira__CompareWebhookConfig(t *testing.T) { + h := &JiraWebhookHandler{} + + testCases := []struct { + name string + configA any + configB any + expectEqual bool + expectError bool + }{ + { + name: "identical configurations", + configA: WebhookConfiguration{ + EventType: "jira:issue_created", + Project: "TEST", + }, + configB: WebhookConfiguration{ + EventType: "jira:issue_created", + Project: "TEST", + }, + expectEqual: true, + expectError: false, + }, + { + name: "different event types", + configA: WebhookConfiguration{ + EventType: "jira:issue_created", + Project: "TEST", + }, + configB: WebhookConfiguration{ + EventType: "jira:issue_updated", + Project: "TEST", + }, + expectEqual: false, + expectError: false, + }, + { + name: "different projects", + configA: WebhookConfiguration{ + EventType: "jira:issue_created", + Project: "TEST", + }, + configB: WebhookConfiguration{ + EventType: "jira:issue_created", + Project: "OTHER", + }, + expectEqual: false, + expectError: false, + }, + { + name: "both fields different", + configA: WebhookConfiguration{ + EventType: "jira:issue_created", + Project: "TEST", + }, + configB: WebhookConfiguration{ + EventType: "jira:issue_updated", + Project: "OTHER", + }, + expectEqual: false, + expectError: false, + }, + { + name: "comparing map representations", + configA: map[string]any{ + "eventType": "jira:issue_created", + "project": "TEST", + }, + configB: map[string]any{ + "eventType": "jira:issue_created", + "project": "TEST", + }, + expectEqual: true, + expectError: false, + }, + { + name: "invalid first configuration", + configA: "invalid", + configB: WebhookConfiguration{ + EventType: "jira:issue_created", + Project: "TEST", + }, + expectEqual: false, + expectError: true, + }, + { + name: "invalid second configuration", + configA: WebhookConfiguration{ + EventType: "jira:issue_created", + Project: "TEST", + }, + configB: "invalid", + expectEqual: false, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + equal, err := h.CompareConfig(tc.configA, tc.configB) + + if tc.expectError { + assert.Error(t, err) + } else { + require.NoError(t, err) + } + + assert.Equal(t, tc.expectEqual, equal) + }) + } +} + +func Test__Jira__HandleAction(t *testing.T) { + j := &Jira{} + + t.Run("getFailedWebhooks -> success", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"values":[{"id":"1","url":"http://example.com","failureReason":"timeout","latestFailureTime":"2024-01-01T00:00:00Z"}]}`)), + }, + }, + } + + appCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "baseUrl": "https://test.atlassian.net", + "email": "test@example.com", + "apiToken": "test-token", + }, + } + + err := j.HandleAction(core.IntegrationActionContext{ + Name: "getFailedWebhooks", + HTTP: httpContext, + Integration: appCtx, + }) + + require.NoError(t, err) + require.Len(t, httpContext.Requests, 1) + assert.Contains(t, httpContext.Requests[0].URL.String(), "/rest/api/3/webhook/failed") + }) + + t.Run("unknown action -> error", func(t *testing.T) { + appCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "baseUrl": "https://test.atlassian.net", + "email": "test@example.com", + "apiToken": "test-token", + }, + } + + err := j.HandleAction(core.IntegrationActionContext{ + Name: "unknownAction", + HTTP: &contexts.HTTPContext{}, + Integration: appCtx, + }) + + require.ErrorContains(t, err, "unknown action: unknownAction") + }) +} + +func Test__Jira__Actions(t *testing.T) { + j := &Jira{} + actions := j.Actions() + + require.Len(t, actions, 1) + + assert.Equal(t, "getFailedWebhooks", actions[0].Name) + assert.True(t, actions[0].UserAccessible) +} + +func Test__Jira__IntegrationInfo(t *testing.T) { + j := &Jira{} + + assert.Equal(t, "jira", j.Name()) + assert.Equal(t, "Jira", j.Label()) + assert.Equal(t, "jira", j.Icon()) + assert.NotEmpty(t, j.Description()) +} + +func Test__Jira__Components(t *testing.T) { + j := &Jira{} + components := j.Components() + + require.Len(t, components, 1) + assert.Equal(t, "jira.createIssue", components[0].Name()) +} + +func Test__Jira__Triggers(t *testing.T) { + j := &Jira{} + triggers := j.Triggers() + + require.Len(t, triggers, 1) + assert.Equal(t, "jira.onIssueCreated", triggers[0].Name()) +} diff --git a/pkg/integrations/jira/oauth.go b/pkg/integrations/jira/oauth.go new file mode 100644 index 0000000000..8f5d7fb799 --- /dev/null +++ b/pkg/integrations/jira/oauth.go @@ -0,0 +1,206 @@ +package jira + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/superplanehq/superplane/pkg/core" +) + +const ( + AuthTypeAPIToken = "apiToken" + AuthTypeOAuth = "oauth" + OAuthAccessToken = "accessToken" + OAuthRefreshToken = "refreshToken" + + atlassianAuthURL = "https://auth.atlassian.com/authorize" + atlassianTokenURL = "https://auth.atlassian.com/oauth/token" + atlassianResourcesURL = "https://api.atlassian.com/oauth/token/accessible-resources" + + // OAuth scopes required for Jira operations and webhook management + oauthScopes = "read:jira-work write:jira-work read:jira-user manage:jira-webhook offline_access" +) + +// OAuthTokenResponse represents the response from Atlassian OAuth token endpoint. +type OAuthTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` + TokenType string `json:"token_type"` +} + +// GetExpiration returns the duration until the token expires. +// Returns half the expiration time to allow for early refresh. +func (r *OAuthTokenResponse) GetExpiration() time.Duration { + if r.ExpiresIn > 0 { + return time.Duration(r.ExpiresIn/2) * time.Second + } + return time.Hour +} + +// AccessibleResource represents a Jira Cloud instance the user has access to. +type AccessibleResource struct { + ID string `json:"id"` + URL string `json:"url"` + Name string `json:"name"` + Scopes []string `json:"scopes"` + AvatarURL string `json:"avatarUrl"` +} + +// buildAuthorizationURL generates the Atlassian authorization URL for OAuth 2.0 (3LO). +func buildAuthorizationURL(clientID, redirectURI, state string) string { + params := url.Values{} + params.Set("audience", "api.atlassian.com") + params.Set("client_id", clientID) + params.Set("scope", oauthScopes) + params.Set("redirect_uri", redirectURI) + params.Set("state", state) + params.Set("response_type", "code") + params.Set("prompt", "consent") + + return fmt.Sprintf("%s?%s", atlassianAuthURL, params.Encode()) +} + +// exchangeCodeForTokens exchanges an authorization code for access and refresh tokens. +func exchangeCodeForTokens(httpCtx core.HTTPContext, clientID, clientSecret, code, redirectURI string) (*OAuthTokenResponse, error) { + requestBody := map[string]string{ + "grant_type": "authorization_code", + "client_id": clientID, + "client_secret": clientSecret, + "code": code, + "redirect_uri": redirectURI, + } + + body, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("error marshaling request: %v", err) + } + + req, err := http.NewRequest(http.MethodPost, atlassianTokenURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("error creating request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := httpCtx.Do(req) + if err != nil { + return nil, fmt.Errorf("error executing request: %v", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response: %v", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var tokenResponse OAuthTokenResponse + if err := json.Unmarshal(respBody, &tokenResponse); err != nil { + return nil, fmt.Errorf("error parsing token response: %v", err) + } + + return &tokenResponse, nil +} + +// refreshOAuthToken refreshes the access token using a refresh token. +func refreshOAuthToken(httpCtx core.HTTPContext, clientID, clientSecret, refreshToken string) (*OAuthTokenResponse, error) { + requestBody := map[string]string{ + "grant_type": "refresh_token", + "client_id": clientID, + "client_secret": clientSecret, + "refresh_token": refreshToken, + } + + body, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("error marshaling request: %v", err) + } + + req, err := http.NewRequest(http.MethodPost, atlassianTokenURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("error creating request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := httpCtx.Do(req) + if err != nil { + return nil, fmt.Errorf("error executing request: %v", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response: %v", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token refresh failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var tokenResponse OAuthTokenResponse + if err := json.Unmarshal(respBody, &tokenResponse); err != nil { + return nil, fmt.Errorf("error parsing token response: %v", err) + } + + return &tokenResponse, nil +} + +// getAccessibleResources fetches the Jira Cloud instances the user has access to. +func getAccessibleResources(httpCtx core.HTTPContext, accessToken string) ([]AccessibleResource, error) { + req, err := http.NewRequest(http.MethodGet, atlassianResourcesURL, nil) + if err != nil { + return nil, fmt.Errorf("error creating request: %v", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + req.Header.Set("Accept", "application/json") + + resp, err := httpCtx.Do(req) + if err != nil { + return nil, fmt.Errorf("error executing request: %v", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response: %v", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get accessible resources with status %d: %s", resp.StatusCode, string(respBody)) + } + + var resources []AccessibleResource + if err := json.Unmarshal(respBody, &resources); err != nil { + return nil, fmt.Errorf("error parsing resources response: %v", err) + } + + return resources, nil +} + +// findOAuthSecret finds a secret by name from the integration secrets. +func findOAuthSecret(ctx core.IntegrationContext, name string) (string, error) { + secrets, err := ctx.GetSecrets() + if err != nil { + return "", fmt.Errorf("error getting secrets: %v", err) + } + + for _, secret := range secrets { + if secret.Name == name { + return string(secret.Value), nil + } + } + + return "", nil +} diff --git a/pkg/integrations/jira/on_issue_created.go b/pkg/integrations/jira/on_issue_created.go new file mode 100644 index 0000000000..3bde77d240 --- /dev/null +++ b/pkg/integrations/jira/on_issue_created.go @@ -0,0 +1,186 @@ +package jira + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type OnIssueCreated struct{} + +type OnIssueCreatedConfiguration struct { + Project string `json:"project" mapstructure:"project"` + IssueTypes []string `json:"issueTypes" mapstructure:"issueTypes"` +} + +func (t *OnIssueCreated) Name() string { + return "jira.onIssueCreated" +} + +func (t *OnIssueCreated) Label() string { + return "On Issue Created" +} + +func (t *OnIssueCreated) Description() string { + return "Listen for new issues created in Jira" +} + +func (t *OnIssueCreated) Documentation() string { + return `The On Issue Created trigger starts a workflow execution when a new issue is created in a Jira project. + +## Use Cases + +- **Issue automation**: Automate responses to new issues +- **Notification workflows**: Send notifications when issues are created +- **Task management**: Sync issues with external task management systems +- **Triage automation**: Automatically categorize or assign new issues + +## Configuration + +- **Project**: Select the Jira project to monitor +- **Issue Types**: Optionally filter by issue type (Task, Bug, Story, etc.) + +## Event Data + +Each issue created event includes: +- **webhookEvent**: The event type (jira:issue_created) +- **issue**: Complete issue information including key, summary, fields +- **user**: User who created the issue + +## Webhook Setup + +This trigger automatically sets up a Jira webhook when configured. The webhook is managed by SuperPlane and will be cleaned up when the trigger is removed.` +} + +func (t *OnIssueCreated) Icon() string { + return "jira" +} + +func (t *OnIssueCreated) Color() string { + return "blue" +} + +func (t *OnIssueCreated) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "project", + Label: "Project", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "project", + }, + }, + }, + { + Name: "issueTypes", + Label: "Issue Types", + Type: configuration.FieldTypeMultiSelect, + Required: false, + TypeOptions: &configuration.TypeOptions{ + MultiSelect: &configuration.MultiSelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Task", Value: "Task"}, + {Label: "Bug", Value: "Bug"}, + {Label: "Story", Value: "Story"}, + {Label: "Epic", Value: "Epic"}, + {Label: "Sub-task", Value: "Sub-task"}, + }, + }, + }, + }, + } +} + +func (t *OnIssueCreated) Setup(ctx core.TriggerContext) error { + authType, err := ctx.Integration.GetConfig("authType") + if err != nil { + return fmt.Errorf("failed to get authType: %w", err) + } + + if string(authType) != AuthTypeOAuth { + return fmt.Errorf("webhook triggers require OAuth authentication; API Token integrations do not support webhooks") + } + + err = ensureProjectInMetadata( + ctx.Metadata, + ctx.Integration, + ctx.Configuration, + ) + + if err != nil { + return err + } + + var config OnIssueCreatedConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + return ctx.Integration.RequestWebhook(WebhookConfiguration{ + EventType: "jira:issue_created", + Project: config.Project, + }) +} + +func (t *OnIssueCreated) Actions() []core.Action { + return []core.Action{} +} + +func (t *OnIssueCreated) HandleAction(ctx core.TriggerActionContext) (map[string]any, error) { + return nil, nil +} + +func (t *OnIssueCreated) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + config := OnIssueCreatedConfiguration{} + err := mapstructure.Decode(ctx.Configuration, &config) + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("failed to decode configuration: %w", err) + } + + eventType := ctx.Headers.Get("X-Atlassian-Webhook-Identifier") + webhookEvent := "" + + data := map[string]any{} + err = json.Unmarshal(ctx.Body, &data) + if err != nil { + return http.StatusBadRequest, fmt.Errorf("error parsing request body: %v", err) + } + + if we, ok := data["webhookEvent"].(string); ok { + webhookEvent = we + } + + if eventType == "" && webhookEvent == "" { + return http.StatusBadRequest, fmt.Errorf("missing webhook event identifier") + } + + if webhookEvent != "jira:issue_created" { + return http.StatusOK, nil + } + + code, err := verifyJiraSignature(ctx) + if err != nil { + return code, err + } + + if !whitelistedIssueType(data, config.IssueTypes) { + return http.StatusOK, nil + } + + err = ctx.Events.Emit("jira.issueCreated", data) + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("error emitting event: %v", err) + } + + return http.StatusOK, nil +} + +func (t *OnIssueCreated) Cleanup(ctx core.TriggerContext) error { + return nil +} diff --git a/pkg/integrations/jira/on_issue_created_test.go b/pkg/integrations/jira/on_issue_created_test.go new file mode 100644 index 0000000000..d2771bbb4e --- /dev/null +++ b/pkg/integrations/jira/on_issue_created_test.go @@ -0,0 +1,294 @@ +package jira + +import ( + "crypto/hmac" + "crypto/sha256" + "fmt" + "net/http" + "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__OnIssueCreated__HandleWebhook(t *testing.T) { + trigger := &OnIssueCreated{} + + t.Run("missing signature -> 200 OK, event emitted (OAuth webhook)", func(t *testing.T) { + headers := http.Header{} + body := []byte(`{"webhookEvent":"jira:issue_created"}`) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: headers, + Configuration: map[string]any{ + "project": "TEST", + }, + Webhook: &contexts.WebhookContext{Secret: "test-secret"}, + Events: eventContext, + }) + + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Equal(t, 1, eventContext.Count()) + }) + + t.Run("invalid signature -> 403", func(t *testing.T) { + headers := http.Header{} + headers.Set("X-Hub-Signature", "sha256=invalid") + body := []byte(`{"webhookEvent":"jira:issue_created"}`) + + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: headers, + Configuration: map[string]any{ + "project": "TEST", + }, + Webhook: &contexts.WebhookContext{Secret: "test-secret"}, + Events: &contexts.EventContext{}, + }) + + assert.Equal(t, http.StatusForbidden, code) + assert.ErrorContains(t, err, "invalid signature") + }) + + t.Run("wrong event type -> 200 OK, no event emitted", func(t *testing.T) { + body := []byte(`{"webhookEvent":"jira:issue_updated"}`) + secret := "test-secret" + h := hmac.New(sha256.New, []byte(secret)) + h.Write(body) + signature := fmt.Sprintf("%x", h.Sum(nil)) + + headers := http.Header{} + headers.Set("X-Hub-Signature", "sha256="+signature) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: headers, + Configuration: map[string]any{ + "project": "TEST", + }, + 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("issue type not in filter -> 200 OK, no event emitted", func(t *testing.T) { + body := []byte(`{ + "webhookEvent": "jira:issue_created", + "issue": { + "fields": { + "issuetype": {"name": "Epic"} + } + } + }`) + secret := "test-secret" + h := hmac.New(sha256.New, []byte(secret)) + h.Write(body) + signature := fmt.Sprintf("%x", h.Sum(nil)) + + headers := http.Header{} + headers.Set("X-Hub-Signature", "sha256="+signature) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: headers, + Configuration: map[string]any{ + "project": "TEST", + "issueTypes": []string{"Task", "Bug"}, + }, + 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 webhook, issue type matches -> event emitted", func(t *testing.T) { + body := []byte(`{ + "webhookEvent": "jira:issue_created", + "issue": { + "id": "10001", + "key": "TEST-123", + "fields": { + "issuetype": {"name": "Task"}, + "summary": "Test issue" + } + } + }`) + secret := "test-secret" + h := hmac.New(sha256.New, []byte(secret)) + h.Write(body) + signature := fmt.Sprintf("%x", h.Sum(nil)) + + headers := http.Header{} + headers.Set("X-Hub-Signature", "sha256="+signature) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: headers, + Configuration: map[string]any{ + "project": "TEST", + "issueTypes": []string{"Task", "Bug"}, + }, + Webhook: &contexts.WebhookContext{Secret: secret}, + Events: eventContext, + }) + + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Equal(t, 1, eventContext.Count()) + }) + + t.Run("valid webhook, no issue type filter -> event emitted for all types", func(t *testing.T) { + body := []byte(`{ + "webhookEvent": "jira:issue_created", + "issue": { + "id": "10001", + "key": "TEST-123", + "fields": { + "issuetype": {"name": "Epic"}, + "summary": "Test epic" + } + } + }`) + secret := "test-secret" + h := hmac.New(sha256.New, []byte(secret)) + h.Write(body) + signature := fmt.Sprintf("%x", h.Sum(nil)) + + headers := http.Header{} + headers.Set("X-Hub-Signature", "sha256="+signature) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: headers, + Configuration: map[string]any{ + "project": "TEST", + }, + Webhook: &contexts.WebhookContext{Secret: secret}, + Events: eventContext, + }) + + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Equal(t, 1, eventContext.Count()) + }) +} + +func Test__OnIssueCreated__Setup(t *testing.T) { + testProject := Project{ID: "10000", Key: "TEST", Name: "Test Project"} + trigger := OnIssueCreated{} + + t.Run("api token auth -> error", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{"authType": AuthTypeAPIToken}, + } + err := trigger.Setup(core.TriggerContext{ + Integration: integrationCtx, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"project": "TEST"}, + }) + + require.ErrorContains(t, err, "webhook triggers require OAuth") + }) + + t.Run("missing project -> error", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{"authType": AuthTypeOAuth}, + } + err := trigger.Setup(core.TriggerContext{ + Integration: integrationCtx, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"project": ""}, + }) + + require.ErrorContains(t, err, "project is required") + }) + + t.Run("project not found in metadata -> error", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{"authType": AuthTypeOAuth}, + Metadata: Metadata{ + Projects: []Project{testProject}, + }, + } + err := trigger.Setup(core.TriggerContext{ + Integration: integrationCtx, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"project": "OTHER"}, + }) + + require.ErrorContains(t, err, "project OTHER is not accessible") + }) + + t.Run("valid setup -> metadata set, webhook requested", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{"authType": AuthTypeOAuth}, + Metadata: Metadata{ + Projects: []Project{testProject}, + }, + } + + nodeMetadataCtx := contexts.MetadataContext{} + require.NoError(t, trigger.Setup(core.TriggerContext{ + Integration: integrationCtx, + Metadata: &nodeMetadataCtx, + Configuration: map[string]any{"project": "TEST"}, + })) + + require.Equal(t, nodeMetadataCtx.Get(), NodeMetadata{Project: &testProject}) + require.Len(t, integrationCtx.WebhookRequests, 1) + + webhookRequest := integrationCtx.WebhookRequests[0].(WebhookConfiguration) + assert.Equal(t, "jira:issue_created", webhookRequest.EventType) + assert.Equal(t, "TEST", webhookRequest.Project) + }) +} + +func Test__OnIssueCreated__TriggerInfo(t *testing.T) { + trigger := OnIssueCreated{} + + assert.Equal(t, "jira.onIssueCreated", trigger.Name()) + assert.Equal(t, "On Issue Created", trigger.Label()) + assert.Equal(t, "Listen for new issues created in Jira", trigger.Description()) + assert.Equal(t, "jira", trigger.Icon()) + assert.Equal(t, "blue", trigger.Color()) + assert.NotEmpty(t, trigger.Documentation()) +} + +func Test__OnIssueCreated__Configuration(t *testing.T) { + trigger := OnIssueCreated{} + + config := trigger.Configuration() + assert.Len(t, config, 2) + + fieldNames := make([]string, len(config)) + for i, f := range config { + fieldNames[i] = f.Name + } + + assert.Contains(t, fieldNames, "project") + assert.Contains(t, fieldNames, "issueTypes") + + for _, f := range config { + if f.Name == "project" { + assert.True(t, f.Required, "project should be required") + } else if f.Name == "issueTypes" { + assert.False(t, f.Required, "issueTypes should be optional") + } + } +} diff --git a/pkg/integrations/jira/webhook_handler.go b/pkg/integrations/jira/webhook_handler.go new file mode 100644 index 0000000000..98473dbc82 --- /dev/null +++ b/pkg/integrations/jira/webhook_handler.go @@ -0,0 +1,78 @@ +package jira + +import ( + "fmt" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/core" +) + +type JiraWebhookHandler struct{} + +func (h *JiraWebhookHandler) Merge(current, requested any) (any, bool, error) { + return current, false, nil +} + +func (h *JiraWebhookHandler) CompareConfig(a, b any) (bool, error) { + var configA, configB WebhookConfiguration + + if err := mapstructure.Decode(a, &configA); err != nil { + return false, fmt.Errorf("failed to decode config a: %w", err) + } + + if err := mapstructure.Decode(b, &configB); err != nil { + return false, fmt.Errorf("failed to decode config b: %w", err) + } + + return configA.EventType == configB.EventType && configA.Project == configB.Project, nil +} + +func (h *JiraWebhookHandler) Setup(ctx core.WebhookHandlerContext) (any, error) { + var config WebhookConfiguration + if err := mapstructure.Decode(ctx.Webhook.GetConfiguration(), &config); err != nil { + return nil, fmt.Errorf("failed to decode configuration: %w", err) + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, fmt.Errorf("error creating client: %v", err) + } + + jqlFilter := fmt.Sprintf("project = %q", config.Project) + events := []string{config.EventType} + webhookURL := ctx.Webhook.GetURL() + + response, err := client.RegisterWebhook(webhookURL, jqlFilter, events) + if err != nil { + return nil, fmt.Errorf("error registering webhook: %v", err) + } + + if len(response.WebhookRegistrationResult) == 0 { + return nil, fmt.Errorf("no webhook registration result returned") + } + + result := response.WebhookRegistrationResult[0] + if len(result.Errors) > 0 { + return nil, fmt.Errorf("webhook registration failed: %v", result.Errors) + } + + return WebhookMetadata{ID: result.CreatedWebhookID}, nil +} + +func (h *JiraWebhookHandler) Cleanup(ctx core.WebhookHandlerContext) error { + var metadata WebhookMetadata + if err := mapstructure.Decode(ctx.Webhook.GetMetadata(), &metadata); err != nil { + return fmt.Errorf("failed to decode metadata: %w", err) + } + + if metadata.ID == 0 { + return nil + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("error creating client: %v", err) + } + + return client.DeleteWebhook([]int64{metadata.ID}) +} diff --git a/pkg/public/server.go b/pkg/public/server.go index 00ba9d9e3a..82735a8a18 100644 --- a/pkg/public/server.go +++ b/pkg/public/server.go @@ -414,6 +414,8 @@ func (s *Server) InitRouter(additionalMiddlewares ...mux.MiddlewareFunc) { // // Webhook endpoints for triggers // + // No Content-Type header constraint - some providers (e.g. Jira) + // send "application/json; charset=utf-8" which won't match an exact check. publicRoute. HandleFunc(s.BasePath+"/webhooks/{webhookID}", s.HandleWebhook). Methods("POST") diff --git a/web_src/src/pages/organization/settings/IntegrationDetails.tsx b/web_src/src/pages/organization/settings/IntegrationDetails.tsx index 3d99570788..7bd5e1ef4d 100644 --- a/web_src/src/pages/organization/settings/IntegrationDetails.tsx +++ b/web_src/src/pages/organization/settings/IntegrationDetails.tsx @@ -29,7 +29,6 @@ export function IntegrationDetails({ organizationId }: IntegrationDetailsProps) const navigate = useNavigate(); const { integrationId } = useParams<{ integrationId: string }>(); const { canAct, isLoading: permissionsLoading } = usePermissions(); - const [configValues, setConfigValues] = useState>({}); const [integrationName, setIntegrationName] = useState(""); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const canUpdateIntegrations = canAct("integrations", "update"); @@ -45,17 +44,22 @@ export function IntegrationDetails({ organizationId }: IntegrationDetailsProps) const updateMutation = useUpdateIntegration(organizationId, integrationId || ""); const deleteMutation = useDeleteIntegration(organizationId, integrationId || ""); - // Initialize config values when installation loads + // Track if user has made changes to config + const [configOverrides, setConfigOverrides] = useState | null>(null); + + // Reset overrides when integration changes (e.g., navigating to different integration) useEffect(() => { - if (integration?.spec?.configuration) { - setConfigValues(integration.spec.configuration); - } - }, [integration]); + setConfigOverrides(null); + }, [integrationId]); useEffect(() => { setIntegrationName(integration?.metadata?.name || integration?.spec?.integrationName || ""); }, [integration?.metadata?.name, integration?.spec?.integrationName]); + // Use saved config as base, with user overrides on top + const configValues = configOverrides ?? integration?.spec?.configuration ?? {}; + const setConfigValues = (newValues: Record) => setConfigOverrides(newValues); + // Group usedIn nodes by workflow const workflowGroups = useMemo(() => { if (!integration?.status?.usedIn) return []; diff --git a/web_src/src/pages/workflowv2/mappers/index.ts b/web_src/src/pages/workflowv2/mappers/index.ts index 516017ead9..63dcd1c38e 100644 --- a/web_src/src/pages/workflowv2/mappers/index.ts +++ b/web_src/src/pages/workflowv2/mappers/index.ts @@ -99,6 +99,10 @@ import { eventStateRegistry as openaiEventStateRegistry, } from "./openai/index"; import { + componentMappers as jiraComponentMappers, + triggerRenderers as jiraTriggerRenderers, + eventStateRegistry as jiraEventStateRegistry, +} from "./jira/index"; componentMappers as grafanaComponentMappers, customFieldRenderers as grafanaCustomFieldRenderers, triggerRenderers as grafanaTriggerRenderers, @@ -185,6 +189,7 @@ const appMappers: Record> = { aws: awsComponentMappers, discord: discordComponentMappers, openai: openaiComponentMappers, + jira: jiraComponentMappers, circleci: circleCIComponentMappers, claude: claudeComponentMappers, prometheus: prometheusComponentMappers, @@ -211,6 +216,7 @@ const appTriggerRenderers: Record> = { aws: awsTriggerRenderers, discord: discordTriggerRenderers, openai: openaiTriggerRenderers, + jira: jiraTriggerRenderers, circleci: circleCITriggerRenderers, claude: claudeTriggerRenderers, grafana: grafanaTriggerRenderers, @@ -240,6 +246,7 @@ const appEventStateRegistries: Record claude: claudeEventStateRegistry, statuspage: statuspageEventStateRegistry, aws: awsEventStateRegistry, + jira: jiraEventStateRegistry, grafana: grafanaEventStateRegistry, prometheus: prometheusEventStateRegistry, cursor: cursorEventStateRegistry, diff --git a/web_src/src/pages/workflowv2/mappers/jira/base.ts b/web_src/src/pages/workflowv2/mappers/jira/base.ts new file mode 100644 index 0000000000..4c8af20905 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/jira/base.ts @@ -0,0 +1,68 @@ +import { ComponentBaseProps, EventSection } from "@/ui/componentBase"; +import { getColorClass, getBackgroundColorClass } from "@/utils/colors"; +import { getState, getStateMap, getTriggerRenderer } from ".."; +import jiraIcon from "@/assets/icons/integrations/jira.svg"; +import { + ComponentBaseMapper, + ComponentBaseContext, + SubtitleContext, + ExecutionDetailsContext, + OutputPayload, + ExecutionInfo, + NodeInfo, +} from "../types"; +import { formatTimeAgo } from "@/utils/date"; + +export const baseJiraMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null; + const componentName = context.componentDefinition.name || "unknown"; + + return { + iconSrc: jiraIcon, + iconSlug: "jira", + iconColor: getColorClass(context.componentDefinition.color), + collapsedBackground: getBackgroundColorClass(context.componentDefinition.color), + collapsed: context.node.isCollapsed, + title: + context.node.name || + context.componentDefinition.label || + context.componentDefinition.name || + "Unnamed component", + eventSections: lastExecution ? baseEventSections(context.nodes, lastExecution, componentName) : undefined, + includeEmptyState: !lastExecution, + eventStateMap: getStateMap(componentName), + }; + }, + subtitle(context: SubtitleContext): string { + const timestamp = context.execution.updatedAt || context.execution.createdAt; + return timestamp ? formatTimeAgo(new Date(timestamp)) : ""; + }, + getExecutionDetails(context: ExecutionDetailsContext): Record { + const details: Record = {}; + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + const payload = outputs?.default?.[0]; + + if (payload?.type) { + details["Event Type"] = payload.type; + } + + return details; + }, +}; + +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!, + }, + ]; +} diff --git a/web_src/src/pages/workflowv2/mappers/jira/index.ts b/web_src/src/pages/workflowv2/mappers/jira/index.ts new file mode 100644 index 0000000000..59898de7b7 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/jira/index.ts @@ -0,0 +1,16 @@ +import { ComponentBaseMapper, EventStateRegistry, TriggerRenderer } from "../types"; +import { baseJiraMapper } from "./base"; +import { onIssueCreatedTriggerRenderer } from "./on_issue_created"; +import { buildActionStateRegistry } from "../utils"; + +export const componentMappers: Record = { + createIssue: baseJiraMapper, +}; + +export const triggerRenderers: Record = { + onIssueCreated: onIssueCreatedTriggerRenderer, +}; + +export const eventStateRegistry: Record = { + createIssue: buildActionStateRegistry("created"), +}; diff --git a/web_src/src/pages/workflowv2/mappers/jira/on_issue_created.ts b/web_src/src/pages/workflowv2/mappers/jira/on_issue_created.ts new file mode 100644 index 0000000000..8c45cb7705 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/jira/on_issue_created.ts @@ -0,0 +1,81 @@ +import { getBackgroundColorClass, getColorClass } from "@/utils/colors"; +import { TriggerEventContext, TriggerRenderer, TriggerRendererContext } from "../types"; +import { formatTimeAgo } from "@/utils/date"; +import { TriggerProps } from "@/ui/trigger"; +import jiraIcon from "@/assets/icons/integrations/jira.svg"; + +interface OnIssueCreatedEventData { + issue?: { + key?: string; + fields?: { + summary?: string; + creator?: { + displayName?: string; + }; + }; + }; +} + +export const onIssueCreatedTriggerRenderer: TriggerRenderer = { + getTitleAndSubtitle: (context: TriggerEventContext): { title: string; subtitle: string } => { + const eventData = context.event?.data as OnIssueCreatedEventData | undefined; + const issueKey = eventData?.issue?.key; + const summary = eventData?.issue?.fields?.summary; + const title = issueKey && summary ? `${issueKey}: ${summary}` : issueKey || summary || "Issue created"; + const creator = eventData?.issue?.fields?.creator?.displayName; + const subtitle = buildSubtitle(creator ? `Created by ${creator}` : "Issue created", context.event?.createdAt); + + return { title, subtitle }; + }, + + getRootEventValues: (context: TriggerEventContext): Record => { + const eventData = context.event?.data as OnIssueCreatedEventData | undefined; + + return { + "Issue Key": eventData?.issue?.key || "-", + Summary: eventData?.issue?.fields?.summary || "-", + Creator: eventData?.issue?.fields?.creator?.displayName || "-", + }; + }, + + getTriggerProps: (context: TriggerRendererContext) => { + const { node, definition, lastEvent } = context; + + const props: TriggerProps = { + title: node.name || definition.label || "Unnamed trigger", + iconSrc: jiraIcon, + iconSlug: "jira", + iconColor: getColorClass(definition.color), + collapsedBackground: getBackgroundColorClass(definition.color), + metadata: [], + }; + + if (lastEvent) { + const eventData = lastEvent.data as OnIssueCreatedEventData | undefined; + const issueKey = eventData?.issue?.key; + const summary = eventData?.issue?.fields?.summary; + const title = issueKey && summary ? `${issueKey}: ${summary}` : issueKey || summary || "Issue created"; + const creator = eventData?.issue?.fields?.creator?.displayName; + const subtitle = buildSubtitle(creator ? `Created by ${creator}` : "Issue created", lastEvent.createdAt); + + props.lastEventData = { + title, + subtitle, + receivedAt: new Date(lastEvent.createdAt), + state: "triggered", + eventId: lastEvent.id, + }; + } + + return props; + }, +}; + +function buildSubtitle(content: string, createdAt?: string): string { + const timeAgo = createdAt ? formatTimeAgo(new Date(createdAt)) : ""; + if (content && timeAgo) { + return `${content} ยท ${timeAgo}`; + } + + return content || timeAgo; +} diff --git a/web_src/src/ui/BuildingBlocksSidebar/index.tsx b/web_src/src/ui/BuildingBlocksSidebar/index.tsx index 82689adcff..bb46f2490a 100644 --- a/web_src/src/ui/BuildingBlocksSidebar/index.tsx +++ b/web_src/src/ui/BuildingBlocksSidebar/index.tsx @@ -499,6 +499,7 @@ function CategorySection({ datadog: datadogIcon, discord: discordIcon, github: githubIcon, + jira: jiraIcon, gitlab: gitlabIcon, hetzner: hetznerIcon, grafana: grafanaIcon,