Skip to content
Open
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
4 changes: 0 additions & 4 deletions docs/components/AWS.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,6 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components";
<LinkCard title="SQS • Send Message" href="#sqs-•-send-message" description="Send a message to an SQS queue" />
</CardGrid>

## Instructions

Initially, you can leave the **"IAM Role ARN"** field empty, as you will be guided through the identity provider and IAM role creation process.

<a id="cloud-watch-•-on-alarm"></a>

## CloudWatch • On Alarm
Expand Down
3 changes: 0 additions & 3 deletions docs/components/GitLab.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,6 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components";

## Instructions

When connecting using App OAuth:
- Leave **Client ID** and **Secret** empty to start the setup wizard.

When connecting using Personal Access Token:
- Go to Preferences → Personal Access Token → Add New token
- Use **Scopes**: api, read_user, read_api, write_repository, read_repository
Expand Down
151 changes: 151 additions & 0 deletions docs/components/Linear.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
---
title: "Linear"
---

Manage and react to issues in Linear

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

## Triggers

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

## Actions

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

<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": "590b5685-0c0d-476f-b33b-2afdc1273716@oauthapp.linear.app",
"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"
}
```

2 changes: 1 addition & 1 deletion pkg/integrations/aws/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func (a *AWS) Description() string {
}

func (a *AWS) Instructions() string {
return "Initially, you can leave the **\"IAM Role ARN\"** field empty, as you will be guided through the identity provider and IAM role creation process."
return ""
}

func (a *AWS) Configuration() []configuration.Field {
Expand Down
2 changes: 1 addition & 1 deletion pkg/integrations/cursor/cursor.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func (i *Cursor) Sync(ctx core.SyncContext) error {
}

if config.LaunchAgentKey == "" && config.AdminKey == "" {
return fmt.Errorf("one of the keys is required")
return nil
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor sync leaves stale ready state

Medium Severity

Cursor.Sync() now returns nil when both LaunchAgentKey and AdminKey are empty, but it also doesn’t set any “pending/error” state. If credentials are removed after the integration was previously ready, this early return can leave a stale ready status even though the integration can no longer authenticate.

Fix in Cursor Fix in Web

}

client, err := NewClient(ctx.HTTP, ctx.Integration)
Expand Down
6 changes: 3 additions & 3 deletions pkg/integrations/cursor/cursor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ func Test__Cursor__Sync(t *testing.T) {
require.Len(t, httpContext.Requests, 2)
})

t.Run("no keys provided -> error", func(t *testing.T) {
t.Run("no keys provided -> stays pending", func(t *testing.T) {
httpContext := &contexts.HTTPContext{}

integrationCtx := &contexts.IntegrationContext{
Expand All @@ -116,8 +116,8 @@ func Test__Cursor__Sync(t *testing.T) {
Integration: integrationCtx,
})

require.Error(t, err)
assert.Contains(t, err.Error(), "one of the keys is required")
require.NoError(t, err)
assert.Empty(t, integrationCtx.State)
})

t.Run("invalid cloud agent key -> error", func(t *testing.T) {
Expand Down
3 changes: 2 additions & 1 deletion pkg/integrations/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ func (g *GitHub) Configuration() []configuration.Field {
Name: "organization",
Label: "Organization",
Type: configuration.FieldTypeString,
Description: "Organization to install the app into. If not specified, the app will be installed into the user's account.",
Required: true,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if I want to install on my personal account? Is that not possible anymore?

Description: "GitHub organization to install the app into.",
},
}
}
Expand Down
6 changes: 3 additions & 3 deletions pkg/integrations/gitlab/gitlab.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,6 @@ func (g *GitLab) Description() string {

func (g *GitLab) Instructions() string {
return fmt.Sprintf(`
When connecting using App OAuth:
- Leave **Client ID** and **Secret** empty to start the setup wizard.

When connecting using Personal Access Token:
- Go to Preferences → Personal Access Token → Add New token
- Use **Scopes**: %s
Expand Down Expand Up @@ -162,6 +159,9 @@ func (g *GitLab) Configuration() []configuration.Field {
VisibilityConditions: []configuration.VisibilityCondition{
{Field: "authType", Values: []string{AuthTypePersonalAccessToken}},
},
RequiredConditions: []configuration.RequiredCondition{
{Field: "authType", Values: []string{AuthTypePersonalAccessToken}},
},
},
}
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/integrations/grafana/grafana.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func (g *Grafana) Configuration() []configuration.Field {
Type: configuration.FieldTypeString,
Description: "Grafana API key or service account token",
Sensitive: true,
Required: false,
Required: true,
},
}
}
Expand Down
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