Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 66 additions & 7 deletions docs/components/Jira.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@
title: "Jira"
---

Manage issues in Jira
Manage and react to issues in Jira

import { CardGrid, LinkCard } from "@astrojs/starlight/components";

## Triggers

<CardGrid>
<LinkCard title="On Issue" href="#on-issue" description="Start a workflow when Jira creates, updates, or deletes an issue" />
</CardGrid>

## Actions

<CardGrid>
Expand All @@ -20,13 +26,66 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components";

## Instructions

To connect Jira to SuperPlane:
**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](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.

<a id="on-issue"></a>

## On Issue

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

1. Open [Atlassian API tokens](https://id.atlassian.com/manage-profile/security/api-tokens).
2. Click **Create API token**, give it a recognizable label, and copy the generated token.
3. Paste your **Jira Site URL** into SuperPlane. For Jira Cloud, this usually looks like `https://your-domain.atlassian.net`.
4. Paste the Atlassian account **Email** that owns the API token.
5. Paste the generated **API Token**.
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 Data

```json
{
"action": "created",
"issue": {
"fields": {
"issuetype": {
"name": "Task"
},
"project": {
"id": "10000",
"key": "SP",
"name": "SuperPlane"
},
"status": {
"name": "To Do"
},
"summary": "Example issue"
},
"id": "10001",
"key": "SP-123",
"self": "https://example.atlassian.net/rest/api/3/issue/10001"
},
"webhookEvent": "jira:issue_created"
}
```

<a id="create-incident"></a>

Expand Down
183 changes: 183 additions & 0 deletions pkg/integrations/jira/auth.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading
Loading