From 88d8a1b104f7fb41f30c2d9a2155485c9ce25514 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 May 2025 00:10:37 -0700 Subject: [PATCH] feat(tools): added linear tools/block (#439) * feat(linear): add Linear Issue Reader and Writer tools with types * chore(tools): register Linear tools in global tool registry * feat(icons): add LinearIcon for Linear block * feat(blocks): register Linear block in global block registry * feat(linear): implement OAuth integration for Linear block * feat(linear): add dynamic team and project selectors for Linear block * feat(linear): add backend API endpoints for teams and projects * feat(linear): update UI components for Linear selectors and modal * refactor(linear): update create/read issue tools and types * chore(linear): update block config for Linear integration * fix(auth): update auth and oauth logic for Linear * minor fix * improvement[linear]: require teamId and projectId for all tools and types * style[lint]: fix code style and lint errors * chore(linear): install @linear/sdk package * fix[linear]: address greptile-apps feedback for type safety and error handling * fix[linear]: handle teams API response errors * modified icon, added docs --------- Co-authored-by: sriram2k4 --- apps/docs/content/docs/tools/linear.mdx | 104 +++++++++++++++++ apps/docs/content/docs/tools/meta.json | 1 + .../app/api/tools/linear/projects/route.ts | 63 ++++++++++ apps/sim/app/api/tools/linear/teams/route.ts | 56 +++++++++ .../components/oauth-required-modal.tsx | 2 + .../components/linear-project-selector.tsx | 92 +++++++++++++++ .../components/linear-team-selector.tsx | 88 ++++++++++++++ .../project-selector-input.tsx | 64 +++++++++- apps/sim/blocks/blocks/linear.ts | 109 ++++++++++++++++++ apps/sim/blocks/registry.ts | 2 + apps/sim/components/icons.tsx | 18 +++ apps/sim/lib/auth.ts | 48 ++++++++ apps/sim/lib/env.ts | 2 + apps/sim/lib/oauth.ts | 22 ++++ apps/sim/tools/linear/create_issue.ts | 103 +++++++++++++++++ apps/sim/tools/linear/index.ts | 4 + apps/sim/tools/linear/read_issues.ts | 97 ++++++++++++++++ apps/sim/tools/linear/types.ts | 36 ++++++ apps/sim/tools/registry.ts | 3 + bun.lock | 11 ++ package.json | 1 + 21 files changed, 925 insertions(+), 1 deletion(-) create mode 100644 apps/docs/content/docs/tools/linear.mdx create mode 100644 apps/sim/app/api/tools/linear/projects/route.ts create mode 100644 apps/sim/app/api/tools/linear/teams/route.ts 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 create mode 100644 apps/sim/blocks/blocks/linear.ts 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/docs/content/docs/tools/linear.mdx b/apps/docs/content/docs/tools/linear.mdx new file mode 100644 index 000000000..f6464c14a --- /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 35286ec92..634fd7481 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/app/api/tools/linear/projects/route.ts b/apps/sim/app/api/tools/linear/projects/route.ts new file mode 100644 index 000000000..f8920eddf --- /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 000000000..232cfa45d --- /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 59fbd6798..6e830026b 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 000000000..4c156a251 --- /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 000000000..13178e536 --- /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 3e04d60f1..59dcabaa1 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 000000000..f4eacc5c8 --- /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 4571e9910..448fcce52 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 d7e2f3f22..32448ca2f 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 a247f0095..c66eb8f79 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 ba295661b..8cf52e934 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 000000000..28ea96a87 --- /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 000000000..5726982ed --- /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 000000000..38562188b --- /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 000000000..d91c05b9b --- /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 65fdbfaa6..2f701703d 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/bun.lock b/bun.lock index d41f045c2..802d8cbf2 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", @@ -466,6 +467,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=="], @@ -542,6 +545,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=="], @@ -1858,6 +1863,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=="], @@ -1978,6 +1985,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=="], @@ -2808,6 +2817,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=="], diff --git a/package.json b/package.json index de944b306..40240f516 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"