From c15ed309bc5f88951bf0c3e0448b6de0ce8a25f9 Mon Sep 17 00:00:00 2001 From: sriram2k4 Date: Wed, 28 May 2025 09:34:03 +0530 Subject: [PATCH 01/18] feat(linear): add Linear Issue Reader and Writer tools with types --- apps/sim/tools/linear/create_issue.ts | 73 +++++++++++++++++++++++++++ apps/sim/tools/linear/index.ts | 4 ++ apps/sim/tools/linear/read_issues.ts | 72 ++++++++++++++++++++++++++ apps/sim/tools/linear/types.ts | 36 +++++++++++++ 4 files changed, 185 insertions(+) create mode 100644 apps/sim/tools/linear/create_issue.ts create mode 100644 apps/sim/tools/linear/index.ts create mode 100644 apps/sim/tools/linear/read_issues.ts create mode 100644 apps/sim/tools/linear/types.ts diff --git a/apps/sim/tools/linear/create_issue.ts b/apps/sim/tools/linear/create_issue.ts new file mode 100644 index 00000000000..45016f5d6ab --- /dev/null +++ b/apps/sim/tools/linear/create_issue.ts @@ -0,0 +1,73 @@ +import type { ToolConfig } from '../types' +import type { LinearCreateIssueParams, LinearCreateIssueResponse } from './types' + +export const linearCreateIssueTool: ToolConfig = + { + id: 'linear_create_issue', + name: 'Linear Issue Writer', + description: 'Create a new issue in Linear', + version: '1.0.0', + params: { + teamId: { type: 'string', required: true, description: 'Linear team ID' }, + projectId: { type: 'string', required: false, description: 'Linear project ID' }, + title: { type: 'string', required: true, description: 'Issue title' }, + description: { type: 'string', required: false, description: 'Issue description' }, + }, + request: { + url: 'https://api.linear.app/graphql', + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken || ''}`, + }), + body: (params) => ({ + query: ` + mutation CreateIssue($teamId: String!, $projectId: String, $title: String!, $description: String) { + issueCreate( + input: { + teamId: $teamId + projectId: $projectId + title: $title + description: $description + } + ) { + issue { + id + title + description + state { name } + team { id } + project { id } + } + } + } + `, + variables: { + teamId: params.teamId, + projectId: params.projectId, + title: params.title, + description: params.description, + }, + }), + }, + transformResponse: async (response) => { + const data = await response.json() + if (data.errors) { + return { success: false, output: { issue: null }, error: data.errors[0].message } + } + return { + success: true, + output: { + issue: { + id: data.data.issueCreate.issue.id, + title: data.data.issueCreate.issue.title, + description: data.data.issueCreate.issue.description, + state: data.data.issueCreate.issue.state?.name, + teamId: data.data.issueCreate.issue.team?.id, + projectId: data.data.issueCreate.issue.project?.id, + }, + }, + } + }, + transformError: (error) => error.message || 'Failed to create Linear issue', + } diff --git a/apps/sim/tools/linear/index.ts b/apps/sim/tools/linear/index.ts new file mode 100644 index 00000000000..5726982ed4e --- /dev/null +++ b/apps/sim/tools/linear/index.ts @@ -0,0 +1,4 @@ +import { linearCreateIssueTool } from './create_issue' +import { linearReadIssuesTool } from './read_issues' + +export { linearReadIssuesTool, linearCreateIssueTool } diff --git a/apps/sim/tools/linear/read_issues.ts b/apps/sim/tools/linear/read_issues.ts new file mode 100644 index 00000000000..dc4cb24d0df --- /dev/null +++ b/apps/sim/tools/linear/read_issues.ts @@ -0,0 +1,72 @@ +import type { ToolConfig } from '../types' +import type { LinearReadIssuesParams, LinearReadIssuesResponse } from './types' + +export const linearReadIssuesTool: ToolConfig = { + id: 'linear_read_issues', + name: 'Linear Issue Reader', + description: 'Fetch and filter issues from Linear', + version: '1.0.0', + params: { + teamId: { type: 'string', required: false, description: 'Linear team ID' }, + projectId: { type: 'string', required: false, description: 'Linear project ID' }, + state: { type: 'string', required: false, description: 'Issue state' }, + search: { type: 'string', required: false, description: 'Search query' }, + }, + request: { + url: 'https://api.linear.app/graphql', + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken || ''}`, + }), + body: (params) => ({ + query: ` + query Issues($teamId: String, $projectId: String, $state: String, $search: String) { + issues( + filter: { + team: { id: $teamId } + project: { id: $projectId } + state: { name: { eq: $state } } + search: $search + } + ) { + nodes { + id + title + description + state { name } + team { id } + project { id } + } + } + } + `, + variables: { + teamId: params.teamId, + projectId: params.projectId, + state: params.state, + search: params.search, + }, + }), + }, + transformResponse: async (response) => { + const data = await response.json() + if (data.errors) { + return { success: false, output: { issues: [] }, error: data.errors[0].message } + } + return { + success: true, + output: { + issues: data.data.issues.nodes.map((issue: any) => ({ + id: issue.id, + title: issue.title, + description: issue.description, + state: issue.state?.name, + teamId: issue.team?.id, + projectId: issue.project?.id, + })), + }, + } + }, + transformError: (error) => error.message || 'Failed to fetch Linear issues', +} diff --git a/apps/sim/tools/linear/types.ts b/apps/sim/tools/linear/types.ts new file mode 100644 index 00000000000..cbc85166cb7 --- /dev/null +++ b/apps/sim/tools/linear/types.ts @@ -0,0 +1,36 @@ +import type { ToolResponse } from '../types' + +export interface LinearIssue { + id: string + title: string + description?: string + state?: string + teamId?: string + projectId?: string +} + +export interface LinearReadIssuesParams { + teamId?: string + projectId?: string + state?: string + search?: string +} + +export interface LinearCreateIssueParams { + teamId: string + projectId?: string + title: string + description?: string +} + +export interface LinearReadIssuesResponse extends ToolResponse { + output: { + issues: LinearIssue[] + } +} + +export interface LinearCreateIssueResponse extends ToolResponse { + output: { + issue: LinearIssue + } +} From cf5498c77b5b709ef9f9acecba3d978b46b8e03c Mon Sep 17 00:00:00 2001 From: sriram2k4 Date: Wed, 28 May 2025 09:41:09 +0530 Subject: [PATCH 02/18] chore(tools): register Linear tools in global tool registry --- apps/sim/tools/registry.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 46b6aeebf4d..21807c4f032 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -45,6 +45,7 @@ import { requestTool as httpRequest } from './http' import { contactsTool as hubspotContacts } from './hubspot/contacts' import { readUrlTool } from './jina' import { jiraBulkRetrieveTool, jiraRetrieveTool, jiraUpdateTool, jiraWriteTool } from './jira' +import { linearCreateIssueTool, linearReadIssuesTool } from './linear' import { linkupSearchTool } from './linkup' import { mem0AddMemoriesTool, mem0GetMemoriesTool, mem0SearchMemoriesTool } from './mem0' import { memoryAddTool, memoryDeleteTool, memoryGetAllTool, memoryGetTool } from './memory' @@ -187,4 +188,6 @@ export const tools: Record = { outlook_read: outlookReadTool, outlook_send: outlookSendTool, outlook_draft: outlookDraftTool, + linear_read_issues: linearReadIssuesTool, + linear_create_issue: linearCreateIssueTool, } From 481d0c6cfa11a99eede79073839c15ba13c9bd19 Mon Sep 17 00:00:00 2001 From: sriram2k4 Date: Wed, 28 May 2025 09:48:45 +0530 Subject: [PATCH 03/18] feat(icons): add LinearIcon for Linear block --- apps/sim/components/icons.tsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index d7e2f3f22fe..7be99c905de 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -2278,6 +2278,28 @@ export function JiraIcon(props: SVGProps) { ) } +export function LinearIcon(props: React.SVGProps) { + return ( + + + + + ) +} + export function TelegramIcon(props: SVGProps) { return ( Date: Wed, 28 May 2025 09:56:58 +0530 Subject: [PATCH 04/18] feat(blocks): register Linear block in global block registry --- apps/sim/blocks/blocks/linear.ts | 33 ++++++++++++++++++++++++++++++++ apps/sim/blocks/registry.ts | 2 ++ 2 files changed, 35 insertions(+) create mode 100644 apps/sim/blocks/blocks/linear.ts diff --git a/apps/sim/blocks/blocks/linear.ts b/apps/sim/blocks/blocks/linear.ts new file mode 100644 index 00000000000..660cdb26645 --- /dev/null +++ b/apps/sim/blocks/blocks/linear.ts @@ -0,0 +1,33 @@ +import { LinearIcon } from '@/components/icons' +import type { LinearReadIssuesResponse } from '@/tools/linear/types' +import type { BlockConfig } from '../types' + +export const LinearBlock: BlockConfig = { + type: 'linear', + name: 'Linear', + description: 'Read and create issues in Linear', + longDescription: + 'Integrate with Linear to fetch, filter, and create issues directly from your workflow.', + category: 'tools', + bgColor: '#5E6AD2', + icon: LinearIcon, + subBlocks: [], + tools: { + access: ['linear_read_issues', 'linear_create_issue'], + }, + inputs: { + teamId: { type: 'string', required: false, description: 'Linear team ID' }, + projectId: { type: 'string', required: false, description: 'Linear project ID' }, + state: { type: 'string', required: false, description: 'Issue state' }, + search: { type: 'string', required: false, description: 'Search query' }, + title: { type: 'string', required: false, description: 'Issue title (for create)' }, + description: { type: 'string', required: false, description: 'Issue description (for create)' }, + }, + outputs: { + response: { + type: { + issues: 'json', + }, + }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 4571e9910cc..0d38fcfc052 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -28,6 +28,7 @@ import { GoogleSheetsBlock } from './blocks/google_sheets' import { ImageGeneratorBlock } from './blocks/image_generator' import { JinaBlock } from './blocks/jina' import { JiraBlock } from './blocks/jira' +import { LinearBlock } from './blocks/linear' import { LinkupBlock } from './blocks/linkup' import { Mem0Block } from './blocks/mem0' // import { GuestyBlock } from './blocks/guesty' @@ -116,6 +117,7 @@ export const registry: Record = { whatsapp: WhatsAppBlock, x: XBlock, youtube: YouTubeBlock, + linear: LinearBlock, } // Helper functions to access the registry From 15077eb289f29cd7cab604d09df527150ead9864 Mon Sep 17 00:00:00 2001 From: sriram2k4 Date: Wed, 28 May 2025 10:39:33 +0530 Subject: [PATCH 05/18] feat(linear): implement OAuth integration for Linear block --- apps/sim/blocks/blocks/linear.ts | 106 ++++++++++++++++++++++++-- apps/sim/lib/auth.ts | 45 +++++++++++ apps/sim/lib/env.ts | 2 + apps/sim/lib/oauth.ts | 22 ++++++ apps/sim/tools/linear/create_issue.ts | 4 + apps/sim/tools/linear/read_issues.ts | 4 + apps/sim/tools/linear/types.ts | 2 + 7 files changed, 178 insertions(+), 7 deletions(-) diff --git a/apps/sim/blocks/blocks/linear.ts b/apps/sim/blocks/blocks/linear.ts index 660cdb26645..2cc0a11703c 100644 --- a/apps/sim/blocks/blocks/linear.ts +++ b/apps/sim/blocks/blocks/linear.ts @@ -11,17 +11,109 @@ export const LinearBlock: BlockConfig = { category: 'tools', bgColor: '#5E6AD2', icon: LinearIcon, - subBlocks: [], + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + layout: 'full', + options: [ + { label: 'Read Issues', id: 'read' }, + { label: 'Create Issue', id: 'write' }, + ], + }, + { + id: 'credential', + title: 'Linear Account', + type: 'oauth-input', + layout: 'full', + provider: 'linear', + serviceId: 'linear', + requiredScopes: [], + placeholder: 'Select Linear account', + }, + { + id: 'teamId', + title: 'Team', + type: 'project-selector', + layout: 'full', + provider: 'linear', + serviceId: 'linear', + placeholder: 'Select a team', + }, + { + id: 'projectId', + title: 'Project', + type: 'project-selector', + layout: 'full', + provider: 'linear', + serviceId: 'linear', + placeholder: 'Select a project', + }, + { + id: 'state', + title: 'Issue State', + type: 'dropdown', + options: ['Backlog', 'Todo', 'In Progress', 'Done', 'Canceled'], + description: 'Filter by issue state', + condition: { field: 'operation', value: ['read'] }, + }, + { + id: 'search', + title: 'Search', + type: 'short-input', + description: 'Search issues', + condition: { field: 'operation', value: ['read'] }, + }, + { + id: 'title', + title: 'Title', + type: 'short-input', + description: 'Title for new issue', + condition: { field: 'operation', value: ['write'] }, + }, + { + id: 'description', + title: 'Description', + type: 'long-input', + description: 'Description for new issue', + condition: { field: 'operation', value: ['write'] }, + }, + ], tools: { access: ['linear_read_issues', 'linear_create_issue'], + config: { + tool: (params) => + params.operation === 'write' ? 'linear_create_issue' : 'linear_read_issues', + params: (params) => { + if (params.operation === 'write') { + return { + credential: params.credential, + teamId: params.teamId, + projectId: params.projectId, + title: params.title, + description: params.description, + } + } + return { + credential: params.credential, + teamId: params.teamId, + projectId: params.projectId, + state: params.state, + search: params.search, + } + }, + }, }, inputs: { - teamId: { type: 'string', required: false, description: 'Linear team ID' }, - projectId: { type: 'string', required: false, description: 'Linear project ID' }, - state: { type: 'string', required: false, description: 'Issue state' }, - search: { type: 'string', required: false, description: 'Search query' }, - title: { type: 'string', required: false, description: 'Issue title (for create)' }, - description: { type: 'string', required: false, description: 'Issue description (for create)' }, + operation: { type: 'string', required: true }, + credential: { type: 'string', required: true }, + teamId: { type: 'string', required: false }, + projectId: { type: 'string', required: false }, + state: { type: 'string', required: false }, + search: { type: 'string', required: false }, + title: { type: 'string', required: false }, + description: { type: 'string', required: false }, }, outputs: { response: { diff --git a/apps/sim/lib/auth.ts b/apps/sim/lib/auth.ts index 2963b22611c..9260bc013ab 100644 --- a/apps/sim/lib/auth.ts +++ b/apps/sim/lib/auth.ts @@ -776,6 +776,51 @@ export const auth = betterAuth({ } }, }, + + { + providerId: 'linear', + clientId: env.LINEAR_CLIENT_ID as string, + clientSecret: env.LINEAR_CLIENT_SECRET as string, + authorizationUrl: 'https://linear.app/oauth/authorize', + tokenUrl: 'https://api.linear.app/oauth/token', + scopes: [], // Add required scopes if Linear supports them + responseType: 'code', + accessType: 'offline', + prompt: 'consent', + pkce: false, + redirectURI: `${env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/linear`, + getUserInfo: async (tokens) => { + const response = await fetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: `\n query {\n viewer {\n id\n name\n email\n avatarUrl\n }\n }\n `, + }), + }) + + if (!response.ok) { + throw new Error('Failed to fetch Linear user info') + } + + const { data } = await response.json() + const user = data?.viewer + if (!user) throw new Error('No user info returned from Linear') + + const now = new Date() + return { + id: user.id, + name: user.name, + email: user.email, + image: user.avatarUrl, + emailVerified: true, + createdAt: now, + updatedAt: now, + } + }, + }, ], }), // Only include the Stripe plugin in production diff --git a/apps/sim/lib/env.ts b/apps/sim/lib/env.ts index a247f00957d..c66eb8f79c2 100644 --- a/apps/sim/lib/env.ts +++ b/apps/sim/lib/env.ts @@ -97,6 +97,8 @@ export const env = createEnv({ HUBSPOT_CLIENT_ID: z.string().optional(), HUBSPOT_CLIENT_SECRET: z.string().optional(), DOCKER_BUILD: z.boolean().optional(), + LINEAR_CLIENT_ID: z.string().optional(), + LINEAR_CLIENT_SECRET: z.string().optional(), }, client: { diff --git a/apps/sim/lib/oauth.ts b/apps/sim/lib/oauth.ts index ba295661b05..aadafd6a0f1 100644 --- a/apps/sim/lib/oauth.ts +++ b/apps/sim/lib/oauth.ts @@ -11,6 +11,7 @@ import { GoogleIcon, GoogleSheetsIcon, JiraIcon, + LinearIcon, MicrosoftIcon, MicrosoftTeamsIcon, NotionIcon, @@ -35,6 +36,7 @@ export type OAuthProvider = | 'jira' | 'discord' | 'microsoft' + | 'linear' | string export type OAuthService = @@ -53,6 +55,7 @@ export type OAuthService = | 'discord' | 'microsoft-teams' | 'outlook' + | 'linear' // Define the interface for OAuth provider configuration export interface OAuthProviderConfig { id: OAuthProvider @@ -330,6 +333,23 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'notion', }, + linear: { + id: 'linear', + name: 'Linear', + icon: (props) => LinearIcon(props), + services: { + linear: { + id: 'linear', + name: 'Linear', + description: 'Manage issues and projects in Linear.', + providerId: 'linear', + icon: (props) => LinearIcon(props), + baseProviderIcon: (props) => LinearIcon(props), + scopes: [], // Add required scopes if Linear supports granular OAuth scopes + }, + }, + defaultService: 'linear', + }, } // Helper function to get a service by provider and service ID @@ -394,6 +414,8 @@ export function getServiceIdFromScopes(provider: OAuthProvider, scopes: string[] return 'notion' } else if (provider === 'discord') { return 'discord' + } else if (provider === 'linear') { + return 'linear' } return providerConfig.defaultService diff --git a/apps/sim/tools/linear/create_issue.ts b/apps/sim/tools/linear/create_issue.ts index 45016f5d6ab..32e93416acc 100644 --- a/apps/sim/tools/linear/create_issue.ts +++ b/apps/sim/tools/linear/create_issue.ts @@ -70,4 +70,8 @@ export const linearCreateIssueTool: ToolConfig error.message || 'Failed to create Linear issue', + oauth: { + required: true, + provider: 'linear', + }, } diff --git a/apps/sim/tools/linear/read_issues.ts b/apps/sim/tools/linear/read_issues.ts index dc4cb24d0df..f0f9ae0dc9d 100644 --- a/apps/sim/tools/linear/read_issues.ts +++ b/apps/sim/tools/linear/read_issues.ts @@ -69,4 +69,8 @@ export const linearReadIssuesTool: ToolConfig error.message || 'Failed to fetch Linear issues', + oauth: { + required: true, + provider: 'linear', + }, } diff --git a/apps/sim/tools/linear/types.ts b/apps/sim/tools/linear/types.ts index cbc85166cb7..ddf48b59c23 100644 --- a/apps/sim/tools/linear/types.ts +++ b/apps/sim/tools/linear/types.ts @@ -14,6 +14,7 @@ export interface LinearReadIssuesParams { projectId?: string state?: string search?: string + accessToken?: string } export interface LinearCreateIssueParams { @@ -21,6 +22,7 @@ export interface LinearCreateIssueParams { projectId?: string title: string description?: string + accessToken?: string } export interface LinearReadIssuesResponse extends ToolResponse { From fe058dc3c06c18f70045403b44c5fc83bb4feb6d Mon Sep 17 00:00:00 2001 From: sriram2k4 Date: Wed, 28 May 2025 11:16:26 +0530 Subject: [PATCH 06/18] feat(linear): add dynamic team and project selectors for Linear block --- .../components/linear-project-selector.tsx | 77 +++++++++++++++++++ .../components/linear-team-selector.tsx | 75 ++++++++++++++++++ .../project-selector-input.tsx | 51 +++++++++++- apps/sim/blocks/blocks/linear.ts | 34 ++++---- 4 files changed, 222 insertions(+), 15 deletions(-) create mode 100644 apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-project-selector.tsx create mode 100644 apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-project-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-project-selector.tsx new file mode 100644 index 00000000000..00bfd29bcf9 --- /dev/null +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-project-selector.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from 'react' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' + +export interface LinearProjectInfo { + id: string + name: string +} + +interface LinearProjectSelectorProps { + value: string + onChange: (projectId: string, projectInfo?: LinearProjectInfo) => void + credential: string + teamId: string + label?: string + disabled?: boolean + showPreview?: boolean +} + +export function LinearProjectSelector({ value, onChange, credential, teamId, label = 'Select Linear project', disabled = false }: LinearProjectSelectorProps) { + const [projects, setProjects] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!credential || !teamId) return + setLoading(true) + setError(null) + fetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${credential}`, + }, + body: JSON.stringify({ + query: `query($teamId: String!) { team(id: $teamId) { projects { nodes { id name } } } }`, + variables: { teamId }, + }), + }) + .then((res) => res.json()) + .then((data) => { + if (data.errors) { + setError(data.errors[0].message) + setProjects([]) + } else { + setProjects(data.data.team?.projects?.nodes || []) + } + }) + .catch((err) => { + setError(err.message) + setProjects([]) + }) + .finally(() => setLoading(false)) + }, [credential, teamId]) + + return ( +
{error}
} + + + ) +} \ No newline at end of file diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx new file mode 100644 index 00000000000..66ff836a994 --- /dev/null +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'react' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' + +export interface LinearTeamInfo { + id: string + name: string +} + +interface LinearTeamSelectorProps { + value: string + onChange: (teamId: string, teamInfo?: LinearTeamInfo) => void + credential: string + label?: string + disabled?: boolean + showPreview?: boolean +} + +export function LinearTeamSelector({ value, onChange, credential, label = 'Select Linear team', disabled = false }: LinearTeamSelectorProps) { + const [teams, setTeams] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!credential) return + setLoading(true) + setError(null) + fetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${credential}`, + }, + body: JSON.stringify({ + query: `query { teams { nodes { id name } } }`, + }), + }) + .then((res) => res.json()) + .then((data) => { + if (data.errors) { + setError(data.errors[0].message) + setTeams([]) + } else { + setTeams(data.data.teams.nodes) + } + }) + .catch((err) => { + setError(err.message) + setTeams([]) + }) + .finally(() => setLoading(false)) + }, [credential]) + + return ( + + ) +} \ No newline at end of file diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx index 3e04d60f1e3..e00a568779b 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx @@ -6,6 +6,8 @@ import type { SubBlockConfig } from '@/blocks/types' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { type DiscordServerInfo, DiscordServerSelector } from './components/discord-server-selector' import { type JiraProjectInfo, JiraProjectSelector } from './components/jira-project-selector' +import { type LinearTeamInfo, LinearTeamSelector } from './components/linear-team-selector' +import { type LinearProjectInfo, LinearProjectSelector } from './components/linear-project-selector' interface ProjectSelectorInputProps { blockId: string @@ -27,6 +29,7 @@ export function ProjectSelectorInput({ // Get provider-specific values const provider = subBlock.provider || 'jira' const isDiscord = provider === 'discord' + const isLinear = provider === 'linear' // For Jira, we need the domain const domain = !isDiscord ? (getValue(blockId, 'domain') as string) || '' : '' @@ -41,7 +44,7 @@ export function ProjectSelectorInput({ }, [blockId, subBlock.id, getValue]) // Handle project selection - const handleProjectChange = (projectId: string, info?: JiraProjectInfo | DiscordServerInfo) => { + const handleProjectChange = (projectId: string, info?: JiraProjectInfo | DiscordServerInfo | LinearTeamInfo | LinearProjectInfo) => { setSelectedProjectId(projectId) setProjectInfo(info || null) setValue(blockId, subBlock.id, projectId) @@ -53,6 +56,9 @@ export function ProjectSelectorInput({ setValue(blockId, 'issueKey', '') } else if (provider === 'discord') { setValue(blockId, 'channelId', '') + } else if (provider === 'linear') { + setValue(blockId, 'credential', '') + setValue(blockId, 'teamId', '') } onProjectSelect?.(projectId) @@ -87,6 +93,49 @@ export function ProjectSelectorInput({ ) } + // Render Linear team/project selector if provider is linear + if (isLinear) { + return ( + + + +
+ {subBlock.id === 'teamId' ? ( + { + handleProjectChange(teamId, teamInfo) + }} + credential={getValue(blockId, 'credential') as string} + label={subBlock.placeholder || 'Select Linear team'} + disabled={disabled || !getValue(blockId, 'credential')} + showPreview={true} + /> + ) : ( + { + handleProjectChange(projectId, projectInfo) + }} + credential={getValue(blockId, 'credential') as string} + teamId={getValue(blockId, 'teamId') as string} + label={subBlock.placeholder || 'Select Linear project'} + disabled={disabled || !getValue(blockId, 'credential') || !getValue(blockId, 'teamId')} + showPreview={true} + /> + )} +
+
+ {!getValue(blockId, 'credential') && ( + +

Please select a Linear account first

+
+ )} +
+
+ ) + } + // Default to Jira project selector return ( diff --git a/apps/sim/blocks/blocks/linear.ts b/apps/sim/blocks/blocks/linear.ts index 2cc0a11703c..c7c6a211253 100644 --- a/apps/sim/blocks/blocks/linear.ts +++ b/apps/sim/blocks/blocks/linear.ts @@ -1,16 +1,18 @@ import { LinearIcon } from '@/components/icons' -import type { LinearReadIssuesResponse } from '@/tools/linear/types' +import type { LinearReadIssuesResponse, LinearCreateIssueResponse } from '@/tools/linear/types' import type { BlockConfig } from '../types' -export const LinearBlock: BlockConfig = { +type LinearResponse = LinearReadIssuesResponse | LinearCreateIssueResponse + +export const LinearBlock: BlockConfig = { type: 'linear', name: 'Linear', description: 'Read and create issues in Linear', longDescription: 'Integrate with Linear to fetch, filter, and create issues directly from your workflow.', category: 'tools', - bgColor: '#5E6AD2', icon: LinearIcon, + bgColor: '#5E6AD2', subBlocks: [ { id: 'operation', @@ -18,7 +20,7 @@ export const LinearBlock: BlockConfig = { type: 'dropdown', layout: 'full', options: [ - { label: 'Read Issues', id: 'read' }, + { label: 'Read Issues', id: 'read-bulk' }, { label: 'Create Issue', id: 'write' }, ], }, @@ -29,7 +31,6 @@ export const LinearBlock: BlockConfig = { layout: 'full', provider: 'linear', serviceId: 'linear', - requiredScopes: [], placeholder: 'Select Linear account', }, { @@ -54,37 +55,37 @@ export const LinearBlock: BlockConfig = { id: 'state', title: 'Issue State', type: 'dropdown', + layout: 'full', options: ['Backlog', 'Todo', 'In Progress', 'Done', 'Canceled'], - description: 'Filter by issue state', - condition: { field: 'operation', value: ['read'] }, + condition: { field: 'operation', value: ['read-bulk'] }, }, { id: 'search', title: 'Search', type: 'short-input', - description: 'Search issues', - condition: { field: 'operation', value: ['read'] }, + layout: 'full', + condition: { field: 'operation', value: ['read-bulk'] }, }, { id: 'title', title: 'Title', type: 'short-input', - description: 'Title for new issue', + layout: 'full', condition: { field: 'operation', value: ['write'] }, }, { id: 'description', title: 'Description', type: 'long-input', - description: 'Description for new issue', + layout: 'full', condition: { field: 'operation', value: ['write'] }, }, + // Add assignee, label, priority, etc. as needed ], tools: { access: ['linear_read_issues', 'linear_create_issue'], config: { - tool: (params) => - params.operation === 'write' ? 'linear_create_issue' : 'linear_read_issues', + tool: (params) => (params.operation === 'write' ? 'linear_create_issue' : 'linear_read_issues'), params: (params) => { if (params.operation === 'write') { return { @@ -93,14 +94,17 @@ export const LinearBlock: BlockConfig = { projectId: params.projectId, title: params.title, description: params.description, + // Add assigneeId, labelIds, etc. if supported } } + // read-bulk return { credential: params.credential, teamId: params.teamId, projectId: params.projectId, state: params.state, search: params.search, + // Add assigneeId, labelId, etc. if supported } }, }, @@ -114,11 +118,13 @@ export const LinearBlock: BlockConfig = { search: { type: 'string', required: false }, title: { type: 'string', required: false }, description: { type: 'string', required: false }, + // Add assigneeId, labelIds, etc. as needed }, outputs: { response: { type: { - issues: 'json', + issues: 'json', // For read-bulk + issue: 'json', // For write }, }, }, From 3b83c63682c144ef0d944d0f06e23a677502070b Mon Sep 17 00:00:00 2001 From: sriram2k4 Date: Wed, 28 May 2025 13:42:46 +0530 Subject: [PATCH 07/18] feat(linear): add backend API endpoints for teams and projects --- .../app/api/tools/linear/projects/route.ts | 70 +++++++++++++++++++ apps/sim/app/api/tools/linear/teams/route.ts | 55 +++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 apps/sim/app/api/tools/linear/projects/route.ts create mode 100644 apps/sim/app/api/tools/linear/teams/route.ts diff --git a/apps/sim/app/api/tools/linear/projects/route.ts b/apps/sim/app/api/tools/linear/projects/route.ts new file mode 100644 index 00000000000..8587eec8293 --- /dev/null +++ b/apps/sim/app/api/tools/linear/projects/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console-logger' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { LinearClient } from '@linear/sdk' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('LinearProjects') + +export async function POST(request: Request) { + try { + const session = await getSession() + const body = await request.json() + const { credential, teamId, workflowId } = body + + if (!credential || !teamId) { + logger.error('Missing credential or teamId in request') + return NextResponse.json({ error: 'Credential and teamId are required' }, { status: 400 }) + } + + const userId = session?.user?.id || '' + if (!userId) { + logger.error('No user ID found in session') + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const accessToken = await refreshAccessTokenIfNeeded(credential, userId, workflowId) + if (!accessToken) { + logger.error('Failed to get access token', { credentialId: credential, userId }) + return NextResponse.json( + { + error: 'Could not retrieve access token', + authRequired: true, + }, + { status: 401 } + ) + } + + const linearClient = new LinearClient({ accessToken }) + let projects = [] + + if (teamId) { + const team = await linearClient.team(teamId) + const projectsResult = await team.projects(); + projects = projectsResult.nodes.map((project: any) => ({ + id: project.id, + name: project.name, + })) + } else { + const allProjects = await linearClient.projects() + projects = allProjects.nodes.map((project: any) => ({ + id: project.id, + name: project.name, + })) + } + + if (projects.length === 0) { + logger.info('No projects found for team', { teamId }) + } + + return NextResponse.json({ projects }) + } catch (error) { + logger.error('Error processing Linear projects request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Linear projects', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/linear/teams/route.ts b/apps/sim/app/api/tools/linear/teams/route.ts new file mode 100644 index 00000000000..c472ad64b4e --- /dev/null +++ b/apps/sim/app/api/tools/linear/teams/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console-logger' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { LinearClient } from '@linear/sdk' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('LinearTeams') + +export async function POST(request: Request) { + try { + const session = await getSession() + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + const userId = session?.user?.id || '' + if (!userId) { + logger.error('No user ID found in session') + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const accessToken = await refreshAccessTokenIfNeeded(credential, userId, workflowId) + if (!accessToken) { + logger.error('Failed to get access token', { credentialId: credential, userId }) + return NextResponse.json( + { + error: 'Could not retrieve access token', + authRequired: true, + }, + { status: 401 } + ) + } + + const linearClient = new LinearClient({ accessToken }) + const teamsResult = await linearClient.teams() + const teams = teamsResult.nodes.map((team: any) => ({ + id: team.id, + name: team.name, + })) + + return NextResponse.json({ teams }) + } catch (error) { + logger.error('Error processing Linear teams request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Linear teams', details: (error as Error).message }, + { status: 500 } + ) + } +} From e9a61944c67f6879631891683c6a7b3168140a71 Mon Sep 17 00:00:00 2001 From: sriram2k4 Date: Wed, 28 May 2025 13:59:14 +0530 Subject: [PATCH 08/18] feat(linear): update UI components for Linear selectors and modal --- .../components/oauth-required-modal.tsx | 2 + .../components/linear-project-selector.tsx | 38 +++++++++------- .../components/linear-team-selector.tsx | 36 +++++++++------- .../project-selector-input.tsx | 43 ++++++++++++------- 4 files changed, 73 insertions(+), 46 deletions(-) diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx index 59fbd6798db..6e830026b91 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx @@ -106,6 +106,8 @@ const SCOPE_DESCRIPTIONS: Record = { 'messages.read': 'Read your Discord messages', guilds: 'Read your Discord guilds', 'guilds.members.read': 'Read your Discord guild members', + read: 'Read access to your Linear workspace', + write: 'Write access to your Linear workspace', } // Convert OAuth scope to user-friendly description diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-project-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-project-selector.tsx index 00bfd29bcf9..d6e8e1fd46c 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-project-selector.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-project-selector.tsx @@ -1,5 +1,11 @@ import { useEffect, useState } from 'react' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' export interface LinearProjectInfo { id: string @@ -16,7 +22,14 @@ interface LinearProjectSelectorProps { showPreview?: boolean } -export function LinearProjectSelector({ value, onChange, credential, teamId, label = 'Select Linear project', disabled = false }: LinearProjectSelectorProps) { +export function LinearProjectSelector({ + value, + onChange, + credential, + teamId, + label = 'Select Linear project', + disabled = false, +}: LinearProjectSelectorProps) { const [projects, setProjects] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -24,25 +37,18 @@ export function LinearProjectSelector({ value, onChange, credential, teamId, lab useEffect(() => { if (!credential || !teamId) return setLoading(true) - setError(null) - fetch('https://api.linear.app/graphql', { + fetch('/api/tools/linear/projects', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${credential}`, - }, - body: JSON.stringify({ - query: `query($teamId: String!) { team(id: $teamId) { projects { nodes { id name } } } }`, - variables: { teamId }, - }), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential, teamId }), }) .then((res) => res.json()) .then((data) => { - if (data.errors) { - setError(data.errors[0].message) + if (data.error) { + setError(data.error) setProjects([]) } else { - setProjects(data.data.team?.projects?.nodes || []) + setProjects(data.projects) } }) .catch((err) => { @@ -74,4 +80,4 @@ export function LinearProjectSelector({ value, onChange, credential, teamId, lab ) -} \ No newline at end of file +} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx index 66ff836a994..979cb507dcf 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx @@ -1,5 +1,11 @@ import { useEffect, useState } from 'react' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' export interface LinearTeamInfo { id: string @@ -15,7 +21,13 @@ interface LinearTeamSelectorProps { showPreview?: boolean } -export function LinearTeamSelector({ value, onChange, credential, label = 'Select Linear team', disabled = false }: LinearTeamSelectorProps) { +export function LinearTeamSelector({ + value, + onChange, + credential, + label = 'Select Linear team', + disabled = false, +}: LinearTeamSelectorProps) { const [teams, setTeams] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -23,24 +35,18 @@ export function LinearTeamSelector({ value, onChange, credential, label = 'Selec useEffect(() => { if (!credential) return setLoading(true) - setError(null) - fetch('https://api.linear.app/graphql', { + fetch('/api/tools/linear/teams', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${credential}`, - }, - body: JSON.stringify({ - query: `query { teams { nodes { id name } } }`, - }), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential }), }) .then((res) => res.json()) .then((data) => { - if (data.errors) { - setError(data.errors[0].message) + if (data.error) { + setError(data.error) setTeams([]) } else { - setTeams(data.data.teams.nodes) + setTeams(data.teams) } }) .catch((err) => { @@ -72,4 +78,4 @@ export function LinearTeamSelector({ value, onChange, credential, label = 'Selec ) -} \ No newline at end of file +} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx index e00a568779b..3db21fd86f3 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx @@ -6,8 +6,8 @@ import type { SubBlockConfig } from '@/blocks/types' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { type DiscordServerInfo, DiscordServerSelector } from './components/discord-server-selector' import { type JiraProjectInfo, JiraProjectSelector } from './components/jira-project-selector' -import { type LinearTeamInfo, LinearTeamSelector } from './components/linear-team-selector' import { type LinearProjectInfo, LinearProjectSelector } from './components/linear-project-selector' +import { type LinearTeamInfo, LinearTeamSelector } from './components/linear-team-selector' interface ProjectSelectorInputProps { blockId: string @@ -44,7 +44,10 @@ export function ProjectSelectorInput({ }, [blockId, subBlock.id, getValue]) // Handle project selection - const handleProjectChange = (projectId: string, info?: JiraProjectInfo | DiscordServerInfo | LinearTeamInfo | LinearProjectInfo) => { + const handleProjectChange = ( + projectId: string, + info?: JiraProjectInfo | DiscordServerInfo | LinearTeamInfo | LinearProjectInfo + ) => { setSelectedProjectId(projectId) setProjectInfo(info || null) setValue(blockId, subBlock.id, projectId) @@ -57,8 +60,10 @@ export function ProjectSelectorInput({ } else if (provider === 'discord') { setValue(blockId, 'channelId', '') } else if (provider === 'linear') { - setValue(blockId, 'credential', '') - setValue(blockId, 'teamId', '') + if (subBlock.id === 'teamId') { + setValue(blockId, 'teamId', projectId) + setValue(blockId, 'projectId', '') // Optionally clear project selection + } } onProjectSelect?.(projectId) @@ -112,17 +117,25 @@ export function ProjectSelectorInput({ showPreview={true} /> ) : ( - { - handleProjectChange(projectId, projectInfo) - }} - credential={getValue(blockId, 'credential') as string} - teamId={getValue(blockId, 'teamId') as string} - label={subBlock.placeholder || 'Select Linear project'} - disabled={disabled || !getValue(blockId, 'credential') || !getValue(blockId, 'teamId')} - showPreview={true} - /> + (() => { + const credential = getValue(blockId, 'credential') as string + const teamId = getValue(blockId, 'teamId') as string + const isDisabled = disabled || !credential || !teamId + console.log('ProjectSelector:', { credential, teamId, disabled: isDisabled }) + return ( + { + handleProjectChange(projectId, projectInfo) + }} + credential={credential} + teamId={teamId} + label={subBlock.placeholder || 'Select Linear project'} + disabled={isDisabled} + showPreview={true} + /> + ) + })() )} From 23abcee49e476ff1367c5fbcceac2777ed71928c Mon Sep 17 00:00:00 2001 From: sriram2k4 Date: Wed, 28 May 2025 14:00:43 +0530 Subject: [PATCH 09/18] refactor(linear): update create/read issue tools and types --- apps/sim/tools/linear/create_issue.ts | 119 +++++++++++++++----------- apps/sim/tools/linear/read_issues.ts | 20 ++--- apps/sim/tools/linear/types.ts | 2 - 3 files changed, 77 insertions(+), 64 deletions(-) diff --git a/apps/sim/tools/linear/create_issue.ts b/apps/sim/tools/linear/create_issue.ts index 32e93416acc..ccb16aaaa6d 100644 --- a/apps/sim/tools/linear/create_issue.ts +++ b/apps/sim/tools/linear/create_issue.ts @@ -1,28 +1,31 @@ import type { ToolConfig } from '../types' import type { LinearCreateIssueParams, LinearCreateIssueResponse } from './types' -export const linearCreateIssueTool: ToolConfig = - { - id: 'linear_create_issue', - name: 'Linear Issue Writer', - description: 'Create a new issue in Linear', - version: '1.0.0', - params: { - teamId: { type: 'string', required: true, description: 'Linear team ID' }, - projectId: { type: 'string', required: false, description: 'Linear project ID' }, - title: { type: 'string', required: true, description: 'Issue title' }, - description: { type: 'string', required: false, description: 'Issue description' }, - }, - request: { - url: 'https://api.linear.app/graphql', - method: 'POST', - headers: (params) => ({ - 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken || ''}`, - }), - body: (params) => ({ - query: ` - mutation CreateIssue($teamId: String!, $projectId: String, $title: String!, $description: String) { +export const linearCreateIssueTool: ToolConfig = { + id: 'linear_create_issue', + name: 'Linear Issue Writer', + description: 'Create a new issue in Linear', + version: '1.0.0', + oauth: { + required: true, + provider: 'linear', + }, + params: { + teamId: { type: 'string', required: true, description: 'Linear team ID' }, + projectId: { type: 'string', required: false, description: 'Linear project ID' }, + title: { type: 'string', required: true, description: 'Issue title' }, + description: { type: 'string', required: false, description: 'Issue description' }, + }, + request: { + url: 'https://api.linear.app/graphql', + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken || ''}`, + }), + body: (params) => ({ + query: ` + mutation CreateIssue($teamId: ID!, $projectId: ID, $title: String!, $description: String) { issueCreate( input: { teamId: $teamId @@ -42,36 +45,54 @@ export const linearCreateIssueTool: ToolConfig { - const data = await response.json() - if (data.errors) { - return { success: false, output: { issue: null }, error: data.errors[0].message } - } + variables: { + teamId: params.teamId, + projectId: params.projectId, + title: params.title, + description: params.description, + }, + }), + }, + transformResponse: async (response) => { + const data = await response.json() + if (data.errors) { return { - success: true, + success: false, output: { issue: { - id: data.data.issueCreate.issue.id, - title: data.data.issueCreate.issue.title, - description: data.data.issueCreate.issue.description, - state: data.data.issueCreate.issue.state?.name, - teamId: data.data.issueCreate.issue.team?.id, - projectId: data.data.issueCreate.issue.project?.id, + id: '', + title: '', + description: '', + state: '', + teamId: '', + projectId: '', }, }, + error: data.errors[0].message, } - }, - transformError: (error) => error.message || 'Failed to create Linear issue', - oauth: { - required: true, - provider: 'linear', - }, - } + } + return { + success: true, + output: { + issue: data.data.issueCreate.issue + ? { + id: data.data.issueCreate.issue.id, + title: data.data.issueCreate.issue.title, + description: data.data.issueCreate.issue.description, + state: data.data.issueCreate.issue.state?.name, + teamId: data.data.issueCreate.issue.team?.id, + projectId: data.data.issueCreate.issue.project?.id, + } + : { + id: '', + title: '', + description: '', + state: '', + teamId: '', + projectId: '', + }, + }, + } + }, + transformError: (error) => error.message || 'Failed to create Linear issue', +} diff --git a/apps/sim/tools/linear/read_issues.ts b/apps/sim/tools/linear/read_issues.ts index f0f9ae0dc9d..068c6834935 100644 --- a/apps/sim/tools/linear/read_issues.ts +++ b/apps/sim/tools/linear/read_issues.ts @@ -6,11 +6,13 @@ export const linearReadIssuesTool: ToolConfig ({ query: ` - query Issues($teamId: String, $projectId: String, $state: String, $search: String) { + query Issues($teamId: ID, $projectId: ID) { issues( filter: { - team: { id: $teamId } - project: { id: $projectId } - state: { name: { eq: $state } } - search: $search + team: { id: { eq: $teamId } } + project: { id: { eq: $projectId } } } ) { nodes { @@ -44,8 +44,6 @@ export const linearReadIssuesTool: ToolConfig error.message || 'Failed to fetch Linear issues', - oauth: { - required: true, - provider: 'linear', - }, } diff --git a/apps/sim/tools/linear/types.ts b/apps/sim/tools/linear/types.ts index ddf48b59c23..c8b2d7734ef 100644 --- a/apps/sim/tools/linear/types.ts +++ b/apps/sim/tools/linear/types.ts @@ -12,8 +12,6 @@ export interface LinearIssue { export interface LinearReadIssuesParams { teamId?: string projectId?: string - state?: string - search?: string accessToken?: string } From 40d9641b775a1f543870a494c255ff1075491782 Mon Sep 17 00:00:00 2001 From: sriram2k4 Date: Wed, 28 May 2025 14:01:24 +0530 Subject: [PATCH 10/18] chore(linear): update block config for Linear integration --- apps/sim/blocks/blocks/linear.ts | 27 +++++---------------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/apps/sim/blocks/blocks/linear.ts b/apps/sim/blocks/blocks/linear.ts index c7c6a211253..2d35dba4b76 100644 --- a/apps/sim/blocks/blocks/linear.ts +++ b/apps/sim/blocks/blocks/linear.ts @@ -1,5 +1,5 @@ import { LinearIcon } from '@/components/icons' -import type { LinearReadIssuesResponse, LinearCreateIssueResponse } from '@/tools/linear/types' +import type { LinearCreateIssueResponse, LinearReadIssuesResponse } from '@/tools/linear/types' import type { BlockConfig } from '../types' type LinearResponse = LinearReadIssuesResponse | LinearCreateIssueResponse @@ -31,6 +31,7 @@ export const LinearBlock: BlockConfig = { layout: 'full', provider: 'linear', serviceId: 'linear', + requiredScopes: ['read', 'write'], placeholder: 'Select Linear account', }, { @@ -51,21 +52,6 @@ export const LinearBlock: BlockConfig = { serviceId: 'linear', placeholder: 'Select a project', }, - { - id: 'state', - title: 'Issue State', - type: 'dropdown', - layout: 'full', - options: ['Backlog', 'Todo', 'In Progress', 'Done', 'Canceled'], - condition: { field: 'operation', value: ['read-bulk'] }, - }, - { - id: 'search', - title: 'Search', - type: 'short-input', - layout: 'full', - condition: { field: 'operation', value: ['read-bulk'] }, - }, { id: 'title', title: 'Title', @@ -85,7 +71,8 @@ export const LinearBlock: BlockConfig = { tools: { access: ['linear_read_issues', 'linear_create_issue'], config: { - tool: (params) => (params.operation === 'write' ? 'linear_create_issue' : 'linear_read_issues'), + tool: (params) => + params.operation === 'write' ? 'linear_create_issue' : 'linear_read_issues', params: (params) => { if (params.operation === 'write') { return { @@ -102,8 +89,6 @@ export const LinearBlock: BlockConfig = { credential: params.credential, teamId: params.teamId, projectId: params.projectId, - state: params.state, - search: params.search, // Add assigneeId, labelId, etc. if supported } }, @@ -114,8 +99,6 @@ export const LinearBlock: BlockConfig = { credential: { type: 'string', required: true }, teamId: { type: 'string', required: false }, projectId: { type: 'string', required: false }, - state: { type: 'string', required: false }, - search: { type: 'string', required: false }, title: { type: 'string', required: false }, description: { type: 'string', required: false }, // Add assigneeId, labelIds, etc. as needed @@ -124,7 +107,7 @@ export const LinearBlock: BlockConfig = { response: { type: { issues: 'json', // For read-bulk - issue: 'json', // For write + issue: 'json', // For write }, }, }, From 84c2d6bf5a064bf665b46de48bc19b4d47c8c97f Mon Sep 17 00:00:00 2001 From: sriram2k4 Date: Wed, 28 May 2025 14:01:45 +0530 Subject: [PATCH 11/18] fix(auth): update auth and oauth logic for Linear --- apps/sim/lib/auth.ts | 2 +- apps/sim/lib/oauth.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/auth.ts b/apps/sim/lib/auth.ts index 9260bc013ab..90a1aa4020e 100644 --- a/apps/sim/lib/auth.ts +++ b/apps/sim/lib/auth.ts @@ -783,7 +783,7 @@ export const auth = betterAuth({ clientSecret: env.LINEAR_CLIENT_SECRET as string, authorizationUrl: 'https://linear.app/oauth/authorize', tokenUrl: 'https://api.linear.app/oauth/token', - scopes: [], // Add required scopes if Linear supports them + scopes: ['read', 'write'], responseType: 'code', accessType: 'offline', prompt: 'consent', diff --git a/apps/sim/lib/oauth.ts b/apps/sim/lib/oauth.ts index aadafd6a0f1..8cf52e9348e 100644 --- a/apps/sim/lib/oauth.ts +++ b/apps/sim/lib/oauth.ts @@ -345,7 +345,7 @@ export const OAUTH_PROVIDERS: Record = { providerId: 'linear', icon: (props) => LinearIcon(props), baseProviderIcon: (props) => LinearIcon(props), - scopes: [], // Add required scopes if Linear supports granular OAuth scopes + scopes: ['read', 'write'], }, }, defaultService: 'linear', From 7dd3b3515d0648b52882ef3514a3cacd54961076 Mon Sep 17 00:00:00 2001 From: sriram2k4 Date: Wed, 28 May 2025 15:42:34 +0530 Subject: [PATCH 12/18] minor fix --- apps/sim/components/icons.tsx | 18 +++++++----------- apps/sim/tools/linear/create_issue.ts | 2 +- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 7be99c905de..b4ba6ad8fa5 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -2281,20 +2281,16 @@ export function JiraIcon(props: SVGProps) { export function LinearIcon(props: React.SVGProps) { return ( - ) diff --git a/apps/sim/tools/linear/create_issue.ts b/apps/sim/tools/linear/create_issue.ts index ccb16aaaa6d..00da27683eb 100644 --- a/apps/sim/tools/linear/create_issue.ts +++ b/apps/sim/tools/linear/create_issue.ts @@ -25,7 +25,7 @@ export const linearCreateIssueTool: ToolConfig ({ query: ` - mutation CreateIssue($teamId: ID!, $projectId: ID, $title: String!, $description: String) { + mutation CreateIssue($teamId: String!, $projectId: String, $title: String!, $description: String) { issueCreate( input: { teamId: $teamId From d744f7ce8b3618b2c7b672019a12fe99e78d788c Mon Sep 17 00:00:00 2001 From: sriram2k4 Date: Wed, 28 May 2025 22:34:26 +0530 Subject: [PATCH 13/18] improvement[linear]: require teamId and projectId for all tools and types --- apps/sim/blocks/blocks/linear.ts | 15 +++++---------- apps/sim/tools/linear/create_issue.ts | 4 ++-- apps/sim/tools/linear/read_issues.ts | 6 +++--- apps/sim/tools/linear/types.ts | 10 +++++----- 4 files changed, 15 insertions(+), 20 deletions(-) diff --git a/apps/sim/blocks/blocks/linear.ts b/apps/sim/blocks/blocks/linear.ts index 2d35dba4b76..1524bc94b3a 100644 --- a/apps/sim/blocks/blocks/linear.ts +++ b/apps/sim/blocks/blocks/linear.ts @@ -66,7 +66,6 @@ export const LinearBlock: BlockConfig = { layout: 'full', condition: { field: 'operation', value: ['write'] }, }, - // Add assignee, label, priority, etc. as needed ], tools: { access: ['linear_read_issues', 'linear_create_issue'], @@ -81,15 +80,12 @@ export const LinearBlock: BlockConfig = { projectId: params.projectId, title: params.title, description: params.description, - // Add assigneeId, labelIds, etc. if supported } } - // read-bulk return { credential: params.credential, teamId: params.teamId, projectId: params.projectId, - // Add assigneeId, labelId, etc. if supported } }, }, @@ -97,17 +93,16 @@ export const LinearBlock: BlockConfig = { inputs: { operation: { type: 'string', required: true }, credential: { type: 'string', required: true }, - teamId: { type: 'string', required: false }, - projectId: { type: 'string', required: false }, - title: { type: 'string', required: false }, + teamId: { type: 'string', required: true }, + projectId: { type: 'string', required: true }, + title: { type: 'string', required: true }, description: { type: 'string', required: false }, - // Add assigneeId, labelIds, etc. as needed }, outputs: { response: { type: { - issues: 'json', // For read-bulk - issue: 'json', // For write + issues: 'json', + issue: 'json', }, }, }, diff --git a/apps/sim/tools/linear/create_issue.ts b/apps/sim/tools/linear/create_issue.ts index 00da27683eb..49cc17a506b 100644 --- a/apps/sim/tools/linear/create_issue.ts +++ b/apps/sim/tools/linear/create_issue.ts @@ -12,7 +12,7 @@ export const linearCreateIssueTool: ToolConfig ({ query: ` - mutation CreateIssue($teamId: String!, $projectId: String, $title: String!, $description: String) { + mutation CreateIssue($teamId: String!, $projectId: String!, $title: String!, $description: String) { issueCreate( input: { teamId: $teamId diff --git a/apps/sim/tools/linear/read_issues.ts b/apps/sim/tools/linear/read_issues.ts index 068c6834935..9d4b7f43985 100644 --- a/apps/sim/tools/linear/read_issues.ts +++ b/apps/sim/tools/linear/read_issues.ts @@ -11,8 +11,8 @@ export const linearReadIssuesTool: ToolConfig ({ query: ` - query Issues($teamId: ID, $projectId: ID) { + query Issues($teamId: ID!, $projectId: ID!) { issues( filter: { team: { id: { eq: $teamId } } diff --git a/apps/sim/tools/linear/types.ts b/apps/sim/tools/linear/types.ts index c8b2d7734ef..d91c05b9b44 100644 --- a/apps/sim/tools/linear/types.ts +++ b/apps/sim/tools/linear/types.ts @@ -5,19 +5,19 @@ export interface LinearIssue { title: string description?: string state?: string - teamId?: string - projectId?: string + teamId: string + projectId: string } export interface LinearReadIssuesParams { - teamId?: string - projectId?: string + teamId: string + projectId: string accessToken?: string } export interface LinearCreateIssueParams { teamId: string - projectId?: string + projectId: string title: string description?: string accessToken?: string From 8db78a172edefce92fdb443e9168d6b19da6752d Mon Sep 17 00:00:00 2001 From: sriram2k4 Date: Wed, 28 May 2025 22:36:35 +0530 Subject: [PATCH 14/18] style[lint]: fix code style and lint errors --- .../app/api/tools/linear/projects/route.ts | 4 +- apps/sim/app/api/tools/linear/teams/route.ts | 2 +- apps/sim/components/icons.tsx | 14 +- apps/sim/tools/linear/create_issue.ts | 135 +++++++++--------- 4 files changed, 78 insertions(+), 77 deletions(-) diff --git a/apps/sim/app/api/tools/linear/projects/route.ts b/apps/sim/app/api/tools/linear/projects/route.ts index 8587eec8293..1b70a06f6b3 100644 --- a/apps/sim/app/api/tools/linear/projects/route.ts +++ b/apps/sim/app/api/tools/linear/projects/route.ts @@ -1,8 +1,8 @@ +import { LinearClient } from '@linear/sdk' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console-logger' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' -import { LinearClient } from '@linear/sdk' export const dynamic = 'force-dynamic' @@ -42,7 +42,7 @@ export async function POST(request: Request) { if (teamId) { const team = await linearClient.team(teamId) - const projectsResult = await team.projects(); + const projectsResult = await team.projects() projects = projectsResult.nodes.map((project: any) => ({ id: project.id, name: project.name, diff --git a/apps/sim/app/api/tools/linear/teams/route.ts b/apps/sim/app/api/tools/linear/teams/route.ts index c472ad64b4e..2715d7c3884 100644 --- a/apps/sim/app/api/tools/linear/teams/route.ts +++ b/apps/sim/app/api/tools/linear/teams/route.ts @@ -1,8 +1,8 @@ +import { LinearClient } from '@linear/sdk' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console-logger' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' -import { LinearClient } from '@linear/sdk' export const dynamic = 'force-dynamic' diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index b4ba6ad8fa5..a04745c2906 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -2282,15 +2282,15 @@ export function LinearIcon(props: React.SVGProps) { return ( ) diff --git a/apps/sim/tools/linear/create_issue.ts b/apps/sim/tools/linear/create_issue.ts index 49cc17a506b..0899ba1a5ee 100644 --- a/apps/sim/tools/linear/create_issue.ts +++ b/apps/sim/tools/linear/create_issue.ts @@ -1,30 +1,31 @@ import type { ToolConfig } from '../types' import type { LinearCreateIssueParams, LinearCreateIssueResponse } from './types' -export const linearCreateIssueTool: ToolConfig = { - id: 'linear_create_issue', - name: 'Linear Issue Writer', - description: 'Create a new issue in Linear', - version: '1.0.0', - oauth: { - required: true, - provider: 'linear', - }, - params: { - teamId: { type: 'string', required: true, description: 'Linear team ID' }, - projectId: { type: 'string', required: true, description: 'Linear project ID' }, - title: { type: 'string', required: true, description: 'Issue title' }, - description: { type: 'string', required: false, description: 'Issue description' }, - }, - request: { - url: 'https://api.linear.app/graphql', - method: 'POST', - headers: (params) => ({ - 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken || ''}`, - }), - body: (params) => ({ - query: ` +export const linearCreateIssueTool: ToolConfig = + { + id: 'linear_create_issue', + name: 'Linear Issue Writer', + description: 'Create a new issue in Linear', + version: '1.0.0', + oauth: { + required: true, + provider: 'linear', + }, + params: { + teamId: { type: 'string', required: true, description: 'Linear team ID' }, + projectId: { type: 'string', required: true, description: 'Linear project ID' }, + title: { type: 'string', required: true, description: 'Issue title' }, + description: { type: 'string', required: false, description: 'Issue description' }, + }, + request: { + url: 'https://api.linear.app/graphql', + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken || ''}`, + }), + body: (params) => ({ + query: ` mutation CreateIssue($teamId: String!, $projectId: String!, $title: String!, $description: String) { issueCreate( input: { @@ -45,45 +46,21 @@ export const linearCreateIssueTool: ToolConfig { - const data = await response.json() - if (data.errors) { - return { - success: false, - output: { - issue: { - id: '', - title: '', - description: '', - state: '', - teamId: '', - projectId: '', - }, + variables: { + teamId: params.teamId, + projectId: params.projectId, + title: params.title, + description: params.description, }, - error: data.errors[0].message, - } - } - return { - success: true, - output: { - issue: data.data.issueCreate.issue - ? { - id: data.data.issueCreate.issue.id, - title: data.data.issueCreate.issue.title, - description: data.data.issueCreate.issue.description, - state: data.data.issueCreate.issue.state?.name, - teamId: data.data.issueCreate.issue.team?.id, - projectId: data.data.issueCreate.issue.project?.id, - } - : { + }), + }, + transformResponse: async (response) => { + const data = await response.json() + if (data.errors) { + return { + success: false, + output: { + issue: { id: '', title: '', description: '', @@ -91,8 +68,32 @@ export const linearCreateIssueTool: ToolConfig error.message || 'Failed to create Linear issue', -} + }, + error: data.errors[0].message, + } + } + return { + success: true, + output: { + issue: data.data.issueCreate.issue + ? { + id: data.data.issueCreate.issue.id, + title: data.data.issueCreate.issue.title, + description: data.data.issueCreate.issue.description, + state: data.data.issueCreate.issue.state?.name, + teamId: data.data.issueCreate.issue.team?.id, + projectId: data.data.issueCreate.issue.project?.id, + } + : { + id: '', + title: '', + description: '', + state: '', + teamId: '', + projectId: '', + }, + }, + } + }, + transformError: (error) => error.message || 'Failed to create Linear issue', + } From b370609443a1b84bd465a89f077e7921ed270c6a Mon Sep 17 00:00:00 2001 From: sriram2k4 Date: Wed, 28 May 2025 23:19:50 +0530 Subject: [PATCH 15/18] chore(linear): install @linear/sdk package --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index de944b30649..40240f5164d 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "tailwindcss": "3.4.1" }, "dependencies": { + "@linear/sdk": "40.0.0", "@t3-oss/env-nextjs": "0.13.4", "@vercel/analytics": "1.5.0", "remark-gfm": "4.0.1" From 7483381e98281816f53aa2a24ce28e0ffb21c5bc Mon Sep 17 00:00:00 2001 From: sriram2k4 Date: Thu, 29 May 2025 12:42:52 +0530 Subject: [PATCH 16/18] fix[linear]: address greptile-apps feedback for type safety and error handling --- .../app/api/tools/linear/projects/route.ts | 21 ++-- apps/sim/app/api/tools/linear/teams/route.ts | 3 +- .../components/linear-project-selector.tsx | 13 ++- .../components/linear-team-selector.tsx | 4 + .../project-selector-input.tsx | 6 +- apps/sim/blocks/blocks/linear.ts | 4 +- apps/sim/blocks/registry.ts | 2 +- apps/sim/lib/auth.ts | 5 +- apps/sim/tools/linear/create_issue.ts | 96 ++++++++++--------- apps/sim/tools/linear/read_issues.ts | 49 +++++++--- 10 files changed, 122 insertions(+), 81 deletions(-) diff --git a/apps/sim/app/api/tools/linear/projects/route.ts b/apps/sim/app/api/tools/linear/projects/route.ts index 1b70a06f6b3..f8920eddfc4 100644 --- a/apps/sim/app/api/tools/linear/projects/route.ts +++ b/apps/sim/app/api/tools/linear/projects/route.ts @@ -1,3 +1,4 @@ +import type { Project } from '@linear/sdk' import { LinearClient } from '@linear/sdk' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' @@ -40,20 +41,12 @@ export async function POST(request: Request) { const linearClient = new LinearClient({ accessToken }) let projects = [] - if (teamId) { - const team = await linearClient.team(teamId) - const projectsResult = await team.projects() - projects = projectsResult.nodes.map((project: any) => ({ - id: project.id, - name: project.name, - })) - } else { - const allProjects = await linearClient.projects() - projects = allProjects.nodes.map((project: any) => ({ - id: project.id, - name: project.name, - })) - } + const team = await linearClient.team(teamId) + const projectsResult = await team.projects() + projects = projectsResult.nodes.map((project: Project) => ({ + id: project.id, + name: project.name, + })) if (projects.length === 0) { logger.info('No projects found for team', { teamId }) diff --git a/apps/sim/app/api/tools/linear/teams/route.ts b/apps/sim/app/api/tools/linear/teams/route.ts index 2715d7c3884..232cfa45da1 100644 --- a/apps/sim/app/api/tools/linear/teams/route.ts +++ b/apps/sim/app/api/tools/linear/teams/route.ts @@ -1,3 +1,4 @@ +import type { Team } from '@linear/sdk' import { LinearClient } from '@linear/sdk' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' @@ -39,7 +40,7 @@ export async function POST(request: Request) { const linearClient = new LinearClient({ accessToken }) const teamsResult = await linearClient.teams() - const teams = teamsResult.nodes.map((team: any) => ({ + const teams = teamsResult.nodes.map((team: Team) => ({ id: team.id, name: team.name, })) diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-project-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-project-selector.tsx index d6e8e1fd46c..4c156a251a6 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-project-selector.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-project-selector.tsx @@ -19,7 +19,6 @@ interface LinearProjectSelectorProps { teamId: string label?: string disabled?: boolean - showPreview?: boolean } export function LinearProjectSelector({ @@ -36,13 +35,21 @@ export function LinearProjectSelector({ useEffect(() => { if (!credential || !teamId) return + const controller = new AbortController() setLoading(true) fetch('/api/tools/linear/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ credential, teamId }), + signal: controller.signal, }) - .then((res) => res.json()) + .then(async (res) => { + if (!res.ok) { + const errorText = await res.text() + throw new Error(`HTTP error! status: ${res.status} - ${errorText}`) + } + return res.json() + }) .then((data) => { if (data.error) { setError(data.error) @@ -52,10 +59,12 @@ export function LinearProjectSelector({ } }) .catch((err) => { + if (err.name === 'AbortError') return setError(err.message) setProjects([]) }) .finally(() => setLoading(false)) + return () => controller.abort() }, [credential, teamId]) return ( diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx index 979cb507dcf..42bb5b969b1 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx @@ -34,11 +34,13 @@ export function LinearTeamSelector({ useEffect(() => { if (!credential) return + const controller = new AbortController() setLoading(true) fetch('/api/tools/linear/teams', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ credential }), + signal: controller.signal, }) .then((res) => res.json()) .then((data) => { @@ -50,10 +52,12 @@ export function LinearTeamSelector({ } }) .catch((err) => { + if (err.name === 'AbortError') return setError(err.message) setTeams([]) }) .finally(() => setLoading(false)) + return () => controller.abort() }, [credential]) return ( diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx index 3db21fd86f3..59dcabaa115 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx @@ -62,7 +62,9 @@ export function ProjectSelectorInput({ } else if (provider === 'linear') { if (subBlock.id === 'teamId') { setValue(blockId, 'teamId', projectId) - setValue(blockId, 'projectId', '') // Optionally clear project selection + setValue(blockId, 'projectId', '') + } else if (subBlock.id === 'projectId') { + setValue(blockId, 'projectId', projectId) } } @@ -121,7 +123,6 @@ export function ProjectSelectorInput({ const credential = getValue(blockId, 'credential') as string const teamId = getValue(blockId, 'teamId') as string const isDisabled = disabled || !credential || !teamId - console.log('ProjectSelector:', { credential, teamId, disabled: isDisabled }) return ( ) })() diff --git a/apps/sim/blocks/blocks/linear.ts b/apps/sim/blocks/blocks/linear.ts index 1524bc94b3a..f4eacc5c891 100644 --- a/apps/sim/blocks/blocks/linear.ts +++ b/apps/sim/blocks/blocks/linear.ts @@ -20,7 +20,7 @@ export const LinearBlock: BlockConfig = { type: 'dropdown', layout: 'full', options: [ - { label: 'Read Issues', id: 'read-bulk' }, + { label: 'Read Issues', id: 'read' }, { label: 'Create Issue', id: 'write' }, ], }, @@ -95,7 +95,7 @@ export const LinearBlock: BlockConfig = { credential: { type: 'string', required: true }, teamId: { type: 'string', required: true }, projectId: { type: 'string', required: true }, - title: { type: 'string', required: true }, + title: { type: 'string', required: false }, description: { type: 'string', required: false }, }, outputs: { diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 0d38fcfc052..448fcce52b9 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -89,6 +89,7 @@ export const registry: Record = { image_generator: ImageGeneratorBlock, jina: JinaBlock, jira: JiraBlock, + linear: LinearBlock, linkup: LinkupBlock, mem0: Mem0Block, mistral_parse: MistralParseBlock, @@ -117,7 +118,6 @@ export const registry: Record = { whatsapp: WhatsAppBlock, x: XBlock, youtube: YouTubeBlock, - linear: LinearBlock, } // Helper functions to access the registry diff --git a/apps/sim/lib/auth.ts b/apps/sim/lib/auth.ts index 90a1aa4020e..8b5233a54da 100644 --- a/apps/sim/lib/auth.ts +++ b/apps/sim/lib/auth.ts @@ -805,7 +805,10 @@ export const auth = betterAuth({ throw new Error('Failed to fetch Linear user info') } - const { data } = await response.json() + const data = await response.json() + if (data.errors && data.errors.length > 0) { + throw new Error(data.errors.map((e: any) => e.message).join('; ')) + } const user = data?.viewer if (!user) throw new Error('No user info returned from Linear') diff --git a/apps/sim/tools/linear/create_issue.ts b/apps/sim/tools/linear/create_issue.ts index 0899ba1a5ee..28ea96a8798 100644 --- a/apps/sim/tools/linear/create_issue.ts +++ b/apps/sim/tools/linear/create_issue.ts @@ -20,12 +20,21 @@ export const linearCreateIssueTool: ToolConfig ({ - 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken || ''}`, - }), - body: (params) => ({ - query: ` + headers: (params) => { + if (!params.accessToken) { + throw new Error('Missing access token for Linear API request') + } + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + } + }, + body: (params) => { + if (!params.title || !params.title.trim()) { + throw new Error('Title is required to create a Linear issue') + } + return { + query: ` mutation CreateIssue($teamId: String!, $projectId: String!, $title: String!, $description: String) { issueCreate( input: { @@ -46,54 +55,49 @@ export const linearCreateIssueTool: ToolConfig { const data = await response.json() - if (data.errors) { - return { - success: false, - output: { - issue: { - id: '', - title: '', - description: '', - state: '', - teamId: '', - projectId: '', - }, - }, - error: data.errors[0].message, - } + const issue = data.data.issueCreate.issue + if (!issue) { + throw new Error('Failed to create issue: No issue returned from Linear API') } return { success: true, output: { - issue: data.data.issueCreate.issue - ? { - id: data.data.issueCreate.issue.id, - title: data.data.issueCreate.issue.title, - description: data.data.issueCreate.issue.description, - state: data.data.issueCreate.issue.state?.name, - teamId: data.data.issueCreate.issue.team?.id, - projectId: data.data.issueCreate.issue.project?.id, - } - : { - id: '', - title: '', - description: '', - state: '', - teamId: '', - projectId: '', - }, + issue: { + id: issue.id, + title: issue.title, + description: issue.description, + state: issue.state?.name, + teamId: issue.team?.id, + projectId: issue.project?.id, + }, }, } }, - transformError: (error) => error.message || 'Failed to create Linear issue', + transformError: (error) => { + if (error instanceof Error) { + return error.message + } + + if (typeof error === 'object' && error !== null) { + if (error.error) { + return typeof error.error === 'string' ? error.error : JSON.stringify(error.error) + } + if (error.message) { + return error.message + } + } + + return 'Failed to create Linear issue' + }, } diff --git a/apps/sim/tools/linear/read_issues.ts b/apps/sim/tools/linear/read_issues.ts index 9d4b7f43985..38562188bd3 100644 --- a/apps/sim/tools/linear/read_issues.ts +++ b/apps/sim/tools/linear/read_issues.ts @@ -1,5 +1,5 @@ import type { ToolConfig } from '../types' -import type { LinearReadIssuesParams, LinearReadIssuesResponse } from './types' +import type { LinearIssue, LinearReadIssuesParams, LinearReadIssuesResponse } from './types' export const linearReadIssuesTool: ToolConfig = { id: 'linear_read_issues', @@ -17,10 +17,15 @@ export const linearReadIssuesTool: ToolConfig ({ - 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken || ''}`, - }), + headers: (params) => { + if (!params.accessToken) { + throw new Error('Missing access token for Linear API request') + } + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + } + }, body: (params) => ({ query: ` query Issues($teamId: ID!, $projectId: ID!) { @@ -50,21 +55,43 @@ export const linearReadIssuesTool: ToolConfig { const data = await response.json() if (data.errors) { - return { success: false, output: { issues: [] }, error: data.errors[0].message } + return { + success: false, + output: { issues: [] }, + error: data.errors.map((e: any) => e.message).join('; '), + } } return { success: true, output: { - issues: data.data.issues.nodes.map((issue: any) => ({ + issues: (data.data.issues.nodes as LinearIssue[]).map((issue) => ({ id: issue.id, title: issue.title, description: issue.description, - state: issue.state?.name, - teamId: issue.team?.id, - projectId: issue.project?.id, + state: issue.state, + teamId: issue.teamId, + projectId: issue.projectId, })), }, } }, - transformError: (error) => error.message || 'Failed to fetch Linear issues', + transformError: (error) => { + // If it's an Error instance with a message, use that + if (error instanceof Error) { + return error.message + } + + // If it's an object with an error or message property + if (typeof error === 'object' && error !== null) { + if (error.error) { + return typeof error.error === 'string' ? error.error : JSON.stringify(error.error) + } + if (error.message) { + return error.message + } + } + + // Default fallback message + return 'Failed to fetch Linear issues' + }, } From 211d5b1655fe3fdafd63313f72dfdb162397578c Mon Sep 17 00:00:00 2001 From: sriram2k4 Date: Thu, 29 May 2025 12:56:14 +0530 Subject: [PATCH 17/18] fix[linear]: handle teams API response errors --- .../project-selector/components/linear-team-selector.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx index 42bb5b969b1..13178e5366d 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx @@ -42,7 +42,10 @@ export function LinearTeamSelector({ body: JSON.stringify({ credential }), signal: controller.signal, }) - .then((res) => res.json()) + .then((res) => { + if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`) + return res.json() + }) .then((data) => { if (data.error) { setError(data.error) From 7c282b39096fe93935bd555fc8e04e8cb20f1153 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 29 May 2025 23:20:12 -0700 Subject: [PATCH 18/18] modified icon, added docs --- apps/docs/content/docs/tools/linear.mdx | 104 ++++++++++++++++++++++++ apps/docs/content/docs/tools/meta.json | 1 + apps/sim/components/icons.tsx | 2 +- bun.lock | 11 +++ 4 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 apps/docs/content/docs/tools/linear.mdx diff --git a/apps/docs/content/docs/tools/linear.mdx b/apps/docs/content/docs/tools/linear.mdx new file mode 100644 index 00000000000..f6464c14a4e --- /dev/null +++ b/apps/docs/content/docs/tools/linear.mdx @@ -0,0 +1,104 @@ +--- +title: Linear +description: Read and create issues in Linear +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + +`} +/> + +{/* MANUAL-CONTENT-START:intro */} +[Linear](https://linear.app) is a leading project management and issue tracking platform that helps teams plan, track, and manage their work effectively. As a modern project management tool, Linear has become increasingly popular among software development teams and project management professionals for its streamlined interface and powerful features. + +Linear provides a comprehensive set of tools for managing complex projects through its flexible and customizable workflow system. With its robust API and integration capabilities, Linear enables teams to streamline their development processes and maintain clear visibility of project progress. + +Key features of Linear include: + +- Agile Project Management: Support for Scrum and Kanban methodologies with customizable boards and workflows +- Issue Tracking: Sophisticated tracking system for bugs, stories, epics, and tasks with detailed reporting +- Workflow Automation: Powerful automation rules to streamline repetitive tasks and processes +- Advanced Search: Complex filtering and reporting capabilities for efficient issue management + +In Sim Studio, the Linear integration allows your agents to seamlessly interact with your project management workflow. This creates opportunities for automated issue creation, updates, and tracking as part of your AI workflows. The integration enables agents to read existing issues and create new ones programmatically, facilitating automated project management tasks and ensuring that important information is properly tracked and documented. By connecting Sim Studio with Linear, you can build intelligent agents that maintain project visibility while automating routine project management tasks, enhancing team productivity and ensuring consistent project tracking. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate with Linear to fetch, filter, and create issues directly from your workflow. + + + +## Tools + +### `linear_read_issues` + +Fetch and filter issues from Linear + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `teamId` | string | Yes | Linear team ID | +| `projectId` | string | Yes | Linear project ID | + +#### Output + +| Parameter | Type | +| --------- | ---- | +| `issues` | string | + +### `linear_create_issue` + +Create a new issue in Linear + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `teamId` | string | Yes | Linear team ID | +| `projectId` | string | Yes | Linear project ID | +| `title` | string | Yes | Issue title | +| `description` | string | No | Issue description | + +#### Output + +| Parameter | Type | +| --------- | ---- | +| `issue` | string | +| `title` | string | +| `description` | string | +| `state` | string | +| `teamId` | string | +| `projectId` | string | + + + +## Block Configuration + +### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `operation` | string | Yes | Operation | + + + +### Outputs + +| Output | Type | Description | +| ------ | ---- | ----------- | +| `response` | object | Output from response | +| ↳ `issues` | json | issues of the response | +| ↳ `issue` | json | issue of the response | + + +## Notes + +- Category: `tools` +- Type: `linear` diff --git a/apps/docs/content/docs/tools/meta.json b/apps/docs/content/docs/tools/meta.json index 35286ec928d..634fd7481ee 100644 --- a/apps/docs/content/docs/tools/meta.json +++ b/apps/docs/content/docs/tools/meta.json @@ -21,6 +21,7 @@ "image_generator", "jina", "jira", + "linear", "linkup", "mem0", "memory", diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index a04745c2906..32448ca2f29 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -2289,7 +2289,7 @@ export function LinearIcon(props: React.SVGProps) { viewBox='0 0 100 100' > diff --git a/bun.lock b/bun.lock index e9f6ac4d605..b9f1f9f560e 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,7 @@ "": { "name": "simstudio", "dependencies": { + "@linear/sdk": "40.0.0", "@t3-oss/env-nextjs": "0.13.4", "@vercel/analytics": "1.5.0", "remark-gfm": "4.0.1", @@ -465,6 +466,8 @@ "@google/genai": ["@google/genai@0.8.0", "", { "dependencies": { "google-auth-library": "^9.14.2", "ws": "^8.18.0" } }, "sha512-Zs+OGyZKyMbFofGJTR9/jTQSv8kITh735N3tEuIZj4VlMQXTC0soCFahysJ9NaeenRlD7xGb6fyqmX+FwrpU6Q=="], + "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], + "@grpc/grpc-js": ["@grpc/grpc-js@1.13.3", "", { "dependencies": { "@grpc/proto-loader": "^0.7.13", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-FTXHdOoPbZrBjlVLHuKbDZnsTxXv2BlHF57xw6LuThXacXvtkahEPED0CKMk6obZDf65Hv4k3z62eyPNpvinIg=="], "@grpc/proto-loader": ["@grpc/proto-loader@0.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="], @@ -541,6 +544,8 @@ "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], + "@linear/sdk": ["@linear/sdk@40.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.0", "graphql": "^15.4.0", "isomorphic-unfetch": "^3.1.0" } }, "sha512-R4lyDIivdi00fO+DYPs7gWNX221dkPJhgDowFrsfos/rNG6o5HixsCPgwXWtKN0GA0nlqLvFTmzvzLXpud1xKw=="], + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw=="], "@next/env": ["@next/env@15.3.2", "", {}, "sha512-xURk++7P7qR9JG1jJtLzPzf0qEvqCN0A/T3DXf8IPMKo9/6FfjxtEffRJIIew/bIL4T3C2jLLqBor8B/zVlx6g=="], @@ -1853,6 +1858,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "graphql": ["graphql@15.10.1", "", {}, "sha512-BL/Xd/T9baO6NFzoMpiMD7YUZ62R6viR5tp/MULVEnbYJXZA//kRNW7J0j1w/wXArgL0sCxhDfK5dczSKn3+cg=="], + "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], "groq-sdk": ["groq-sdk@0.15.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-aYDEdr4qczx3cLCRRe+Beb37I7g/9bD5kHF+EEDxcrREWw1vKoRcfP3vHEkJB7Ud/8oOuF0scRwDpwWostTWuQ=="], @@ -1973,6 +1980,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isomorphic-unfetch": ["isomorphic-unfetch@3.1.0", "", { "dependencies": { "node-fetch": "^2.6.1", "unfetch": "^4.2.0" } }, "sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], @@ -2803,6 +2812,8 @@ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unfetch": ["unfetch@4.2.0", "", {}, "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA=="], + "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],