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
14 changes: 14 additions & 0 deletions pkg/integrations/linear/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Linear PoC (Issue #2485)

This folder contains a minimal, reviewable PoC for the Linear integration bounty:

- Webhook parsing for `Issue` + `create` events
- Mutation variable builder for Linear `issueCreate`
- Unit tests for both paths

The PoC is intentionally scoped to de-risk the API wiring before adding:

- Full integration registration
- Trigger/action components
- Frontend mappers
- End-to-end setup flow and demo video
24 changes: 24 additions & 0 deletions pkg/integrations/linear/example_data_on_issue_created.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"action": "create",
"type": "Issue",
"data": {
"id": "issue_123",
"identifier": "ENG-42",
"title": "Investigate failed deploy webhook",
"url": "https://linear.app/acme/issue/ENG-42",
"team": {
"id": "team_1",
"name": "Engineering"
},
"labels": [
{
"id": "label_1",
"name": "bug"
},
{
"id": "label_2",
"name": "prod"
}
]
}
}
14 changes: 14 additions & 0 deletions pkg/integrations/linear/example_output_issue_create_variables.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"input": {
"teamId": "team_1",
"title": "Auto-created issue from SuperPlane",
"description": "Generated from PoC",
"assigneeId": "user_1",
"labelIds": [
"label_bug",
"label_backend"
],
"priority": 2,
"stateId": "state_todo"
}
}
163 changes: 163 additions & 0 deletions pkg/integrations/linear/poc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package linear

import (
"encoding/json"
"errors"
"fmt"
"strings"
)

var (
ErrInvalidWebhookPayload = errors.New("invalid linear webhook payload")
ErrNotIssueCreatedEvent = errors.New("not a linear issue.created event")
)

type webhookPayload struct {
Action string `json:"action"`
Type string `json:"type"`
Data webhookData `json:"data"`
}

type webhookData struct {
ID string `json:"id"`
Identifier string `json:"identifier"`
Title string `json:"title"`
URL string `json:"url"`
Team webhookTeam `json:"team"`
Labels []webhookLabel `json:"labels"`
}

type webhookTeam struct {
ID string `json:"id"`
Name string `json:"name"`
}

type webhookLabel struct {
ID string `json:"id"`
Name string `json:"name"`
}

type IssueCreatedEvent struct {
IssueID string `json:"issueId"`
Identifier string `json:"identifier"`
Title string `json:"title"`
TeamID string `json:"teamId"`
TeamName string `json:"teamName"`
IssueURL string `json:"issueUrl"`
IssueLabels []string `json:"issueLabels"`
}

func ParseIssueCreatedWebhook(body []byte) (IssueCreatedEvent, error) {
payload := webhookPayload{}
if err := json.Unmarshal(body, &payload); err != nil {
return IssueCreatedEvent{}, fmt.Errorf("%w: %v", ErrInvalidWebhookPayload, err)
}

if payload.Action != "create" || payload.Type != "Issue" {
return IssueCreatedEvent{}, ErrNotIssueCreatedEvent
}

if payload.Data.ID == "" || payload.Data.Team.ID == "" {
return IssueCreatedEvent{}, ErrInvalidWebhookPayload
}

labels := make([]string, 0, len(payload.Data.Labels))
seenLabels := make(map[string]struct{}, len(payload.Data.Labels))
for _, label := range payload.Data.Labels {
name := strings.TrimSpace(label.Name)
if name == "" {
continue
}
if _, exists := seenLabels[name]; exists {
continue
}
seenLabels[name] = struct{}{}
labels = append(labels, name)
}

return IssueCreatedEvent{
IssueID: payload.Data.ID,
Identifier: payload.Data.Identifier,
Title: payload.Data.Title,
TeamID: payload.Data.Team.ID,
TeamName: payload.Data.Team.Name,
IssueURL: payload.Data.URL,
IssueLabels: labels,
}, nil
}

type CreateIssueInput struct {
TeamID string
Title string
Description string
AssigneeID string
LabelIDs []string
Priority int
StateID string
}

func BuildIssueCreateVariables(input CreateIssueInput) (map[string]any, error) {
teamID := strings.TrimSpace(input.TeamID)
title := strings.TrimSpace(input.Title)
if teamID == "" {
return nil, errors.New("teamId is required")
}
if title == "" {
return nil, errors.New("title is required")
}
if input.Priority < 0 || input.Priority > 4 {
return nil, errors.New("priority must be in range 0..4")
}

mutationInput := map[string]any{
"teamId": teamID,
"title": title,
"priority": input.Priority,
}

if description := strings.TrimSpace(input.Description); description != "" {
mutationInput["description"] = description
}
if assigneeID := strings.TrimSpace(input.AssigneeID); assigneeID != "" {
mutationInput["assigneeId"] = assigneeID
}
if len(input.LabelIDs) > 0 {
labelIDs := make([]string, 0, len(input.LabelIDs))
seenLabelIDs := make(map[string]struct{}, len(input.LabelIDs))
for _, raw := range input.LabelIDs {
labelID := strings.TrimSpace(raw)
if labelID == "" {
continue
}
if _, exists := seenLabelIDs[labelID]; exists {
continue
}
seenLabelIDs[labelID] = struct{}{}
labelIDs = append(labelIDs, labelID)
}
if len(labelIDs) > 0 {
mutationInput["labelIds"] = labelIDs
}
}
if stateID := strings.TrimSpace(input.StateID); stateID != "" {
mutationInput["stateId"] = stateID
}

return map[string]any{
"input": mutationInput,
}, nil
}

