diff --git a/docs/components/AWS.mdx b/docs/components/AWS.mdx index 786cec56ff..830c590d67 100644 --- a/docs/components/AWS.mdx +++ b/docs/components/AWS.mdx @@ -61,10 +61,6 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; -## 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. - ## CloudWatch • On Alarm diff --git a/docs/components/GitLab.mdx b/docs/components/GitLab.mdx index 8c56b39b56..bdd5fa30e8 100644 --- a/docs/components/GitLab.mdx +++ b/docs/components/GitLab.mdx @@ -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 diff --git a/docs/components/Linear.mdx b/docs/components/Linear.mdx new file mode 100644 index 0000000000..20d1a13b5b --- /dev/null +++ b/docs/components/Linear.mdx @@ -0,0 +1,151 @@ +--- +title: "Linear" +--- + +Manage and react to issues in Linear + +import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + +## Triggers + + + + + +## Actions + + + + + + + +## 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" +} +``` + + + +## 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" +} +``` + diff --git a/pkg/integrations/aws/aws.go b/pkg/integrations/aws/aws.go index ef31db7efd..eaf9a641b1 100644 --- a/pkg/integrations/aws/aws.go +++ b/pkg/integrations/aws/aws.go @@ -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 { diff --git a/pkg/integrations/cursor/cursor.go b/pkg/integrations/cursor/cursor.go index c24e3743b5..0e19e34c64 100644 --- a/pkg/integrations/cursor/cursor.go +++ b/pkg/integrations/cursor/cursor.go @@ -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 } client, err := NewClient(ctx.HTTP, ctx.Integration) diff --git a/pkg/integrations/cursor/cursor_test.go b/pkg/integrations/cursor/cursor_test.go index 21e038f4eb..5c0db54fb5 100644 --- a/pkg/integrations/cursor/cursor_test.go +++ b/pkg/integrations/cursor/cursor_test.go @@ -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{ @@ -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) { diff --git a/pkg/integrations/github/github.go b/pkg/integrations/github/github.go index f74b9527fd..28231d0774 100644 --- a/pkg/integrations/github/github.go +++ b/pkg/integrations/github/github.go @@ -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, + Description: "GitHub organization to install the app into.", }, } } diff --git a/pkg/integrations/gitlab/gitlab.go b/pkg/integrations/gitlab/gitlab.go index ec29d2be04..2ecd00e755 100644 --- a/pkg/integrations/gitlab/gitlab.go +++ b/pkg/integrations/gitlab/gitlab.go @@ -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 @@ -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}}, + }, }, } } diff --git a/pkg/integrations/grafana/grafana.go b/pkg/integrations/grafana/grafana.go index a7be7b7825..f675b1201d 100644 --- a/pkg/integrations/grafana/grafana.go +++ b/pkg/integrations/grafana/grafana.go @@ -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, }, } } diff --git a/pkg/integrations/linear/auth.go b/pkg/integrations/linear/auth.go new file mode 100644 index 0000000000..1d6cd2cf65 --- /dev/null +++ b/pkg/integrations/linear/auth.go @@ -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 +} diff --git a/pkg/integrations/linear/auth_test.go b/pkg/integrations/linear/auth_test.go new file mode 100644 index 0000000000..b53ac3c9f5 --- /dev/null +++ b/pkg/integrations/linear/auth_test.go @@ -0,0 +1,180 @@ +package linear + +import ( + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func linearMockResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader(body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + } +} + +func Test__Auth__exchangeCode(t *testing.T) { + t.Run("success", func(t *testing.T) { + mock := &contexts.HTTPContext{ + Responses: []*http.Response{ + linearMockResponse(http.StatusOK, `{ + "access_token": "access-123", + "refresh_token": "refresh-123", + "expires_in": 86399 + }`), + }, + } + + auth := NewAuth(mock) + resp, err := auth.exchangeCode("client-id", "client-secret", "code-123", "https://example.com/callback") + + require.NoError(t, err) + assert.Equal(t, "access-123", resp.AccessToken) + assert.Equal(t, "refresh-123", resp.RefreshToken) + assert.Equal(t, 86399, resp.ExpiresIn) + + require.Len(t, mock.Requests, 1) + req := mock.Requests[0] + assert.Equal(t, "POST", req.Method) + assert.Equal(t, linearTokenURL, req.URL.String()) + + body, _ := io.ReadAll(req.Body) + values, _ := url.ParseQuery(string(body)) + assert.Equal(t, "authorization_code", values.Get("grant_type")) + assert.Equal(t, "code-123", values.Get("code")) + assert.Equal(t, "client-id", values.Get("client_id")) + assert.Equal(t, "client-secret", values.Get("client_secret")) + assert.Equal(t, "https://example.com/callback", values.Get("redirect_uri")) + }) + + t.Run("error response", func(t *testing.T) { + mock := &contexts.HTTPContext{ + Responses: []*http.Response{ + linearMockResponse(http.StatusBadRequest, `{"error": "invalid_grant"}`), + }, + } + + auth := NewAuth(mock) + _, err := auth.exchangeCode("id", "secret", "code", "redirect") + + require.Error(t, err) + assert.Contains(t, err.Error(), "status 400") + }) +} + +func Test__Auth__RefreshToken(t *testing.T) { + t.Run("success", func(t *testing.T) { + mock := &contexts.HTTPContext{ + Responses: []*http.Response{ + linearMockResponse(http.StatusOK, `{ + "access_token": "access-new", + "refresh_token": "refresh-new", + "expires_in": 86399 + }`), + }, + } + + auth := NewAuth(mock) + resp, err := auth.RefreshToken("client-id", "client-secret", "refresh-old") + + require.NoError(t, err) + assert.Equal(t, "access-new", resp.AccessToken) + assert.Equal(t, "refresh-new", resp.RefreshToken) + + require.Len(t, mock.Requests, 1) + req := mock.Requests[0] + assert.Equal(t, "POST", req.Method) + assert.Equal(t, linearTokenURL, req.URL.String()) + + body, _ := io.ReadAll(req.Body) + values, _ := url.ParseQuery(string(body)) + assert.Equal(t, "refresh_token", values.Get("grant_type")) + assert.Equal(t, "refresh-old", values.Get("refresh_token")) + assert.Equal(t, "client-id", values.Get("client_id")) + assert.Equal(t, "client-secret", values.Get("client_secret")) + }) + + t.Run("error response", func(t *testing.T) { + mock := &contexts.HTTPContext{ + Responses: []*http.Response{ + linearMockResponse(http.StatusUnauthorized, `{}`), + }, + } + + auth := NewAuth(mock) + _, err := auth.RefreshToken("id", "secret", "bad-refresh") + + require.Error(t, err) + assert.Contains(t, err.Error(), "status 401") + }) +} + +func Test__Auth__HandleCallback(t *testing.T) { + t.Run("valid callback", func(t *testing.T) { + mock := &contexts.HTTPContext{ + Responses: []*http.Response{ + linearMockResponse(http.StatusOK, `{"access_token": "ok", "refresh_token": "ref"}`), + }, + } + + auth := NewAuth(mock) + req, _ := http.NewRequest("GET", "/?code=123&state=xyz", nil) + resp, err := auth.HandleCallback(req, "id", "secret", "xyz", "https://example.com/callback") + + require.NoError(t, err) + assert.Equal(t, "ok", resp.AccessToken) + assert.Equal(t, "ref", resp.RefreshToken) + }) + + t.Run("invalid state", func(t *testing.T) { + auth := NewAuth(&contexts.HTTPContext{}) + req, _ := http.NewRequest("GET", "/?code=123&state=bad", nil) + _, err := auth.HandleCallback(req, "id", "secret", "valid-state", "uri") + + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid state") + }) + + t.Run("missing code", func(t *testing.T) { + auth := NewAuth(&contexts.HTTPContext{}) + req, _ := http.NewRequest("GET", "/?state=xyz", nil) + _, err := auth.HandleCallback(req, "id", "secret", "xyz", "uri") + + require.Error(t, err) + assert.Contains(t, err.Error(), "missing code or state") + }) + + t.Run("OAuth error param", func(t *testing.T) { + auth := NewAuth(&contexts.HTTPContext{}) + req, _ := http.NewRequest("GET", "/?error=access_denied&error_description=user+denied", nil) + _, err := auth.HandleCallback(req, "id", "secret", "xyz", "uri") + + require.Error(t, err) + assert.Contains(t, err.Error(), "OAuth error") + assert.Contains(t, err.Error(), "access_denied") + }) +} + +func Test__TokenResponse__GetExpiration(t *testing.T) { + t.Run("returns half of expires_in", func(t *testing.T) { + resp := TokenResponse{ExpiresIn: 86400} + assert.Equal(t, 43200, int(resp.GetExpiration().Seconds())) + }) + + t.Run("minimum 1 second", func(t *testing.T) { + resp := TokenResponse{ExpiresIn: 1} + assert.Equal(t, 1, int(resp.GetExpiration().Seconds())) + }) + + t.Run("defaults to 1 hour when zero", func(t *testing.T) { + resp := TokenResponse{ExpiresIn: 0} + assert.Equal(t, 3600, int(resp.GetExpiration().Seconds())) + }) +} diff --git a/pkg/integrations/linear/client.go b/pkg/integrations/linear/client.go new file mode 100644 index 0000000000..dd52c5a39e --- /dev/null +++ b/pkg/integrations/linear/client.go @@ -0,0 +1,277 @@ +package linear + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/superplanehq/superplane/pkg/core" +) + +const ( + graphqlURL = "https://api.linear.app/graphql" + maxPages = 50 // safety cap for paginated queries +) + +type Client struct { + accessToken string + http core.HTTPContext +} + +func NewClient(httpCtx core.HTTPContext, ctx core.IntegrationContext) (*Client, error) { + accessToken, err := findSecret(ctx, OAuthAccessToken) + if err != nil { + return nil, fmt.Errorf("get access token: %w", err) + } + if accessToken == "" { + return nil, fmt.Errorf("OAuth access token not found") + } + return &Client{ + accessToken: accessToken, + http: httpCtx, + }, nil +} + +// graphqlReq is the JSON body for a GraphQL request. +type graphqlReq struct { + Query string `json:"query"` + Variables map[string]any `json:"variables,omitempty"` +} + +// graphqlRes is the generic GraphQL response envelope. +type graphqlRes struct { + Data json.RawMessage `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors,omitempty"` +} + +func (c *Client) execGraphQL(query string, variables map[string]any, result any) error { + body := graphqlReq{Query: query, Variables: variables} + raw, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal graphql request: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, graphqlURL, bytes.NewReader(raw)) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+c.accessToken) + + res, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("execute request: %w", err) + } + defer res.Body.Close() + + resBody, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("read body: %w", err) + } + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return fmt.Errorf("request got %d: %s", res.StatusCode, string(resBody)) + } + + var envelope graphqlRes + if err := json.Unmarshal(resBody, &envelope); err != nil { + return fmt.Errorf("parse response: %w", err) + } + + if len(envelope.Errors) > 0 { + return fmt.Errorf("graphql errors: %s", envelope.Errors[0].Message) + } + + if result != nil && len(envelope.Data) > 0 { + if err := json.Unmarshal(envelope.Data, result); err != nil { + return fmt.Errorf("decode data: %w", err) + } + } + return nil +} + +// Viewer is the authenticated user. +type Viewer struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` +} + +// GetViewer returns the current user (verifies credentials). +func (c *Client) GetViewer() (*Viewer, error) { + const query = `query { viewer { id name email } }` + var out struct { + Viewer Viewer `json:"viewer"` + } + if err := c.execGraphQL(query, nil, &out); err != nil { + return nil, err + } + return &out.Viewer, nil +} + +// teamsResponse matches the GraphQL teams query. +type teamsResponse struct { + Teams struct { + Nodes []Team `json:"nodes"` + PageInfo pageInfo `json:"pageInfo"` + } `json:"teams"` +} + +// ListTeams returns all teams the user can access. +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 range maxPages { + 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 + } + newCursor := out.Teams.PageInfo.EndCursor + if cursor != nil && newCursor == *cursor { + break + } + cursor = &newCursor + } + return all, nil +} + +// FindTeam fetches all teams and returns the one matching the given ID. +func (c *Client) FindTeam(id string) (*Team, error) { + teams, err := c.ListTeams() + if err != nil { + return nil, fmt.Errorf("list teams: %w", err) + } + for i := range teams { + if teams[i].ID == id { + return &teams[i], nil + } + } + return nil, fmt.Errorf("team %s not found", id) +} + +// organizationLabelsResponse for org-level labels. +type organizationLabelsResponse struct { + Organization struct { + Labels struct { + Nodes []Label `json:"nodes"` + PageInfo pageInfo `json:"pageInfo"` + } `json:"labels"` + } `json:"organization"` +} + +// ListLabels returns all labels in the organization. +func (c *Client) ListLabels() ([]Label, error) { + const query = `query($after: String) { organization { labels(first: 100, after: $after) { nodes { id name } pageInfo { hasNextPage endCursor } } } }` + var all []Label + var cursor *string + for range maxPages { + vars := map[string]any{"after": cursor} + var out organizationLabelsResponse + if err := c.execGraphQL(query, vars, &out); err != nil { + return nil, err + } + all = append(all, out.Organization.Labels.Nodes...) + if !out.Organization.Labels.PageInfo.HasNextPage { + break + } + newCursor := out.Organization.Labels.PageInfo.EndCursor + if cursor != nil && newCursor == *cursor { + break + } + cursor = &newCursor + } + return all, nil +} + +// teamStatesResponse matches the team states query. +type teamStatesResponse struct { + Team struct { + States struct { + Nodes []WorkflowState `json:"nodes"` + } `json:"states"` + } `json:"team"` +} + +// ListWorkflowStates returns all workflow states for a team. +func (c *Client) ListWorkflowStates(teamID string) ([]WorkflowState, error) { + const query = `query($id: String!) { team(id: $id) { states { nodes { id name type } } } }` + var out teamStatesResponse + if err := c.execGraphQL(query, map[string]any{"id": teamID}, &out); err != nil { + return nil, err + } + return out.Team.States.Nodes, nil +} + +// teamMembersResponse matches the team members query. +type teamMembersResponse struct { + Team struct { + Members struct { + Nodes []Member `json:"nodes"` + } `json:"members"` + } `json:"team"` +} + +// ListTeamMembers returns all human members of a team (excludes the app user itself). +func (c *Client) ListTeamMembers(teamID string) ([]Member, error) { + const query = `query($id: String!) { team(id: $id) { members { nodes { id name displayName email active isMe } } } }` + var out teamMembersResponse + if err := c.execGraphQL(query, map[string]any{"id": teamID}, &out); err != nil { + return nil, err + } + members := make([]Member, 0, len(out.Team.Members.Nodes)) + for _, m := range out.Team.Members.Nodes { + if m.Active && !m.IsMe && m.Email != "" { + members = append(members, m) + } + } + return members, nil +} + +// IssueCreateInput is the input for issueCreate mutation. +type IssueCreateInput struct { + TeamID string `json:"teamId"` + Title string `json:"title"` + Description *string `json:"description,omitempty"` + AssigneeID *string `json:"assigneeId,omitempty"` + LabelIDs []string `json:"labelIds,omitempty"` + Priority *int `json:"priority,omitempty"` + StateID *string `json:"stateId,omitempty"` +} + +// IssueCreateResponse is the issueCreate mutation result. +type IssueCreateResponse struct { + IssueCreate struct { + Success bool `json:"success"` + Issue Issue `json:"issue"` + } `json:"issueCreate"` +} + +// IssueCreate creates a new issue. +func (c *Client) IssueCreate(input IssueCreateInput) (*Issue, error) { + const query = ` +mutation IssueCreate($input: IssueCreateInput!) { + issueCreate(input: $input) { + success + issue { id identifier title description priority url createdAt team { id } state { id } assignee { id } } + } +}` + var out IssueCreateResponse + if err := c.execGraphQL(query, map[string]any{"input": input}, &out); err != nil { + return nil, err + } + if !out.IssueCreate.Success { + return nil, fmt.Errorf("issueCreate returned success: false") + } + return &out.IssueCreate.Issue, nil +} diff --git a/pkg/integrations/linear/client_test.go b/pkg/integrations/linear/client_test.go new file mode 100644 index 0000000000..2e680e92d1 --- /dev/null +++ b/pkg/integrations/linear/client_test.go @@ -0,0 +1,78 @@ +package linear + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__NewClient(t *testing.T) { + t.Run("missing access token -> error", func(t *testing.T) { + appCtx := &contexts.IntegrationContext{ + Secrets: map[string]core.IntegrationSecret{}, + } + _, err := NewClient(&contexts.HTTPContext{}, appCtx) + require.Error(t, err) + assert.Contains(t, err.Error(), "access token") + }) + + t.Run("successful client creation", func(t *testing.T) { + appCtx := &contexts.IntegrationContext{ + Secrets: map[string]core.IntegrationSecret{ + OAuthAccessToken: {Name: OAuthAccessToken, Value: []byte("test-key")}, + }, + } + client, err := NewClient(&contexts.HTTPContext{}, appCtx) + require.NoError(t, err) + assert.Equal(t, "test-key", client.accessToken) + }) +} + +func Test__Client__GetViewer(t *testing.T) { + t.Run("success", func(t *testing.T) { + resp := `{"data":{"viewer":{"id":"u1","name":"Alice","email":"a@b.com"}}}` + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + linearMockResponse(http.StatusOK, resp), + }, + } + appCtx := &contexts.IntegrationContext{ + Secrets: map[string]core.IntegrationSecret{ + OAuthAccessToken: {Name: OAuthAccessToken, Value: []byte("key")}, + }, + } + client, err := NewClient(httpCtx, appCtx) + require.NoError(t, err) + viewer, err := client.GetViewer() + require.NoError(t, err) + assert.Equal(t, "u1", viewer.ID) + assert.Equal(t, "Alice", viewer.Name) + }) +} + +func Test__Client__ListTeams(t *testing.T) { + t.Run("success", func(t *testing.T) { + resp := `{"data":{"teams":{"nodes":[{"id":"t1","name":"Eng","key":"ENG"}]}}}` + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + linearMockResponse(http.StatusOK, resp), + }, + } + appCtx := &contexts.IntegrationContext{ + Secrets: map[string]core.IntegrationSecret{ + OAuthAccessToken: {Name: OAuthAccessToken, Value: []byte("key")}, + }, + } + client, err := NewClient(httpCtx, appCtx) + require.NoError(t, err) + teams, err := client.ListTeams() + require.NoError(t, err) + require.Len(t, teams, 1) + assert.Equal(t, "t1", teams[0].ID) + assert.Equal(t, "Eng", teams[0].Name) + }) +} diff --git a/pkg/integrations/linear/common.go b/pkg/integrations/linear/common.go new file mode 100644 index 0000000000..f2b94b850d --- /dev/null +++ b/pkg/integrations/linear/common.go @@ -0,0 +1,62 @@ +package linear + +// Team represents a Linear team. +type Team struct { + ID string `json:"id"` + Name string `json:"name"` + Key string `json:"key"` +} + +// Label represents a Linear issue label. +type Label struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// Issue represents a Linear issue (minimal for create response and webhook payload). +type Issue struct { + ID string `json:"id"` + Identifier string `json:"identifier"` + Title string `json:"title"` + Description *string `json:"description,omitempty"` + Priority *int `json:"priority,omitempty"` + URL string `json:"url,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + Team *IDRef `json:"team,omitempty"` + State *IDRef `json:"state,omitempty"` + Assignee *IDRef `json:"assignee,omitempty"` +} + +// IDRef is a minimal reference with just an ID, used for nested GraphQL objects. +type IDRef struct { + ID string `json:"id"` +} + +// pageInfo holds Relay-style cursor pagination info. +type pageInfo struct { + HasNextPage bool `json:"hasNextPage"` + EndCursor string `json:"endCursor"` +} + +// WorkflowState represents a Linear workflow state (e.g., Todo, In Progress, Done). +type WorkflowState struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` // backlog, unstarted, started, completed, cancelled +} + +// Member represents a Linear team member. +type Member struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"displayName,omitempty"` + Email string `json:"email,omitempty"` + Active bool `json:"active,omitempty"` + IsMe bool `json:"isMe,omitempty"` +} + +// NodeMetadata stores metadata on trigger/component nodes. +type NodeMetadata struct { + Team *Team `json:"team,omitempty" mapstructure:"team,omitempty"` + SubscriptionID *string `json:"appSubscriptionID,omitempty" mapstructure:"appSubscriptionID,omitempty"` +} diff --git a/pkg/integrations/linear/create_issue.go b/pkg/integrations/linear/create_issue.go new file mode 100644 index 0000000000..161c67962c --- /dev/null +++ b/pkg/integrations/linear/create_issue.go @@ -0,0 +1,283 @@ +package linear + +import ( + "fmt" + "net/http" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +const createIssuePayloadType = "linear.issue" + +type CreateIssue struct{} + +type CreateIssueSpec struct { + Team string `json:"team"` + Title string `json:"title"` + Description string `json:"description"` + AssigneeID *string `json:"assigneeId,omitempty"` + LabelIDs []string `json:"labelIds,omitempty"` + Priority any `json:"priority,omitempty"` // string from select "0"-"4" or number + StateID *string `json:"stateId,omitempty"` +} + +func (c *CreateIssue) Name() string { + return "linear.createIssue" +} + +func (c *CreateIssue) Label() string { + return "Create Issue" +} + +func (c *CreateIssue) Description() string { + return "Create a new issue in Linear" +} + +func (c *CreateIssue) Documentation() string { + return `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.` +} + +func (c *CreateIssue) Icon() string { + return "linear" +} + +func (c *CreateIssue) Color() string { + return "blue" +} + +func (c *CreateIssue) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *CreateIssue) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "team", + Label: "Team", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "The Linear team to create the issue in", + Placeholder: "Select a team", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "team", + }, + }, + }, + { + Name: "title", + Label: "Title", + Type: configuration.FieldTypeString, + Required: true, + Description: "Issue title. Supports expressions.", + Placeholder: "Issue title", + }, + { + Name: "description", + Label: "Description", + Type: configuration.FieldTypeString, + Required: false, + Description: "Optional issue description. Supports expressions.", + Placeholder: "Issue description", + }, + { + Name: "assigneeId", + Label: "Assignee", + Type: configuration.FieldTypeIntegrationResource, + Required: false, + Description: "Optional team member to assign the issue to", + Placeholder: "Select an assignee", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "member", + Parameters: []configuration.ParameterRef{ + { + Name: "team", + ValueFrom: &configuration.ParameterValueFrom{Field: "team"}, + }, + }, + }, + }, + }, + { + Name: "labelIds", + Label: "Labels", + Type: configuration.FieldTypeIntegrationResource, + Required: false, + Description: "Optional labels", + Placeholder: "Select labels", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "label", + Multi: true, + }, + }, + }, + { + Name: "priority", + Label: "Priority", + Type: configuration.FieldTypeSelect, + Required: false, + Description: "Optional priority (0 = none, 1 = urgent, 2 = high, 3 = medium, 4 = low)", + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "None", Value: "0"}, + {Label: "Urgent", Value: "1"}, + {Label: "High", Value: "2"}, + {Label: "Medium", Value: "3"}, + {Label: "Low", Value: "4"}, + }, + }, + }, + }, + { + Name: "stateId", + Label: "Status", + Type: configuration.FieldTypeIntegrationResource, + Required: false, + Description: "Optional workflow state for the issue", + Placeholder: "Select a status", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "state", + Parameters: []configuration.ParameterRef{ + { + Name: "team", + ValueFrom: &configuration.ParameterValueFrom{Field: "team"}, + }, + }, + }, + }, + }, + } +} + +func (c *CreateIssue) Setup(ctx core.SetupContext) error { + spec := CreateIssueSpec{} + if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { + return fmt.Errorf("decode configuration: %w", err) + } + if spec.Team == "" { + return fmt.Errorf("team is required") + } + if spec.Title == "" { + return fmt.Errorf("title is required") + } + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("create client: %w", err) + } + team, err := client.FindTeam(spec.Team) + if err != nil { + return err + } + return ctx.Metadata.Set(NodeMetadata{Team: team}) +} + +func (c *CreateIssue) Execute(ctx core.ExecutionContext) error { + spec := CreateIssueSpec{} + if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { + return fmt.Errorf("decode configuration: %w", err) + } + if spec.Team == "" { + return fmt.Errorf("team is required") + } + if spec.Title == "" { + return fmt.Errorf("title is required") + } + if ctx.Integration == nil { + return fmt.Errorf("integration not configured") + } + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("create client: %w", err) + } + input := IssueCreateInput{ + TeamID: spec.Team, + Title: spec.Title, + } + if spec.Description != "" { + input.Description = &spec.Description + } + input.AssigneeID = spec.AssigneeID + input.LabelIDs = spec.LabelIDs + if p := parsePriority(spec.Priority); p != nil { + input.Priority = p + } + input.StateID = spec.StateID + issue, err := client.IssueCreate(input) + if err != nil { + return fmt.Errorf("create issue: %w", err) + } + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + createIssuePayloadType, + []any{issue}, + ) +} + +func (c *CreateIssue) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *CreateIssue) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *CreateIssue) Actions() []core.Action { + return nil +} + +func (c *CreateIssue) HandleAction(ctx core.ActionContext) error { + return nil +} + +func (c *CreateIssue) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return http.StatusOK, nil +} + +func (c *CreateIssue) Cleanup(ctx core.SetupContext) error { + return nil +} + +func parsePriority(v any) *int { + if v == nil { + return nil + } + switch x := v.(type) { + case float64: + i := int(x) + return &i + case int: + return &x + case string: + var i int + if _, err := fmt.Sscanf(x, "%d", &i); err != nil { + return nil + } + return &i + default: + return nil + } +} diff --git a/pkg/integrations/linear/create_issue_test.go b/pkg/integrations/linear/create_issue_test.go new file mode 100644 index 0000000000..53aeebac6f --- /dev/null +++ b/pkg/integrations/linear/create_issue_test.go @@ -0,0 +1,127 @@ +package linear + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__CreateIssue__Setup(t *testing.T) { + component := &CreateIssue{} + + t.Run("missing team -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Integration: &contexts.IntegrationContext{ + Secrets: map[string]core.IntegrationSecret{ + OAuthAccessToken: {Name: OAuthAccessToken, Value: []byte("x")}, + }, + }, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"title": "Fix bug"}, + }) + require.Error(t, err) + assert.ErrorContains(t, err, "team is required") + }) + + t.Run("missing title -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Integration: &contexts.IntegrationContext{ + Secrets: map[string]core.IntegrationSecret{ + OAuthAccessToken: {Name: OAuthAccessToken, Value: []byte("x")}, + }, + }, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"team": "t1"}, + }) + require.Error(t, err) + assert.ErrorContains(t, err, "title is required") + }) + + t.Run("team not found -> error", func(t *testing.T) { + teamsResp := `{"data":{"teams":{"nodes":[{"id":"other","name":"Other","key":"O"}]}}}` + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(teamsResp))}, + }, + } + appCtx := &contexts.IntegrationContext{ + Secrets: map[string]core.IntegrationSecret{ + OAuthAccessToken: {Name: OAuthAccessToken, Value: []byte("key")}, + }, + } + err := component.Setup(core.SetupContext{ + HTTP: httpCtx, + Integration: appCtx, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"team": "t1", "title": "Task"}, + }) + require.Error(t, err) + assert.ErrorContains(t, err, "not found") + }) + + t.Run("success", func(t *testing.T) { + teamsResp := `{"data":{"teams":{"nodes":[{"id":"t1","name":"Eng","key":"ENG"}]}}}` + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(teamsResp))}, + }, + } + appCtx := &contexts.IntegrationContext{ + Secrets: map[string]core.IntegrationSecret{ + OAuthAccessToken: {Name: OAuthAccessToken, Value: []byte("key")}, + }, + } + metaCtx := &contexts.MetadataContext{} + err := component.Setup(core.SetupContext{ + HTTP: httpCtx, + Integration: appCtx, + Metadata: metaCtx, + Configuration: map[string]any{"team": "t1", "title": "Task"}, + }) + require.NoError(t, err) + md, _ := metaCtx.Get().(NodeMetadata) + require.NotNil(t, md.Team) + assert.Equal(t, "t1", md.Team.ID) + }) +} + +func Test__CreateIssue__Execute(t *testing.T) { + component := &CreateIssue{} + + t.Run("nil integration -> error", func(t *testing.T) { + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{"team": "t1", "title": "Task"}, + }) + require.Error(t, err) + assert.ErrorContains(t, err, "integration not configured") + }) + + t.Run("success", func(t *testing.T) { + createResp := `{"data":{"issueCreate":{"success":true,"issue":{"id":"i1","identifier":"ENG-1","title":"Task","team":{"id":"t1"},"state":{"id":"s1"}}}}}` + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(createResp))}, + }, + } + appCtx := &contexts.IntegrationContext{ + Secrets: map[string]core.IntegrationSecret{ + OAuthAccessToken: {Name: OAuthAccessToken, Value: []byte("key")}, + }, + } + execState := &contexts.ExecutionStateContext{} + err := component.Execute(core.ExecutionContext{ + HTTP: httpCtx, + Integration: appCtx, + Configuration: map[string]any{"team": "t1", "title": "Task"}, + ExecutionState: execState, + }) + require.NoError(t, err) + require.Len(t, execState.Payloads, 1) + }) +} diff --git a/pkg/integrations/linear/example.go b/pkg/integrations/linear/example.go new file mode 100644 index 0000000000..4a6ed5b30f --- /dev/null +++ b/pkg/integrations/linear/example.go @@ -0,0 +1,29 @@ +package linear + +import ( + _ "embed" + "sync" + + "github.com/superplanehq/superplane/pkg/utils" +) + +//go:embed example_data_on_issue.json +var exampleDataOnIssueBytes []byte + +var exampleDataOnIssueOnce sync.Once +var exampleDataOnIssue map[string]any + +// UnmarshalExampleDataOnIssue returns example webhook payload for On Issue. +func UnmarshalExampleDataOnIssue() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleDataOnIssueOnce, exampleDataOnIssueBytes, &exampleDataOnIssue) +} + +//go:embed example_output_create_issue.json +var exampleOutputCreateIssueBytes []byte + +var exampleOutputCreateIssueOnce sync.Once +var exampleOutputCreateIssue map[string]any + +func (c *CreateIssue) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputCreateIssueOnce, exampleOutputCreateIssueBytes, &exampleOutputCreateIssue) +} diff --git a/pkg/integrations/linear/example_data_on_issue.json b/pkg/integrations/linear/example_data_on_issue.json new file mode 100644 index 0000000000..3d96445162 --- /dev/null +++ b/pkg/integrations/linear/example_data_on_issue.json @@ -0,0 +1,57 @@ +{ + "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" +} \ No newline at end of file diff --git a/pkg/integrations/linear/example_output_create_issue.json b/pkg/integrations/linear/example_output_create_issue.json new file mode 100644 index 0000000000..1adbc13bfd --- /dev/null +++ b/pkg/integrations/linear/example_output_create_issue.json @@ -0,0 +1,19 @@ +{ + "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" +} \ No newline at end of file diff --git a/pkg/integrations/linear/linear.go b/pkg/integrations/linear/linear.go new file mode 100644 index 0000000000..0036c273b1 --- /dev/null +++ b/pkg/integrations/linear/linear.go @@ -0,0 +1,424 @@ +package linear + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/crypto" + "github.com/superplanehq/superplane/pkg/registry" +) + +const ( + OAuthAccessToken = "accessToken" + OAuthRefreshToken = "refreshToken" + + linearScopes = "read,write,issues:create" + + appSetupDescription = ` +- Click the **Continue** button to go to the Linear API settings page +- Click **Create new** under OAuth2 Applications: + - **Application name**: SuperPlane + - **Redirect callback URLs**: Use the Callback URL shown above + - Enable **Webhooks** and set the webhook URL to the Webhook URL shown above + - Under webhook events, check **Issues** +- Copy the **Client ID**, **Client Secret**, and **Webhook signing secret** and paste them in the fields below. +- Click **Save** to complete the setup. +` + + appConnectDescription = `Click **Continue** to authorize SuperPlane to access your Linear workspace.` +) + +func init() { + registry.RegisterIntegration("linear", &Linear{}) +} + +type Linear struct{} + +type Metadata struct { + State *string `json:"state,omitempty" mapstructure:"state,omitempty"` + Teams []Team `json:"teams" mapstructure:"teams"` + Labels []Label `json:"labels" mapstructure:"labels"` + WebhookURL string `json:"webhookUrl,omitempty" mapstructure:"webhookUrl,omitempty"` + CallbackURL string `json:"callbackUrl,omitempty" mapstructure:"callbackUrl,omitempty"` +} + +func (l *Linear) Name() string { + return "linear" +} + +func (l *Linear) Label() string { + return "Linear" +} + +func (l *Linear) Icon() string { + return "linear" +} + +func (l *Linear) Description() string { + return "Manage and react to issues in Linear" +} + +func (l *Linear) Instructions() string { + return "" +} + +func (l *Linear) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "clientId", + Label: "Client ID", + Type: configuration.FieldTypeString, + Description: "OAuth Client ID from your Linear app", + }, + { + Name: "clientSecret", + Label: "Client Secret", + Type: configuration.FieldTypeString, + Sensitive: true, + Description: "OAuth Client Secret from your Linear app", + }, + { + Name: "webhookSecret", + Label: "Webhook Secret", + Type: configuration.FieldTypeString, + Sensitive: true, + Description: "Webhook signing secret from your Linear app", + }, + } +} + +func (l *Linear) Components() []core.Component { + return []core.Component{ + &CreateIssue{}, + } +} + +func (l *Linear) Triggers() []core.Trigger { + return []core.Trigger{ + &OnIssue{}, + } +} + +func (l *Linear) Cleanup(ctx core.IntegrationCleanupContext) error { + return nil +} + +func (l *Linear) Sync(ctx core.SyncContext) error { + baseURL := ctx.WebhooksBaseURL + if baseURL == "" { + baseURL = ctx.BaseURL + } + 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()) + + clientID, _ := ctx.Integration.GetConfig("clientId") + clientSecret, _ := ctx.Integration.GetConfig("clientSecret") + + // No credentials yet — show setup instructions with URLs in metadata. + if string(clientID) == "" || string(clientSecret) == "" { + ctx.Integration.SetMetadata(Metadata{ + WebhookURL: webhookURL, + CallbackURL: callbackURL, + }) + ctx.Integration.NewBrowserAction(core.BrowserAction{ + Description: appSetupDescription, + URL: "https://linear.app/settings/api/applications/new", + Method: "GET", + }) + + return nil + } + + // No access token — ask user to authorize. + accessToken, _ := findSecret(ctx.Integration, OAuthAccessToken) + if accessToken == "" { + return l.handleOAuthNoAccessToken(ctx, callbackURL, string(clientID)) + } + + // Refresh token and update metadata. + err := l.refreshToken(ctx, string(clientID), string(clientSecret)) + if err != nil { + ctx.Logger.Errorf("Failed to refresh token: %v", err) + return err + } + + if err := l.updateMetadata(ctx); err != nil { + ctx.Integration.Error(err.Error()) + return nil + } + + ctx.Integration.RemoveBrowserAction() + ctx.Integration.Ready() + return nil +} + +func (l *Linear) handleOAuthNoAccessToken(ctx core.SyncContext, callbackURL, clientID string) error { + metadata := Metadata{} + if err := mapstructure.Decode(ctx.Integration.GetMetadata(), &metadata); err != nil { + ctx.Logger.Errorf("Failed to decode metadata while setting state: %v", err) + } + + if metadata.State == nil { + s, err := crypto.Base64String(32) + if err != nil { + return fmt.Errorf("failed to generate state: %v", err) + } + metadata.State = &s + ctx.Integration.SetMetadata(metadata) + } + + authURL := fmt.Sprintf( + "%s?client_id=%s&redirect_uri=%s&response_type=code&scope=%s&state=%s&actor=%s", + linearAuthorizeURL, + url.QueryEscape(clientID), + url.QueryEscape(callbackURL), + url.QueryEscape(linearScopes), + url.QueryEscape(*metadata.State), + url.QueryEscape("app"), + ) + + ctx.Integration.NewBrowserAction(core.BrowserAction{ + Description: appConnectDescription, + URL: authURL, + Method: "GET", + }) + + return nil +} + +func (l *Linear) refreshToken(ctx core.SyncContext, clientID, clientSecret string) error { + refreshToken, _ := findSecret(ctx.Integration, OAuthRefreshToken) + if refreshToken == "" { + ctx.Logger.Warn("Linear integration has no refresh token - not refreshing token") + return nil + } + + ctx.Logger.Info("Refreshing Linear token") + auth := NewAuth(ctx.HTTP) + tokenResponse, err := auth.RefreshToken(clientID, clientSecret, refreshToken) + + if err != nil { + _ = ctx.Integration.SetSecret(OAuthRefreshToken, []byte("")) + _ = ctx.Integration.SetSecret(OAuthAccessToken, []byte("")) + return fmt.Errorf("failed to refresh token: %v", err) + } + + if tokenResponse.AccessToken != "" { + ctx.Logger.Info("Saving access token") + if err := ctx.Integration.SetSecret(OAuthAccessToken, []byte(tokenResponse.AccessToken)); err != nil { + return fmt.Errorf("failed to save access token: %v", err) + } + } + + if tokenResponse.RefreshToken != "" { + ctx.Logger.Info("Saving refresh token") + if err := ctx.Integration.SetSecret(OAuthRefreshToken, []byte(tokenResponse.RefreshToken)); err != nil { + return fmt.Errorf("failed to save refresh token: %v", err) + } + } + + ctx.Logger.Info("Token refreshed successfully") + return ctx.Integration.ScheduleResync(tokenResponse.GetExpiration()) +} + +func (l *Linear) updateMetadata(ctx core.SyncContext) error { + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("create client: %w", err) + } + + _, err = client.GetViewer() + if err != nil { + return fmt.Errorf("verify credentials: %w", err) + } + + teams, err := client.ListTeams() + if err != nil { + return fmt.Errorf("list teams: %w", err) + } + + labels, err := client.ListLabels() + if err != nil { + return fmt.Errorf("list labels: %w", err) + } + + metadata := Metadata{} + if err := mapstructure.Decode(ctx.Integration.GetMetadata(), &metadata); err != nil { + return fmt.Errorf("decode metadata: %w", err) + } + + metadata.Teams = teams + metadata.Labels = labels + metadata.State = nil + metadata.WebhookURL = "" + metadata.CallbackURL = "" + ctx.Integration.SetMetadata(metadata) + return nil +} + +func (l *Linear) HandleRequest(ctx core.HTTPRequestContext) { + switch { + case strings.HasSuffix(ctx.Request.URL.Path, "/webhook"): + l.handleWebhookEvent(ctx) + case strings.HasSuffix(ctx.Request.URL.Path, "/callback"): + clientID, err := ctx.Integration.GetConfig("clientId") + if err != nil { + ctx.Response.WriteHeader(http.StatusInternalServerError) + return + } + + clientSecret, err := ctx.Integration.GetConfig("clientSecret") + if err != nil { + ctx.Response.WriteHeader(http.StatusInternalServerError) + return + } + + l.handleCallback(ctx, string(clientID), string(clientSecret)) + default: + ctx.Response.WriteHeader(http.StatusNotFound) + } +} + +func (l *Linear) handleWebhookEvent(ctx core.HTTPRequestContext) { + defer ctx.Request.Body.Close() + body, err := io.ReadAll(ctx.Request.Body) + if err != nil { + ctx.Logger.Errorf("error reading webhook body: %v", err) + ctx.Response.WriteHeader(http.StatusBadRequest) + return + } + + webhookSecret, _ := ctx.Integration.GetConfig("webhookSecret") + if len(webhookSecret) == 0 { + ctx.Logger.Errorf("webhook secret not configured - refusing to accept unverified webhook") + ctx.Response.WriteHeader(http.StatusUnauthorized) + return + } + + signature := ctx.Request.Header.Get("Linear-Signature") + if signature == "" { + ctx.Logger.Errorf("missing Linear-Signature header") + ctx.Response.WriteHeader(http.StatusUnauthorized) + return + } + + mac := hmac.New(sha256.New, webhookSecret) + mac.Write(body) + expected := hex.EncodeToString(mac.Sum(nil)) + + if !hmac.Equal([]byte(expected), []byte(signature)) { + ctx.Logger.Errorf("invalid webhook signature") + ctx.Response.WriteHeader(http.StatusUnauthorized) + return + } + + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + ctx.Logger.Errorf("error unmarshaling webhook payload: %v", err) + ctx.Response.WriteHeader(http.StatusBadRequest) + return + } + + subscriptions, err := ctx.Integration.ListSubscriptions() + if err != nil { + ctx.Logger.Errorf("error listing subscriptions: %v", err) + ctx.Response.WriteHeader(http.StatusInternalServerError) + return + } + + for _, subscription := range subscriptions { + if err := subscription.SendMessage(payload); err != nil { + ctx.Logger.Errorf("error sending message to subscription: %v", err) + } + } + + ctx.Response.WriteHeader(http.StatusOK) +} + +func (l *Linear) handleCallback(ctx core.HTTPRequestContext, clientID, clientSecret string) { + redirectBaseURL := ctx.BaseURL + metadata := Metadata{} + if err := mapstructure.Decode(ctx.Integration.GetMetadata(), &metadata); err != nil { + ctx.Response.WriteHeader(http.StatusInternalServerError) + return + } + + // Use WebhooksBaseURL for the redirect URI to match what was sent during OAuth initiation in Sync(). + webhooksBaseURL := ctx.WebhooksBaseURL + if webhooksBaseURL == "" { + webhooksBaseURL = ctx.BaseURL + } + redirectURI := fmt.Sprintf("%s/api/v1/integrations/%s/callback", webhooksBaseURL, ctx.Integration.ID().String()) + + if metadata.State == nil { + ctx.Logger.Errorf("Callback error: missing OAuth state in metadata") + http.Redirect(ctx.Response, ctx.Request, + fmt.Sprintf("%s/%s/settings/integrations/%s", redirectBaseURL, ctx.OrganizationID, ctx.Integration.ID().String()), + http.StatusSeeOther) + return + } + + auth := NewAuth(ctx.HTTP) + tokenResponse, err := auth.HandleCallback(ctx.Request, clientID, clientSecret, *metadata.State, redirectURI) + + if err != nil { + ctx.Logger.Errorf("Callback error: %v", err) + http.Redirect(ctx.Response, ctx.Request, + fmt.Sprintf("%s/%s/settings/integrations/%s", redirectBaseURL, ctx.OrganizationID, ctx.Integration.ID().String()), + http.StatusSeeOther) + return + } + + if tokenResponse.AccessToken != "" { + if err := ctx.Integration.SetSecret(OAuthAccessToken, []byte(tokenResponse.AccessToken)); err != nil { + ctx.Response.WriteHeader(http.StatusInternalServerError) + return + } + } + + if tokenResponse.RefreshToken != "" { + if err := ctx.Integration.SetSecret(OAuthRefreshToken, []byte(tokenResponse.RefreshToken)); err != nil { + ctx.Response.WriteHeader(http.StatusInternalServerError) + return + } + } + + if err := ctx.Integration.ScheduleResync(tokenResponse.GetExpiration()); err != nil { + ctx.Response.WriteHeader(http.StatusInternalServerError) + return + } + + if err := l.updateMetadata(core.SyncContext{ + HTTP: ctx.HTTP, + Integration: ctx.Integration, + }); err != nil { + ctx.Logger.Errorf("Callback error: failed to update metadata: %v", err) + ctx.Response.WriteHeader(http.StatusInternalServerError) + return + } + + ctx.Integration.RemoveBrowserAction() + ctx.Integration.Ready() + + http.Redirect(ctx.Response, ctx.Request, + fmt.Sprintf("%s/%s/settings/integrations/%s", redirectBaseURL, ctx.OrganizationID, ctx.Integration.ID().String()), + http.StatusSeeOther) +} + +func (l *Linear) Actions() []core.Action { + return []core.Action{} +} + +func (l *Linear) HandleAction(ctx core.IntegrationActionContext) error { + return nil +} diff --git a/pkg/integrations/linear/linear_test.go b/pkg/integrations/linear/linear_test.go new file mode 100644 index 0000000000..6e74a7021e --- /dev/null +++ b/pkg/integrations/linear/linear_test.go @@ -0,0 +1,226 @@ +package linear + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__Linear__Sync(t *testing.T) { + integration := &Linear{} + logger := logrus.NewEntry(logrus.New()) + + t.Run("missing clientId and clientSecret -> setup instructions", func(t *testing.T) { + appCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{}, + } + err := integration.Sync(core.SyncContext{ + Configuration: map[string]any{}, + Integration: appCtx, + Logger: logger, + }) + require.NoError(t, err) + require.NotNil(t, appCtx.BrowserAction) + assert.Contains(t, appCtx.BrowserAction.URL, "linear.app/settings/api/applications") + assert.Contains(t, appCtx.BrowserAction.Description, "OAuth2 Applications") + }) + + t.Run("has clientId but no clientSecret -> setup instructions", func(t *testing.T) { + appCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "clientId": "id", + }, + } + err := integration.Sync(core.SyncContext{ + Configuration: appCtx.Configuration, + Integration: appCtx, + Logger: logger, + }) + require.NoError(t, err) + require.NotNil(t, appCtx.BrowserAction) + assert.Contains(t, appCtx.BrowserAction.URL, "linear.app/settings/api/applications") + }) + + t.Run("has credentials but no access token -> authorize button", func(t *testing.T) { + appCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "clientId": "test-client-id", + "clientSecret": "test-client-secret", + }, + Secrets: map[string]core.IntegrationSecret{}, + } + err := integration.Sync(core.SyncContext{ + Configuration: appCtx.Configuration, + Integration: appCtx, + Logger: logger, + }) + require.NoError(t, err) + require.NotNil(t, appCtx.BrowserAction) + assert.Contains(t, appCtx.BrowserAction.URL, "linear.app/oauth/authorize") + assert.Contains(t, appCtx.BrowserAction.URL, "client_id=test-client-id") + assert.Contains(t, appCtx.BrowserAction.URL, "actor=app") + assert.Contains(t, appCtx.BrowserAction.Description, "authorize SuperPlane") + }) + + t.Run("has tokens -> refreshes and reaches ready", func(t *testing.T) { + appCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "clientId": "id", + "clientSecret": "secret", + }, + Secrets: map[string]core.IntegrationSecret{ + OAuthAccessToken: {Name: OAuthAccessToken, Value: []byte("access-token")}, + OAuthRefreshToken: {Name: OAuthRefreshToken, Value: []byte("refresh-token")}, + }, + } + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + // Token refresh + linearMockResponse(http.StatusOK, `{"access_token":"new-access","refresh_token":"new-refresh","expires_in":86399}`), + // GetViewer + linearMockResponse(http.StatusOK, `{"data":{"viewer":{"id":"u1","name":"User","email":"u@x.com"}}}`), + // ListTeams + linearMockResponse(http.StatusOK, `{"data":{"teams":{"nodes":[{"id":"t1","name":"Team 1","key":"T1"}]}}}`), + // ListLabels + linearMockResponse(http.StatusOK, `{"data":{"organization":{"labels":{"nodes":[{"id":"l1","name":"Bug"}]}}}}`), + }, + } + err := integration.Sync(core.SyncContext{ + Configuration: appCtx.Configuration, + Integration: appCtx, + HTTP: httpCtx, + Logger: logger, + }) + require.NoError(t, err) + assert.Equal(t, "ready", appCtx.State) + assert.Nil(t, appCtx.BrowserAction) + }) + + t.Run("has access token but no refresh token -> skips refresh and reaches ready", func(t *testing.T) { + appCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "clientId": "id", + "clientSecret": "secret", + }, + Secrets: map[string]core.IntegrationSecret{ + OAuthAccessToken: {Name: OAuthAccessToken, Value: []byte("access-token")}, + }, + } + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + // GetViewer + linearMockResponse(http.StatusOK, `{"data":{"viewer":{"id":"u1","name":"User","email":"u@x.com"}}}`), + // ListTeams + linearMockResponse(http.StatusOK, `{"data":{"teams":{"nodes":[{"id":"t1","name":"Team 1","key":"T1"}]}}}`), + // ListLabels + linearMockResponse(http.StatusOK, `{"data":{"organization":{"labels":{"nodes":[{"id":"l1","name":"Bug"}]}}}}`), + }, + } + err := integration.Sync(core.SyncContext{ + Configuration: appCtx.Configuration, + Integration: appCtx, + HTTP: httpCtx, + Logger: logger, + }) + require.NoError(t, err) + assert.Equal(t, "ready", appCtx.State) + }) +} + +func Test__Linear__HandleRequest(t *testing.T) { + l := &Linear{} + logger := logrus.NewEntry(logrus.New()) + + t.Run("unknown path -> 404", func(t *testing.T) { + appCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{}, + } + recorder := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/unknown", nil) + + l.HandleRequest(core.HTTPRequestContext{ + Request: req, + Response: recorder, + Integration: appCtx, + Logger: logger, + }) + + assert.Equal(t, http.StatusNotFound, recorder.Code) + }) + + t.Run("callback success -> stores tokens and redirects", func(t *testing.T) { + state := "test-state" + appCtx := &contexts.IntegrationContext{ + Metadata: Metadata{State: &state}, + Configuration: map[string]any{ + "clientId": "id", + "clientSecret": "secret", + }, + Secrets: make(map[string]core.IntegrationSecret), + } + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + // Token exchange + linearMockResponse(http.StatusOK, `{"access_token":"access","refresh_token":"refresh","expires_in":86399}`), + // GetViewer + linearMockResponse(http.StatusOK, `{"data":{"viewer":{"id":"u1","name":"User","email":"u@x.com"}}}`), + // ListTeams + linearMockResponse(http.StatusOK, `{"data":{"teams":{"nodes":[{"id":"t1","name":"Eng","key":"ENG"}]}}}`), + // ListLabels + linearMockResponse(http.StatusOK, `{"data":{"organization":{"labels":{"nodes":[{"id":"l1","name":"Bug"}]}}}}`), + }, + } + recorder := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/callback?code=code123&state="+url.QueryEscape(state), nil) + + l.HandleRequest(core.HTTPRequestContext{ + Request: req, + Response: recorder, + Integration: appCtx, + HTTP: httpCtx, + Logger: logger, + }) + + assert.Equal(t, http.StatusSeeOther, recorder.Code) + assert.Equal(t, "ready", appCtx.State) + assert.Equal(t, "access", string(appCtx.Secrets[OAuthAccessToken].Value)) + assert.Equal(t, "refresh", string(appCtx.Secrets[OAuthRefreshToken].Value)) + }) + + t.Run("callback failure -> redirect back", func(t *testing.T) { + state := "valid-state" + appCtx := &contexts.IntegrationContext{ + Metadata: Metadata{State: &state}, + Configuration: map[string]any{ + "clientId": "id", + "clientSecret": "secret", + }, + Secrets: make(map[string]core.IntegrationSecret), + } + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + linearMockResponse(http.StatusBadRequest, `{"error":"invalid_grant"}`), + }, + } + recorder := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/callback?code=bad&state=valid-state", nil) + + l.HandleRequest(core.HTTPRequestContext{ + Request: req, + Response: recorder, + Integration: appCtx, + HTTP: httpCtx, + Logger: logger, + }) + + assert.Equal(t, http.StatusSeeOther, recorder.Code) + assert.NotEqual(t, "ready", appCtx.State) + }) +} diff --git a/pkg/integrations/linear/list_resources.go b/pkg/integrations/linear/list_resources.go new file mode 100644 index 0000000000..67a25ed920 --- /dev/null +++ b/pkg/integrations/linear/list_resources.go @@ -0,0 +1,113 @@ +package linear + +import ( + "fmt" + "strings" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/core" +) + +func (l *Linear) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { + switch resourceType { + case "team": + metadata, err := decodeMetadata(ctx) + if err != nil { + return nil, err + } + resources := make([]core.IntegrationResource, 0, len(metadata.Teams)) + for _, team := range metadata.Teams { + resources = append(resources, core.IntegrationResource{ + Type: resourceType, + Name: fmt.Sprintf("%s (%s)", team.Name, team.Key), + ID: team.ID, + }) + } + return resources, nil + case "label": + metadata, err := decodeMetadata(ctx) + if err != nil { + return nil, err + } + resources := make([]core.IntegrationResource, 0, len(metadata.Labels)) + for _, label := range metadata.Labels { + resources = append(resources, core.IntegrationResource{ + Type: resourceType, + Name: label.Name, + ID: label.ID, + }) + } + return resources, nil + case "state": + teamID := ctx.Parameters["team"] + if teamID == "" { + return []core.IntegrationResource{}, nil + } + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, fmt.Errorf("create client: %w", err) + } + states, err := client.ListWorkflowStates(teamID) + if err != nil { + return nil, fmt.Errorf("list workflow states: %w", err) + } + resources := make([]core.IntegrationResource, 0, len(states)) + for _, s := range states { + resources = append(resources, core.IntegrationResource{ + Type: resourceType, + Name: s.Name, + ID: s.ID, + }) + } + return resources, nil + case "member": + teamID := ctx.Parameters["team"] + if teamID == "" { + return []core.IntegrationResource{}, nil + } + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, fmt.Errorf("create client: %w", err) + } + members, err := client.ListTeamMembers(teamID) + if err != nil { + return nil, fmt.Errorf("list team members: %w", err) + } + resources := make([]core.IntegrationResource, 0, len(members)) + for _, m := range members { + displayName := memberDisplayLabel(m) + resources = append(resources, core.IntegrationResource{ + Type: resourceType, + Name: displayName, + ID: m.ID, + }) + } + return resources, nil + default: + return nil, fmt.Errorf("unknown resource type: %s", resourceType) + } +} + +// memberDisplayLabel returns the display label for a team member, matching Linear's UX +// (e.g. "Andrew Gonzales" rather than "cool.dev12701" or email). +func memberDisplayLabel(m Member) string { + if m.DisplayName != "" { + return m.DisplayName + } + // Avoid showing raw email as primary label + if m.Name != "" && !strings.Contains(m.Name, "@") { + return m.Name + } + if m.Email != "" { + return m.Email + } + return "Unnamed" +} + +func decodeMetadata(ctx core.ListResourcesContext) (*Metadata, error) { + metadata := Metadata{} + if err := mapstructure.Decode(ctx.Integration.GetMetadata(), &metadata); err != nil { + return nil, fmt.Errorf("decode metadata: %w", err) + } + return &metadata, nil +} diff --git a/pkg/integrations/linear/on_issue.go b/pkg/integrations/linear/on_issue.go new file mode 100644 index 0000000000..0011392e83 --- /dev/null +++ b/pkg/integrations/linear/on_issue.go @@ -0,0 +1,257 @@ +package linear + +import ( + "fmt" + "net/http" + "slices" + + "github.com/mitchellh/mapstructure" + "github.com/sirupsen/logrus" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +const onIssuePayloadType = "linear.issue" + +type OnIssue struct{} + +type OnIssueConfiguration struct { + Team string `json:"team"` + Labels []string `json:"labels"` +} + +func (t *OnIssue) Name() string { + return "linear.onIssue" +} + +func (t *OnIssue) Label() string { + return "On Issue" +} + +func (t *OnIssue) Description() string { + return "Start a workflow when an issue is created, updated, or removed in Linear" +} + +func (t *OnIssue) Documentation() string { + return `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".` +} + +func (t *OnIssue) Icon() string { + return "linear" +} + +func (t *OnIssue) Color() string { + return "gray" +} + +func (t *OnIssue) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "team", + Label: "Team", + Type: configuration.FieldTypeIntegrationResource, + Required: false, + Description: "Limit to this team, or leave empty for all public teams", + Placeholder: "Select a team (optional)", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "team", + }, + }, + }, + { + Name: "labels", + Label: "Labels", + Type: configuration.FieldTypeIntegrationResource, + Required: false, + Description: "Only trigger when the issue has at least one of these labels", + Placeholder: "Select labels (optional)", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "label", + Multi: true, + }, + }, + }, + } +} + +func (t *OnIssue) ExampleData() map[string]any { + return UnmarshalExampleDataOnIssue() +} + +func (t *OnIssue) Setup(ctx core.TriggerContext) error { + config := OnIssueConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("decode configuration: %w", err) + } + + var metadata NodeMetadata + if err := mapstructure.Decode(ctx.Metadata.Get(), &metadata); err != nil { + return fmt.Errorf("decode metadata: %w", err) + } + + if config.Team != "" { + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("create client: %w", err) + } + team, err := client.FindTeam(config.Team) + if err != nil { + return err + } + metadata.Team = team + } + + subscriptionID, err := t.subscribe(ctx, metadata) + if err != nil { + return fmt.Errorf("subscribe: %w", err) + } + + metadata.SubscriptionID = subscriptionID + return ctx.Metadata.Set(metadata) +} + +func (t *OnIssue) subscribe(ctx core.TriggerContext, metadata NodeMetadata) (*string, error) { + if metadata.SubscriptionID != nil { + logrus.Infof("using existing subscription %s", *metadata.SubscriptionID) + return metadata.SubscriptionID, nil + } + + logrus.Infof("creating new subscription") + subscriptionID, err := ctx.Integration.Subscribe(struct{}{}) + if err != nil { + return nil, fmt.Errorf("failed to subscribe: %w", err) + } + + s := subscriptionID.String() + return &s, nil +} + +func (t *OnIssue) Actions() []core.Action { + return nil +} + +func (t *OnIssue) HandleAction(ctx core.TriggerActionContext) (map[string]any, error) { + return nil, nil +} + +func (t *OnIssue) HandleWebhook(_ core.WebhookRequestContext) (int, error) { + // no-op, since events are received through the integration + // and routed to OnIntegrationMessage() + return http.StatusOK, nil +} + +func (t *OnIssue) OnIntegrationMessage(ctx core.IntegrationMessageContext) error { + config := OnIssueConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("decode configuration: %w", err) + } + + message, ok := ctx.Message.(map[string]any) + if !ok { + return fmt.Errorf("unexpected message type: %T", ctx.Message) + } + + payload := LinearWebhookPayload{ + Action: stringField(message, "action"), + Type: stringField(message, "type"), + URL: stringField(message, "url"), + CreatedAt: stringField(message, "createdAt"), + } + + if data, ok := message["data"].(map[string]any); ok { + payload.Data = data + } + if actor, ok := message["actor"].(map[string]any); ok { + payload.Actor = actor + } + if ts, ok := message["webhookTimestamp"].(float64); ok { + payload.WebhookTimestamp = int64(ts) + } + + if !onIssueAcceptPayload(&payload, config) { + ctx.Logger.Infof("payload filtered out (action=%s type=%s), ignoring", payload.Action, payload.Type) + return nil + } + + emitPayload := map[string]any{ + "action": payload.Action, + "type": payload.Type, + "data": payload.Data, + "actor": payload.Actor, + "url": payload.URL, + "createdAt": payload.CreatedAt, + "webhookTimestamp": payload.WebhookTimestamp, + } + + return ctx.Events.Emit(onIssuePayloadType, emitPayload) +} + +// onIssueAcceptPayload returns true if the payload is an Issue event that passes team/label filters. +func onIssueAcceptPayload(payload *LinearWebhookPayload, config OnIssueConfiguration) bool { + if payload.Type != "Issue" { + return false + } + if config.Team != "" { + teamID, _ := payload.Data["teamId"].(string) + if teamID != config.Team { + return false + } + } + if len(config.Labels) > 0 { + ids := payloadLabelIDs(payload.Data) + if !slices.ContainsFunc(config.Labels, func(want string) bool { return slices.Contains(ids, want) }) { + return false + } + } + return true +} + +func payloadLabelIDs(data map[string]any) []string { + raw, _ := data["labelIds"].([]any) + var ids []string + for _, id := range raw { + if s, ok := id.(string); ok { + ids = append(ids, s) + } + } + return ids +} + +func stringField(m map[string]any, key string) string { + v, _ := m[key].(string) + return v +} + +func (t *OnIssue) Cleanup(ctx core.TriggerContext) error { + return nil +} + +// LinearWebhookPayload matches Linear's webhook POST body. +type LinearWebhookPayload struct { + Action string `json:"action"` + Type string `json:"type"` + Data map[string]any `json:"data"` + Actor map[string]any `json:"actor"` + URL string `json:"url"` + CreatedAt string `json:"createdAt"` + WebhookTimestamp int64 `json:"webhookTimestamp"` + UpdatedFrom map[string]any `json:"updatedFrom,omitempty"` +} diff --git a/pkg/integrations/linear/on_issue_test.go b/pkg/integrations/linear/on_issue_test.go new file mode 100644 index 0000000000..3b3b957e39 --- /dev/null +++ b/pkg/integrations/linear/on_issue_test.go @@ -0,0 +1,189 @@ +package linear + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__OnIssue__HandleWebhook(t *testing.T) { + trigger := &OnIssue{} + + t.Run("no-op returns 200", func(t *testing.T) { + code, err := trigger.HandleWebhook(core.WebhookRequestContext{}) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, code) + }) +} + +func Test__OnIssue__OnIntegrationMessage(t *testing.T) { + trigger := &OnIssue{} + logger := logrus.NewEntry(logrus.New()) + + t.Run("type not Issue -> no emit", func(t *testing.T) { + eventCtx := &contexts.EventContext{} + err := trigger.OnIntegrationMessage(core.IntegrationMessageContext{ + Message: map[string]any{ + "action": "create", + "type": "Comment", + "data": map[string]any{}, + }, + Configuration: map[string]any{}, + Events: eventCtx, + Logger: logger, + }) + require.NoError(t, err) + assert.Equal(t, 0, eventCtx.Count()) + }) + + t.Run("create Issue -> emit", func(t *testing.T) { + eventCtx := &contexts.EventContext{} + err := trigger.OnIntegrationMessage(core.IntegrationMessageContext{ + Message: map[string]any{ + "action": "create", + "type": "Issue", + "data": map[string]any{"id": "i1", "teamId": "t1"}, + "actor": map[string]any{"name": "Bob"}, + "url": "https://linear.app/x", + "createdAt": "2020-01-01T00:00:00Z", + "webhookTimestamp": float64(123), + }, + Configuration: map[string]any{}, + Events: eventCtx, + Logger: logger, + }) + require.NoError(t, err) + require.Equal(t, 1, eventCtx.Count()) + assert.Equal(t, onIssuePayloadType, eventCtx.Payloads[0].Type) + }) + + t.Run("update Issue -> emit", func(t *testing.T) { + eventCtx := &contexts.EventContext{} + err := trigger.OnIntegrationMessage(core.IntegrationMessageContext{ + Message: map[string]any{ + "action": "update", + "type": "Issue", + "data": map[string]any{"id": "i1", "teamId": "t1"}, + }, + Configuration: map[string]any{}, + Events: eventCtx, + Logger: logger, + }) + require.NoError(t, err) + require.Equal(t, 1, eventCtx.Count()) + assert.Equal(t, onIssuePayloadType, eventCtx.Payloads[0].Type) + }) + + t.Run("team filter mismatch -> no emit", func(t *testing.T) { + eventCtx := &contexts.EventContext{} + err := trigger.OnIntegrationMessage(core.IntegrationMessageContext{ + Message: map[string]any{ + "action": "create", + "type": "Issue", + "data": map[string]any{"id": "i1", "teamId": "other-team"}, + }, + Configuration: map[string]any{"team": "my-team"}, + Events: eventCtx, + Logger: logger, + }) + require.NoError(t, err) + assert.Equal(t, 0, eventCtx.Count()) + }) +} + +func Test__OnIssue__Setup(t *testing.T) { + trigger := &OnIssue{} + + t.Run("team not found -> error", func(t *testing.T) { + teamsResp := `{"data":{"teams":{"nodes":[{"id":"other","name":"Other","key":"O"}]}}}` + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(teamsResp))}, + }, + } + integrationCtx := &contexts.IntegrationContext{ + Secrets: map[string]core.IntegrationSecret{ + OAuthAccessToken: {Name: OAuthAccessToken, Value: []byte("key")}, + }, + } + err := trigger.Setup(core.TriggerContext{ + HTTP: httpCtx, + Integration: integrationCtx, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"team": "team-id-1"}, + }) + require.Error(t, err) + assert.ErrorContains(t, err, "not found") + }) + + t.Run("subscribes with team", func(t *testing.T) { + teamsResp := `{"data":{"teams":{"nodes":[{"id":"team-id-1","name":"Eng","key":"ENG"}]}}}` + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(teamsResp))}, + }, + } + integrationCtx := &contexts.IntegrationContext{ + Secrets: map[string]core.IntegrationSecret{ + OAuthAccessToken: {Name: OAuthAccessToken, Value: []byte("key")}, + }, + } + metaCtx := &contexts.MetadataContext{} + err := trigger.Setup(core.TriggerContext{ + HTTP: httpCtx, + Integration: integrationCtx, + Metadata: metaCtx, + Configuration: map[string]any{"team": "team-id-1"}, + }) + require.NoError(t, err) + require.Len(t, integrationCtx.Subscriptions, 1) + md, ok := metaCtx.Get().(NodeMetadata) + require.True(t, ok) + require.NotNil(t, md.Team) + assert.Equal(t, "team-id-1", md.Team.ID) + require.NotNil(t, md.SubscriptionID) + }) + + t.Run("subscribes without team", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{} + metaCtx := &contexts.MetadataContext{} + err := trigger.Setup(core.TriggerContext{ + Integration: integrationCtx, + Metadata: metaCtx, + Configuration: map[string]any{}, + }) + require.NoError(t, err) + require.Len(t, integrationCtx.Subscriptions, 1) + md, ok := metaCtx.Get().(NodeMetadata) + require.True(t, ok) + require.NotNil(t, md.SubscriptionID) + }) + + t.Run("re-setup reuses existing subscription", func(t *testing.T) { + existingSubID := "existing-sub-id" + integrationCtx := &contexts.IntegrationContext{} + metaCtx := &contexts.MetadataContext{ + Metadata: NodeMetadata{ + SubscriptionID: &existingSubID, + }, + } + err := trigger.Setup(core.TriggerContext{ + Integration: integrationCtx, + Metadata: metaCtx, + Configuration: map[string]any{}, + }) + require.NoError(t, err) + assert.Empty(t, integrationCtx.Subscriptions) + md, ok := metaCtx.Get().(NodeMetadata) + require.True(t, ok) + require.NotNil(t, md.SubscriptionID) + assert.Equal(t, "existing-sub-id", *md.SubscriptionID) + }) +} diff --git a/pkg/integrations/prometheus/prometheus.go b/pkg/integrations/prometheus/prometheus.go index 0d3c521d29..c9d6d3a0cb 100644 --- a/pkg/integrations/prometheus/prometheus.go +++ b/pkg/integrations/prometheus/prometheus.go @@ -106,6 +106,9 @@ func (p *Prometheus) Configuration() []configuration.Field { VisibilityConditions: []configuration.VisibilityCondition{ {Field: "authType", Values: []string{AuthTypeBasic}}, }, + RequiredConditions: []configuration.RequiredCondition{ + {Field: "authType", Values: []string{AuthTypeBasic}}, + }, }, { Name: "password", @@ -116,6 +119,9 @@ func (p *Prometheus) Configuration() []configuration.Field { VisibilityConditions: []configuration.VisibilityCondition{ {Field: "authType", Values: []string{AuthTypeBasic}}, }, + RequiredConditions: []configuration.RequiredCondition{ + {Field: "authType", Values: []string{AuthTypeBasic}}, + }, }, { Name: "bearerToken", @@ -126,6 +132,9 @@ func (p *Prometheus) Configuration() []configuration.Field { VisibilityConditions: []configuration.VisibilityCondition{ {Field: "authType", Values: []string{AuthTypeBearer}}, }, + RequiredConditions: []configuration.RequiredCondition{ + {Field: "authType", Values: []string{AuthTypeBearer}}, + }, }, { Name: "webhookBearerToken", diff --git a/pkg/server/server.go b/pkg/server/server.go index 40728a2079..8057e1c8d3 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -52,6 +52,7 @@ import ( _ "github.com/superplanehq/superplane/pkg/integrations/hetzner" _ "github.com/superplanehq/superplane/pkg/integrations/jfrog_artifactory" _ "github.com/superplanehq/superplane/pkg/integrations/jira" + _ "github.com/superplanehq/superplane/pkg/integrations/linear" _ "github.com/superplanehq/superplane/pkg/integrations/openai" _ "github.com/superplanehq/superplane/pkg/integrations/pagerduty" _ "github.com/superplanehq/superplane/pkg/integrations/prometheus" diff --git a/web_src/src/assets/icons/integrations/linear.svg b/web_src/src/assets/icons/integrations/linear.svg new file mode 100644 index 0000000000..c101eecc84 --- /dev/null +++ b/web_src/src/assets/icons/integrations/linear.svg @@ -0,0 +1 @@ +Linear diff --git a/web_src/src/hooks/useIntegrations.ts b/web_src/src/hooks/useIntegrations.ts index a13b9c5029..f118ad0caf 100644 --- a/web_src/src/hooks/useIntegrations.ts +++ b/web_src/src/hooks/useIntegrations.ts @@ -97,7 +97,10 @@ export const useIntegrationResources = ( integrationId: string, resourceType: string, parameters?: Record, + options?: { enabled?: boolean }, ) => { + const enabled = (options?.enabled ?? true) && !!organizationId && !!integrationId && !!resourceType; + return useQuery({ queryKey: integrationKeys.resources(organizationId, integrationId, resourceType, parameters), queryFn: async () => { @@ -119,7 +122,7 @@ export const useIntegrationResources = ( }, staleTime: 2 * 60 * 1000, // 2 minutes gcTime: 5 * 60 * 1000, // 5 minutes - enabled: !!organizationId && !!integrationId && !!resourceType, + enabled, }); }; diff --git a/web_src/src/pages/organization/settings/Integrations.tsx b/web_src/src/pages/organization/settings/Integrations.tsx index cc95e14ab7..4669ebe179 100644 --- a/web_src/src/pages/organization/settings/Integrations.tsx +++ b/web_src/src/pages/organization/settings/Integrations.tsx @@ -5,6 +5,7 @@ import { useAvailableIntegrations, useConnectedIntegrations, useCreateIntegration, + useUpdateIntegration, } from "../../../hooks/useIntegrations"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -12,13 +13,18 @@ import { Label } from "@/components/ui/label"; import { PermissionTooltip } from "@/components/PermissionGate"; import { usePermissions } from "@/contexts/PermissionsContext"; import { ConfigurationFieldRenderer } from "../../../ui/configurationFieldRenderer"; -import type { IntegrationsIntegrationDefinition } from "../../../api-client/types.gen"; +import type { + IntegrationsIntegrationDefinition, + OrganizationsIntegration, + OrganizationsBrowserAction, +} from "../../../api-client/types.gen"; import { getApiErrorMessage } from "@/utils/errors"; import { getIntegrationTypeDisplayName } from "@/utils/integrationDisplayName"; import { Icon } from "@/components/Icon"; import { showErrorToast } from "@/utils/toast"; import { IntegrationIcon } from "@/ui/componentSidebar/integrationIcons"; import { IntegrationInstructions } from "@/ui/IntegrationInstructions"; +import { renderIntegrationMetadata } from "./integrationMetadataRenderers"; interface IntegrationsProps { organizationId: string; @@ -32,12 +38,16 @@ export function Integrations({ organizationId }: IntegrationsProps) { const [configuration, setConfiguration] = useState>({}); const [isModalOpen, setIsModalOpen] = useState(false); const [filterQuery, setFilterQuery] = useState(""); + const [createdIntegration, setCreatedIntegration] = useState(null); + const [wizardBrowserAction, setWizardBrowserAction] = useState(undefined); const canCreateIntegrations = canAct("integrations", "create"); const canUpdateIntegrations = canAct("integrations", "update"); const { data: availableIntegrations = [], isLoading: loadingAvailable } = useAvailableIntegrations(); const { data: organizationIntegrations = [], isLoading: loadingInstalled } = useConnectedIntegrations(organizationId); const createIntegrationMutation = useCreateIntegration(organizationId); + const createdIntegrationId = createdIntegration?.metadata?.id ?? ""; + const updateIntegrationMutation = useUpdateIntegration(organizationId, createdIntegrationId); const isLoading = loadingAvailable || loadingInstalled; const integrationNames = useMemo(() => { @@ -167,26 +177,95 @@ export function Integrations({ organizationId }: IntegrationsProps) { name: integrationName, configuration, }); + const integration = result.data?.integration; + if (!integration) return; + + const hasMetadataContent = selectedIntegration + ? renderIntegrationMetadata(selectedIntegration.name, integration) !== null + : false; + + if (integration.status?.state === "pending" || hasMetadataContent) { + setCreatedIntegration(integration); + setConfiguration(integration.spec?.configuration ?? {}); + if (integration.status?.browserAction) { + setWizardBrowserAction(integration.status.browserAction); + } + return; + } + setIsModalOpen(false); setSelectedIntegration(null); setIntegrationName(""); setConfiguration({}); - - // Redirect to the integration details page - if (result.data?.integration?.metadata?.id) { - navigate(`/${organizationId}/settings/integrations/${result.data.integration.metadata.id}`); + if (integration.metadata?.id) { + navigate(`/${organizationId}/settings/integrations/${integration.metadata.id}`); } } catch (_error) { showErrorToast("Failed to create integration"); } }; + const handleWizardSave = async () => { + if (!createdIntegration?.metadata?.id) return; + + try { + const result = await updateIntegrationMutation.mutateAsync({ + name: integrationName, + configuration, + }); + const integration = result.data?.integration; + + if (integration?.status?.browserAction) { + setCreatedIntegration(integration); + setWizardBrowserAction(integration.status.browserAction); + return; + } + + handleCloseModal(); + } catch (_error) { + showErrorToast("Failed to update integration"); + } + }; + + const handleWizardBrowserAction = () => { + if (!wizardBrowserAction) return; + const { url, method, formFields } = wizardBrowserAction; + + if (method?.toUpperCase() === "POST" && formFields) { + const form = document.createElement("form"); + form.method = "POST"; + form.action = url || ""; + form.target = "_blank"; + form.style.display = "none"; + Object.entries(formFields).forEach(([key, value]) => { + const input = document.createElement("input"); + input.type = "hidden"; + input.name = key; + input.value = String(value); + form.appendChild(input); + }); + document.body.appendChild(form); + form.submit(); + document.body.removeChild(form); + } else if (url) { + window.open(url, "_blank"); + } + }; + const handleCloseModal = () => { + const integrationId = createdIntegration?.metadata?.id; setIsModalOpen(false); setSelectedIntegration(null); setIntegrationName(""); setConfiguration({}); + setCreatedIntegration(null); + setWizardBrowserAction(undefined); createIntegrationMutation.reset(); + updateIntegrationMutation.reset(); + + if (integrationId) { + navigate(`/${organizationId}/settings/integrations/${integrationId}`); + } }; if (isLoading) { @@ -353,6 +432,24 @@ export function Integrations({ organizationId }: IntegrationsProps) { selectedIntegration && (() => { const integrationTypeName = selectedIntegration.name; + const isSetupPhase = createdIntegration != null; + const isReadyWithMetadata = isSetupPhase && createdIntegration?.status?.state !== "pending"; + const creationFields = + selectedIntegration.configuration?.filter( + (f) => f.required || (f.requiredConditions && f.requiredConditions.length > 0), + ) ?? []; + const allFields = selectedIntegration.configuration ?? []; + const fieldsToShow = isSetupPhase ? allFields : creationFields; + const isBusy = createIntegrationMutation.isPending || updateIntegrationMutation.isPending; + const metadataContent = + isSetupPhase && createdIntegration + ? renderIntegrationMetadata(selectedIntegration.name, createdIntegration) + : null; + const activeBrowserAction = + isSetupPhase && createdIntegration + ? (wizardBrowserAction ?? createdIntegration.status?.browserAction) + : undefined; + return (
@@ -365,43 +462,55 @@ export function Integrations({ organizationId }: IntegrationsProps) { className="w-6 h-6 text-gray-500 dark:text-gray-400" />

- Connect {selectedIntegration.label || selectedIntegration.name} + {isSetupPhase ? "Set up" : "Connect"} {selectedIntegration.label || selectedIntegration.name}

- {selectedInstructions && } - {/* Integration Name Field */} -
- - setIntegrationName(e.target.value)} - placeholder="e.g., my-app-integration" - required - disabled={!canCreateIntegrations} + {!isSetupPhase && selectedInstructions && ( + + )} + + {metadataContent} + + {activeBrowserAction && ( + -

- A unique name for this integration -

-
+ )} - {/* Configuration Fields */} - {selectedIntegration.configuration && selectedIntegration.configuration.length > 0 && ( + {!isSetupPhase && ( +
+ + setIntegrationName(e.target.value)} + placeholder="e.g., my-app-integration" + required + disabled={!canCreateIntegrations} + /> +

+ A unique name for this integration +

+
+ )} + + {fieldsToShow.length > 0 && (
- {selectedIntegration.configuration.map((field) => { + {fieldsToShow.map((field) => { if (!field.name) return null; return ( ); })} @@ -421,32 +531,65 @@ export function Integrations({ organizationId }: IntegrationsProps) {
- - + {isSetupPhase ? ( + <> + {isReadyWithMetadata ? ( + + ) : ( + <> + + + + )} + + ) : ( + <> + + + + )}
- {createIntegrationMutation.isError && ( + {(createIntegrationMutation.isError || updateIntegrationMutation.isError) && (

- Failed to create integration: {getApiErrorMessage(createIntegrationMutation.error)} + {createIntegrationMutation.isError + ? `Failed to create integration: ${getApiErrorMessage(createIntegrationMutation.error)}` + : `Failed to update integration: ${getApiErrorMessage(updateIntegrationMutation.error)}`}

)} diff --git a/web_src/src/pages/organization/settings/integrationMetadataRenderers/components.tsx b/web_src/src/pages/organization/settings/integrationMetadataRenderers/components.tsx new file mode 100644 index 0000000000..8f3b99013e --- /dev/null +++ b/web_src/src/pages/organization/settings/integrationMetadataRenderers/components.tsx @@ -0,0 +1,47 @@ +import { Icon } from "@/components/Icon"; +import { showErrorToast } from "@/utils/toast"; +import { useState } from "react"; + +export function CopyButton({ text, label }: { text: string; label: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (_error) { + showErrorToast(`Failed to copy ${label}`); + } + }; + + return ( + + ); +} + +export function URLField({ label, url }: { label: string; url: string }) { + return ( +
+
{label}
+
+
+ + {url} + +
+ +
+
+ ); +} diff --git a/web_src/src/pages/organization/settings/integrationMetadataRenderers/dash0.tsx b/web_src/src/pages/organization/settings/integrationMetadataRenderers/dash0.tsx index fcb712cb15..a8b1a5a119 100644 --- a/web_src/src/pages/organization/settings/integrationMetadataRenderers/dash0.tsx +++ b/web_src/src/pages/organization/settings/integrationMetadataRenderers/dash0.tsx @@ -1,7 +1,5 @@ -import { Icon } from "@/components/Icon"; -import { showErrorToast } from "@/utils/toast"; -import { useState } from "react"; import { IntegrationMetadataRenderer } from "./types"; +import { CopyButton } from "./components"; export const dash0MetadataRenderer: IntegrationMetadataRenderer = ({ integration }) => { const metadata = integration.status?.metadata as Record | undefined; @@ -12,31 +10,6 @@ export const dash0MetadataRenderer: IntegrationMetadataRenderer = ({ integration const normalizedWebhookURL = webhookUrl.trim(); - const CopyButton = () => { - const [copied, setCopied] = useState(false); - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(normalizedWebhookURL); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (_error) { - showErrorToast("Failed to copy webhook URL"); - } - }; - - return ( - - ); - }; - return (
Dash0 Notification Webhook
@@ -67,7 +40,7 @@ export const dash0MetadataRenderer: IntegrationMetadataRenderer = ({ integration {normalizedWebhookURL}
- +
); diff --git a/web_src/src/pages/organization/settings/integrationMetadataRenderers/index.tsx b/web_src/src/pages/organization/settings/integrationMetadataRenderers/index.tsx index 7c808af0aa..6f175c810d 100644 --- a/web_src/src/pages/organization/settings/integrationMetadataRenderers/index.tsx +++ b/web_src/src/pages/organization/settings/integrationMetadataRenderers/index.tsx @@ -1,9 +1,11 @@ import { dash0MetadataRenderer } from "./dash0"; +import { linearMetadataRenderer } from "./linear"; import { OrganizationsIntegration } from "@/api-client"; import { IntegrationMetadataRenderer } from "./types"; const integrationMetadataRenderers: Record = { dash0: dash0MetadataRenderer, + linear: linearMetadataRenderer, }; export function renderIntegrationMetadata( diff --git a/web_src/src/pages/organization/settings/integrationMetadataRenderers/linear.tsx b/web_src/src/pages/organization/settings/integrationMetadataRenderers/linear.tsx new file mode 100644 index 0000000000..fec85db966 --- /dev/null +++ b/web_src/src/pages/organization/settings/integrationMetadataRenderers/linear.tsx @@ -0,0 +1,37 @@ +import { IntegrationMetadataRenderer } from "./types"; +import { URLField } from "./components"; + +export const linearMetadataRenderer: IntegrationMetadataRenderer = ({ integration }) => { + const metadata = integration.status?.metadata as Record | undefined; + const webhookUrl = metadata?.webhookUrl; + const callbackUrl = metadata?.callbackUrl; + + const hasWebhook = typeof webhookUrl === "string" && webhookUrl.trim().length > 0; + const hasCallback = typeof callbackUrl === "string" && callbackUrl.trim().length > 0; + + if (!hasWebhook && !hasCallback) { + return null; + } + + return ( +
+
Linear OAuth Application URLs
+
+ Use these URLs when creating your OAuth2 application in{" "} + + Linear API Settings + + . +
+
+ {hasCallback && } + {hasWebhook && } +
+
+ ); +}; diff --git a/web_src/src/pages/workflowv2/index.tsx b/web_src/src/pages/workflowv2/index.tsx index 02f244a877..cae3f628b6 100644 --- a/web_src/src/pages/workflowv2/index.tsx +++ b/web_src/src/pages/workflowv2/index.tsx @@ -1668,7 +1668,18 @@ export function WorkflowPageV2() { // Save snapshot before making changes saveWorkflowSnapshot(canvas); - const { buildingBlock, configuration, position, sourceConnection, integrationRef } = newNodeData; + let { buildingBlock, configuration, position, sourceConnection, integrationRef } = newNodeData; + + // For integration components/triggers, use first connected integration if none selected + if (buildingBlock.integrationName && !integrationRef?.id) { + const firstIntegration = integrations.find((i) => i.spec?.integrationName === buildingBlock.integrationName); + if (firstIntegration?.metadata?.id) { + integrationRef = { + id: firstIntegration.metadata.id, + name: firstIntegration.metadata.name, + }; + } + } // Filter configuration to only include visible fields const filteredConfiguration = filterVisibleConfiguration(configuration, buildingBlock.configuration || []); @@ -1760,6 +1771,7 @@ export function WorkflowPageV2() { organizationId, canvasId, queryClient, + integrations, saveWorkflowSnapshot, handleSaveWorkflow, canAutoSave, @@ -1886,6 +1898,18 @@ export function WorkflowPageV2() { updatedNode.blueprint = { id: data.buildingBlock.id }; } + // For integration components/triggers, set the integration ref (required for validation) + const integrationName = data.buildingBlock.integrationName; + if (integrationName) { + const firstIntegration = integrations.find((i) => i.spec?.integrationName === integrationName); + if (firstIntegration?.metadata?.id) { + updatedNode.integration = { + id: firstIntegration.metadata.id, + name: firstIntegration.metadata.name, + }; + } + } + const updatedNodes = [...(canvas.spec?.nodes || [])]; updatedNodes[nodeIndex] = updatedNode; @@ -1937,6 +1961,7 @@ export function WorkflowPageV2() { organizationId, canvasId, queryClient, + integrations, saveWorkflowSnapshot, handleSaveWorkflow, canAutoSave, diff --git a/web_src/src/pages/workflowv2/mappers/index.ts b/web_src/src/pages/workflowv2/mappers/index.ts index 12327de26a..19bc92b3f0 100644 --- a/web_src/src/pages/workflowv2/mappers/index.ts +++ b/web_src/src/pages/workflowv2/mappers/index.ts @@ -156,6 +156,11 @@ import { triggerRenderers as dockerhubTriggerRenderers, eventStateRegistry as dockerhubEventStateRegistry, } from "./dockerhub"; +import { + componentMappers as linearComponentMappers, + triggerRenderers as linearTriggerRenderers, + eventStateRegistry as linearEventStateRegistry, +} from "./linear/index"; import { componentMappers as gcpComponentMappers, customFieldRenderers as gcpCustomFieldRenderers, @@ -168,7 +173,6 @@ import { triggerRenderers as servicenowTriggerRenderers, eventStateRegistry as servicenowEventStateRegistry, } from "./servicenow/index"; - import { filterMapper, FILTER_STATE_REGISTRY } from "./filter"; import { sshMapper, SSH_STATE_REGISTRY } from "./ssh"; import { waitCustomFieldRenderer, waitMapper, WAIT_STATE_REGISTRY } from "./wait"; @@ -229,6 +233,7 @@ const appMappers: Record> = { jfrogArtifactory: jfrogArtifactoryComponentMappers, statuspage: statuspageComponentMappers, dockerhub: dockerhubComponentMappers, + linear: linearComponentMappers, harness: harnessComponentMappers, servicenow: servicenowComponentMappers, }; @@ -262,6 +267,7 @@ const appTriggerRenderers: Record> = { jfrogArtifactory: jfrogArtifactoryTriggerRenderers, statuspage: statuspageTriggerRenderers, dockerhub: dockerhubTriggerRenderers, + linear: linearTriggerRenderers, harness: harnessTriggerRenderers, servicenow: servicenowTriggerRenderers, }; @@ -294,6 +300,7 @@ const appEventStateRegistries: Record gitlab: gitlabEventStateRegistry, jfrogArtifactory: jfrogArtifactoryEventStateRegistry, dockerhub: dockerhubEventStateRegistry, + linear: linearEventStateRegistry, harness: harnessEventStateRegistry, servicenow: servicenowEventStateRegistry, }; diff --git a/web_src/src/pages/workflowv2/mappers/linear/base.ts b/web_src/src/pages/workflowv2/mappers/linear/base.ts new file mode 100644 index 0000000000..2c28cfec53 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/linear/base.ts @@ -0,0 +1,30 @@ +import { Issue } from "./types"; + +const priorityLabels: Record = { + 0: "None", + 1: "Urgent", + 2: "High", + 3: "Medium", + 4: "Low", +}; + +export function getDetailsForIssue(issue: Issue): Record { + const details: Record = {}; + + Object.assign(details, { + "Created At": issue?.createdAt ? new Date(issue.createdAt).toLocaleString() : "-", + }); + + details.Identifier = issue?.identifier || "-"; + details.Title = issue?.title || "-"; + + if (issue?.priority !== undefined && issue.priority !== null) { + details.Priority = priorityLabels[issue.priority] || String(issue.priority); + } + + if (issue?.url) { + details.URL = issue.url; + } + + return details; +} diff --git a/web_src/src/pages/workflowv2/mappers/linear/create_issue.ts b/web_src/src/pages/workflowv2/mappers/linear/create_issue.ts new file mode 100644 index 0000000000..665cc7b0a8 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/linear/create_issue.ts @@ -0,0 +1,83 @@ +import { ComponentBaseProps, EventSection } from "@/ui/componentBase"; +import { getBackgroundColorClass } from "@/utils/colors"; +import { getState, getStateMap, getTriggerRenderer } from ".."; +import { + ComponentBaseContext, + ComponentBaseMapper, + ExecutionDetailsContext, + ExecutionInfo, + NodeInfo, + OutputPayload, + SubtitleContext, +} from "../types"; +import { MetadataItem } from "@/ui/metadataList"; +import linearIcon from "@/assets/icons/integrations/linear.svg"; +import { Issue } from "./types"; +import { getDetailsForIssue } from "./base"; +import { formatTimeAgo } from "@/utils/date"; + +export const createIssueMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null; + const componentName = context.componentDefinition.name || "unknown"; + + return { + iconSrc: linearIcon, + collapsedBackground: getBackgroundColorClass(context.componentDefinition.color), + collapsed: context.node.isCollapsed, + title: + context.node.name || + context.componentDefinition.label || + context.componentDefinition.name || + "Unnamed component", + eventSections: lastExecution ? baseEventSections(context.nodes, lastExecution, componentName) : undefined, + metadata: metadataList(context.node), + includeEmptyState: !lastExecution, + eventStateMap: getStateMap(componentName), + }; + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + const outputs = context.execution.outputs as { default: OutputPayload[] }; + if (!outputs?.default || outputs.default.length === 0) { + return {}; + } + const issue = outputs.default[0].data as Issue; + return getDetailsForIssue(issue); + }, + + subtitle(context: SubtitleContext): string { + if (!context.execution.createdAt) return ""; + return formatTimeAgo(new Date(context.execution.createdAt)); + }, +}; + +function metadataList(node: NodeInfo): MetadataItem[] { + const metadata: MetadataItem[] = []; + const nodeMetadata = node.metadata as { team?: { name?: string; key?: string } }; + + if (nodeMetadata?.team?.name) { + const label = nodeMetadata.team.key + ? `Team: ${nodeMetadata.team.name} (${nodeMetadata.team.key})` + : `Team: ${nodeMetadata.team.name}`; + metadata.push({ icon: "funnel", label }); + } + + return metadata; +} + +function baseEventSections(nodes: NodeInfo[], execution: ExecutionInfo, componentName: string): EventSection[] { + const rootTriggerNode = nodes.find((n) => n.id === execution.rootEvent?.nodeId); + const rootTriggerRenderer = getTriggerRenderer(rootTriggerNode?.componentName ?? ""); + const { title } = rootTriggerRenderer.getTitleAndSubtitle({ event: execution.rootEvent }); + + return [ + { + receivedAt: new Date(execution.createdAt!), + eventTitle: title, + eventSubtitle: formatTimeAgo(new Date(execution.createdAt!)), + eventState: getState(componentName)(execution), + eventId: execution.rootEvent!.id!, + }, + ]; +} diff --git a/web_src/src/pages/workflowv2/mappers/linear/index.ts b/web_src/src/pages/workflowv2/mappers/linear/index.ts new file mode 100644 index 0000000000..fc51c48162 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/linear/index.ts @@ -0,0 +1,16 @@ +import { ComponentBaseMapper, EventStateRegistry, TriggerRenderer } from "../types"; +import { onIssueTriggerRenderer } from "./on_issue"; +import { createIssueMapper } from "./create_issue"; +import { buildActionStateRegistry } from "../utils"; + +export const componentMappers: Record = { + createIssue: createIssueMapper, +}; + +export const triggerRenderers: Record = { + onIssue: onIssueTriggerRenderer, +}; + +export const eventStateRegistry: Record = { + createIssue: buildActionStateRegistry("created"), +}; diff --git a/web_src/src/pages/workflowv2/mappers/linear/on_issue.ts b/web_src/src/pages/workflowv2/mappers/linear/on_issue.ts new file mode 100644 index 0000000000..21ef5250e3 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/linear/on_issue.ts @@ -0,0 +1,110 @@ +import { getBackgroundColorClass } from "@/utils/colors"; +import { formatTimeAgo } from "@/utils/date"; +import { TriggerEventContext, TriggerRenderer, TriggerRendererContext } from "../types"; +import { TriggerProps } from "@/ui/trigger"; +import linearIcon from "@/assets/icons/integrations/linear.svg"; +import { Issue } from "./types"; + +interface OnIssueEventData { + action?: string; + type?: string; + data?: Issue & { teamId?: string; stateId?: string; assigneeId?: string }; + actor?: { name?: string; email?: string }; + url?: string; +} + +export const onIssueTriggerRenderer: TriggerRenderer = { + getTitleAndSubtitle: (context: TriggerEventContext): { title: string; subtitle: string } => { + const eventData = context.event?.data as OnIssueEventData; + const issue = eventData?.data; + const subtitle = buildSubtitle(issue?.identifier, context.event?.createdAt); + + return { + title: buildTitle(eventData), + subtitle, + }; + }, + + getRootEventValues: (context: TriggerEventContext): Record => { + const eventData = context.event?.data as OnIssueEventData; + const issue = eventData?.data; + if (!issue) return {}; + + const details: Record = {}; + + Object.assign(details, { + "Created At": issue.createdAt ? new Date(issue.createdAt).toLocaleString() : "-", + }); + + details.Identifier = issue.identifier || "-"; + details.Title = issue.title || "-"; + + if (eventData?.actor?.name) { + details.Actor = eventData.actor.name; + } + + if (eventData?.url) { + details.URL = eventData.url; + } + + return details; + }, + + getTriggerProps: (context: TriggerRendererContext) => { + const { node, definition, lastEvent } = context; + const metadataItems = []; + + const nodeMetadata = node.metadata as { team?: { name?: string; key?: string } }; + if (nodeMetadata?.team?.name) { + const label = nodeMetadata.team.key + ? `Team: ${nodeMetadata.team.name} (${nodeMetadata.team.key})` + : `Team: ${nodeMetadata.team.name}`; + metadataItems.push({ icon: "funnel", label }); + } + + const props: TriggerProps = { + title: node.name!, + iconSrc: linearIcon, + collapsedBackground: getBackgroundColorClass(definition.color), + metadata: metadataItems, + }; + + if (lastEvent) { + const eventData = lastEvent.data as OnIssueEventData; + const issue = eventData?.data; + const subtitle = buildSubtitle(issue?.identifier, lastEvent.createdAt); + + props.lastEventData = { + title: buildTitle(eventData), + subtitle, + receivedAt: new Date(lastEvent.createdAt), + state: "triggered", + eventId: lastEvent.id, + }; + } + + return props; + }, +}; + +const actionLabels: Record = { + create: "Issue created", + update: "Issue updated", + remove: "Issue removed", +}; + +function buildTitle(eventData?: OnIssueEventData): string { + const action = eventData?.action; + const label = (action && actionLabels[action]) || "Issue"; + const title = eventData?.data?.title; + return title ? `${label}: ${title}` : label; +} + +function buildSubtitle(identifier?: string, createdAt?: string): string { + const timeAgo = createdAt ? formatTimeAgo(new Date(createdAt)) : ""; + if (identifier && timeAgo) { + return `${identifier} · ${timeAgo}`; + } + + return identifier || timeAgo; +} diff --git a/web_src/src/pages/workflowv2/mappers/linear/types.ts b/web_src/src/pages/workflowv2/mappers/linear/types.ts new file mode 100644 index 0000000000..4322f91cfa --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/linear/types.ts @@ -0,0 +1,12 @@ +export interface Issue { + id?: string; + identifier?: string; + title?: string; + description?: string; + priority?: number; + url?: string; + createdAt?: string; + team?: { id?: string }; + state?: { id?: string }; + assignee?: { id?: string }; +} diff --git a/web_src/src/ui/BuildingBlocksSidebar/index.tsx b/web_src/src/ui/BuildingBlocksSidebar/index.tsx index 8303404f32..de89bc620f 100644 --- a/web_src/src/ui/BuildingBlocksSidebar/index.tsx +++ b/web_src/src/ui/BuildingBlocksSidebar/index.tsx @@ -25,6 +25,7 @@ import telegramIcon from "@/assets/icons/integrations/telegram.svg"; import githubIcon from "@/assets/icons/integrations/github.svg"; import gitlabIcon from "@/assets/icons/integrations/gitlab.svg"; import jiraIcon from "@/assets/icons/integrations/jira.svg"; +import linearIcon from "@/assets/icons/integrations/linear.svg"; import grafanaIcon from "@/assets/icons/integrations/grafana.svg"; import openAiIcon from "@/assets/icons/integrations/openai.svg"; import claudeIcon from "@/assets/icons/integrations/claude.svg"; @@ -427,6 +428,7 @@ function CategorySection({ jfrogArtifactory: jfrogArtifactoryIcon, grafana: grafanaIcon, jira: jiraIcon, + linear: linearIcon, openai: openAiIcon, "open-ai": openAiIcon, claude: claudeIcon, @@ -517,6 +519,7 @@ function CategorySection({ github: githubIcon, gitlab: gitlabIcon, hetzner: hetznerIcon, + linear: linearIcon, jfrogArtifactory: jfrogArtifactoryIcon, grafana: grafanaIcon, openai: openAiIcon, diff --git a/web_src/src/ui/IntegrationInstructions.tsx b/web_src/src/ui/IntegrationInstructions.tsx index 72a7791ae5..6a622e3032 100644 --- a/web_src/src/ui/IntegrationInstructions.tsx +++ b/web_src/src/ui/IntegrationInstructions.tsx @@ -1,5 +1,6 @@ import ReactMarkdown from "react-markdown"; -import { ExternalLink } from "lucide-react"; +import { Check, Copy, ExternalLink } from "lucide-react"; +import { useState } from "react"; import { Button } from "@/components/ui/button"; const INSTRUCTIONS_CLASSES = @@ -48,7 +49,7 @@ export function IntegrationInstructions({ description, onContinue, className = " {children} ), - code: ({ children }) => {children}, + code: ({ children }) => {children}, strong: ({ children }) => {children}, em: ({ children }) => {children}, }} @@ -66,3 +67,26 @@ export function IntegrationInstructions({ description, onContinue, className = " ); } + +function CopyableCode({ children }: { children: React.ReactNode }) { + const [copied, setCopied] = useState(false); + const text = String(children); + + return ( + { + navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }} + > + {children} + {copied ? ( + + ) : ( + + )} + + ); +} diff --git a/web_src/src/ui/componentSidebar/index.tsx b/web_src/src/ui/componentSidebar/index.tsx index cce854bc87..08725264e0 100644 --- a/web_src/src/ui/componentSidebar/index.tsx +++ b/web_src/src/ui/componentSidebar/index.tsx @@ -19,6 +19,7 @@ import { ConfigurationFieldRenderer } from "@/ui/configurationFieldRenderer"; import { getApiErrorMessage } from "@/utils/errors"; import { showErrorToast } from "@/utils/toast"; import { IntegrationInstructions } from "@/ui/IntegrationInstructions"; +import { renderIntegrationMetadata } from "@/pages/organization/settings/integrationMetadataRenderers"; import { ChildEventsState } from "../composite"; import { TabData } from "./SidebarEventItem/SidebarEventItem"; import { SidebarEvent } from "./types"; @@ -257,6 +258,7 @@ export const ComponentSidebar = ({ const [createIntegrationBrowserAction, setCreateIntegrationBrowserAction] = useState< OrganizationsBrowserAction | undefined >(undefined); + const [createdWizardIntegration, setCreatedWizardIntegration] = useState(null); const [configureIntegrationId, setConfigureIntegrationId] = useState(null); const [configureIntegrationName, setConfigureIntegrationName] = useState(""); // Use autocompleteExampleObj directly - current node is already filtered out upstream @@ -264,6 +266,8 @@ export const ComponentSidebar = ({ const { data: availableIntegrationDefinitions = [] } = useAvailableIntegrations(); const createIntegrationMutation = useCreateIntegration(domainId ?? ""); + const createdWizardIntegrationId = createdWizardIntegration?.metadata?.id ?? ""; + const wizardUpdateMutation = useUpdateIntegration(domainId ?? "", createdWizardIntegrationId); const { data: configureIntegration, isLoading: configureIntegrationLoading } = useIntegration( domainId ?? "", configureIntegrationId ?? "", @@ -315,8 +319,10 @@ export const ComponentSidebar = ({ setCreateIntegrationName(""); setCreateIntegrationConfig({}); setCreateIntegrationBrowserAction(undefined); + setCreatedWizardIntegration(null); createIntegrationMutation.reset(); - }, [createIntegrationMutation]); + wizardUpdateMutation.reset(); + }, [createIntegrationMutation, wizardUpdateMutation]); const handleCreateIntegrationSubmit = useCallback(async () => { if (!selectedIntegrationForDialog?.name || !domainId) return; @@ -332,17 +338,28 @@ export const ComponentSidebar = ({ name: nextName, configuration: createIntegrationConfig, }); - const browserAction = result.data?.integration?.status?.browserAction; - if (browserAction) { - setCreateIntegrationBrowserAction(browserAction); + const integration = result.data?.integration; + if (!integration) return; + + const hasMetadataContent = selectedIntegrationForDialog + ? renderIntegrationMetadata(selectedIntegrationForDialog.name, integration) !== null + : false; + + if (integration.status?.state === "pending" || hasMetadataContent) { + setCreatedWizardIntegration(integration); + setCreateIntegrationConfig(integration.spec?.configuration ?? {}); + if (integration.status?.browserAction) { + setCreateIntegrationBrowserAction(integration.status.browserAction); + } return; } + handleCloseCreateIntegrationDialog(); } catch (error) { showErrorToast(`Failed to create integration: ${getApiErrorMessage(error)}`); } }, [ - selectedIntegrationForDialog?.name, + selectedIntegrationForDialog, domainId, createIntegrationName, createIntegrationConfig, @@ -350,6 +367,35 @@ export const ComponentSidebar = ({ handleCloseCreateIntegrationDialog, ]); + const handleWizardSaveSubmit = useCallback(async () => { + if (!createdWizardIntegration?.metadata?.id || !domainId) return; + + try { + const result = await wizardUpdateMutation.mutateAsync({ + name: createIntegrationName, + configuration: createIntegrationConfig, + }); + const integration = result.data?.integration; + + if (integration?.status?.browserAction) { + setCreatedWizardIntegration(integration); + setCreateIntegrationBrowserAction(integration.status.browserAction); + return; + } + + handleCloseCreateIntegrationDialog(); + } catch (error) { + showErrorToast(`Failed to update integration: ${getApiErrorMessage(error)}`); + } + }, [ + createdWizardIntegration?.metadata?.id, + domainId, + createIntegrationName, + createIntegrationConfig, + wizardUpdateMutation, + handleCloseCreateIntegrationDialog, + ]); + const handleCreateBrowserAction = useCallback(() => { if (!createIntegrationBrowserAction) return; const { url, method, formFields } = createIntegrationBrowserAction; @@ -945,123 +991,168 @@ export const ComponentSidebar = ({ > - {selectedIntegrationForDialog && ( - <> - -
- -
- - Configure{" "} - {getIntegrationTypeDisplayName(undefined, selectedIntegrationForDialog.name) || - selectedIntegrationForDialog.name} - - - - + {selectedIntegrationForDialog && + (() => { + const isSetupPhase = createdWizardIntegration != null; + const isReadyWithMetadata = isSetupPhase && createdWizardIntegration?.status?.state !== "pending"; + const creationFields = + selectedIntegrationForDialog.configuration?.filter( + (f: ConfigurationField) => f.required || (f.requiredConditions && f.requiredConditions.length > 0), + ) ?? []; + const allFields = selectedIntegrationForDialog.configuration ?? []; + const fieldsToShow = isSetupPhase ? allFields : creationFields; + const isBusy = createIntegrationMutation.isPending || wizardUpdateMutation.isPending; + const metadataContent = + isSetupPhase && createdWizardIntegration + ? renderIntegrationMetadata(selectedIntegrationForDialog.name, createdWizardIntegration) + : null; + + return ( + <> + +
+ +
+ + {isSetupPhase ? "Set up" : "Configure"}{" "} + {getIntegrationTypeDisplayName(undefined, selectedIntegrationForDialog.name) || + selectedIntegrationForDialog.name} + + + + +
+
+ {!isSetupPhase && selectedInstructions && ( + + )} +
+
+ {metadataContent} + + {isSetupPhase && createIntegrationBrowserAction && ( + + )} + + {!isSetupPhase && ( +
+ + setCreateIntegrationName(e.target.value)} + placeholder="e.g., my-app-integration" + /> +

+ A unique name for this integration +

+
+ )} + + {fieldsToShow.length > 0 && ( +
+ {fieldsToShow.map((field: ConfigurationField) => { + if (!field.name) return null; + return ( + + setCreateIntegrationConfig((prev) => ({ ...prev, [field.name || ""]: value })) + } + allValues={createIntegrationConfig} + domainId={domainId ?? ""} + domainType="DOMAIN_TYPE_ORGANIZATION" + organizationId={domainId ?? ""} + appInstallationId={createdWizardIntegration?.metadata?.id} + /> + ); + })} +
+ )}
-
- {(createIntegrationBrowserAction?.description || selectedInstructions) && ( - - )} - -
-
- - setCreateIntegrationName(e.target.value)} - placeholder="e.g., my-app-integration" - /> -

A unique name for this integration

-
- {selectedIntegrationForDialog.configuration && - selectedIntegrationForDialog.configuration.length > 0 && ( -
- {selectedIntegrationForDialog.configuration.map((field: ConfigurationField) => { - if (!field.name) return null; - return ( - - setCreateIntegrationConfig((prev) => ({ ...prev, [field.name || ""]: value })) - } - allValues={createIntegrationConfig} - domainId={domainId ?? ""} - domainType="DOMAIN_TYPE_ORGANIZATION" - organizationId={domainId ?? ""} - /> - ); - })} + + {isSetupPhase ? ( + <> + {isReadyWithMetadata ? ( + + ) : ( + <> + + + + )} + + ) : ( + <> + + + + )} + + {(createIntegrationMutation.isError || wizardUpdateMutation.isError) && ( +
+

+ {createIntegrationMutation.isError + ? `Failed to create integration: ${getApiErrorMessage(createIntegrationMutation.error)}` + : `Failed to update integration: ${getApiErrorMessage(wizardUpdateMutation.error)}`} +

)} -
- - {createIntegrationBrowserAction ? ( - <> - - - - ) : ( - <> - - - - )} - - {createIntegrationMutation.isError && ( -
-

- Failed to create integration: {getApiErrorMessage(createIntegrationMutation.error)} -

-
- )} - - )} + + ); + })()} diff --git a/web_src/src/ui/componentSidebar/integrationIcons.tsx b/web_src/src/ui/componentSidebar/integrationIcons.tsx index 57e3083ea5..7805e3d314 100644 --- a/web_src/src/ui/componentSidebar/integrationIcons.tsx +++ b/web_src/src/ui/componentSidebar/integrationIcons.tsx @@ -24,6 +24,7 @@ import githubIcon from "@/assets/icons/integrations/github.svg"; import gitlabIcon from "@/assets/icons/integrations/gitlab.svg"; import grafanaIcon from "@/assets/icons/integrations/grafana.svg"; import jiraIcon from "@/assets/icons/integrations/jira.svg"; +import linearIcon from "@/assets/icons/integrations/linear.svg"; import openAiIcon from "@/assets/icons/integrations/openai.svg"; import claudeIcon from "@/assets/icons/integrations/claude.svg"; import gcpIcon from "@/assets/icons/integrations/gcp.svg"; @@ -61,6 +62,7 @@ export const INTEGRATION_APP_LOGO_MAP: Record = { jfrogArtifactory: jfrogArtifactoryIcon, grafana: grafanaIcon, jira: jiraIcon, + linear: linearIcon, openai: openAiIcon, "open-ai": openAiIcon, claude: claudeIcon, @@ -97,6 +99,7 @@ export const APP_LOGO_MAP: Record> = { jfrogArtifactory: jfrogArtifactoryIcon, grafana: grafanaIcon, jira: jiraIcon, + linear: linearIcon, openai: openAiIcon, "open-ai": openAiIcon, claude: claudeIcon, diff --git a/web_src/src/ui/configurationFieldRenderer/IntegrationResourceFieldRenderer.tsx b/web_src/src/ui/configurationFieldRenderer/IntegrationResourceFieldRenderer.tsx index bedf6aea7d..63c54e2bf0 100644 --- a/web_src/src/ui/configurationFieldRenderer/IntegrationResourceFieldRenderer.tsx +++ b/web_src/src/ui/configurationFieldRenderer/IntegrationResourceFieldRenderer.tsx @@ -42,6 +42,38 @@ function isExpressionValue(value: string | string[] | undefined): boolean { return /\{\{[\s\S]*?\}\}/.test(trimmed); } +const RESOURCE_TYPE_ERROR_MESSAGES: Record = { + member: "Could not get list of team members", + state: "Could not get list of status", + label: "Could not get list of labels", + team: "Could not get list of teams", +}; + +/** User-facing label for resource type; aligned with Linear's UX (members, teammates). */ +const RESOURCE_TYPE_DISPLAY_LABEL: Record = { + member: "team members", + state: "status", + label: "labels", + team: "teams", +}; + +function getResourceDisplayLabel(resourceType: string | undefined): string { + if (resourceType && RESOURCE_TYPE_DISPLAY_LABEL[resourceType]) { + return RESOURCE_TYPE_DISPLAY_LABEL[resourceType]; + } + return resourceType ?? "resources"; +} + +function getLoadErrorMessage(resourceType: string | undefined, fieldLabel?: string): string { + if (resourceType && RESOURCE_TYPE_ERROR_MESSAGES[resourceType]) { + return RESOURCE_TYPE_ERROR_MESSAGES[resourceType]; + } + if (fieldLabel) { + return `Could not get list of ${fieldLabel.toLowerCase()}`; + } + return "Could not load resources"; +} + export const IntegrationResourceFieldRenderer = ({ field, value, @@ -104,11 +136,24 @@ export const IntegrationResourceFieldRenderer = ({ return parameters; }, [resourceParameters, allValues]); + // 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); + const { data: resources, isLoading: isLoadingResources, error: resourcesError, - } = useIntegrationResources(organizationId ?? "", integrationId ?? "", resourceType ?? "", additionalQueryParameters); + } = useIntegrationResources( + organizationId ?? "", + integrationId ?? "", + resourceType ?? "", + additionalQueryParameters, + { + enabled: hasRequiredParameters, + }, + ); // All hooks must be called before any early returns // Multi-select options (always compute, even if not used) @@ -168,18 +213,28 @@ export const IntegrationResourceFieldRenderer = ({ ); } + const resourceDisplayLabel = getResourceDisplayLabel(resourceType); + + if (!hasRequiredParameters) { + const paramNames = resourceParameters.map((p) => p.valueFrom?.field || p.name).filter(Boolean); + const hint = + paramNames.length > 0 ? `Select ${paramNames.join(" and ")} first` : "Complete the required fields above"; + return
{hint}
; + } + if (isLoadingResources) { - return
Loading {resourceType} resources...
; + return
Loading {resourceDisplayLabel}...
; } if (resourcesError) { - return
Failed to load resources
; + const loadErrorMessage = getLoadErrorMessage(resourceType, field.label); + return
{loadErrorMessage}
; } const hasResources = Boolean(resources && resources.length > 0); // Single select mode if (!isMulti) { - const options: AutoCompleteOption[] = (resources ?? []) + const resourceOptions: AutoCompleteOption[] = (resources ?? []) .map((resource) => { const optionValue = useNameAsValue ? (resource.name ?? resource.id ?? "") @@ -190,6 +245,11 @@ export const IntegrationResourceFieldRenderer = ({ }) .filter((option): option is AutoCompleteOption => option !== null); + // Optional single-select: allow clearing selection via empty option (driven by field.required) + const options: AutoCompleteOption[] = !field.required + ? [{ value: "", label: "None" }, ...resourceOptions] + : resourceOptions; + const selectedValue = useNameAsValue && typeof value === "string" && value ? ((resources ?? []).find((r) => r.id === value)?.name ?? value) @@ -204,7 +264,7 @@ export const IntegrationResourceFieldRenderer = ({ options={options} value={selectedValue} onChange={(val) => onChange(val || undefined)} - placeholder={field.placeholder ?? `Select ${resourceType}`} + placeholder={field.placeholder ?? `Select ${resourceDisplayLabel}`} /> ) : (