Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
155 changes: 155 additions & 0 deletions docs/components/Linear.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
---
title: "Linear"
---

Manage and react to issues in Linear

## Triggers

<CardGrid>
<LinkCard title="On Issue" href="#on-issue" description="Start a workflow when an issue is created, updated, or removed in Linear" />
</CardGrid>

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

## Actions

<CardGrid>
<LinkCard title="Create Issue" href="#create-issue" description="Create a new issue in Linear" />
</CardGrid>

## Instructions

Leave **Client ID** and **Client Secret** empty to start the setup wizard.

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

## On Issue

The On Issue trigger starts a workflow when an issue is created, updated, or removed in Linear.

### Use Cases

- **Issue automation**: Run workflows when issues change
- **Notification workflows**: Notify channels or create tasks elsewhere
- **Filter by team/label**: Optionally restrict to a team and/or labels

### Configuration

- **Team**: Optional. Select a team to listen to, or leave empty to listen to all public teams.
- **Labels**: Optional. Only trigger when the issue has at least one of these labels.

### Event Data

The payload includes Linear webhook fields: action, type, data (issue), actor, url, createdAt, webhookTimestamp.
The action field indicates the event type: "create", "update", or "remove".

### Example Data

```json
{
"data": {
"action": "create",
"actor": {
"email": "[email protected]",
"id": "bd35d567-0f57-46fa-85e1-9b1e53529393",
"name": "superplane",
"type": "user",
"url": "https://linear.app/brainstormmingdev/profiles/superplane1"
},
"createdAt": "2026-02-16T21:35:23.762Z",
"data": {
"addedToTeamAt": "2026-02-16T21:35:23.788Z",
"botActor": null,
"createdAt": "2026-02-16T21:35:23.762Z",
"creatorId": "bd35d567-0f57-46fa-85e1-9b1e53529393",
"description": "test test",
"descriptionData": "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"test test\"}]}]}",
"id": "4aae5d23-07a5-4e68-912e-098dbf5a5b4e",
"identifier": "BRA-45",
"labelIds": [],
"labels": [],
"number": 45,
"previousIdentifiers": [],
"priority": 0,
"priorityLabel": "No priority",
"prioritySortOrder": -4135,
"reactionData": [],
"slaType": "all",
"sortOrder": -4029,
"state": {
"color": "#bec2c8",
"id": "af874731-64fd-4b3e-b871-b762922637b6",
"name": "Backlog",
"type": "backlog"
},
"stateId": "af874731-64fd-4b3e-b871-b762922637b6",
"subscriberIds": [
"bd35d567-0f57-46fa-85e1-9b1e53529393"
],
"team": {
"id": "01d0cb17-f38a-4fea-9e5c-717dbb27766f",
"key": "BRA",
"name": "BrainStormmingDev"
},
"teamId": "01d0cb17-f38a-4fea-9e5c-717dbb27766f",
"title": "Test superplane",
"updatedAt": "2026-02-16T21:35:23.762Z",
"url": "https://linear.app/brainstormmingdev/issue/BRA-45/test-superplane"
},
"type": "Issue",
"url": "https://linear.app/brainstormmingdev/issue/BRA-45/test-superplane",
"webhookTimestamp": 1771277724034
},
"timestamp": "2026-02-16T21:35:22.388339184Z",
"type": "linear.issue"
}
```

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

## Create Issue

The Create Issue component creates a new issue in Linear.

### Use Cases

- **Task creation**: Automatically create issues from workflow events
- **Bug tracking**: Create issues from alerts or external systems
- **Feature requests**: Generate issues from forms or other triggers

### Configuration

- **Team**: The Linear team to create the issue in
- **Title**: Issue title (required)
- **Description**: Optional description
- **Assignee**, **Labels**, **Priority**, **Status**: Optional

### Output

Returns the created issue: id, identifier, title, description, priority, url, createdAt.

### Example Output

```json
{
"data": {
"createdAt": "2026-02-16T21:35:23.762Z",
"description": "test test",
"id": "4aae5d23-07a5-4e68-912e-098dbf5a5b4e",
"identifier": "BRA-45",
"priority": 0,
"state": {
"id": "af874731-64fd-4b3e-b871-b762922637b6"
},
"team": {
"id": "01d0cb17-f38a-4fea-9e5c-717dbb27766f"
},
"title": "Test superplane",
"url": "https://linear.app/brainstormmingdev/issue/BRA-45/test-superplane"
},
"timestamp": "2026-02-16T21:35:22.010159122Z",
"type": "linear.issue"
}
```

129 changes: 129 additions & 0 deletions pkg/integrations/linear/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package linear

import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"

"github.com/superplanehq/superplane/pkg/core"
)

const (
linearAuthorizeURL = "https://linear.app/oauth/authorize"
linearTokenURL = "https://api.linear.app/oauth/token"
)

type Auth struct {
client core.HTTPContext
}

func NewAuth(client core.HTTPContext) *Auth {
return &Auth{client: client}
}

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"`
}

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 (a *Auth) exchangeCode(clientID, clientSecret, code, redirectURI string) (*TokenResponse, error) {
data := url.Values{}
data.Set("grant_type", "authorization_code")
data.Set("client_id", clientID)
data.Set("client_secret", clientSecret)
data.Set("code", code)
data.Set("redirect_uri", redirectURI)
return a.postToken(data)
}

func (a *Auth) RefreshToken(clientID, clientSecret, refreshToken string) (*TokenResponse, error) {
data := url.Values{}
data.Set("grant_type", "refresh_token")
data.Set("client_id", clientID)
data.Set("client_secret", clientSecret)
data.Set("refresh_token", refreshToken)
return a.postToken(data)
}

func (a *Auth) postToken(data url.Values) (*TokenResponse, error) {
req, err := http.NewRequest(http.MethodPost, linearTokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")

resp, err := a.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

body, 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(body))
}

var tokenResp TokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, err
}

return &tokenResp, nil
}

func (a *Auth) HandleCallback(req *http.Request, clientID, clientSecret, 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(clientID, clientSecret, code, redirectURI)
}

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
}
Loading