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..f8920eddfc4 --- /dev/null +++ b/apps/sim/app/api/tools/linear/projects/route.ts @@ -0,0 +1,63 @@ +import type { Project } from '@linear/sdk' +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' + +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 = [] + + 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 }) + } + + 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..232cfa45da1 --- /dev/null +++ b/apps/sim/app/api/tools/linear/teams/route.ts @@ -0,0 +1,56 @@ +import type { Team } from '@linear/sdk' +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' + +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: Team) => ({ + 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 } + ) + } +} 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 new file mode 100644 index 00000000000..4c156a251a6 --- /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,92 @@ +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 +} + +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 + 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(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) + setProjects([]) + } else { + setProjects(data.projects) + } + }) + .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 new file mode 100644 index 00000000000..13178e5366d --- /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,88 @@ +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 + 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) => { + if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`) + return res.json() + }) + .then((data) => { + if (data.error) { + setError(data.error) + setTeams([]) + } else { + setTeams(data.teams) + } + }) + .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 3e04d60f1e3..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 @@ -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 LinearProjectInfo, LinearProjectSelector } from './components/linear-project-selector' +import { type LinearTeamInfo, LinearTeamSelector } from './components/linear-team-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,10 @@ 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 +59,13 @@ export function ProjectSelectorInput({ setValue(blockId, 'issueKey', '') } else if (provider === 'discord') { setValue(blockId, 'channelId', '') + } else if (provider === 'linear') { + if (subBlock.id === 'teamId') { + setValue(blockId, 'teamId', projectId) + setValue(blockId, 'projectId', '') + } else if (subBlock.id === 'projectId') { + setValue(blockId, 'projectId', projectId) + } } onProjectSelect?.(projectId) @@ -87,6 +100,55 @@ 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} + /> + ) : ( + (() => { + const credential = getValue(blockId, 'credential') as string + const teamId = getValue(blockId, 'teamId') as string + const isDisabled = disabled || !credential || !teamId + return ( + { + handleProjectChange(projectId, projectInfo) + }} + credential={credential} + teamId={teamId} + label={subBlock.placeholder || 'Select Linear project'} + disabled={isDisabled} + /> + ) + })() + )} +
+
+ {!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 new file mode 100644 index 00000000000..f4eacc5c891 --- /dev/null +++ b/apps/sim/blocks/blocks/linear.ts @@ -0,0 +1,109 @@ +import { LinearIcon } from '@/components/icons' +import type { LinearCreateIssueResponse, LinearReadIssuesResponse } from '@/tools/linear/types' +import type { BlockConfig } from '../types' + +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', + icon: LinearIcon, + bgColor: '#5E6AD2', + 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: ['read', 'write'], + 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: 'title', + title: 'Title', + type: 'short-input', + layout: 'full', + condition: { field: 'operation', value: ['write'] }, + }, + { + id: 'description', + title: 'Description', + type: 'long-input', + layout: 'full', + 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, + } + }, + }, + }, + inputs: { + operation: { type: 'string', required: true }, + credential: { type: 'string', required: true }, + teamId: { type: 'string', required: true }, + projectId: { type: 'string', required: true }, + title: { type: 'string', required: false }, + description: { type: 'string', required: false }, + }, + outputs: { + response: { + type: { + issues: 'json', + issue: 'json', + }, + }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 4571e9910cc..448fcce52b9 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' @@ -88,6 +89,7 @@ export const registry: Record = { image_generator: ImageGeneratorBlock, jina: JinaBlock, jira: JiraBlock, + linear: LinearBlock, linkup: LinkupBlock, mem0: Mem0Block, mistral_parse: MistralParseBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index d7e2f3f22fe..a04745c2906 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -2278,6 +2278,24 @@ export function JiraIcon(props: SVGProps) { ) } +export function LinearIcon(props: React.SVGProps) { + return ( + + + + ) +} + export function TelegramIcon(props: SVGProps) { return ( { + 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() + 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') + + 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..8cf52e9348e 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: ['read', 'write'], + }, + }, + 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 new file mode 100644 index 00000000000..28ea96a8798 --- /dev/null +++ b/apps/sim/tools/linear/create_issue.ts @@ -0,0 +1,103 @@ +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) => { + 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: { + 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() + 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: { + id: issue.id, + title: issue.title, + description: issue.description, + state: issue.state?.name, + teamId: issue.team?.id, + projectId: issue.project?.id, + }, + }, + } + }, + 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/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..38562188bd3 --- /dev/null +++ b/apps/sim/tools/linear/read_issues.ts @@ -0,0 +1,97 @@ +import type { ToolConfig } from '../types' +import type { LinearIssue, 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', + oauth: { + required: true, + provider: 'linear', + }, + params: { + teamId: { type: 'string', required: true, description: 'Linear team ID' }, + projectId: { type: 'string', required: true, description: 'Linear project ID' }, + }, + request: { + url: 'https://api.linear.app/graphql', + method: 'POST', + 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!) { + issues( + filter: { + team: { id: { eq: $teamId } } + project: { id: { eq: $projectId } } + } + ) { + nodes { + id + title + description + state { name } + team { id } + project { id } + } + } + } + `, + variables: { + teamId: params.teamId, + projectId: params.projectId, + }, + }), + }, + transformResponse: async (response) => { + const data = await response.json() + if (data.errors) { + return { + success: false, + output: { issues: [] }, + error: data.errors.map((e: any) => e.message).join('; '), + } + } + return { + success: true, + output: { + issues: (data.data.issues.nodes as LinearIssue[]).map((issue) => ({ + id: issue.id, + title: issue.title, + description: issue.description, + state: issue.state, + teamId: issue.teamId, + projectId: issue.projectId, + })), + }, + } + }, + 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' + }, +} diff --git a/apps/sim/tools/linear/types.ts b/apps/sim/tools/linear/types.ts new file mode 100644 index 00000000000..d91c05b9b44 --- /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 + accessToken?: string +} + +export interface LinearCreateIssueParams { + teamId: string + projectId: string + title: string + description?: string + accessToken?: string +} + +export interface LinearReadIssuesResponse extends ToolResponse { + output: { + issues: LinearIssue[] + } +} + +export interface LinearCreateIssueResponse extends ToolResponse { + output: { + issue: LinearIssue + } +} 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, } 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"