Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions apps/sim/app/api/tools/linear/projects/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
Comment thread
waleedlatif1 marked this conversation as resolved.

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 }
)
}
}
56 changes: 56 additions & 0 deletions apps/sim/app/api/tools/linear/teams/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
'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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<LinearProjectInfo[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(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 (
<Select
value={value}
onValueChange={(projectId) => {
const projectInfo = projects.find((p) => p.id === projectId)
onChange(projectId, projectInfo)
}}
disabled={disabled || loading || !credential || !teamId}
>
<SelectTrigger className='w-full'>
<SelectValue placeholder={loading ? 'Loading projects...' : label} />
</SelectTrigger>
<SelectContent>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
{error && <div className='px-2 py-1 text-red-500'>{error}</div>}
</SelectContent>
</Select>
)
}
Original file line number Diff line number Diff line change
@@ -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
Comment thread
waleedlatif1 marked this conversation as resolved.
}

export function LinearTeamSelector({
value,
onChange,
credential,
label = 'Select Linear team',
disabled = false,
}: LinearTeamSelectorProps) {
const [teams, setTeams] = useState<LinearTeamInfo[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(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 (
<Select
value={value}
onValueChange={(teamId) => {
const teamInfo = teams.find((t) => t.id === teamId)
onChange(teamId, teamInfo)
}}
disabled={disabled || loading || !credential}
>
<SelectTrigger className='w-full'>
<SelectValue placeholder={loading ? 'Loading teams...' : label} />
</SelectTrigger>
<SelectContent>
{teams.map((team) => (
<SelectItem key={team.id} value={team.id}>
{team.name}
</SelectItem>
))}
{error && <div className='px-2 py-1 text-red-500'>{error}</div>}
</SelectContent>
</Select>
)
}
Loading