From b25ff9af795d695c495f699d4228070ec37857ae Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Tue, 19 May 2026 09:30:00 +0300 Subject: [PATCH] feat: add jira on issue trigger Signed-off-by: WashingtonKK --- docs/components/Jira.mdx | 69 ++-- pkg/integrations/jira/auth.go | 183 +++++++++ pkg/integrations/jira/client.go | 201 ++++++++-- pkg/integrations/jira/client_test.go | 289 ++++++------- pkg/integrations/jira/common.go | 62 +++ pkg/integrations/jira/example.go | 12 +- .../jira/example_data_on_issue.json | 23 ++ pkg/integrations/jira/jira.go | 379 ++++++++++++++++-- pkg/integrations/jira/jira_test.go | 143 ++++--- pkg/integrations/jira/on_issue.go | 275 +++++++++++++ pkg/integrations/jira/on_issue_test.go | 209 ++++++++++ pkg/integrations/jira/webhook_handler.go | 99 +++++ pkg/integrations/jira/webhook_handler_test.go | 92 +++++ web_src/src/pages/workflowv2/mappers/index.ts | 2 + .../pages/workflowv2/mappers/jira/index.ts | 6 + .../pages/workflowv2/mappers/jira/on_issue.ts | 134 +++++++ 16 files changed, 1857 insertions(+), 321 deletions(-) create mode 100644 pkg/integrations/jira/auth.go create mode 100644 pkg/integrations/jira/example_data_on_issue.json create mode 100644 pkg/integrations/jira/on_issue.go create mode 100644 pkg/integrations/jira/on_issue_test.go create mode 100644 pkg/integrations/jira/webhook_handler.go create mode 100644 pkg/integrations/jira/webhook_handler_test.go create mode 100644 web_src/src/pages/workflowv2/mappers/jira/index.ts create mode 100644 web_src/src/pages/workflowv2/mappers/jira/on_issue.ts diff --git a/docs/components/Jira.mdx b/docs/components/Jira.mdx index 449e0c4c5d..89adf42462 100644 --- a/docs/components/Jira.mdx +++ b/docs/components/Jira.mdx @@ -6,49 +6,72 @@ Manage and react to issues in Jira import { CardGrid, LinkCard } from "@astrojs/starlight/components"; -## Actions +## Triggers - + - +## Instructions -## Create Issue +**Setup steps:** +1. Click **Connect** once with **Client ID** and **Client Secret** empty. The same setup box at the top of this modal will change to show a **Callback URL**. If you close the modal, you can also see it on the Jira integration details page in the yellow setup box. -The Create Issue component creates a new issue in Jira. +2. Open the [Atlassian Developer Console](https://developer.atlassian.com/console/myapps/), then select **Create app → OAuth 2.0 integration**. + + > **Required scopes:** + > `read:jira-work` · `write:jira-work` · `manage:jira-webhook` · `offline_access` + +3. In the Atlassian app, go to **Authorization → OAuth 2.0 (3LO)** and add the callback URL shown by SuperPlane. +4. Copy the Atlassian app **Client ID** and **Client Secret** into the fields below, then save. +5. Click **Continue** to authorize Jira. SuperPlane creates and manages the Jira issue webhook automatically. + + + +## On Issue + +The On Issue trigger starts a workflow execution when Jira sends an issue webhook. ### Use Cases -- **Task creation**: Automatically create tasks from workflow events -- **Bug tracking**: Create bugs from error detection systems -- **Feature requests**: Generate feature request issues from external inputs +- **Issue automation**: Run workflows when a Jira issue is created, updated, or deleted +- **Project routing**: Filter issue events to a specific Jira project +- **Notification workflows**: Send updates to other systems when issue activity happens ### Configuration -- **Project**: The Jira project to create the issue in -- **Issue Type**: The type of issue (e.g. Task, Bug, Story) -- **Summary**: The issue summary/title -- **Description**: Optional description text +- **Project**: Optionally filter events to one Jira project. Leave empty to receive issues from all projects. +- **Actions**: Optionally filter to created, updated, or deleted issue events. Leave empty to receive all issue events. -### Output +### Webhook Setup -Returns the created issue including: -- **id**: The issue ID -- **key**: The issue key (e.g. PROJ-123) -- **self**: API URL for the issue +The webhook is created automatically in Jira through the REST API when you save the canvas. SuperPlane keeps one Jira webhook per connected Jira site and routes matching issue events to the configured triggers. -### Example Output +### Example Data ```json { - "data": { + "action": "created", + "issue": { + "fields": { + "issuetype": { + "name": "Task" + }, + "project": { + "id": "10000", + "key": "SP", + "name": "SuperPlane" + }, + "status": { + "name": "To Do" + }, + "summary": "Example issue" + }, "id": "10001", - "key": "PROJ-123", - "self": "https://your-domain.atlassian.net/rest/api/3/issue/10001" + "key": "SP-123", + "self": "https://example.atlassian.net/rest/api/3/issue/10001" }, - "timestamp": "2026-01-19T12:00:00Z", - "type": "jira.issue" + "webhookEvent": "jira:issue_created" } ``` diff --git a/pkg/integrations/jira/auth.go b/pkg/integrations/jira/auth.go new file mode 100644 index 0000000000..1e83dfc3de --- /dev/null +++ b/pkg/integrations/jira/auth.go @@ -0,0 +1,183 @@ +package jira + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strings" + + "github.com/superplanehq/superplane/pkg/core" +) + +const ( + atlassianAuthorizeURL = "https://auth.atlassian.com/authorize" + atlassianTokenURL = "https://auth.atlassian.com/oauth/token" + atlassianAccessibleResourcesURL = "https://api.atlassian.com/oauth/token/accessible-resources" +) + +type Auth struct { + client core.HTTPContext +} + +type TokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` +} + +type AccessibleResource struct { + ID string `json:"id"` + URL string `json:"url"` + Name string `json:"name"` + Scopes []string `json:"scopes"` +} + +func NewAuth(client core.HTTPContext) *Auth { + return &Auth{client: client} +} + +func jiraOAuthURL(clientID, redirectURI, state string) string { + values := url.Values{} + values.Set("audience", "api.atlassian.com") + values.Set("client_id", clientID) + values.Set("scope", strings.Join(oauthScopes, " ")) + values.Set("redirect_uri", redirectURI) + values.Set("state", state) + values.Set("response_type", "code") + values.Set("prompt", "consent") + + return fmt.Sprintf("%s?%s", atlassianAuthorizeURL, values.Encode()) +} + +func (a *Auth) HandleCallback(req *http.Request, config Configuration, expectedState, redirectURI string) (*TokenResponse, error) { + code := req.URL.Query().Get("code") + state := req.URL.Query().Get("state") + errorParam := req.URL.Query().Get("error") + + if errorParam != "" { + errorDesc := req.URL.Query().Get("error_description") + return nil, fmt.Errorf("OAuth error: %s - %s", errorParam, errorDesc) + } + + if code == "" || state == "" { + return nil, fmt.Errorf("missing code or state") + } + + if state != expectedState { + return nil, fmt.Errorf("invalid state") + } + + return a.ExchangeCode(config.ClientID, config.ClientSecret, code, redirectURI) +} + +func (a *Auth) ExchangeCode(clientID, clientSecret, code, redirectURI string) (*TokenResponse, error) { + payload := map[string]string{ + "grant_type": "authorization_code", + "client_id": clientID, + "client_secret": clientSecret, + "code": code, + "redirect_uri": redirectURI, + } + + return a.tokenRequest(payload) +} + +func (a *Auth) RefreshToken(clientID, clientSecret, refreshToken string) (*TokenResponse, error) { + payload := map[string]string{ + "grant_type": "refresh_token", + "client_id": clientID, + "client_secret": clientSecret, + "refresh_token": refreshToken, + } + + return a.tokenRequest(payload) +} + +func (a *Auth) tokenRequest(payload map[string]string) (*TokenResponse, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, atlassianTokenURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := a.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token request failed: status %d, body: %s", resp.StatusCode, string(responseBody)) + } + + tokenResponse := TokenResponse{} + if err := json.Unmarshal(responseBody, &tokenResponse); err != nil { + return nil, err + } + + return &tokenResponse, nil +} + +func (a *Auth) AccessibleResources(accessToken string) ([]AccessibleResource, error) { + req, err := http.NewRequest(http.MethodGet, atlassianAccessibleResourcesURL, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := a.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("accessible resources request failed: status %d, body: %s", resp.StatusCode, string(responseBody)) + } + + resources := []AccessibleResource{} + if err := json.Unmarshal(responseBody, &resources); err != nil { + return nil, err + } + + return resources, nil +} + +func firstJiraResource(resources []AccessibleResource) (*AccessibleResource, error) { + for i := range resources { + if slices.Contains(resources[i].Scopes, "read:jira-work") || strings.Contains(resources[i].URL, ".atlassian.net") { + return &resources[i], nil + } + } + + if len(resources) > 0 { + return &resources[0], nil + } + + return nil, fmt.Errorf("no Jira sites are available to this OAuth grant") +} diff --git a/pkg/integrations/jira/client.go b/pkg/integrations/jira/client.go index 55a504e2f2..7593469b8d 100644 --- a/pkg/integrations/jira/client.go +++ b/pkg/integrations/jira/client.go @@ -2,53 +2,69 @@ package jira import ( "bytes" - "encoding/base64" "encoding/json" "fmt" "io" "net/http" + "net/url" + "github.com/mitchellh/mapstructure" "github.com/superplanehq/superplane/pkg/core" ) type Client struct { - Email string - Token string - BaseURL string - http core.HTTPContext + Token string + BaseURL string + AuthType string + http core.HTTPContext +} + +type APIError struct { + StatusCode int + Body string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("request got %d code: %s", e.StatusCode, e.Body) } func NewClient(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) + return newOAuthClientFromIntegration(httpCtx, ctx) +} + +func NewOAuthClient(httpCtx core.HTTPContext, accessToken, cloudID string) *Client { + return &Client{ + Token: accessToken, + BaseURL: fmt.Sprintf("https://api.atlassian.com/ex/jira/%s", url.PathEscape(cloudID)), + AuthType: AuthTypeOAuth, + http: httpCtx, } +} - email, err := ctx.GetConfig("email") +func newOAuthClientFromIntegration(httpCtx core.HTTPContext, ctx core.IntegrationContext) (*Client, error) { + accessToken, err := requireOAuthSecret(ctx, OAuthAccessToken) if err != nil { - return nil, fmt.Errorf("error getting email: %v", err) + return nil, err } - apiToken, err := ctx.GetConfig("apiToken") - if err != nil { - return nil, fmt.Errorf("error getting apiToken: %v", err) + metadata := Metadata{} + if err := mapstructure.Decode(ctx.GetMetadata(), &metadata); err != nil { + return nil, fmt.Errorf("failed to decode Jira metadata: %w", err) } - return &Client{ - Email: string(email), - Token: string(apiToken), - BaseURL: string(baseURL), - http: httpCtx, - }, nil + if metadata.CloudID == "" { + return nil, fmt.Errorf("Jira cloud ID is missing: integration needs to sync") + } + + return NewOAuthClient(httpCtx, accessToken, metadata.CloudID), nil } func (c *Client) authHeader() string { - credentials := fmt.Sprintf("%s:%s", c.Email, c.Token) - return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(credentials))) + return "Bearer " + c.Token } -func (c *Client) execRequest(method, url string, body io.Reader) ([]byte, error) { - req, err := http.NewRequest(method, url, body) +func (c *Client) execRequest(method, path string, body io.Reader) ([]byte, error) { + req, err := http.NewRequest(method, c.url(path), body) if err != nil { return nil, fmt.Errorf("error building request: %v", err) } @@ -69,12 +85,16 @@ func (c *Client) execRequest(method, url string, body io.Reader) ([]byte, error) } if res.StatusCode < 200 || res.StatusCode >= 300 { - return nil, fmt.Errorf("request got %d code: %s", res.StatusCode, string(responseBody)) + return nil, &APIError{StatusCode: res.StatusCode, Body: string(responseBody)} } return responseBody, nil } +func (c *Client) url(path string) string { + return fmt.Sprintf("%s%s", c.BaseURL, path) +} + // User represents a Jira user from the /myself endpoint. type User struct { AccountID string `json:"accountId"` @@ -84,8 +104,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) - responseBody, err := c.execRequest(http.MethodGet, url, nil) + responseBody, err := c.execRequest(http.MethodGet, "/rest/api/3/myself", nil) if err != nil { return nil, err } @@ -107,8 +126,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) - responseBody, err := c.execRequest(http.MethodGet, url, nil) + responseBody, err := c.execRequest(http.MethodGet, "/rest/api/3/project", nil) if err != nil { return nil, err } @@ -131,8 +149,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) - responseBody, err := c.execRequest(http.MethodGet, url, nil) + responseBody, err := c.execRequest(http.MethodGet, fmt.Sprintf("/rest/api/3/issue/%s", url.PathEscape(issueKey)), nil) if err != nil { return nil, err } @@ -215,14 +232,12 @@ 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) - 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)) + responseBody, err := c.execRequest(http.MethodPost, "/rest/api/3/issue", bytes.NewReader(body)) if err != nil { return nil, err } @@ -234,3 +249,123 @@ func (c *Client) CreateIssue(req *CreateIssueRequest) (*CreateIssueResponse, err return &response, nil } + +type CreateWebhookRequest struct { + URL string `json:"url"` + Webhooks []WebhookRegistration `json:"webhooks"` +} + +type WebhookRegistration struct { + Events []string `json:"events"` + JQLFilter string `json:"jqlFilter"` +} + +type CreateWebhookResponse struct { + WebhookRegistrationResult []WebhookRegistrationResult `json:"webhookRegistrationResult"` +} + +type WebhookRegistrationResult struct { + CreatedWebhookID int64 `json:"createdWebhookId"` + Errors []string `json:"errors,omitempty"` +} + +type DeleteWebhookRequest struct { + WebhookIDs []int64 `json:"webhookIds"` +} + +func (c *Client) CreateWebhook(req CreateWebhookRequest) (*CreateWebhookResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("error marshaling webhook request: %v", err) + } + + responseBody, err := c.execRequest(http.MethodPost, "/rest/api/3/webhook", bytes.NewReader(body)) + if err != nil { + return nil, err + } + + response := CreateWebhookResponse{} + if err := json.Unmarshal(responseBody, &response); err != nil { + return nil, fmt.Errorf("error parsing create webhook response: %v", err) + } + + return &response, nil +} + +func (c *Client) DeleteWebhook(webhookID int64) error { + body, err := json.Marshal(DeleteWebhookRequest{WebhookIDs: []int64{webhookID}}) + if err != nil { + return fmt.Errorf("error marshaling delete webhook request: %v", err) + } + + _, err = c.execRequest(http.MethodDelete, "/rest/api/3/webhook", bytes.NewReader(body)) + return err +} + +type listWebhooksPage struct { + IsLast bool `json:"isLast"` + Values []Webhook `json:"values"` +} + +type Webhook struct { + ID int64 `json:"id"` +} + +type RefreshWebhookRequest struct { + WebhookIDs []int64 `json:"webhookIds"` +} + +type RefreshWebhookResponse struct { + ExpirationDate string `json:"expirationDate"` +} + +// ListWebhooks returns every webhook visible to this OAuth app context. +// Pagination follows Jira's standard maxResults/startAt scheme. +func (c *Client) ListWebhooks() ([]Webhook, error) { + var all []Webhook + startAt := 0 + for { + path := fmt.Sprintf("/rest/api/3/webhook?startAt=%d&maxResults=100", startAt) + body, err := c.execRequest(http.MethodGet, path, nil) + if err != nil { + return nil, err + } + + page := listWebhooksPage{} + if err := json.Unmarshal(body, &page); err != nil { + return nil, fmt.Errorf("error parsing webhooks response: %v", err) + } + + all = append(all, page.Values...) + if page.IsLast || len(page.Values) == 0 { + return all, nil + } + + startAt += len(page.Values) + } +} + +// RefreshWebhooks extends the expiration of the given webhooks by 30 days. +// Jira accepts at most 100 webhook IDs per call. +func (c *Client) RefreshWebhooks(ids []int64) (*RefreshWebhookResponse, error) { + if len(ids) == 0 { + return nil, nil + } + + body, err := json.Marshal(RefreshWebhookRequest{WebhookIDs: ids}) + if err != nil { + return nil, fmt.Errorf("error marshaling refresh webhook request: %v", err) + } + + responseBody, err := c.execRequest(http.MethodPut, "/rest/api/3/webhook/refresh", bytes.NewReader(body)) + if err != nil { + return nil, err + } + + response := RefreshWebhookResponse{} + if err := json.Unmarshal(responseBody, &response); err != nil { + return nil, fmt.Errorf("error parsing refresh webhook response: %v", err) + } + + return &response, nil +} diff --git a/pkg/integrations/jira/client_test.go b/pkg/integrations/jira/client_test.go index 3aff8b0ee3..0ce2ae2a29 100644 --- a/pkg/integrations/jira/client_test.go +++ b/pkg/integrations/jira/client_test.go @@ -1,78 +1,46 @@ package jira import ( - "io" "net/http" - "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" "github.com/superplanehq/superplane/test/support/contexts" ) func Test__NewClient(t *testing.T) { - t.Run("missing baseUrl -> error", func(t *testing.T) { - appCtx := &contexts.IntegrationContext{ - Configuration: map[string]any{ - "email": "test@example.com", - "apiToken": "test-token", - }, - } - + t.Run("missing access token -> error", func(t *testing.T) { httpCtx := &contexts.HTTPContext{} - _, err := NewClient(httpCtx, appCtx) + _, err := NewClient(httpCtx, &contexts.IntegrationContext{ + Metadata: Metadata{CloudID: "cloud-123"}, + }) require.Error(t, err) - assert.Contains(t, err.Error(), "baseUrl") + assert.Contains(t, err.Error(), "OAuth accessToken not found") }) - t.Run("missing email -> error", func(t *testing.T) { - appCtx := &contexts.IntegrationContext{ - Configuration: map[string]any{ - "baseUrl": "https://test.atlassian.net", - "apiToken": "test-token", - }, - } - + t.Run("missing cloud ID -> error", func(t *testing.T) { httpCtx := &contexts.HTTPContext{} - _, err := NewClient(httpCtx, appCtx) - - require.Error(t, err) - assert.Contains(t, err.Error(), "email") - }) - - t.Run("missing apiToken -> error", func(t *testing.T) { - appCtx := &contexts.IntegrationContext{ - Configuration: map[string]any{ - "baseUrl": "https://test.atlassian.net", - "email": "test@example.com", + _, err := NewClient(httpCtx, &contexts.IntegrationContext{ + CurrentSecrets: map[string]core.IntegrationSecret{ + OAuthAccessToken: {Name: OAuthAccessToken, Value: []byte("access-token")}, }, - } - - httpCtx := &contexts.HTTPContext{} - _, err := NewClient(httpCtx, appCtx) + }) require.Error(t, err) - assert.Contains(t, err.Error(), "apiToken") + assert.Contains(t, err.Error(), "cloud ID is missing") }) - t.Run("successful client creation", func(t *testing.T) { - appCtx := &contexts.IntegrationContext{ - Configuration: map[string]any{ - "baseUrl": "https://test.atlassian.net", - "email": "test@example.com", - "apiToken": "test-token", - }, - } - + t.Run("successful oauth client creation", func(t *testing.T) { httpCtx := &contexts.HTTPContext{} - client, err := NewClient(httpCtx, appCtx) + client, err := NewClient(httpCtx, oauthIntegrationContext()) require.NoError(t, err) - assert.Equal(t, "https://test.atlassian.net", client.BaseURL) - assert.Equal(t, "test@example.com", client.Email) - assert.Equal(t, "test-token", client.Token) + assert.Equal(t, "https://api.atlassian.com/ex/jira/cloud-123", client.BaseURL) + assert.Equal(t, AuthTypeOAuth, client.AuthType) + assert.Equal(t, "access-token", client.Token) }) } @@ -80,22 +48,11 @@ func Test__Client__GetCurrentUser(t *testing.T) { t.Run("successful get current user", func(t *testing.T) { httpContext := &contexts.HTTPContext{ Responses: []*http.Response{ - { - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{"accountId":"123","displayName":"Test User","emailAddress":"test@example.com"}`)), - }, + response(http.StatusOK, `{"accountId":"123","displayName":"Test User","emailAddress":"test@example.com"}`), }, } - appCtx := &contexts.IntegrationContext{ - Configuration: map[string]any{ - "baseUrl": "https://test.atlassian.net", - "email": "test@example.com", - "apiToken": "test-token", - }, - } - - client, err := NewClient(httpContext, appCtx) + client, err := NewClient(httpContext, oauthIntegrationContext()) require.NoError(t, err) user, err := client.GetCurrentUser() @@ -105,27 +62,17 @@ func Test__Client__GetCurrentUser(t *testing.T) { assert.Equal(t, "Test User", user.DisplayName) require.Len(t, httpContext.Requests, 1) assert.Contains(t, httpContext.Requests[0].URL.String(), "/rest/api/3/myself") + assert.Equal(t, "Bearer access-token", httpContext.Requests[0].Header.Get("Authorization")) }) t.Run("auth failure -> error", func(t *testing.T) { httpContext := &contexts.HTTPContext{ Responses: []*http.Response{ - { - StatusCode: http.StatusUnauthorized, - Body: io.NopCloser(strings.NewReader(`{"message":"unauthorized"}`)), - }, - }, - } - - appCtx := &contexts.IntegrationContext{ - Configuration: map[string]any{ - "baseUrl": "https://test.atlassian.net", - "email": "test@example.com", - "apiToken": "invalid-token", + response(http.StatusUnauthorized, `{"message":"unauthorized"}`), }, } - client, err := NewClient(httpContext, appCtx) + client, err := NewClient(httpContext, oauthIntegrationContext()) require.NoError(t, err) _, err = client.GetCurrentUser() @@ -138,22 +85,11 @@ func Test__Client__ListProjects(t *testing.T) { t.Run("successful list projects", func(t *testing.T) { httpContext := &contexts.HTTPContext{ Responses: []*http.Response{ - { - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`[{"id":"10000","key":"TEST","name":"Test Project"},{"id":"10001","key":"DEMO","name":"Demo Project"}]`)), - }, + response(http.StatusOK, `[{"id":"10000","key":"TEST","name":"Test Project"},{"id":"10001","key":"DEMO","name":"Demo Project"}]`), }, } - appCtx := &contexts.IntegrationContext{ - Configuration: map[string]any{ - "baseUrl": "https://test.atlassian.net", - "email": "test@example.com", - "apiToken": "test-token", - }, - } - - client, err := NewClient(httpContext, appCtx) + client, err := NewClient(httpContext, oauthIntegrationContext()) require.NoError(t, err) projects, err := client.ListProjects() @@ -169,22 +105,11 @@ func Test__Client__ListProjects(t *testing.T) { t.Run("empty projects list", func(t *testing.T) { httpContext := &contexts.HTTPContext{ Responses: []*http.Response{ - { - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`[]`)), - }, - }, - } - - appCtx := &contexts.IntegrationContext{ - Configuration: map[string]any{ - "baseUrl": "https://test.atlassian.net", - "email": "test@example.com", - "apiToken": "test-token", + response(http.StatusOK, `[]`), }, } - client, err := NewClient(httpContext, appCtx) + client, err := NewClient(httpContext, oauthIntegrationContext()) require.NoError(t, err) projects, err := client.ListProjects() @@ -198,22 +123,11 @@ func Test__Client__GetIssue(t *testing.T) { t.Run("successful get issue", func(t *testing.T) { httpContext := &contexts.HTTPContext{ Responses: []*http.Response{ - { - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{"id":"10001","key":"TEST-123","self":"https://test.atlassian.net/rest/api/3/issue/10001","fields":{"summary":"Test issue"}}`)), - }, + response(http.StatusOK, `{"id":"10001","key":"TEST-123","self":"https://test.atlassian.net/rest/api/3/issue/10001","fields":{"summary":"Test issue"}}`), }, } - appCtx := &contexts.IntegrationContext{ - Configuration: map[string]any{ - "baseUrl": "https://test.atlassian.net", - "email": "test@example.com", - "apiToken": "test-token", - }, - } - - client, err := NewClient(httpContext, appCtx) + client, err := NewClient(httpContext, oauthIntegrationContext()) require.NoError(t, err) issue, err := client.GetIssue("TEST-123") @@ -229,22 +143,11 @@ func Test__Client__GetIssue(t *testing.T) { t.Run("issue not found -> error", func(t *testing.T) { httpContext := &contexts.HTTPContext{ Responses: []*http.Response{ - { - StatusCode: http.StatusNotFound, - Body: io.NopCloser(strings.NewReader(`{"errorMessages":["Issue does not exist"]}`)), - }, - }, - } - - appCtx := &contexts.IntegrationContext{ - Configuration: map[string]any{ - "baseUrl": "https://test.atlassian.net", - "email": "test@example.com", - "apiToken": "test-token", + response(http.StatusNotFound, `{"errorMessages":["Issue does not exist"]}`), }, } - client, err := NewClient(httpContext, appCtx) + client, err := NewClient(httpContext, oauthIntegrationContext()) require.NoError(t, err) _, err = client.GetIssue("INVALID-999") @@ -257,22 +160,11 @@ func Test__Client__CreateIssue(t *testing.T) { t.Run("successful issue creation", func(t *testing.T) { httpContext := &contexts.HTTPContext{ Responses: []*http.Response{ - { - StatusCode: http.StatusCreated, - Body: io.NopCloser(strings.NewReader(`{"id":"10002","key":"TEST-124","self":"https://test.atlassian.net/rest/api/3/issue/10002"}`)), - }, - }, - } - - appCtx := &contexts.IntegrationContext{ - Configuration: map[string]any{ - "baseUrl": "https://test.atlassian.net", - "email": "test@example.com", - "apiToken": "test-token", + response(http.StatusCreated, `{"id":"10002","key":"TEST-124","self":"https://test.atlassian.net/rest/api/3/issue/10002"}`), }, } - client, err := NewClient(httpContext, appCtx) + client, err := NewClient(httpContext, oauthIntegrationContext()) require.NoError(t, err) req := &CreateIssueRequest{ @@ -296,22 +188,11 @@ func Test__Client__CreateIssue(t *testing.T) { t.Run("issue creation with description", func(t *testing.T) { httpContext := &contexts.HTTPContext{ Responses: []*http.Response{ - { - StatusCode: http.StatusCreated, - Body: io.NopCloser(strings.NewReader(`{"id":"10003","key":"TEST-125","self":"https://test.atlassian.net/rest/api/3/issue/10003"}`)), - }, + response(http.StatusCreated, `{"id":"10003","key":"TEST-125","self":"https://test.atlassian.net/rest/api/3/issue/10003"}`), }, } - appCtx := &contexts.IntegrationContext{ - Configuration: map[string]any{ - "baseUrl": "https://test.atlassian.net", - "email": "test@example.com", - "apiToken": "test-token", - }, - } - - client, err := NewClient(httpContext, appCtx) + client, err := NewClient(httpContext, oauthIntegrationContext()) require.NoError(t, err) req := &CreateIssueRequest{ @@ -332,22 +213,11 @@ func Test__Client__CreateIssue(t *testing.T) { t.Run("issue creation failure -> error", func(t *testing.T) { httpContext := &contexts.HTTPContext{ Responses: []*http.Response{ - { - StatusCode: http.StatusBadRequest, - Body: io.NopCloser(strings.NewReader(`{"errorMessages":["Project is required"]}`)), - }, + response(http.StatusBadRequest, `{"errorMessages":["Project is required"]}`), }, } - appCtx := &contexts.IntegrationContext{ - Configuration: map[string]any{ - "baseUrl": "https://test.atlassian.net", - "email": "test@example.com", - "apiToken": "test-token", - }, - } - - client, err := NewClient(httpContext, appCtx) + client, err := NewClient(httpContext, oauthIntegrationContext()) require.NoError(t, err) req := &CreateIssueRequest{ @@ -360,6 +230,81 @@ func Test__Client__CreateIssue(t *testing.T) { }) } +func Test__Client__ListWebhooks(t *testing.T) { + t.Run("paginated list collects all values", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + response(http.StatusOK, `{"isLast":false,"values":[{"id":1},{"id":2}]}`), + response(http.StatusOK, `{"isLast":true,"values":[{"id":3}]}`), + }, + } + + client, err := NewClient(httpContext, oauthIntegrationContext()) + require.NoError(t, err) + + webhooks, err := client.ListWebhooks() + + require.NoError(t, err) + require.Len(t, webhooks, 3) + assert.Equal(t, int64(1), webhooks[0].ID) + assert.Equal(t, int64(3), webhooks[2].ID) + require.Len(t, httpContext.Requests, 2) + assert.Contains(t, httpContext.Requests[0].URL.String(), "startAt=0") + assert.Contains(t, httpContext.Requests[1].URL.String(), "startAt=2") + }) + + t.Run("empty response stops pagination", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + response(http.StatusOK, `{"isLast":false,"values":[]}`), + }, + } + + client, err := NewClient(httpContext, oauthIntegrationContext()) + require.NoError(t, err) + + webhooks, err := client.ListWebhooks() + + require.NoError(t, err) + assert.Empty(t, webhooks) + }) +} + +func Test__Client__RefreshWebhooks(t *testing.T) { + t.Run("empty IDs -> noop", func(t *testing.T) { + httpContext := &contexts.HTTPContext{} + + client, err := NewClient(httpContext, oauthIntegrationContext()) + require.NoError(t, err) + + response, err := client.RefreshWebhooks(nil) + require.NoError(t, err) + assert.Nil(t, response) + assert.Empty(t, httpContext.Requests) + }) + + t.Run("sends webhook IDs and parses response", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + response(http.StatusOK, `{"expirationDate":"2030-02-01T00:00:00.000+0000"}`), + }, + } + + client, err := NewClient(httpContext, oauthIntegrationContext()) + require.NoError(t, err) + + response, err := client.RefreshWebhooks([]int64{111, 222}) + require.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, "2030-02-01T00:00:00.000+0000", response.ExpirationDate) + + require.Len(t, httpContext.Requests, 1) + req := httpContext.Requests[0] + assert.Equal(t, http.MethodPut, req.Method) + assert.Contains(t, req.URL.String(), "/rest/api/3/webhook/refresh") + }) +} + func Test__WrapInADF(t *testing.T) { t.Run("wraps text in ADF format", func(t *testing.T) { result := WrapInADF("Hello world") @@ -379,3 +324,15 @@ func Test__WrapInADF(t *testing.T) { assert.Nil(t, result) }) } + +func oauthIntegrationContext() *contexts.IntegrationContext { + return &contexts.IntegrationContext{ + Metadata: Metadata{ + AuthType: AuthTypeOAuth, + CloudID: "cloud-123", + }, + CurrentSecrets: map[string]core.IntegrationSecret{ + OAuthAccessToken: {Name: OAuthAccessToken, Value: []byte("access-token")}, + }, + } +} diff --git a/pkg/integrations/jira/common.go b/pkg/integrations/jira/common.go index 6461311ece..89342bbc51 100644 --- a/pkg/integrations/jira/common.go +++ b/pkg/integrations/jira/common.go @@ -1,6 +1,68 @@ package jira +import ( + "fmt" + "strings" + + "github.com/superplanehq/superplane/pkg/core" +) + +const ( + AuthTypeOAuth = "oauth" + + OAuthAccessToken = "accessToken" + OAuthRefreshToken = "refreshToken" +) + // NodeMetadata stores metadata on trigger/component nodes. type NodeMetadata struct { Project *Project `json:"project,omitempty"` } + +func getConfigString(ctx core.IntegrationContext, name string) string { + value, err := ctx.GetConfig(name) + if err != nil { + return "" + } + + return string(value) +} + +func loadConfiguration(ctx core.IntegrationContext) Configuration { + config := Configuration{ + ClientID: getConfigString(ctx, "clientId"), + ClientSecret: getConfigString(ctx, "clientSecret"), + } + + config.ClientID = strings.TrimSpace(config.ClientID) + config.ClientSecret = strings.TrimSpace(config.ClientSecret) + return config +} + +func findSecret(integration core.IntegrationContext, name string) (string, error) { + secrets, err := integration.GetSecrets() + if err != nil { + return "", err + } + + for _, secret := range secrets { + if secret.Name == name { + return string(secret.Value), nil + } + } + + return "", nil +} + +func requireOAuthSecret(integration core.IntegrationContext, name string) (string, error) { + value, err := findSecret(integration, name) + if err != nil { + return "", err + } + + if value == "" { + return "", fmt.Errorf("OAuth %s not found", name) + } + + return value, nil +} diff --git a/pkg/integrations/jira/example.go b/pkg/integrations/jira/example.go index 8b8a9bb23c..52ac00fc6c 100644 --- a/pkg/integrations/jira/example.go +++ b/pkg/integrations/jira/example.go @@ -7,12 +7,12 @@ import ( "github.com/superplanehq/superplane/pkg/utils" ) -//go:embed example_output_create_issue.json -var exampleOutputCreateIssueBytes []byte +//go:embed example_data_on_issue.json +var exampleDataOnIssueBytes []byte -var exampleOutputCreateIssueOnce sync.Once -var exampleOutputCreateIssue map[string]any +var exampleDataOnIssueOnce sync.Once +var exampleDataOnIssue map[string]any -func (c *CreateIssue) ExampleOutput() map[string]any { - return utils.UnmarshalEmbeddedJSON(&exampleOutputCreateIssueOnce, exampleOutputCreateIssueBytes, &exampleOutputCreateIssue) +func (t *OnIssue) ExampleData() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleDataOnIssueOnce, exampleDataOnIssueBytes, &exampleDataOnIssue) } diff --git a/pkg/integrations/jira/example_data_on_issue.json b/pkg/integrations/jira/example_data_on_issue.json new file mode 100644 index 0000000000..18b3c62f0a --- /dev/null +++ b/pkg/integrations/jira/example_data_on_issue.json @@ -0,0 +1,23 @@ +{ + "webhookEvent": "jira:issue_created", + "action": "created", + "issue": { + "id": "10001", + "key": "SP-123", + "self": "https://example.atlassian.net/rest/api/3/issue/10001", + "fields": { + "summary": "Example issue", + "project": { + "id": "10000", + "key": "SP", + "name": "SuperPlane" + }, + "issuetype": { + "name": "Task" + }, + "status": { + "name": "To Do" + } + } + } +} diff --git a/pkg/integrations/jira/jira.go b/pkg/integrations/jira/jira.go index 5c7d34ce4f..11eef590d4 100644 --- a/pkg/integrations/jira/jira.go +++ b/pkg/integrations/jira/jira.go @@ -2,29 +2,63 @@ package jira import ( "fmt" + "net/http" + "net/url" + "strings" + "time" "github.com/mitchellh/mapstructure" "github.com/superplanehq/superplane/pkg/configuration" "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/crypto" "github.com/superplanehq/superplane/pkg/registry" ) func init() { - registry.RegisterIntegration("jira", &Jira{}) + registry.RegisterIntegrationWithWebhookHandler("jira", &Jira{}, &JiraWebhookHandler{}) } type Jira struct{} type Configuration struct { - BaseURL string `json:"baseUrl"` - Email string `json:"email"` - APIToken string `json:"apiToken"` + ClientID string `json:"clientId" mapstructure:"clientId"` + ClientSecret string `json:"clientSecret" mapstructure:"clientSecret"` } type Metadata struct { - Projects []Project `json:"projects"` + AuthType string `json:"authType,omitempty" mapstructure:"authType"` + State *string `json:"state,omitempty" mapstructure:"state"` + CloudID string `json:"cloudId,omitempty" mapstructure:"cloudId"` + BaseURL string `json:"baseUrl,omitempty" mapstructure:"baseUrl"` + SiteName string `json:"siteName,omitempty" mapstructure:"siteName"` + User *User `json:"user,omitempty" mapstructure:"user"` + Projects []Project `json:"projects" mapstructure:"projects"` } +var oauthScopes = []string{ + "read:jira-work", + "write:jira-work", + "manage:jira-webhook", + "offline_access", +} + +const ( + atlassianDeveloperConsoleURL = "https://developer.atlassian.com/console/myapps/" + + oauthSetupDescription = ` +Use this **Callback URL** when configuring OAuth 2.0 (3LO) in your Atlassian app: + +` + "`%s`" + ` + +Required scopes: +` + "`%s`" + ` + +Click **Continue** to open the [Atlassian Developer Console](` + atlassianDeveloperConsoleURL + `), create the OAuth app, then copy its **Client ID** and **Client Secret** into SuperPlane. +` + + oauthConnectDescription = "Authorize SuperPlane to access Jira and create the issue webhook." +) + func (j *Jira) Name() string { return "jira" } @@ -42,44 +76,49 @@ func (j *Jira) Description() string { } func (j *Jira) Instructions() string { - return "" + return ` +**Setup steps:** +1. Click **Connect** once with **Client ID** and **Client Secret** empty. The same setup box at the top of this modal will change to show a **Callback URL**. If you close the modal, you can also see it on the Jira integration details page in the yellow setup box. + +2. Open the [Atlassian Developer Console](` + atlassianDeveloperConsoleURL + `), then select **Create app → OAuth 2.0 integration**. + + > **Required scopes:** + > ` + "`read:jira-work`" + ` · ` + "`write:jira-work`" + ` · ` + "`manage:jira-webhook`" + ` · ` + "`offline_access`" + ` + +3. In the Atlassian app, go to **Authorization → OAuth 2.0 (3LO)** and add the callback URL shown by SuperPlane. +4. Copy the Atlassian app **Client ID** and **Client Secret** into the fields below, then save. +5. Click **Continue** to authorize Jira. SuperPlane creates and manages the Jira issue webhook automatically. +` } func (j *Jira) Configuration() []configuration.Field { return []configuration.Field{ { - Name: "baseUrl", - Label: "Base URL", - Type: configuration.FieldTypeString, - Required: true, - Description: "Jira Cloud instance URL (e.g. https://your-domain.atlassian.net)", - }, - { - Name: "email", - Label: "Email", + Name: "clientId", + Label: "Client ID", Type: configuration.FieldTypeString, - Required: true, - Description: "Email address for API authentication", + Description: "Client ID from your Atlassian OAuth 2.0 (3LO) app. This is not your Atlassian email address.", + Placeholder: "Atlassian OAuth app Client ID", }, { - Name: "apiToken", - Label: "API Token", + Name: "clientSecret", + Label: "Client Secret", Type: configuration.FieldTypeString, - Required: true, Sensitive: true, - Description: "Jira API token", + Description: "Client secret from your Atlassian OAuth 2.0 (3LO) app.", + Placeholder: "Atlassian OAuth app Client Secret", }, } } func (j *Jira) Actions() []core.Action { - return []core.Action{ - &CreateIssue{}, - } + return []core.Action{} } func (j *Jira) Triggers() []core.Trigger { - return []core.Trigger{} + return []core.Trigger{ + &OnIssue{}, + } } func (j *Jira) Cleanup(ctx core.IntegrationCleanupContext) error { @@ -87,46 +126,302 @@ func (j *Jira) Cleanup(ctx core.IntegrationCleanupContext) error { } func (j *Jira) Sync(ctx core.SyncContext) error { - config := Configuration{} - err := mapstructure.Decode(ctx.Configuration, &config) - if err != nil { - return fmt.Errorf("failed to decode config: %v", err) + return j.oauthSync(ctx, loadConfiguration(ctx.Integration)) +} + +func (j *Jira) oauthSync(ctx core.SyncContext, config Configuration) error { + // Atlassian rejects http://localhost callbacks; the WebhooksBaseURL (e.g. an + // ngrok HTTPS tunnel) is the externally reachable origin. It falls back to + // BaseURL when WEBHOOKS_BASE_URL is not set, so production stays unchanged. + callbackURL := fmt.Sprintf("%s/api/v1/integrations/%s/callback", externalBaseURL(ctx.WebhooksBaseURL, ctx.BaseURL), ctx.Integration.ID()) + + if config.ClientID == "" || config.ClientSecret == "" { + ctx.Integration.NewBrowserAction(core.BrowserAction{ + Description: fmt.Sprintf(oauthSetupDescription, callbackURL, strings.Join(oauthScopes, " ")), + URL: "https://developer.atlassian.com/console/myapps/", + Method: http.MethodGet, + }) + return nil + } + + if err := validateOAuthConfiguration(config); err != nil { + return err } - if config.BaseURL == "" { - return fmt.Errorf("baseUrl is required") + accessToken, _ := findSecret(ctx.Integration, OAuthAccessToken) + if accessToken == "" { + return j.handleOAuthNoAccessToken(ctx, callbackURL, config.ClientID) } - if config.Email == "" { - return fmt.Errorf("email is required") + refreshToken, _ := findSecret(ctx.Integration, OAuthRefreshToken) + if refreshToken != "" { + if err := j.refreshOAuthToken(ctx, config.ClientID, config.ClientSecret, refreshToken); err != nil { + ctx.Logger.Errorf("failed to refresh Jira OAuth token: %v", err) + return err + } } - if config.APIToken == "" { - return fmt.Errorf("apiToken is required") + if err := j.updateOAuthMetadata(ctx); err != nil { + ctx.Integration.Error(err.Error()) + return nil } + // Best-effort: extend the 30-day expiry on every webhook this app owns. + // Failures here are logged but do not fail the sync. + j.refreshWebhooks(ctx) + + ctx.Integration.RemoveBrowserAction() + ctx.Integration.Ready() + return nil +} + +func (j *Jira) refreshWebhooks(ctx core.SyncContext) { client, err := NewClient(ctx.HTTP, ctx.Integration) if err != nil { - return fmt.Errorf("error creating client: %v", err) + if ctx.Logger != nil { + ctx.Logger.Warnf("skipping Jira webhook refresh: %v", err) + } + return + } + + webhooks, err := client.ListWebhooks() + if err != nil { + if ctx.Logger != nil { + ctx.Logger.Warnf("failed to list Jira webhooks for refresh: %v", err) + } + return + } + + if len(webhooks) == 0 { + return + } + + ids := make([]int64, 0, len(webhooks)) + for _, w := range webhooks { + ids = append(ids, w.ID) + } + + // Jira's /webhook/refresh accepts a max of 100 IDs per call. + const refreshBatchSize = 100 + for start := 0; start < len(ids); start += refreshBatchSize { + end := start + refreshBatchSize + if end > len(ids) { + end = len(ids) + } + if _, err := client.RefreshWebhooks(ids[start:end]); err != nil && ctx.Logger != nil { + ctx.Logger.Warnf("failed to refresh Jira webhooks (batch %d-%d): %v", start, end, err) + } + } +} + +// externalBaseURL returns the externally reachable origin for OAuth callbacks. +// Falls back to baseURL when webhooksBaseURL is empty (e.g. WEBHOOKS_BASE_URL not set). +func externalBaseURL(webhooksBaseURL, baseURL string) string { + if strings.TrimSpace(webhooksBaseURL) != "" { + return webhooksBaseURL + } + + return baseURL +} + +func validateOAuthConfiguration(config Configuration) error { + if strings.Contains(config.ClientID, "@") { + return fmt.Errorf("clientId must be the Atlassian OAuth app Client ID, not an email address") + } + + return nil +} + +func (j *Jira) handleOAuthNoAccessToken(ctx core.SyncContext, callbackURL, clientID string) error { + metadata := Metadata{} + if err := mapstructure.Decode(ctx.Integration.GetMetadata(), &metadata); err != nil { + ctx.Logger.Errorf("failed to decode Jira metadata while setting OAuth state: %v", err) + } + + if metadata.State == nil { + state, err := crypto.Base64String(32) + if err != nil { + return fmt.Errorf("failed to generate OAuth state: %v", err) + } + + metadata.State = &state + metadata.AuthType = AuthTypeOAuth + ctx.Integration.SetMetadata(metadata) + } + + authURL := jiraOAuthURL(clientID, callbackURL, *metadata.State) + ctx.Integration.NewBrowserAction(core.BrowserAction{ + Description: oauthConnectDescription, + URL: authURL, + Method: http.MethodGet, + }) + + return nil +} + +func (j *Jira) refreshOAuthToken(ctx core.SyncContext, clientID, clientSecret, refreshToken string) error { + auth := NewAuth(ctx.HTTP) + tokenResponse, err := auth.RefreshToken(clientID, clientSecret, refreshToken) + if err != nil { + _ = ctx.Integration.SetSecret(OAuthRefreshToken, []byte("")) + _ = ctx.Integration.SetSecret(OAuthAccessToken, []byte("")) + return fmt.Errorf("failed to refresh token: %v", err) + } + + if tokenResponse.AccessToken != "" { + if err := ctx.Integration.SetSecret(OAuthAccessToken, []byte(tokenResponse.AccessToken)); err != nil { + return fmt.Errorf("failed to save access token: %v", err) + } + } + + if tokenResponse.RefreshToken != "" { + if err := ctx.Integration.SetSecret(OAuthRefreshToken, []byte(tokenResponse.RefreshToken)); err != nil { + return fmt.Errorf("failed to save refresh token: %v", err) + } + } + + return ctx.Integration.ScheduleResync(tokenResponse.GetExpiration()) +} + +func (j *Jira) updateOAuthMetadata(ctx core.SyncContext) error { + accessToken, err := requireOAuthSecret(ctx.Integration, OAuthAccessToken) + if err != nil { + return err } - _, err = client.GetCurrentUser() + auth := NewAuth(ctx.HTTP) + resources, err := auth.AccessibleResources(accessToken) if err != nil { - return fmt.Errorf("error verifying credentials: %v", err) + return err + } + + resource, err := firstJiraResource(resources) + if err != nil { + return err + } + + client := NewOAuthClient(ctx.HTTP, accessToken, resource.ID) + user, err := client.GetCurrentUser() + if err != nil { + return fmt.Errorf("error verifying Jira OAuth credentials: %v", err) } projects, err := client.ListProjects() if err != nil { - return fmt.Errorf("error listing projects: %v", err) + return fmt.Errorf("error listing Jira projects: %v", err) } - ctx.Integration.SetMetadata(Metadata{Projects: projects}) - ctx.Integration.Ready() + ctx.Integration.SetMetadata(Metadata{ + AuthType: AuthTypeOAuth, + CloudID: resource.ID, + BaseURL: resource.URL, + SiteName: resource.Name, + User: user, + Projects: projects, + }) + return nil } func (j *Jira) HandleRequest(ctx core.HTTPRequestContext) { - // no-op + if !strings.HasSuffix(ctx.Request.URL.Path, "/callback") { + ctx.Response.WriteHeader(http.StatusNotFound) + return + } + + config := loadConfiguration(ctx.Integration) + if config.ClientID == "" || config.ClientSecret == "" { + ctx.Response.WriteHeader(http.StatusInternalServerError) + return + } + + j.handleCallback(ctx, config) +} + +func (j *Jira) handleCallback(ctx core.HTTPRequestContext, config Configuration) { + // The redirect_uri sent to Atlassian during token exchange must match the + // one used at /authorize time exactly — see oauthSync for the same rule. + externalURL := externalBaseURL(ctx.WebhooksBaseURL, ctx.BaseURL) + callbackURL := fmt.Sprintf("%s/api/v1/integrations/%s/callback", externalURL, ctx.Integration.ID()) + // The post-OAuth redirect back to the SuperPlane UI uses BaseURL since + // that's where the user's browser session lives. + redirectBaseURL := ctx.BaseURL + + metadata := Metadata{} + if err := mapstructure.Decode(ctx.Integration.GetMetadata(), &metadata); err != nil { + ctx.Response.WriteHeader(http.StatusInternalServerError) + return + } + + if metadata.State == nil { + ctx.Response.WriteHeader(http.StatusBadRequest) + return + } + + auth := NewAuth(ctx.HTTP) + tokenResponse, err := auth.HandleCallback(ctx.Request, config, *metadata.State, callbackURL) + if err != nil { + ctx.Logger.Errorf("Jira OAuth callback error: %v", err) + http.Redirect(ctx.Response, ctx.Request, j.integrationSettingsURL(redirectBaseURL, ctx.OrganizationID, ctx.Integration.ID().String()), http.StatusSeeOther) + return + } + + if tokenResponse.AccessToken != "" { + if err := ctx.Integration.SetSecret(OAuthAccessToken, []byte(tokenResponse.AccessToken)); err != nil { + ctx.Response.WriteHeader(http.StatusInternalServerError) + return + } + } + + if tokenResponse.RefreshToken != "" { + if err := ctx.Integration.SetSecret(OAuthRefreshToken, []byte(tokenResponse.RefreshToken)); err != nil { + ctx.Response.WriteHeader(http.StatusInternalServerError) + return + } + } + + // Schedule a near-immediate Sync so metadata population + verification + // happens with full SyncContext (Logger, error state transitions). This + // runs in the background; we still try the inline updateOAuthMetadata + // below as a best-effort fast-path so the integration goes Ready before + // the user lands on the settings page. + if err := ctx.Integration.ScheduleResync(time.Second); err != nil { + ctx.Logger.Errorf("Jira OAuth callback: failed to schedule resync: %v", err) + ctx.Response.WriteHeader(http.StatusInternalServerError) + return + } + + // Best-effort: try to populate metadata now so the user sees Ready right + // away. If anything Atlassian-facing fails (transient network, token not + // yet propagated, etc.), don't 500 — the scheduled Sync above will retry. + if err := j.updateOAuthMetadata(core.SyncContext{ + Logger: ctx.Logger, + HTTP: ctx.HTTP, + Integration: ctx.Integration, + }); err != nil { + ctx.Logger.Warnf("Jira OAuth callback: inline metadata update failed, deferring to Sync: %v", err) + } else { + ctx.Integration.RemoveBrowserAction() + ctx.Integration.Ready() + } + + http.Redirect(ctx.Response, ctx.Request, j.integrationSettingsURL(redirectBaseURL, ctx.OrganizationID, ctx.Integration.ID().String()), http.StatusSeeOther) +} + +func (j *Jira) integrationSettingsURL(baseURL, organizationID, integrationID string) string { + return fmt.Sprintf("%s/%s/settings/integrations/%s", baseURL, url.PathEscape(organizationID), url.PathEscape(integrationID)) +} + +func (t *TokenResponse) GetExpiration() time.Duration { + if t.ExpiresIn > 0 { + seconds := t.ExpiresIn / 2 + if seconds < 1 { + seconds = 1 + } + return time.Duration(seconds) * time.Second + } + + return time.Hour } func (j *Jira) Hooks() []core.Hook { diff --git a/pkg/integrations/jira/jira_test.go b/pkg/integrations/jira/jira_test.go index 21e5aa49d7..f2b967aaa0 100644 --- a/pkg/integrations/jira/jira_test.go +++ b/pkg/integrations/jira/jira_test.go @@ -1,9 +1,8 @@ package jira import ( - "io" "net/http" - "strings" + "net/url" "testing" "github.com/stretchr/testify/assert" @@ -15,114 +14,156 @@ import ( func Test__Jira__Sync(t *testing.T) { j := &Jira{} - t.Run("no baseUrl -> error", func(t *testing.T) { + t.Run("instructions use setup steps", func(t *testing.T) { + instructions := j.Instructions() + assert.Contains(t, instructions, "**Setup steps:**") + assert.Contains(t, instructions, "same setup box at the top of this modal") + assert.Contains(t, instructions, "Atlassian Developer Console") + assert.Contains(t, instructions, atlassianDeveloperConsoleURL) + assert.Contains(t, instructions, "`read:jira-work`") + assert.Contains(t, instructions, "`manage:jira-webhook`") + }) + + t.Run("oauth without app credentials -> browser action", func(t *testing.T) { appCtx := &contexts.IntegrationContext{ - Configuration: map[string]any{ - "baseUrl": "", - "email": "test@example.com", - "apiToken": "test-token", - }, + Configuration: map[string]any{}, } err := j.Sync(core.SyncContext{ Configuration: appCtx.Configuration, + BaseURL: "https://superplane.example", Integration: appCtx, }) - require.ErrorContains(t, err, "baseUrl is required") + require.NoError(t, err) + require.NotNil(t, appCtx.BrowserAction) + assert.Equal(t, http.MethodGet, appCtx.BrowserAction.Method) + assert.Contains(t, appCtx.BrowserAction.URL, "https://developer.atlassian.com/console/myapps/") + assert.Contains(t, appCtx.BrowserAction.Description, "Callback URL") + assert.Contains(t, appCtx.BrowserAction.Description, atlassianDeveloperConsoleURL) + assert.NotEqual(t, "ready", appCtx.State) }) - t.Run("no email -> error", func(t *testing.T) { + t.Run("oauth without access token -> authorize browser action", func(t *testing.T) { appCtx := &contexts.IntegrationContext{ Configuration: map[string]any{ - "baseUrl": "https://test.atlassian.net", - "email": "", - "apiToken": "test-token", + "clientId": "client-id", + "clientSecret": "client-secret", }, } err := j.Sync(core.SyncContext{ Configuration: appCtx.Configuration, + BaseURL: "https://superplane.example", Integration: appCtx, }) - require.ErrorContains(t, err, "email is required") + require.NoError(t, err) + require.NotNil(t, appCtx.BrowserAction) + + authURL, err := url.Parse(appCtx.BrowserAction.URL) + require.NoError(t, err) + assert.Equal(t, "https", authURL.Scheme) + assert.Equal(t, "auth.atlassian.com", authURL.Host) + assert.Equal(t, "/authorize", authURL.Path) + assert.Equal(t, "api.atlassian.com", authURL.Query().Get("audience")) + assert.Equal(t, "client-id", authURL.Query().Get("client_id")) + assert.Equal(t, "https://superplane.example/api/v1/integrations/"+appCtx.ID().String()+"/callback", authURL.Query().Get("redirect_uri")) + assert.Equal(t, "code", authURL.Query().Get("response_type")) + assert.Equal(t, "consent", authURL.Query().Get("prompt")) + + metadata, ok := appCtx.Metadata.(Metadata) + require.True(t, ok) + require.NotNil(t, metadata.State) + assert.Equal(t, *metadata.State, authURL.Query().Get("state")) }) - t.Run("no apiToken -> error", func(t *testing.T) { + t.Run("callback URL prefers WebhooksBaseURL when set (e.g. ngrok)", func(t *testing.T) { appCtx := &contexts.IntegrationContext{ Configuration: map[string]any{ - "baseUrl": "https://test.atlassian.net", - "email": "test@example.com", - "apiToken": "", + "clientId": "client-id", + "clientSecret": "client-secret", }, } err := j.Sync(core.SyncContext{ - Configuration: appCtx.Configuration, - Integration: appCtx, + Configuration: appCtx.Configuration, + BaseURL: "http://localhost:8000", + WebhooksBaseURL: "https://example.ngrok-free.app", + Integration: appCtx, }) - require.ErrorContains(t, err, "apiToken is required") - }) + require.NoError(t, err) + require.NotNil(t, appCtx.BrowserAction) - t.Run("successful sync -> ready", func(t *testing.T) { - httpContext := &contexts.HTTPContext{ - Responses: []*http.Response{ - { - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{"accountId":"123"}`)), - }, - { - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`[{"id":"10000","key":"TEST","name":"Test Project"}]`)), - }, - }, - } + authURL, err := url.Parse(appCtx.BrowserAction.URL) + require.NoError(t, err) + assert.Equal(t, + "https://example.ngrok-free.app/api/v1/integrations/"+appCtx.ID().String()+"/callback", + authURL.Query().Get("redirect_uri"), + ) + }) + t.Run("email as client ID -> error", func(t *testing.T) { appCtx := &contexts.IntegrationContext{ Configuration: map[string]any{ - "baseUrl": "https://test.atlassian.net", - "email": "test@example.com", - "apiToken": "test-token", + "clientId": "user@example.com", + "clientSecret": "client-secret", }, } err := j.Sync(core.SyncContext{ Configuration: appCtx.Configuration, - HTTP: httpContext, + BaseURL: "https://superplane.example", Integration: appCtx, }) - require.NoError(t, err) - assert.Equal(t, "ready", appCtx.State) + require.ErrorContains(t, err, "not an email address") }) - t.Run("auth failure -> error", func(t *testing.T) { + t.Run("oauth with access token -> ready and refreshes webhooks", func(t *testing.T) { httpContext := &contexts.HTTPContext{ Responses: []*http.Response{ - { - StatusCode: http.StatusUnauthorized, - Body: io.NopCloser(strings.NewReader(`{"message":"unauthorized"}`)), - }, + response(http.StatusOK, `[{"id":"cloud-123","url":"https://test.atlassian.net","name":"Test Jira","scopes":["read:jira-work"]}]`), + response(http.StatusOK, `{"accountId":"123","displayName":"Test User"}`), + response(http.StatusOK, `[{"id":"10000","key":"TEST","name":"Test Project"}]`), + response(http.StatusOK, `{"isLast":true,"values":[{"id":555},{"id":777}]}`), + response(http.StatusOK, `{"expirationDate":"2030-01-01T00:00:00.000+0000"}`), }, } appCtx := &contexts.IntegrationContext{ Configuration: map[string]any{ - "baseUrl": "https://test.atlassian.net", - "email": "test@example.com", - "apiToken": "invalid-token", + "clientId": "client-id", + "clientSecret": "client-secret", + }, + CurrentSecrets: map[string]core.IntegrationSecret{ + OAuthAccessToken: {Name: OAuthAccessToken, Value: []byte("access-token")}, }, } err := j.Sync(core.SyncContext{ Configuration: appCtx.Configuration, HTTP: httpContext, + BaseURL: "https://superplane.example", Integration: appCtx, }) - require.Error(t, err) - assert.NotEqual(t, "ready", appCtx.State) + require.NoError(t, err) + assert.Equal(t, "ready", appCtx.State) + + metadata, ok := appCtx.Metadata.(Metadata) + require.True(t, ok) + assert.Equal(t, AuthTypeOAuth, metadata.AuthType) + assert.Equal(t, "cloud-123", metadata.CloudID) + require.Len(t, metadata.Projects, 1) + assert.Equal(t, "TEST", metadata.Projects[0].Key) + + // 4th request lists webhooks, 5th refreshes them. + require.GreaterOrEqual(t, len(httpContext.Requests), 5) + assert.Equal(t, http.MethodGet, httpContext.Requests[3].Method) + assert.Contains(t, httpContext.Requests[3].URL.String(), "/rest/api/3/webhook?") + assert.Equal(t, http.MethodPut, httpContext.Requests[4].Method) + assert.Equal(t, "https://api.atlassian.com/ex/jira/cloud-123/rest/api/3/webhook/refresh", httpContext.Requests[4].URL.String()) }) } diff --git a/pkg/integrations/jira/on_issue.go b/pkg/integrations/jira/on_issue.go new file mode 100644 index 0000000000..c1edd802fb --- /dev/null +++ b/pkg/integrations/jira/on_issue.go @@ -0,0 +1,275 @@ +package jira + +import ( + "encoding/json" + "fmt" + "net/http" + "slices" + "strings" + + jwt "github.com/golang-jwt/jwt/v4" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +const ( + IssueActionCreated = "created" + IssueActionUpdated = "updated" + IssueActionDeleted = "deleted" + + jiraWebhookEventCreated = "jira:issue_created" + jiraWebhookEventUpdated = "jira:issue_updated" + jiraWebhookEventDeleted = "jira:issue_deleted" +) + +type OnIssue struct{} + +type OnIssueConfiguration struct { + Project string `json:"project" mapstructure:"project"` + Actions []string `json:"actions" mapstructure:"actions"` +} + +func (t *OnIssue) Name() string { + return "jira.onIssue" +} + +func (t *OnIssue) Label() string { + return "On Issue" +} + +func (t *OnIssue) Description() string { + return "Start a workflow when Jira creates, updates, or deletes an issue" +} + +func (t *OnIssue) Documentation() string { + return `The On Issue trigger starts a workflow execution when Jira sends an issue webhook. + +## Use Cases + +- **Issue automation**: Run workflows when a Jira issue is created, updated, or deleted +- **Project routing**: Filter issue events to a specific Jira project +- **Notification workflows**: Send updates to other systems when issue activity happens + +## Configuration + +- **Project**: Optionally filter events to one Jira project. Leave empty to receive issues from all projects. +- **Actions**: Optionally filter to created, updated, or deleted issue events. Leave empty to receive all issue events. + +## Webhook Setup + +The webhook is created automatically in Jira through the REST API when you save the canvas. SuperPlane keeps one Jira webhook per connected Jira site and routes matching issue events to the configured triggers.` +} + +func (t *OnIssue) Icon() string { + return "jira" +} + +func (t *OnIssue) Color() string { + return "blue" +} + +func (t *OnIssue) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "project", + Label: "Project", + Type: configuration.FieldTypeIntegrationResource, + Required: false, + Description: "Filter by project. Leave empty to receive issues from all projects.", + Placeholder: "Select a project", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "project", + }, + }, + }, + { + Name: "actions", + Label: "Actions", + Type: configuration.FieldTypeMultiSelect, + Required: false, + Description: "Filter by issue action. Leave empty to receive all issue events.", + TypeOptions: &configuration.TypeOptions{ + MultiSelect: &configuration.MultiSelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Created", Value: IssueActionCreated}, + {Label: "Updated", Value: IssueActionUpdated}, + {Label: "Deleted", Value: IssueActionDeleted}, + }, + }, + }, + }, + } +} + +func (t *OnIssue) Setup(ctx core.TriggerContext) error { + config := OnIssueConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + for _, action := range config.Actions { + if !isKnownIssueAction(action) { + return fmt.Errorf("unsupported issue action: %s", action) + } + } + + metadata := Metadata{} + if err := mapstructure.Decode(ctx.Integration.GetMetadata(), &metadata); err != nil { + return fmt.Errorf("failed to decode Jira metadata: %w", err) + } + + if metadata.CloudID == "" { + return fmt.Errorf("Jira integration is not connected yet — complete the OAuth flow before saving this trigger") + } + + var project *Project + if strings.TrimSpace(config.Project) != "" { + found := slices.IndexFunc(metadata.Projects, func(p Project) bool { + return p.Key == config.Project || p.ID == config.Project + }) + if found == -1 { + return fmt.Errorf("project %s is not accessible to integration", config.Project) + } + + project = &metadata.Projects[found] + } + + if err := ctx.Metadata.Set(NodeMetadata{Project: project}); err != nil { + return fmt.Errorf("failed to set node metadata: %w", err) + } + + return ctx.Integration.RequestWebhook(WebhookConfiguration{CloudID: metadata.CloudID}) +} + +func (t *OnIssue) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { + if status, err := verifyJiraWebhookAuthorization(ctx); err != nil { + return status, nil, err + } + + config := OnIssueConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return http.StatusInternalServerError, nil, fmt.Errorf("failed to decode configuration: %w", err) + } + + payload := map[string]any{} + if err := json.Unmarshal(ctx.Body, &payload); err != nil { + return http.StatusBadRequest, nil, fmt.Errorf("failed to parse Jira webhook payload: %w", err) + } + + webhookEvent, _ := payload["webhookEvent"].(string) + action := issueActionFromWebhookEvent(webhookEvent) + if action == "" { + return http.StatusOK, nil, nil + } + + if len(config.Actions) > 0 && !slices.Contains(config.Actions, action) { + return http.StatusOK, nil, nil + } + + if config.Project != "" && !payloadMatchesProject(payload, config.Project) { + return http.StatusOK, nil, nil + } + + payload["action"] = action + if err := ctx.Events.Emit("jira.issue."+action, payload); err != nil { + return http.StatusInternalServerError, nil, fmt.Errorf("failed to emit Jira issue event: %w", err) + } + + return http.StatusOK, nil, nil +} + +func (t *OnIssue) Hooks() []core.Hook { + return []core.Hook{} +} + +func (t *OnIssue) HandleHook(ctx core.TriggerHookContext) (map[string]any, error) { + return nil, nil +} + +func (t *OnIssue) Cleanup(ctx core.TriggerContext) error { + return nil +} + +func verifyJiraWebhookAuthorization(ctx core.WebhookRequestContext) (int, error) { + config := loadConfiguration(ctx.Integration) + if config.ClientSecret == "" { + return http.StatusForbidden, fmt.Errorf("client secret is required for Jira webhook verification") + } + + header := ctx.Headers.Get("Authorization") + if header == "" { + return http.StatusForbidden, fmt.Errorf("missing Authorization header") + } + + tokenString := stripJiraAuthorizationPrefix(header) + if tokenString == "" { + return http.StatusForbidden, fmt.Errorf("invalid Authorization header") + } + + token, err := jwt.Parse(strings.TrimSpace(tokenString), func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + + return []byte(config.ClientSecret), nil + }) + if err != nil || !token.Valid { + return http.StatusForbidden, fmt.Errorf("invalid Jira webhook authorization") + } + + return http.StatusOK, nil +} + +func issueActionFromWebhookEvent(event string) string { + switch event { + case jiraWebhookEventCreated: + return IssueActionCreated + case jiraWebhookEventUpdated: + return IssueActionUpdated + case jiraWebhookEventDeleted: + return IssueActionDeleted + default: + return "" + } +} + +// stripJiraAuthorizationPrefix accepts either the Atlassian Connect-style +// "JWT " header or the more common "Bearer " header. +// Comparison is case-insensitive on the scheme. +func stripJiraAuthorizationPrefix(header string) string { + header = strings.TrimSpace(header) + for _, prefix := range []string{"JWT ", "Bearer "} { + if len(header) >= len(prefix) && strings.EqualFold(header[:len(prefix)], prefix) { + return strings.TrimSpace(header[len(prefix):]) + } + } + + return "" +} + +func isKnownIssueAction(action string) bool { + return action == IssueActionCreated || action == IssueActionUpdated || action == IssueActionDeleted +} + +func payloadMatchesProject(payload map[string]any, project string) bool { + issue, ok := payload["issue"].(map[string]any) + if !ok { + return false + } + + fields, ok := issue["fields"].(map[string]any) + if !ok { + return false + } + + projectData, ok := fields["project"].(map[string]any) + if !ok { + return false + } + + key, _ := projectData["key"].(string) + id, _ := projectData["id"].(string) + return project == key || project == id +} diff --git a/pkg/integrations/jira/on_issue_test.go b/pkg/integrations/jira/on_issue_test.go new file mode 100644 index 0000000000..67387e0536 --- /dev/null +++ b/pkg/integrations/jira/on_issue_test.go @@ -0,0 +1,209 @@ +package jira + +import ( + "io" + "net/http" + "strings" + "testing" + "time" + + jwt "github.com/golang-jwt/jwt/v4" + "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__OnIssue__Setup(t *testing.T) { + trigger := &OnIssue{} + + t.Run("valid setup requests shared webhook", func(t *testing.T) { + appCtx := &contexts.IntegrationContext{ + Metadata: Metadata{ + AuthType: AuthTypeOAuth, + CloudID: "cloud-123", + Projects: []Project{ + {ID: "10000", Key: "SP", Name: "SuperPlane"}, + }, + }, + } + + metadataCtx := &contexts.MetadataContext{} + err := trigger.Setup(core.TriggerContext{ + Integration: appCtx, + Metadata: metadataCtx, + Configuration: map[string]any{ + "project": "SP", + "actions": []string{IssueActionCreated}, + }, + }) + + require.NoError(t, err) + require.Len(t, appCtx.WebhookRequests, 1) + assert.Equal(t, WebhookConfiguration{CloudID: "cloud-123"}, appCtx.WebhookRequests[0]) + + nodeMetadata, ok := metadataCtx.Metadata.(NodeMetadata) + require.True(t, ok) + require.NotNil(t, nodeMetadata.Project) + assert.Equal(t, "SP", nodeMetadata.Project.Key) + }) + + t.Run("invalid action -> error", func(t *testing.T) { + err := trigger.Setup(core.TriggerContext{ + Integration: &contexts.IntegrationContext{}, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{ + "actions": []string{"renamed"}, + }, + }) + + require.ErrorContains(t, err, "unsupported issue action") + }) + + t.Run("requires completed oauth flow", func(t *testing.T) { + err := trigger.Setup(core.TriggerContext{ + Integration: &contexts.IntegrationContext{ + Metadata: Metadata{}, + }, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{}, + }) + + require.ErrorContains(t, err, "not connected yet") + }) +} + +func Test__OnIssue__HandleWebhook(t *testing.T) { + trigger := &OnIssue{} + + t.Run("emits matching issue event", func(t *testing.T) { + events := &contexts.EventContext{} + status, _, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Headers: signedJiraHeaders(t, "secret"), + Body: []byte(issueWebhookPayload(jiraWebhookEventCreated, "SP")), + Configuration: map[string]any{"project": "SP", "actions": []string{IssueActionCreated}}, + Events: events, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "clientSecret": "secret", + }, + }, + }) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, status) + require.Len(t, events.Payloads, 1) + assert.Equal(t, "jira.issue.created", events.Payloads[0].Type) + }) + + t.Run("filters unmatched project", func(t *testing.T) { + events := &contexts.EventContext{} + status, _, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Headers: signedJiraHeaders(t, "secret"), + Body: []byte(issueWebhookPayload(jiraWebhookEventUpdated, "OTHER")), + Configuration: map[string]any{"project": "SP"}, + Events: events, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "clientSecret": "secret", + }, + }, + }) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, status) + assert.Empty(t, events.Payloads) + }) + + t.Run("accepts JWT prefix", func(t *testing.T) { + events := &contexts.EventContext{} + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "iss": "jira", + "exp": time.Now().Add(time.Hour).Unix(), + }) + + signed, err := token.SignedString([]byte("secret")) + require.NoError(t, err) + + headers := http.Header{"Authorization": []string{"JWT " + signed}} + + status, _, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Headers: headers, + Body: []byte(issueWebhookPayload(jiraWebhookEventCreated, "SP")), + Configuration: map[string]any{}, + Events: events, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "clientSecret": "secret", + }, + }, + }) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, status) + require.Len(t, events.Payloads, 1) + }) + + t.Run("missing auth header -> forbidden", func(t *testing.T) { + status, _, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Headers: http.Header{}, + Body: []byte(issueWebhookPayload(jiraWebhookEventCreated, "SP")), + Configuration: map[string]any{}, + Events: &contexts.EventContext{}, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "clientSecret": "secret", + }, + }, + }) + + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, status) + }) +} + +func signedJiraHeaders(t *testing.T, secret string) http.Header { + t.Helper() + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "iss": "jira", + "exp": time.Now().Add(time.Hour).Unix(), + }) + + signed, err := token.SignedString([]byte(secret)) + require.NoError(t, err) + + return http.Header{"Authorization": []string{"Bearer " + signed}} +} + +func issueWebhookPayload(event, projectKey string) string { + return `{ + "webhookEvent": "` + event + `", + "issue": { + "id": "10001", + "key": "` + projectKey + `-1", + "fields": { + "summary": "Test issue", + "project": { + "id": "10000", + "key": "` + projectKey + `", + "name": "Test Project" + } + } + } + }` +} + +func Test__OnIssue__ExampleData(t *testing.T) { + data := (&OnIssue{}).ExampleData() + require.NotEmpty(t, data) + assert.Equal(t, jiraWebhookEventCreated, data["webhookEvent"]) +} + +func response(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader(body)), + } +} diff --git a/pkg/integrations/jira/webhook_handler.go b/pkg/integrations/jira/webhook_handler.go new file mode 100644 index 0000000000..1fb3429b1b --- /dev/null +++ b/pkg/integrations/jira/webhook_handler.go @@ -0,0 +1,99 @@ +package jira + +import ( + "fmt" + "net/http" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/core" +) + +type WebhookConfiguration struct { + CloudID string `json:"cloudId" mapstructure:"cloudId"` +} + +type WebhookMetadata struct { + WebhookID int64 `json:"webhookId" mapstructure:"webhookId"` +} + +type JiraWebhookHandler struct{} + +func (h *JiraWebhookHandler) CompareConfig(a, b any) (bool, error) { + configA := WebhookConfiguration{} + configB := WebhookConfiguration{} + + if err := mapstructure.Decode(a, &configA); err != nil { + return false, err + } + + if err := mapstructure.Decode(b, &configB); err != nil { + return false, err + } + + return configA.CloudID == configB.CloudID, nil +} + +func (h *JiraWebhookHandler) Merge(current, requested any) (any, bool, error) { + return current, false, nil +} + +func (h *JiraWebhookHandler) Setup(ctx core.WebhookHandlerContext) (any, error) { + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, fmt.Errorf("failed to create Jira client: %w", err) + } + + webhook, err := client.CreateWebhook(CreateWebhookRequest{ + URL: ctx.Webhook.GetURL(), + Webhooks: []WebhookRegistration{ + { + Events: []string{ + jiraWebhookEventCreated, + jiraWebhookEventUpdated, + jiraWebhookEventDeleted, + }, + JQLFilter: "project IS NOT EMPTY", + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to create Jira webhook: %w", err) + } + + if len(webhook.WebhookRegistrationResult) == 0 { + return nil, fmt.Errorf("Jira webhook response did not include a registration result") + } + + result := webhook.WebhookRegistrationResult[0] + if result.CreatedWebhookID == 0 { + return nil, fmt.Errorf("Jira webhook was not created: %v", result.Errors) + } + + return WebhookMetadata{WebhookID: result.CreatedWebhookID}, nil +} + +func (h *JiraWebhookHandler) Cleanup(ctx core.WebhookHandlerContext) error { + metadata := WebhookMetadata{} + if err := mapstructure.Decode(ctx.Webhook.GetMetadata(), &metadata); err != nil { + return fmt.Errorf("failed to decode Jira webhook metadata: %w", err) + } + + if metadata.WebhookID == 0 { + return nil + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create Jira client: %w", err) + } + + if err := client.DeleteWebhook(metadata.WebhookID); err != nil { + if apiErr, ok := err.(*APIError); ok && apiErr.StatusCode == http.StatusNotFound { + return nil + } + + return fmt.Errorf("failed to delete Jira webhook: %w", err) + } + + return nil +} diff --git a/pkg/integrations/jira/webhook_handler_test.go b/pkg/integrations/jira/webhook_handler_test.go new file mode 100644 index 0000000000..2be8397889 --- /dev/null +++ b/pkg/integrations/jira/webhook_handler_test.go @@ -0,0 +1,92 @@ +package jira + +import ( + "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__JiraWebhookHandler__Setup(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + response(http.StatusOK, `{"webhookRegistrationResult":[{"createdWebhookId":12345}]}`), + }, + } + + appCtx := &contexts.IntegrationContext{ + Metadata: Metadata{CloudID: "cloud-123"}, + CurrentSecrets: map[string]core.IntegrationSecret{ + OAuthAccessToken: {Name: OAuthAccessToken, Value: []byte("access-token")}, + }, + } + + webhookCtx := &contexts.WebhookContext{ + URL: "https://superplane.example/api/v1/webhooks/webhook-id", + Configuration: WebhookConfiguration{CloudID: "cloud-123"}, + } + + metadata, err := (&JiraWebhookHandler{}).Setup(core.WebhookHandlerContext{ + HTTP: httpContext, + Integration: appCtx, + Webhook: webhookCtx, + }) + + require.NoError(t, err) + assert.Equal(t, WebhookMetadata{WebhookID: 12345}, metadata) + + require.Len(t, httpContext.Requests, 1) + request := httpContext.Requests[0] + assert.Equal(t, http.MethodPost, request.Method) + assert.Equal(t, "https://api.atlassian.com/ex/jira/cloud-123/rest/api/3/webhook", request.URL.String()) + assert.Equal(t, "Bearer access-token", request.Header.Get("Authorization")) +} + +func Test__JiraWebhookHandler__Cleanup(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + response(http.StatusNoContent, ""), + }, + } + + appCtx := &contexts.IntegrationContext{ + Metadata: Metadata{CloudID: "cloud-123"}, + CurrentSecrets: map[string]core.IntegrationSecret{ + OAuthAccessToken: {Name: OAuthAccessToken, Value: []byte("access-token")}, + }, + } + + err := (&JiraWebhookHandler{}).Cleanup(core.WebhookHandlerContext{ + HTTP: httpContext, + Integration: appCtx, + Webhook: &contexts.WebhookContext{ + Metadata: WebhookMetadata{WebhookID: 12345}, + }, + }) + + require.NoError(t, err) + require.Len(t, httpContext.Requests, 1) + assert.Equal(t, http.MethodDelete, httpContext.Requests[0].Method) + assert.Equal(t, "https://api.atlassian.com/ex/jira/cloud-123/rest/api/3/webhook", httpContext.Requests[0].URL.String()) +} + +func Test__JiraWebhookHandler__CompareConfig(t *testing.T) { + matches, err := (&JiraWebhookHandler{}).CompareConfig( + WebhookConfiguration{CloudID: "cloud-123"}, + WebhookConfiguration{CloudID: "cloud-123"}, + ) + + require.NoError(t, err) + assert.True(t, matches) + + matches, err = (&JiraWebhookHandler{}).CompareConfig( + WebhookConfiguration{CloudID: "cloud-123"}, + WebhookConfiguration{CloudID: "cloud-456"}, + ) + + require.NoError(t, err) + assert.False(t, matches) +} diff --git a/web_src/src/pages/workflowv2/mappers/index.ts b/web_src/src/pages/workflowv2/mappers/index.ts index c14dbcfc9a..971ae4d60a 100644 --- a/web_src/src/pages/workflowv2/mappers/index.ts +++ b/web_src/src/pages/workflowv2/mappers/index.ts @@ -35,6 +35,7 @@ import { triggerRenderers as gitlabTriggerRenderers, eventStateRegistry as gitlabEventStateRegistry, } from "./gitlab/index"; +import { triggerRenderers as jiraTriggerRenderers } from "./jira/index"; import { componentMappers as pagerdutyComponentMappers, triggerRenderers as pagerdutyTriggerRenderers, @@ -337,6 +338,7 @@ const appTriggerRenderers: Record> = { semaphore: semaphoreTriggerRenderers, github: githubTriggerRenderers, gitlab: gitlabTriggerRenderers, + jira: jiraTriggerRenderers, pagerduty: pagerdutyTriggerRenderers, dash0: dash0TriggerRenderers, daytona: daytonaTriggerRenderers, 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..08f8040d6d --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/jira/index.ts @@ -0,0 +1,6 @@ +import type { TriggerRenderer } from "../types"; +import { onIssueTriggerRenderer } from "./on_issue"; + +export const triggerRenderers: Record = { + onIssue: onIssueTriggerRenderer, +}; diff --git a/web_src/src/pages/workflowv2/mappers/jira/on_issue.ts b/web_src/src/pages/workflowv2/mappers/jira/on_issue.ts new file mode 100644 index 0000000000..4f68d1a4bc --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/jira/on_issue.ts @@ -0,0 +1,134 @@ +import { getBackgroundColorClass, getColorClass } from "@/lib/colors"; +import type { TriggerEventContext, TriggerRenderer, TriggerRendererContext } from "../types"; +import type { TriggerProps } from "@/ui/trigger"; +import jiraIcon from "@/assets/icons/integrations/jira.svg"; +import { buildSubtitle, stringOrDash } from "../utils"; + +interface OnIssueConfiguration { + project?: string; + actions?: string[]; +} + +interface JiraProject { + id?: string; + key?: string; + name?: string; +} + +interface JiraIssue { + id?: string; + key?: string; + self?: string; + fields?: { + summary?: string; + project?: JiraProject; + issuetype?: { + name?: string; + }; + status?: { + name?: string; + }; + }; +} + +interface OnIssueEventData { + action?: string; + issue?: JiraIssue; +} + +interface JiraNodeMetadata { + project?: JiraProject; +} + +const actionLabels: Record = { + created: "Created", + updated: "Updated", + deleted: "Deleted", +}; + +function formatAction(action?: string): string { + if (!action) { + return ""; + } + + return actionLabels[action] || action; +} + +function issueTitle(issue?: JiraIssue): string { + const key = issue?.key; + const summary = issue?.fields?.summary; + if (key && summary) { + return `${key} - ${summary}`; + } + + return key || summary || "Jira issue"; +} + +function getDetailsForIssue(issue?: JiraIssue, action?: string): Record { + return { + Action: stringOrDash(formatAction(action)), + Key: stringOrDash(issue?.key), + Summary: stringOrDash(issue?.fields?.summary), + Project: stringOrDash(issue?.fields?.project?.name || issue?.fields?.project?.key), + Type: stringOrDash(issue?.fields?.issuetype?.name), + Status: stringOrDash(issue?.fields?.status?.name), + URL: stringOrDash(issue?.self), + }; +} + +export const onIssueTriggerRenderer: TriggerRenderer = { + getTitleAndSubtitle: (context: TriggerEventContext) => { + const eventData = context.event?.data as OnIssueEventData; + return { + title: issueTitle(eventData?.issue), + subtitle: buildSubtitle(formatAction(eventData?.action), context.event?.createdAt), + }; + }, + + getRootEventValues: (context: TriggerEventContext): Record => { + const eventData = context.event?.data as OnIssueEventData; + return getDetailsForIssue(eventData?.issue, eventData?.action); + }, + + getTriggerProps: (context: TriggerRendererContext) => { + const { node, definition, lastEvent } = context; + const configuration = node.configuration as OnIssueConfiguration; + const metadata = node.metadata as JiraNodeMetadata; + const metadataItems: { icon: string; label: string }[] = []; + + if (metadata?.project?.name || configuration?.project) { + metadataItems.push({ + icon: "folder", + label: metadata?.project?.name || configuration?.project || "", + }); + } + + if (configuration?.actions?.length) { + metadataItems.push({ + icon: "funnel", + label: configuration.actions.map(formatAction).join(", "), + }); + } + + const props: TriggerProps = { + title: node.name || definition.label || "Unnamed trigger", + iconSrc: jiraIcon, + iconColor: getColorClass(definition.color), + collapsedBackground: getBackgroundColorClass(definition.color), + metadata: metadataItems, + }; + + if (lastEvent) { + const eventData = lastEvent.data as OnIssueEventData; + props.lastEventData = { + title: issueTitle(eventData?.issue), + subtitle: buildSubtitle(formatAction(eventData?.action), lastEvent.createdAt), + receivedAt: new Date(lastEvent.createdAt), + state: "triggered", + eventId: lastEvent.id, + }; + } + + return props; + }, +};