Skip to content

Comments

feat: Add Linear integration#3041

Open
Andrew-127 wants to merge 10 commits intosuperplanehq:mainfrom
Andrew-127:feat/linear-integration
Open

feat: Add Linear integration#3041
Andrew-127 wants to merge 10 commits intosuperplanehq:mainfrom
Andrew-127:feat/linear-integration

Conversation

@Andrew-127
Copy link

Implements #2485

Description

This PR implements the Linear integration for SuperPlane with 2 components:

  • OnIssueCreated (trigger): Activates workflows when Linear issues are created, with optional filtering by team and/or labels. Uses Linear webhooks with HMAC-SHA256 signature verification.
  • CreateIssue (action): Creates new Linear issues with configurable team, title, description, assignee, labels, priority, and status fields.

Authentication is via personal API token, which users can generate in Linear at Settings > API > Personal API keys. The integration uses Linear's GraphQL API exclusively for all operations (teams, labels, issue creation, webhook management).

On setup, the integration syncs available teams and labels from Linear, which are then used to populate resource pickers in the UI.

Changes

Backend (pkg/integrations/linear/)

  • linear.go — Integration entry point, API token auth, metadata sync (teams/labels)
  • client.go — GraphQL API client (viewer, teams, labels, issue create, webhook create/delete)
  • common.go — Shared data models (Team, Label, Issue)
  • on_issue_created.go — Webhook-based trigger with team/label filtering
  • create_issue.go — Issue creation action with full field support
  • webhook_handler.go — Webhook lifecycle (setup, config comparison, cleanup)
  • list_resources.go — Resource discovery for UI pickers
  • example.go — Embedded example payloads

Frontend (web_src/src/pages/workflowv2/mappers/linear/)

  • Trigger renderer and component mapper following existing patterns
  • Icon and sidebar registration

Docs

  • docs/components/Linear.mdx — Generated component documentation

Tests

  • linear_test.go — Integration sync (missing token, invalid creds, successful sync)
  • client_test.go — Client creation and API calls
  • create_issue_test.go — Setup validation and execution
  • on_issue_created_test.go — Signature verification, event filtering, webhook setup
  • webhook_handler_test.go — Config comparison logic

[video-demo]

@AleksandarCole AleksandarCole added bounty This issue has a bounty open pr:stage-1/3 Needs to pass basic review. pr:stage-2/3 Needs to pass functional review and removed pr:stage-1/3 Needs to pass basic review. labels Feb 11, 2026
@Andrew-127 Andrew-127 force-pushed the feat/linear-integration branch from c5c2e78 to 1d14ce9 Compare February 11, 2026 16:05
@Andrew-127 Andrew-127 force-pushed the feat/linear-integration branch 2 times, most recently from 151ded4 to 435e1e7 Compare February 11, 2026 17:24
@Andrew-127 Andrew-127 force-pushed the feat/linear-integration branch 2 times, most recently from 49fc617 to 81243e8 Compare February 11, 2026 18:26
@zekebawt
Copy link

🤖 Claiming this bounty! Will implement Linear integration with OnIssueCreated trigger and CreateIssue action. ETA: 2-3 hours.

@AleksandarCole
Copy link
Collaborator

@brainstormingdev-super Looks good overall and works as expected. Here are some small UX changes we need to address before we move to code review:

  • createIssue uses wrong input fields for title/description/assignee - they are the expression fields that filter/if use, but instead they should be input with expression supported like Wait for in the wait component for example.
  • Let's clean up the details tab for both components - it should show timestamp at the top (Created at:) also, let's remove things like TeamID, StateID, or IDs in general. In the create issue details tab, make sure to include link to the issue - like what we have in onIssueCreated.

@Andrew-127 Andrew-127 force-pushed the feat/linear-integration branch from 81243e8 to a718f32 Compare February 13, 2026 06:32
@Andrew-127 Andrew-127 force-pushed the feat/linear-integration branch from a718f32 to c5c3981 Compare February 13, 2026 07:38
@Andrew-127
Copy link
Author

@AleksandarCole I’ve improved the UX. Please take a look at the video demo below.
video demo

@AleksandarCole AleksandarCole added pr:stage-3/3 Ready for full, in-depth, review and removed pr:stage-2/3 Needs to pass functional review labels Feb 13, 2026
@AleksandarCole
Copy link
Collaborator

@brainstormingdev-super looks good - moved to code review.

@shiroyasha
Copy link
Collaborator

Hey @brainstormingdev-super, tried it out, works good.

When looking at Linear, OAuth seems to be the default way to integrate with the system.
Why did you decide to go with the personal API key?