func IssueCreateMutation() string {
return `mutation IssueCreate($input: IssueCreateInput!) {
issueCreate(input: $input) {
success
issue {
id
identifier
title
url
}
}
}`
}
133 changes: 133 additions & 0 deletions pkg/integrations/linear/poc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package linear

import (
"errors"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestParseIssueCreatedWebhook(t *testing.T) {
t.Run("returns normalized issue.created event", func(t *testing.T) {
body := []byte(`{
"action": "create",
"type": "Issue",
"data": {
"id": "issue_123",
"identifier": "ENG-42",
"title": "Investigate failed deploy webhook",
"url": "https://linear.app/acme/issue/ENG-42",
"team": {"id":"team_1","name":"Engineering"},
"labels": [{"id":"label_1","name":"bug"},{"id":"label_2","name":"prod"}]
}
}`)

got, err := ParseIssueCreatedWebhook(body)
require.NoError(t, err)
assert.Equal(t, "issue_123", got.IssueID)
assert.Equal(t, "ENG-42", got.Identifier)
assert.Equal(t, "team_1", got.TeamID)
assert.Equal(t, []string{"bug", "prod"}, got.IssueLabels)
})

t.Run("trims and deduplicates labels", func(t *testing.T) {
body := []byte(`{
"action": "create",
"type": "Issue",
"data": {
"id": "issue_124",
"identifier": "ENG-43",
"title": "Label normalization",
"url": "https://linear.app/acme/issue/ENG-43",
"team": {"id":"team_1","name":"Engineering"},
"labels": [
{"id":"label_1","name":" bug "},
{"id":"label_2","name":"bug"},
{"id":"label_3","name":""},
{"id":"label_4","name":" prod "}
]
}
}`)

got, err := ParseIssueCreatedWebhook(body)
require.NoError(t, err)
assert.Equal(t, []string{"bug", "prod"}, got.IssueLabels)
})

t.Run("filters out non issue.create events", func(t *testing.T) {
body := []byte(`{
"action": "update",
"type": "Issue",
"data": {"id":"issue_123","team":{"id":"team_1"}}
}`)

_, err := ParseIssueCreatedWebhook(body)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrNotIssueCreatedEvent))
})

t.Run("fails when required fields are missing", func(t *testing.T) {
body := []byte(`{
"action": "create",
"type": "Issue",
"data": {"id":"","team":{"id":""}}
}`)

_, err := ParseIssueCreatedWebhook(body)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrInvalidWebhookPayload))
})
}

func TestBuildIssueCreateVariables(t *testing.T) {
t.Run("builds input variables with optional fields", func(t *testing.T) {
vars, err := BuildIssueCreateVariables(CreateIssueInput{
TeamID: "team_1",
Title: "Auto-created issue from SuperPlane",
Description: "Generated from PoC",
AssigneeID: "user_1",
LabelIDs: []string{"label_bug", "label_backend"},
Priority: 2,
StateID: "state_todo",
})
require.NoError(t, err)

input := vars["input"].(map[string]any)
assert.Equal(t, "team_1", input["teamId"])
assert.Equal(t, 2, input["priority"])
assert.Equal(t, "state_todo", input["stateId"])
assert.Equal(t, []string{"label_bug", "label_backend"}, input["labelIds"])
})

t.Run("rejects invalid priority", func(t *testing.T) {
_, err := BuildIssueCreateVariables(CreateIssueInput{
TeamID: "team_1",
Title: "x",
Priority: 9,
})
require.Error(t, err)
assert.Equal(t, "priority must be in range 0..4", err.Error())
})

t.Run("trims required and optional fields", func(t *testing.T) {
vars, err := BuildIssueCreateVariables(CreateIssueInput{
TeamID: " team_1 ",
Title: " Auto-created issue from SuperPlane ",
Description: " Generated from PoC ",
AssigneeID: " user_1 ",
LabelIDs: []string{" label_bug ", "label_bug", "", " label_backend "},
Priority: 1,
StateID: " state_todo ",
})
require.NoError(t, err)

input := vars["input"].(map[string]any)
assert.Equal(t, "team_1", input["teamId"])
assert.Equal(t, "Auto-created issue from SuperPlane", input["title"])
assert.Equal(t, "Generated from PoC", input["description"])
assert.Equal(t, "user_1", input["assigneeId"])
assert.Equal(t, []string{"label_bug", "label_backend"}, input["labelIds"])
assert.Equal(t, "state_todo", input["stateId"])
})
}