-
Notifications
You must be signed in to change notification settings - Fork 101
feat: Add Linear integration #3041
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Andrew-127
wants to merge
10
commits into
superplanehq:main
Choose a base branch
from
Andrew-127:feat/linear-integration
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+3,500
−225
Open
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
a35842e
feat: Linear Integration
Andrew-127 9135291
fix: Upgrade to OAuth, OnIssue
Andrew-127 7736456
fix: OAuth initiation synced
Andrew-127 d8e9238
fix: address PR review feedback for Linear integration
Andrew-127 294ce96
fix: Resolve unit test failure
Andrew-127 f5966c9
Merge main into feat/linear-integration
AleksandarCole 9fca261
Change integrations flow
AleksandarCole 83e2cde
Formatting and cleanup
AleksandarCole 2d624bb
Unit tests fix
AleksandarCole 24e792e
chore: regenerate component docs after instruction text changes
AleksandarCole File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } | ||
| ``` | ||
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.", | ||
| }, | ||
| } | ||
| } | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 returnsnilwhen bothLaunchAgentKeyandAdminKeyare empty, but it also doesn’t set any “pending/error” state. If credentials are removed after the integration was previouslyready, this early return can leave a stalereadystatus even though the integration can no longer authenticate.