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,