I see two big problems with Personal API key vs OAuth:

1/ When you create an issue with personal keys, the creator is the Personal Key's owner. With OAuth the creator of the issue can be a SuperPlaneBot which is more natural for integrations and workflow automations.
2/ When the user leaves the organization, the SuperPlane automation will no longer work.

cc @AleksandarCole

Returning to stage-2

@shiroyasha shiroyasha added pr:stage-2/3 Needs to pass functional review and removed pr:stage-3/3 Ready for full, in-depth, review labels Feb 13, 2026

const onIssueCreatedPayloadType = "linear.issue.create"

type OnIssueCreated struct{}
Copy link
Contributor

Choose a reason for hiding this comment

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

@AleksandarCole I don't think we should have a onIssueCreated trigger. Instead, we should have a onIssue trigger, that allows you select the action you're interested in (create, close, deleted, ...). This pattern is already used in the github.onIssue and gitlab.onIssue

Comment on lines 122 to 139
func (c *Client) ListTeams() ([]Team, error) {
const query = `query($after: String) { teams(first: 100, after: $after) { nodes { id name key } pageInfo { hasNextPage endCursor } } }`
var all []Team
var cursor *string
for {
vars := map[string]any{"after": cursor}
var out teamsResponse
if err := c.execGraphQL(query, vars, &out); err != nil {
return nil, err
}
all = append(all, out.Teams.Nodes...)
if !out.Teams.PageInfo.HasNextPage {
break
}
cursor = &out.Teams.PageInfo.EndCursor
}
return all, nil
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Minor concern: if hasNextPage stays true and endCursor doesn’t change (e.g., due to an API inconsistency or pagination bug), this could loop forever. Maybe add a cursor-change check or safety cap?

Copy link
Collaborator

Choose a reason for hiding this comment

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

@brainstormingdev-super please review this, sounds serious.

Copy link
Author

Choose a reason for hiding this comment

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

@shiroyasha it is already fixed in updated push.
But I will make it more robust

Comment on lines 1 to 26
{
"action": "create",
"type": "Issue",
"data": {
"id": "2174add1-f7c8-44e3-bbf3-2d60b5ea8bc9",
"createdAt": "2020-01-23T12:53:18.084Z",
"updatedAt": "2020-01-23T12:53:18.084Z",
"title": "Example issue",
"description": "Issue description",
"identifier": "ENG-42",
"teamId": "72b2a2dc-6f4f-4423-9d34-24b5bd10634a",
"stateId": "state-1",
"priority": 2,
"labelIds": ["label-1"],
"assigneeId": "user-1"
},
"actor": {
"id": "b5ea5f1f-8adc-4f52-b4bd-ab4e84cf51ba",
"type": "user",
"name": "Alice",
"email": "alice@example.com"
},
"url": "https://linear.app/company/issue/ENG-42/example-issue",
"createdAt": "2020-01-23T12:53:18.084Z",
"webhookTimestamp": 1676056940508
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't think this is the data that appears on trigger payload (in the sidebar event). Check it and replace it here with mocked data

Copy link
Author

Choose a reason for hiding this comment

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

@forestileao fixed them, please review again

}

func (c *CreateIssue) Execute(ctx core.ExecutionContext) error {
if ctx.Integration == nil {
Copy link
Collaborator

Choose a reason for hiding this comment

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

No need for this nil check.

Copy link
Author

Choose a reason for hiding this comment

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

@shiroyasha removed unused nil check


func (h *LinearWebhookHandler) Merge(current, requested any) (any, bool, error) {
return current, false, nil
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

@brainstormingdev-super do we need this? It seems that it is not doing anything.

You can register integrations without a webhook handler, if you don't need it.

Copy link
Author

Choose a reason for hiding this comment

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

@shiroyasha yep, good catch. I will remove it

Signed-off-by: Andrew <cool.dev12701@gmail.com>
Signed-off-by: Andrew <cool.dev12701@gmail.com>
// When this field depends on other fields (e.g. Assignee/Status depend on Team), only fetch when those params are present
const hasRequiredParameters =
resourceParameters.length === 0 ||
(additionalQueryParameters !== undefined && Object.keys(additionalQueryParameters).length > 0);
Copy link

Choose a reason for hiding this comment

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

Parameter presence check allows partial resolution

Low Severity

The hasRequiredParameters check verifies that at least one parameter from resourceParameters is resolved (Object.keys(...).length > 0), but doesn't verify that all required parameters are resolved. For fields that depend on multiple parameters (e.g., AWS CodeArtifact's repository field depending on both region and domain), resolving only one would allow the fetch to proceed with incomplete parameters. Currently mitigated by VisibilityConditions on existing multi-parameter fields, but the logic doesn't match the comment's stated intent of checking that "those params are present."

Fix in Cursor Fix in Web

@shiroyasha
Copy link
Collaborator

@brainstormingdev-super the code looks good. You did good given how complex this UX flow is. Contact @AleksandarCole to collect your bounty.


On the superplane team's side, when I reviewed it with fresh eyes,
I really hate the UX flow. This is a deeper problem in the SuperPlane
application.

@AleksandarCole @ropsii have scheduled a session to solve it on Monday.
Returning it to stage-2/3 until we have it solved.

They will take over and continue with this PR.

@AleksandarCole AleksandarCole added pr:stage-2/3 Needs to pass functional review and removed pr:stage-3/3 Ready for full, in-depth, review labels Feb 20, 2026
@omarsoufiane
Copy link

@Andrew-127 Hello, I'm from bountyhub,
can you setup you payment information from bountyhub's dashboard? click on connnect stripe from dashboard/get-paid
it's not a regular stripe account that you'll use on a website, it's a connected account.
we use paypal only if it's not available in your country

Co-authored-by: Cursor <cursoragent@cursor.com>
if err != nil {
_ = ctx.Integration.SetSecret(OAuthRefreshToken, []byte(""))
_ = ctx.Integration.SetSecret(OAuthAccessToken, []byte(""))
return fmt.Errorf("failed to refresh token: %v", err)
Copy link

Choose a reason for hiding this comment

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

Refresh failure clears valid tokens forcing re-authorization

Medium Severity

When refreshToken encounters any error (including transient network blips or temporary Linear API outages), both OAuthAccessToken and OAuthRefreshToken are immediately cleared. The old access token likely remains valid for hours, but destroying it forces the user through a full OAuth re-authorization flow. A brief Linear API hiccup would break the integration for all users until they manually re-authorize.

Fix in Cursor Fix in Web

Co-authored-by: Cursor <cursoragent@cursor.com>
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 4 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.


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

}
callbackURL := fmt.Sprintf("%s/api/v1/integrations/%s/callback", baseURL, ctx.Integration.ID())
webhookURL := fmt.Sprintf("%s/api/v1/integrations/%s/webhook", baseURL, ctx.Integration.ID())

Copy link

Choose a reason for hiding this comment

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

Linear webhook/callback URLs may be malformed

High Severity

Linear.Sync() builds callbackURL/webhookURL using fmt.Sprintf(... "%s", ctx.Integration.ID()). If ctx.Integration.ID() is not already a string (commonly a UUID type), %s formatting can produce an invalid URL (e.g., %!s(...)), breaking OAuth redirects and webhook delivery.

Fix in Cursor Fix in Web

ctx.Logger.Errorf("invalid webhook signature")
ctx.Response.WriteHeader(http.StatusUnauthorized)
return
}
Copy link

Choose a reason for hiding this comment

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

Linear signature verification likely mismatches header format

Medium Severity

handleWebhookEvent() compares the Linear-Signature header directly to a raw hex digest string. If Linear prefixes/encodes the signature (e.g., sha256= prefix or non-hex encoding), verification will always fail and all webhooks will be rejected; conversely, if the expected format includes a prefix, the current check doesn’t enforce it.

Fix in Cursor Fix in Web

)}
</code>
);
}
Copy link

