fix(tools): fix linear block

This commit is contained in:
Waleed Latif
2025-05-30 00:47:02 -07:00
parent 51120c8fd5
commit a9196314d7
4 changed files with 297 additions and 88 deletions
@@ -1,11 +1,16 @@
import { useEffect, useState } from 'react'
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
import { LinearIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
export interface LinearProjectInfo {
id: string
@@ -32,11 +37,15 @@ export function LinearProjectSelector({
const [projects, setProjects] = useState<LinearProjectInfo[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [selectedProject, setSelectedProject] = useState<LinearProjectInfo | null>(null)
useEffect(() => {
if (!credential || !teamId) return
const controller = new AbortController()
setLoading(true)
setError(null)
fetch('/api/tools/linear/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -56,6 +65,12 @@ export function LinearProjectSelector({
setProjects([])
} else {
setProjects(data.projects)
// Find selected project info if we have a value
if (value) {
const projectInfo = data.projects.find((p: LinearProjectInfo) => p.id === value)
setSelectedProject(projectInfo || null)
}
}
})
.catch((err) => {
@@ -65,28 +80,107 @@ export function LinearProjectSelector({
})
.finally(() => setLoading(false))
return () => controller.abort()
}, [credential, teamId])
}, [credential, teamId, value])
// Sync selected project with value prop
useEffect(() => {
if (value && projects.length > 0) {
const projectInfo = projects.find((p) => p.id === value)
setSelectedProject(projectInfo || null)
} else if (!value) {
setSelectedProject(null)
}
}, [value, projects])
const handleSelectProject = (project: LinearProjectInfo) => {
setSelectedProject(project)
onChange(project.id, project)
setOpen(false)
}
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
}
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>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='w-full justify-between'
disabled={disabled || !credential || !teamId}
>
{selectedProject ? (
<div className='flex items-center gap-2 overflow-hidden'>
<LinearIcon className='h-4 w-4' />
<span className='truncate font-normal'>{selectedProject.name}</span>
</div>
) : (
<div className='flex items-center gap-2'>
<LinearIcon className='h-4 w-4' />
<span className='text-muted-foreground'>{label}</span>
</div>
)}
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
<Command>
<CommandInput placeholder='Search projects...' />
<CommandList>
<CommandEmpty>
{loading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading projects...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : !credential || !teamId ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>Missing credentials or team</p>
<p className='text-muted-foreground text-xs'>
Please configure Linear credentials and select a team.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No projects found</p>
<p className='text-muted-foreground text-xs'>
No projects available for the selected team.
</p>
</div>
)}
</CommandEmpty>
{projects.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Projects
</div>
{projects.map((project) => (
<CommandItem
key={project.id}
value={`project-${project.id}-${project.name}`}
onSelect={() => handleSelectProject(project)}
className='cursor-pointer'
>
<div className='flex items-center gap-2 overflow-hidden'>
<LinearIcon className='h-4 w-4' />
<span className='truncate font-normal'>{project.name}</span>
</div>
{project.id === value && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
@@ -1,11 +1,16 @@
import { useEffect, useState } from 'react'
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
import { LinearIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
export interface LinearTeamInfo {
id: string
@@ -31,11 +36,15 @@ export function LinearTeamSelector({
const [teams, setTeams] = useState<LinearTeamInfo[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [selectedTeam, setSelectedTeam] = useState<LinearTeamInfo | null>(null)
useEffect(() => {
if (!credential) return
const controller = new AbortController()
setLoading(true)
setError(null)
fetch('/api/tools/linear/teams', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -52,6 +61,12 @@ export function LinearTeamSelector({
setTeams([])
} else {
setTeams(data.teams)
// Find selected team info if we have a value
if (value) {
const teamInfo = data.teams.find((t: LinearTeamInfo) => t.id === value)
setSelectedTeam(teamInfo || null)
}
}
})
.catch((err) => {
@@ -61,28 +76,105 @@ export function LinearTeamSelector({
})
.finally(() => setLoading(false))
return () => controller.abort()
}, [credential])
}, [credential, value])
// Sync selected team with value prop
useEffect(() => {
if (value && teams.length > 0) {
const teamInfo = teams.find((t) => t.id === value)
setSelectedTeam(teamInfo || null)
} else if (!value) {
setSelectedTeam(null)
}
}, [value, teams])
const handleSelectTeam = (team: LinearTeamInfo) => {
setSelectedTeam(team)
onChange(team.id, team)
setOpen(false)
}
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
}
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>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='w-full justify-between'
disabled={disabled || !credential}
>
{selectedTeam ? (
<div className='flex items-center gap-2 overflow-hidden'>
<LinearIcon className='h-4 w-4' />
<span className='truncate font-normal'>{selectedTeam.name}</span>
</div>
) : (
<div className='flex items-center gap-2'>
<LinearIcon className='h-4 w-4' />
<span className='text-muted-foreground'>{label}</span>
</div>
)}
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
<Command>
<CommandInput placeholder='Search teams...' />
<CommandList>
<CommandEmpty>
{loading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading teams...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : !credential ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>Missing credentials</p>
<p className='text-muted-foreground text-xs'>
Please configure Linear credentials.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No teams found</p>
<p className='text-muted-foreground text-xs'>
No teams available for this Linear account.
</p>
</div>
)}
</CommandEmpty>
{teams.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>Teams</div>
{teams.map((team) => (
<CommandItem
key={team.id}
value={`team-${team.id}-${team.name}`}
onSelect={() => handleSelectTeam(team)}
className='cursor-pointer'
>
<div className='flex items-center gap-2 overflow-hidden'>
<LinearIcon className='h-4 w-4' />
<span className='truncate font-normal'>{team.name}</span>
</div>
{team.id === value && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
+2 -2
View File
@@ -2283,13 +2283,13 @@ export function LinearIcon(props: React.SVGProps<SVGSVGElement>) {
<svg
{...props}
xmlns='http://www.w3.org/2000/svg'
fill='none'
fill='currentColor'
width='200'
height='200'
viewBox='0 0 100 100'
>
<path
fill='#fff'
fill='currentColor'
d='M1.22541 61.5228c-.2225-.9485.90748-1.5459 1.59638-.857L39.3342 97.1782c.6889.6889.0915 1.8189-.857 1.5964C20.0515 94.4522 5.54779 79.9485 1.22541 61.5228ZM.00189135 46.8891c-.01764375.2833.08887215.5599.28957165.7606L52.3503 99.7085c.2007.2007.4773.3075.7606.2896 2.3692-.1476 4.6938-.46 6.9624-.9259.7645-.157 1.0301-1.0963.4782-1.6481L2.57595 39.4485c-.55186-.5519-1.49117-.2863-1.648174.4782-.465915 2.2686-.77832 4.5932-.92588465 6.9624ZM4.21093 29.7054c-.16649.3738-.08169.8106.20765 1.1l64.77602 64.776c.2894.2894.7262.3742 1.1.2077 1.7861-.7956 3.5171-1.6927 5.1855-2.684.5521-.328.6373-1.0867.1832-1.5407L8.43566 24.3367c-.45409-.4541-1.21271-.3689-1.54074.1832-.99132 1.6684-1.88843 3.3994-2.68399 5.1855ZM12.6587 18.074c-.3701-.3701-.393-.9637-.0443-1.3541C21.7795 6.45931 35.1114 0 49.9519 0 77.5927 0 100 22.4073 100 50.0481c0 14.8405-6.4593 28.1724-16.7199 37.3375-.3903.3487-.984.3258-1.3542-.0443L12.6587 18.074Z'
/>
</svg>
+55 -32
View File
@@ -261,7 +261,6 @@ export const auth = betterAuth({
) || emails[0]
if (primaryEmail) {
profile.email = primaryEmail.email
// Add information about email verification
profile.emailVerified = primaryEmail.verified || false
}
} else {
@@ -785,42 +784,66 @@ export const auth = betterAuth({
tokenUrl: 'https://api.linear.app/oauth/token',
scopes: ['read', 'write'],
responseType: 'code',
accessType: 'offline',
prompt: 'consent',
pkce: false,
redirectURI: `${env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/linear`,
pkce: true,
prompt: 'consent',
accessType: 'offline',
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 `,
}),
})
try {
const response = await fetch('https://api.linear.app/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${tokens.accessToken}`,
},
body: JSON.stringify({
query: `{
viewer {
id
email
name
avatarUrl
}
}`,
}),
})
if (!response.ok) {
throw new Error('Failed to fetch Linear user info')
}
if (!response.ok) {
const errorText = await response.text()
console.error('Linear API error:', {
status: response.status,
statusText: response.statusText,
body: errorText,
})
throw new Error(`Linear API error: ${response.status} ${response.statusText}`)
}
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 { data, errors } = await response.json()
const now = new Date()
return {
id: user.id,
name: user.name,
email: user.email,
image: user.avatarUrl,
emailVerified: true,
createdAt: now,
updatedAt: now,
if (errors) {
console.error('GraphQL errors:', errors)
throw new Error(`GraphQL errors: ${JSON.stringify(errors)}`)
}
if (!data?.viewer) {
console.error('No viewer data in response:', data)
throw new Error('No viewer data in response')
}
const viewer = data.viewer
return {
id: viewer.id,
email: viewer.email,
name: viewer.name,
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
image: viewer.avatarUrl || null,
}
} catch (error) {
console.error('Error in getUserInfo:', error)
throw error
}
},
},