Choose a reason for hiding this comment

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

CopyableCode can throw unhandled clipboard errors

Low Severity

CopyableCode calls navigator.clipboard.writeText(text) without await/catch. In browsers where the Clipboard API is unavailable or blocked (permissions/HTTP/non-focus), this can raise an unhandled promise rejection and break interaction.

Fix in Cursor Fix in Web

@superplanehq-integration
Copy link

Your SuperPlane environment was created but the health check failed (exit code: 7).

URL: http://3000-cfd33153-be78-43be-9de2-874b9cae76cf.proxy.daytona.works
Sandbox ID: cfd33153-be78-43be-9de2-874b9cae76cf
PR: #3041

The sandbox is still running. You can try accessing the URL manually.
This environment will auto-stop after 60 minutes.

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?

@superplanehq-integration
Copy link

Your SuperPlane environment was created but the health check failed (exit code: 7).

URL: http://3000-8d479e63-105b-4b1c-a953-d6a9cc1625a9.proxy.daytona.works
Sandbox ID: 8d479e63-105b-4b1c-a953-d6a9cc1625a9
PR: #3041

The sandbox is still running. You can try accessing the URL manually.
This environment will auto-stop after 60 minutes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bounty This issue has a bounty open pr:stage-2/3 Needs to pass functional review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants