fix: address review findings (#4853)

This commit is contained in:
Jinjing
2026-06-07 19:50:05 -07:00
committed by GitHub
parent 93f9f86823
commit a67afb5a04
14 changed files with 1003 additions and 55 deletions
+90
View File
@@ -13,6 +13,7 @@ import {
getIssueComments
} from '../linear/issues'
import {
createProject,
getCustomView,
getProject,
listCustomViewIssues,
@@ -56,6 +57,29 @@ function normalizeCustomViewModel(value: unknown): LinearCustomViewModel {
return value
}
function normalizeIdList(value: unknown, fieldName: string): string[] | undefined {
if (value === undefined) {
return undefined
}
if (
!Array.isArray(value) ||
!value.every((id): id is string => typeof id === 'string' && Boolean(id.trim()))
) {
throw new Error(`Invalid ${fieldName}`)
}
return value.map((id) => id.trim())
}
function normalizeOptionalDate(value: unknown, fieldName: string): string | undefined {
if (value === undefined || value === null || value === '') {
return undefined
}
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value.trim())) {
throw new Error(`Invalid ${fieldName}`)
}
return value.trim()
}
export function registerLinearHandlers(): void {
ipcMain.handle('linear:connect', async (_event, args: { apiKey: string }) => {
if (typeof args?.apiKey !== 'string' || !args.apiKey.trim()) {
@@ -284,6 +308,72 @@ export function registerLinearHandlers(): void {
}
)
ipcMain.handle(
'linear:createProject',
async (
_event,
args: {
name: string
description?: string
content?: string
teamIds?: string[]
leadId?: string | null
memberIds?: string[]
labelIds?: string[]
priority?: number
startDate?: string
targetDate?: string
workspaceId?: string
}
) => {
if (typeof args?.name !== 'string' || !args.name.trim()) {
return { ok: false, error: 'Project name is required' }
}
let teamIds: string[]
try {
teamIds = normalizeIdList(args.teamIds, 'team IDs') ?? []
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : 'Invalid team IDs' }
}
if (teamIds.length === 0) {
return { ok: false, error: 'At least one team is required' }
}
if (
args.priority !== undefined &&
(!Number.isInteger(args.priority) || args.priority < 0 || args.priority > 4)
) {
return { ok: false, error: 'Invalid priority' }
}
let memberIds: string[] | undefined
let labelIds: string[] | undefined
let startDate: string | undefined
let targetDate: string | undefined
try {
memberIds = normalizeIdList(args.memberIds, 'member IDs')
labelIds = normalizeIdList(args.labelIds, 'label IDs')
startDate = normalizeOptionalDate(args.startDate, 'start date')
targetDate = normalizeOptionalDate(args.targetDate, 'target date')
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : 'Invalid project' }
}
return createProject(
{
name: args.name.trim(),
description: args.description?.trim() || undefined,
content: args.content?.trim() || undefined,
teamIds,
leadId: normalizeWorkspaceId(args.leadId),
memberIds,
labelIds,
priority: typeof args.priority === 'number' ? args.priority : undefined,
startDate,
targetDate
},
normalizeWorkspaceId(args.workspaceId)
)
}
)
ipcMain.handle(
'linear:getProject',
async (_event, args: { id: string; workspaceId?: string; force?: boolean }) => {
+63
View File
@@ -210,6 +210,69 @@ describe('Linear project queries', () => {
})
})
it('creates a project with team metadata and maps the created project', async () => {
rawRequest.mockResolvedValueOnce({
data: {
projectCreate: {
success: true,
project: {
...rawProject('project-1'),
description: 'Summary',
content: 'Brief',
priority: 2,
targetDate: '2026-08-01',
teams: { nodes: [{ id: 'team-1', name: 'Team', key: 'TM' }] }
}
}
}
})
const { createProject } = await import('./projects')
const result = await createProject(
{
name: 'Roadmap',
description: 'Summary',
content: 'Brief',
teamIds: ['team-1'],
leadId: 'user-1',
memberIds: ['user-1', 'user-2'],
labelIds: ['label-1'],
priority: 2,
startDate: '2026-07-01',
targetDate: '2026-08-01'
},
'workspace-1'
)
expect(result).toMatchObject({
ok: true,
project: {
id: 'project-1',
name: 'project-1',
workspaceId: 'workspace-1',
description: 'Summary',
content: 'Brief',
priority: 2,
targetDate: '2026-08-01',
teams: [{ id: 'team-1', name: 'Team', key: 'TM' }]
}
})
expect(rawRequest.mock.calls[0]?.[1]).toEqual({
input: {
name: 'Roadmap',
description: 'Summary',
content: 'Brief',
teamIds: ['team-1'],
leadId: 'user-1',
memberIds: ['user-1', 'user-2'],
labelIds: ['label-1'],
priority: 2,
startDate: '2026-07-01',
targetDate: '2026-08-01'
}
})
})
it('lets manual custom view list refresh bypass older in-flight reads', async () => {
const staleRequest = deferred<ReturnType<typeof customViewsResponse>>()
const refreshRequest = deferred<ReturnType<typeof customViewsResponse>>()
+64
View File
@@ -160,6 +160,26 @@ type CustomViewConnectionResponse = {
| null
}
type ProjectMutationResponse = {
projectCreate?: {
success?: boolean | null
project?: LinearProjectNode | null
} | null
}
export type LinearProjectCreateInput = {
name: string
description?: string
content?: string
teamIds: string[]
leadId?: string
memberIds?: string[]
labelIds?: string[]
priority?: number
startDate?: string
targetDate?: string
}
const ORCA_PROJECT_FIELDS = `
id
name
@@ -316,6 +336,17 @@ const PROJECT_QUERY = `
}
`
const CREATE_PROJECT_MUTATION = `
mutation OrcaLinearProjectCreate($input: ProjectCreateInput!) {
projectCreate(input: $input) {
success
project {
${ORCA_PROJECT_DETAIL_FIELDS}
}
}
}
`
const PROJECT_ISSUES_QUERY = `
query OrcaLinearProjectIssues(
$id: String!,
@@ -865,6 +896,39 @@ export async function getProject(
)
}
export async function createProject(
input: LinearProjectCreateInput,
workspaceId?: string | null
): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> {
const entry = getClients(workspaceId)[0]
if (!entry) {
return { ok: false, error: 'Not connected to Linear' }
}
await acquire()
try {
const result = await entry.client.client.rawRequest<
ProjectMutationResponse,
LinearRawVariables
>(CREATE_PROJECT_MUTATION, { input })
const payload = result.data?.projectCreate
const project = payload?.project
if (!payload?.success || !project) {
return { ok: false, error: 'Linear project create failed' }
}
return { ok: true, project: mapProjectDetailForWorkspace(entry, project) }
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.workspace.id)
throw error
}
const message = error instanceof Error ? error.message : String(error)
return { ok: false, error: message }
} finally {
release()
}
}
export async function listProjectIssues(
projectId: string,
limit = 20,
+10 -1
View File
@@ -293,13 +293,15 @@ import {
type LinearListFilter
} from '../linear/issues'
import {
createProject as createLinearProject,
getCustomView as getLinearCustomView,
getProject as getLinearProject,
listCustomViewIssues as listLinearCustomViewIssues,
listCustomViewProjects as listLinearCustomViewProjects,
listCustomViews as listLinearCustomViews,
listProjectIssues as listLinearProjectIssues,
listProjects as listLinearProjects
listProjects as listLinearProjects,
type LinearProjectCreateInput
} from '../linear/projects'
import {
getTeamLabels as getLinearTeamLabels,
@@ -13892,6 +13894,13 @@ export class OrcaRuntimeService {
return listLinearProjects(query, Math.min(Math.max(1, limit), 50), workspaceId, force)
}
linearCreateProject(
input: LinearProjectCreateInput,
workspaceId?: string
): ReturnType<typeof createLinearProject> {
return createLinearProject(input, workspaceId)
}
linearGetProject(
id: string,
workspaceId: string,
@@ -0,0 +1,41 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalString, requiredString } from '../schemas'
const LinearPriority = z.number().int().min(0).max(4).optional()
const LinearLabelIds = z.array(requiredString('Invalid label ID')).optional()
const CreateProject = z.object({
name: requiredString('Project name is required'),
description: OptionalString,
content: OptionalString,
workspaceId: OptionalString,
teamIds: z.array(requiredString('Invalid team ID')).min(1, 'At least one team is required'),
leadId: z.union([z.string(), z.null()]).optional(),
memberIds: z.array(requiredString('Invalid member ID')).optional(),
labelIds: LinearLabelIds,
priority: LinearPriority,
startDate: OptionalString,
targetDate: OptionalString
})
export const LINEAR_PROJECT_CREATE_METHOD: RpcMethod = defineMethod({
name: 'linear.createProject',
params: CreateProject,
handler: async (params, { runtime }) =>
runtime.linearCreateProject(
{
name: params.name.trim(),
description: params.description?.trim() || undefined,
content: params.content?.trim() || undefined,
teamIds: params.teamIds.map((id) => id.trim()),
leadId: params.leadId ? params.leadId.trim() : undefined,
memberIds: params.memberIds?.map((id) => id.trim()),
labelIds: params.labelIds?.map((id) => id.trim()),
priority: params.priority,
startDate: params.startDate?.trim() || undefined,
targetDate: params.targetDate?.trim() || undefined
},
params.workspaceId
)
})
@@ -163,6 +163,7 @@ describe('linear RPC methods', () => {
linearListProjectIssues: vi.fn().mockResolvedValue({ items: [{ id: 'issue-2' }] }),
linearListTeams: vi.fn().mockResolvedValue([{ id: 'team-1' }]),
linearListProjects: vi.fn().mockResolvedValue({ items: [{ id: 'project-1' }] }),
linearCreateProject: vi.fn().mockResolvedValue({ ok: true, project: { id: 'project-3' } }),
linearTeamStates: vi.fn().mockResolvedValue([{ id: 'state-1' }]),
linearTeamLabels: vi.fn().mockResolvedValue([{ id: 'label-1' }]),
linearTeamMembers: vi.fn().mockResolvedValue([{ id: 'member-1' }])
@@ -173,6 +174,21 @@ describe('linear RPC methods', () => {
await dispatcher.dispatch(
makeRequest('linear.listProjects', { query: 'roadmap', limit: 5, force: true })
)
await dispatcher.dispatch(
makeRequest('linear.createProject', {
name: 'Roadmap',
description: 'Summary',
content: 'Brief',
teamIds: ['team-1'],
workspaceId: 'workspace-1',
leadId: 'user-1',
memberIds: ['user-1', 'user-2'],
labelIds: ['label-1'],
priority: 2,
startDate: '2026-07-01',
targetDate: '2026-08-01'
})
)
await dispatcher.dispatch(
makeRequest('linear.getProject', {
id: 'project-1',
@@ -232,6 +248,21 @@ describe('linear RPC methods', () => {
expect(runtime.linearListTeams).toHaveBeenCalledWith('all')
expect(runtime.linearListProjects).toHaveBeenCalledWith('roadmap', 5, undefined, true)
expect(runtime.linearCreateProject).toHaveBeenCalledWith(
{
name: 'Roadmap',
description: 'Summary',
content: 'Brief',
teamIds: ['team-1'],
leadId: 'user-1',
memberIds: ['user-1', 'user-2'],
labelIds: ['label-1'],
priority: 2,
startDate: '2026-07-01',
targetDate: '2026-08-01'
},
'workspace-1'
)
expect(runtime.linearGetProject).toHaveBeenCalledWith('project-1', 'workspace-1', true)
expect(runtime.linearListProjectIssues).toHaveBeenCalledWith(
'project-1',
+2
View File
@@ -1,6 +1,7 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
import { LINEAR_PROJECT_CREATE_METHOD } from './linear-project-create'
const VALID_FILTERS = ['assigned', 'created', 'all', 'completed'] as const
const VALID_CUSTOM_VIEW_MODELS = ['issue', 'project'] as const
@@ -222,6 +223,7 @@ export const LINEAR_METHODS: RpcMethod[] = [
handler: async (params, { runtime }) =>
runtime.linearListProjects(params?.query, params?.limit, params?.workspaceId, params?.force)
}),
LINEAR_PROJECT_CREATE_METHOD,
defineMethod({
name: 'linear.getProject',
params: ProjectId,
+1
View File
@@ -227,6 +227,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'linear.addIssueComment',
'linear.connect',
'linear.createIssue',
'linear.createProject',
'linear.issueComments',
'linear.listCustomViewIssues',
'linear.listCustomViewProjects',
+13
View File
@@ -1433,6 +1433,19 @@ export type PreloadApi = {
workspaceId?: LinearWorkspaceSelection
force?: boolean
}) => Promise<LinearCollectionResult<LinearProjectSummary>>
createProject: (args: {
name: string
description?: string
content?: string
teamIds: string[]
workspaceId?: string
leadId?: string | null
memberIds?: string[]
labelIds?: string[]
priority?: number
startDate?: string
targetDate?: string
}) => Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }>
getProject: (args: {
id: string
workspaceId: string
+16
View File
@@ -26,6 +26,7 @@ import type {
GitUpstreamStatus,
GhosttyImportPreview,
ListWorkItemsResult,
LinearProjectDetail,
MemorySnapshot,
NotificationDismissResult,
NotificationDispatchResult,
@@ -1282,6 +1283,21 @@ const api = {
force?: boolean
}): Promise<unknown> => ipcRenderer.invoke('linear:listProjects', args),
createProject: (args: {
name: string
description?: string
content?: string
teamIds: string[]
workspaceId?: string
leadId?: string | null
memberIds?: string[]
labelIds?: string[]
priority?: number
startDate?: string
targetDate?: string
}): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> =>
ipcRenderer.invoke('linear:createProject', args),
getProject: (args: { id: string; workspaceId: string; force?: boolean }): Promise<unknown> =>
ipcRenderer.invoke('linear:getProject', args),
+602 -50
View File
@@ -203,6 +203,7 @@ import {
useTeamLabels
} from '@/hooks/useIssueMetadata'
import {
linearCreateProject,
linearCreateIssue,
linearGetIssue,
linearTeamStates,
@@ -498,10 +499,7 @@ const LINEAR_MODE_OPTIONS: { id: LinearMode; label: string }[] = [
{ id: 'views', label: 'Views' }
]
const LINEAR_CUSTOM_VIEW_MODEL_OPTIONS: { id: LinearCustomViewModel; label: string }[] = [
{ id: 'issue', label: 'Issues' },
{ id: 'project', label: 'Projects' }
]
const LINEAR_CUSTOM_VIEW_MODELS = ['issue', 'project'] satisfies readonly LinearCustomViewModel[]
const LINEAR_VIEW_OPTIONS: {
id: LinearViewMode
@@ -512,6 +510,17 @@ const LINEAR_VIEW_OPTIONS: {
{ id: 'board', label: 'Board', Icon: LayoutGrid }
]
function mergeLinearCollectionResults<T>(
results: LinearCollectionResult<T>[]
): LinearCollectionResult<T> {
const errors = results.flatMap((result) => result.errors ?? [])
return {
items: results.flatMap((result) => result.items),
...(errors.length > 0 ? { errors } : {}),
...(results.some((result) => result.hasMore) ? { hasMore: true } : {})
}
}
const LINEAR_GROUP_OPTIONS: { id: LinearGroupBy; label: string }[] = [
{ id: 'none', label: 'No grouping' },
{ id: 'status', label: 'Status' },
@@ -3200,7 +3209,6 @@ export default function TaskPage(): React.JSX.Element {
>(null)
const [linearProjectIssuesLoading, setLinearProjectIssuesLoading] = useState(false)
const [linearProjectIssuesError, setLinearProjectIssuesError] = useState<string | null>(null)
const [linearCustomViewModel, setLinearCustomViewModel] = useState<LinearCustomViewModel>('issue')
const [linearCustomViewsResult, setLinearCustomViewsResult] = useState<
LinearCollectionResult<LinearCustomViewSummary>
>({ items: [] })
@@ -3442,7 +3450,6 @@ export default function TaskPage(): React.JSX.Element {
}
if (context.kind === 'view' && context.model) {
setLinearCustomViewModel(context.model)
setLinearMode('views')
setLinearCustomViewsLoading(true)
setLinearCustomViewsError(null)
@@ -4246,6 +4253,41 @@ export default function TaskPage(): React.JSX.Element {
[jiraIssues, jiraCacheSnapshot.issueCache, jiraCacheSnapshot.searchCache]
)
// New Linear project dialog state
const [newLinearProjectOpen, setNewLinearProjectOpen] = useState(false)
const [newLinearProjectName, setNewLinearProjectName] = useState('')
const [newLinearProjectDescription, setNewLinearProjectDescription] = useState('')
const [newLinearProjectContent, setNewLinearProjectContent] = useState('')
const [newLinearProjectTeamId, setNewLinearProjectTeamId] = useState<string | null>(null)
const [newLinearProjectLeadId, setNewLinearProjectLeadId] = useState<string | null>(null)
const [newLinearProjectMemberIds, setNewLinearProjectMemberIds] = useState<string[]>([])
const [newLinearProjectLabelIds, setNewLinearProjectLabelIds] = useState<string[]>([])
const [newLinearProjectPriority, setNewLinearProjectPriority] = useState<number>(0)
const [newLinearProjectStartDate, setNewLinearProjectStartDate] = useState('')
const [newLinearProjectTargetDate, setNewLinearProjectTargetDate] = useState('')
const [newLinearProjectSubmitting, setNewLinearProjectSubmitting] = useState(false)
const newLinearProjectTargetTeam = useMemo(
() => availableTeams.find((t) => t.id === newLinearProjectTeamId) ?? availableTeams[0] ?? null,
[availableTeams, newLinearProjectTeamId]
)
const newLinearProjectMembers = useTeamMembers(
newLinearProjectOpen ? (newLinearProjectTargetTeam?.id ?? null) : null,
settings,
newLinearProjectTargetTeam?.workspaceId
)
const newLinearProjectLabels = useTeamLabels(
newLinearProjectOpen ? (newLinearProjectTargetTeam?.id ?? null) : null,
settings,
newLinearProjectTargetTeam?.workspaceId
)
useEffect(() => {
setNewLinearProjectLeadId(null)
setNewLinearProjectMemberIds([])
setNewLinearProjectLabelIds([])
}, [newLinearProjectTargetTeam?.id, newLinearProjectTargetTeam?.workspaceId])
// New Linear issue dialog state
const [newLinearIssueOpen, setNewLinearIssueOpen] = useState(false)
const [newLinearIssueTitle, setNewLinearIssueTitle] = useState('')
@@ -4347,6 +4389,7 @@ export default function TaskPage(): React.JSX.Element {
!gitlabDialogItem &&
!selectedLinearIssue &&
!newIssueOpen &&
!newLinearProjectOpen &&
!newLinearIssueOpen &&
!linearConnectOpen &&
activeModal === 'none',
@@ -5112,6 +5155,7 @@ export default function TaskPage(): React.JSX.Element {
githubMode !== 'items' ||
dialogWorkItem ||
newIssueOpen ||
newLinearProjectOpen ||
newLinearIssueOpen ||
newJiraIssueOpen ||
activeModal !== 'none'
@@ -5154,6 +5198,7 @@ export default function TaskPage(): React.JSX.Element {
dialogWorkItem,
githubMode,
newIssueOpen,
newLinearProjectOpen,
newLinearIssueOpen,
newJiraIssueOpen,
taskSource
@@ -5350,6 +5395,81 @@ export default function TaskPage(): React.JSX.Element {
setDialogWorkItem
])
const handleCreateNewLinearProject = useCallback(async (): Promise<void> => {
if (!newLinearProjectTargetTeam) {
return
}
const name = newLinearProjectName.trim()
if (!name || newLinearProjectSubmitting) {
return
}
setNewLinearProjectSubmitting(true)
try {
const result = await linearCreateProject(settings, {
name,
description: newLinearProjectDescription.trim() || undefined,
content: newLinearProjectContent.trim() || undefined,
teamIds: [newLinearProjectTargetTeam.id],
workspaceId: newLinearProjectTargetTeam.workspaceId,
leadId: newLinearProjectLeadId || undefined,
memberIds: newLinearProjectMemberIds.length > 0 ? newLinearProjectMemberIds : undefined,
labelIds: newLinearProjectLabelIds.length > 0 ? newLinearProjectLabelIds : undefined,
priority: newLinearProjectPriority,
startDate: newLinearProjectStartDate || undefined,
targetDate: newLinearProjectTargetDate || undefined
})
if (!result.ok) {
toast.error(result.error || 'Failed to create project.')
return
}
toast.success(`Created ${result.project.name}`, {
action: result.project.url
? {
label: 'View',
onClick: () => window.open(result.project.url, '_blank')
}
: undefined
})
setNewLinearProjectOpen(false)
setNewLinearProjectName('')
setNewLinearProjectDescription('')
setNewLinearProjectContent('')
setNewLinearProjectLeadId(null)
setNewLinearProjectMemberIds([])
setNewLinearProjectLabelIds([])
setNewLinearProjectPriority(0)
setNewLinearProjectStartDate('')
setNewLinearProjectTargetDate('')
setAppliedLinearProjectSearch('')
setLinearProjectSearchInput('')
setLinearProjectsResult((current) => ({
...current,
items: [result.project, ...current.items.filter((item) => item.id !== result.project.id)]
}))
setSelectedLinearProjectDetail(result.project)
openLinearProjectContext(result.project)
setLinearRefreshNonce((n) => n + 1)
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to create project.')
} finally {
setNewLinearProjectSubmitting(false)
}
}, [
newLinearProjectContent,
newLinearProjectDescription,
newLinearProjectLabelIds,
newLinearProjectLeadId,
newLinearProjectMemberIds,
newLinearProjectName,
newLinearProjectPriority,
newLinearProjectStartDate,
newLinearProjectSubmitting,
newLinearProjectTargetDate,
newLinearProjectTargetTeam,
openLinearProjectContext,
settings
])
const handleCreateNewLinearIssue = useCallback(async (): Promise<void> => {
if (!newLinearIssueTargetTeam) {
return
@@ -5884,17 +6004,28 @@ export default function TaskPage(): React.JSX.Element {
return
}
let cancelled = false
const cached = getCachedLinearCustomViews(linearCustomViewModel, LINEAR_ITEM_LIMIT)
if (cached) {
setLinearCustomViewsResult(cached)
const cachedResults = LINEAR_CUSTOM_VIEW_MODELS.map((model) =>
getCachedLinearCustomViews(model, LINEAR_ITEM_LIMIT)
)
const allCached = cachedResults.every(
(result): result is LinearCollectionResult<LinearCustomViewSummary> => result !== null
)
if (allCached) {
setLinearCustomViewsResult(mergeLinearCollectionResults(cachedResults))
}
const force = linearRefreshNonce > 0
setLinearCustomViewsLoading(force || cached === null)
setLinearCustomViewsLoading(force || !allCached)
setLinearCustomViewsError(null)
void listLinearCustomViews(linearCustomViewModel, LINEAR_ITEM_LIMIT, undefined, { force })
// Why: the Views tab already has a Model column, so listing both view
// models avoids a second, redundant Issues/Projects switch.
void Promise.all(
LINEAR_CUSTOM_VIEW_MODELS.map((model) =>
listLinearCustomViews(model, LINEAR_ITEM_LIMIT, undefined, { force })
)
)
.then((result) => {
if (!cancelled) {
setLinearCustomViewsResult(result)
setLinearCustomViewsResult(mergeLinearCollectionResults(result))
setLinearCustomViewsLoading(false)
}
})
@@ -5917,9 +6048,9 @@ export default function TaskPage(): React.JSX.Element {
linearStatus.connected,
selectedLinearWorkspaceId,
selectedLinearCustomView,
linearCustomViewModel,
linearRefreshNonce,
getCachedLinearCustomViews
getCachedLinearCustomViews,
listLinearCustomViews
])
useEffect(() => {
@@ -6755,6 +6886,20 @@ export default function TaskPage(): React.JSX.Element {
variant="outline"
size="icon"
onClick={() => {
if (linearMode === 'projects' && !selectedLinearProject) {
setNewLinearProjectName('')
setNewLinearProjectDescription('')
setNewLinearProjectContent('')
setNewLinearProjectTeamId(availableTeams[0]?.id ?? null)
setNewLinearProjectLeadId(null)
setNewLinearProjectMemberIds([])
setNewLinearProjectLabelIds([])
setNewLinearProjectPriority(0)
setNewLinearProjectStartDate('')
setNewLinearProjectTargetDate('')
setNewLinearProjectOpen(true)
return
}
setNewLinearIssueTitle('')
setNewLinearIssueBody('')
const projectTeamId =
@@ -6770,14 +6915,20 @@ export default function TaskPage(): React.JSX.Element {
setNewLinearIssueOpen(true)
}}
disabled={availableTeams.length === 0}
aria-label="New Linear issue"
aria-label={
linearMode === 'projects' && !selectedLinearProject
? 'New Linear project'
: 'New Linear issue'
}
className="size-8 border-border/50 bg-transparent hover:bg-muted/50 backdrop-blur-md supports-[backdrop-filter]:bg-transparent"
>
<Plus className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
New Linear issue
{linearMode === 'projects' && !selectedLinearProject
? 'New Linear project'
: 'New Linear issue'}
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -6890,38 +7041,6 @@ export default function TaskPage(): React.JSX.Element {
) : null}
</div>
</div>
) : linearMode === 'views' && !selectedLinearCustomView ? (
<div
className="mt-3 flex min-w-0 flex-wrap items-center gap-2"
role="group"
aria-label="Linear view type"
>
{LINEAR_CUSTOM_VIEW_MODEL_OPTIONS.map((option) => {
const active = option.id === linearCustomViewModel
return (
<button
key={option.id}
type="button"
aria-pressed={active}
onClick={() => {
setSelectedLinearCustomView(null)
setLinearCustomViewModel(option.id)
setLinearCustomViewsResult({ items: [] })
setLinearCustomViewsError(null)
setLinearRefreshNonce((n) => n + 1)
}}
className={cn(
'rounded-md border px-2 py-1 text-xs transition',
active
? 'border-border/50 bg-foreground/90 text-background backdrop-blur-md'
: 'border-border/50 bg-transparent text-foreground hover:bg-muted/50'
)}
>
{option.label}
</button>
)
})}
</div>
) : null}
</div>
) : taskSource === 'jira' && jiraStatus.connected ? (
@@ -8256,7 +8375,6 @@ export default function TaskPage(): React.JSX.Element {
) : null}
<LinearCustomViewTable
views={linearCustomViewsResult.items}
model={linearCustomViewModel}
loading={linearCustomViewsLoading}
hasError={!!linearCustomViewsResult.errors?.length}
workspaceSelection={selectedLinearWorkspaceId}
@@ -8272,7 +8390,7 @@ export default function TaskPage(): React.JSX.Element {
errors={linearCustomViewsResult.errors}
hasMore={linearCustomViewsResult.hasMore}
count={linearCustomViewsResult.items.length}
label={`${linearCustomViewModel} views`}
label="views"
/>
</div>
) : selectedLinearCustomView?.model === 'project' && !selectedLinearProject ? (
@@ -9166,6 +9284,440 @@ export default function TaskPage(): React.JSX.Element {
</DialogContent>
</Dialog>
<Dialog
open={newLinearProjectOpen}
onOpenChange={(open) => {
if (!newLinearProjectSubmitting) {
setNewLinearProjectOpen(open)
}
}}
>
<DialogContent
showCloseButton={false}
className="flex max-h-[88vh] flex-col gap-0 overflow-hidden rounded-xl border-border bg-background p-0 shadow-2xl sm:max-w-3xl"
onKeyDown={(event) => {
if (isScreenSubmitShortcut(event)) {
event.preventDefault()
void handleCreateNewLinearProject()
}
}}
>
<DialogTitle className="sr-only">New Linear project</DialogTitle>
<DialogDescription className="sr-only">
Create a Linear project for the selected team.
</DialogDescription>
<div className="flex items-center justify-between border-b border-border/60 bg-muted/10 px-5 py-3">
<div className="flex min-w-0 items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
New Project
</span>
<span className="text-xs text-muted-foreground/40">/</span>
{availableTeams.length > 1 ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="xs"
className="h-7 max-w-56 gap-1 px-2 text-xs font-medium text-foreground hover:bg-muted"
>
<span className="truncate">
{newLinearProjectTargetTeam
? `${newLinearProjectTargetTeam.key} - ${newLinearProjectTargetTeam.name}`
: 'Select team'}
</span>
<ChevronDown className="size-3 flex-none text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72 p-1">
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Team
</div>
<div className="max-h-64 overflow-y-auto scrollbar-sleek">
{availableTeams.map((team) => (
<button
key={team.id}
type="button"
onClick={() => setNewLinearProjectTeamId(team.id)}
className={cn(
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted',
newLinearProjectTargetTeam?.id === team.id
? 'bg-muted font-medium text-foreground'
: 'text-foreground/80'
)}
>
<span className="truncate">
{team.key} - {team.name}
</span>
{newLinearProjectTargetTeam?.id === team.id ? (
<Check className="size-3 flex-none" />
) : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
) : (
<span className="truncate text-xs font-medium text-foreground">
{newLinearProjectTargetTeam
? `${newLinearProjectTargetTeam.key} - ${newLinearProjectTargetTeam.name}`
: ''}
</span>
)}
</div>
<button
type="button"
onClick={() => setNewLinearProjectOpen(false)}
className="rounded-md p-1 text-muted-foreground transition-colors hover:text-foreground"
disabled={newLinearProjectSubmitting}
aria-label="Close"
>
<X className="size-4" />
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-6 py-5 scrollbar-sleek">
<input
autoFocus
value={newLinearProjectName}
onChange={(event) => setNewLinearProjectName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault()
void handleCreateNewLinearProject()
}
}}
placeholder="Project name"
disabled={newLinearProjectSubmitting}
className="w-full border-none bg-transparent p-0 text-xl font-semibold text-foreground outline-none placeholder:text-muted-foreground/45 focus:outline-none focus:ring-0 focus-visible:ring-0"
/>
<input
value={newLinearProjectDescription}
onChange={(event) => setNewLinearProjectDescription(event.target.value)}
placeholder="Add a short summary..."
disabled={newLinearProjectSubmitting}
className="w-full border-none bg-transparent p-0 text-sm text-foreground outline-none placeholder:text-muted-foreground/45 focus:outline-none focus:ring-0 focus-visible:ring-0"
/>
<div className="flex flex-wrap items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={newLinearProjectSubmitting}
className="flex items-center gap-1.5 rounded-md border border-border/80 bg-muted/15 px-2 py-1 text-xs text-foreground/80 transition-colors hover:bg-muted/50 active:bg-muted disabled:opacity-50"
>
<LinearPriorityIcon priority={newLinearProjectPriority} className="size-3.5" />
<span>{getLinearPriorityLabel(newLinearProjectPriority)}</span>
<ChevronDown className="size-3 text-muted-foreground/70" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-48 p-1">
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Priority
</div>
{[0, 1, 2, 3, 4].map((priority) => (
<button
key={priority}
type="button"
onClick={() => setNewLinearProjectPriority(priority)}
className={cn(
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted',
newLinearProjectPriority === priority
? 'bg-muted font-medium text-foreground'
: 'text-foreground/80'
)}
>
<span className="flex items-center gap-2">
<LinearPriorityIcon priority={priority} className="size-3.5" />
{getLinearPriorityLabel(priority)}
</span>
{newLinearProjectPriority === priority ? (
<Check className="size-3 text-foreground" />
) : null}
</button>
))}
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={newLinearProjectSubmitting}
className="flex items-center gap-1.5 rounded-md border border-border/80 bg-muted/15 px-2 py-1 text-xs text-foreground/80 transition-colors hover:bg-muted/50 active:bg-muted disabled:opacity-50"
>
<UserRound className="size-3.5 text-muted-foreground/70" />
<span className="max-w-[120px] truncate">
{newLinearProjectMembers.data.find(
(member) => member.id === newLinearProjectLeadId
)?.displayName ?? 'Lead'}
</span>
<ChevronDown className="size-3 text-muted-foreground/70" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-1">
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Lead
</div>
{newLinearProjectMembers.loading ? (
<div className="flex items-center justify-center p-4">
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="max-h-64 overflow-y-auto scrollbar-sleek">
<button
type="button"
onClick={() => setNewLinearProjectLeadId(null)}
className={cn(
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted',
newLinearProjectLeadId === null
? 'bg-muted font-medium text-foreground'
: 'text-foreground/80'
)}
>
<span className="flex items-center gap-2">
<UserRound className="size-3.5 text-muted-foreground/50" />
No lead
</span>
{newLinearProjectLeadId === null ? <Check className="size-3" /> : null}
</button>
{newLinearProjectMembers.data.map((member) => (
<button
key={member.id}
type="button"
onClick={() => setNewLinearProjectLeadId(member.id)}
className={cn(
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted',
newLinearProjectLeadId === member.id
? 'bg-muted font-medium text-foreground'
: 'text-foreground/80'
)}
>
<span className="flex min-w-0 items-center gap-2">
{member.avatarUrl ? (
<img
src={member.avatarUrl}
alt={member.displayName}
className="size-3.5 flex-none rounded-full"
/>
) : (
<UserRound className="size-3.5 flex-none text-muted-foreground/70" />
)}
<span className="truncate">{member.displayName}</span>
</span>
{newLinearProjectLeadId === member.id ? (
<Check className="size-3 flex-none" />
) : null}
</button>
))}
</div>
)}
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={newLinearProjectSubmitting}
className="flex items-center gap-1.5 rounded-md border border-border/80 bg-muted/15 px-2 py-1 text-xs text-foreground/80 transition-colors hover:bg-muted/50 active:bg-muted disabled:opacity-50"
>
<Users className="size-3.5 text-muted-foreground/70" />
<span>
{newLinearProjectMemberIds.length === 0
? 'Members'
: `${newLinearProjectMemberIds.length} member${newLinearProjectMemberIds.length > 1 ? 's' : ''}`}
</span>
<ChevronDown className="size-3 text-muted-foreground/70" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-1">
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Members
</div>
{newLinearProjectMembers.loading ? (
<div className="flex items-center justify-center p-4">
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="max-h-64 overflow-y-auto scrollbar-sleek">
{newLinearProjectMembers.data.map((member) => {
const selected = newLinearProjectMemberIds.includes(member.id)
return (
<button
key={member.id}
type="button"
onClick={() =>
setNewLinearProjectMemberIds((current) =>
selected
? current.filter((id) => id !== member.id)
: [...current, member.id]
)
}
className={cn(
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted',
selected
? 'bg-muted font-medium text-foreground'
: 'text-foreground/80'
)}
>
<span className="flex min-w-0 items-center gap-2">
{member.avatarUrl ? (
<img
src={member.avatarUrl}
alt={member.displayName}
className="size-3.5 flex-none rounded-full"
/>
) : (
<UserRound className="size-3.5 flex-none text-muted-foreground/70" />
)}
<span className="truncate">{member.displayName}</span>
</span>
{selected ? <Check className="size-3 flex-none" /> : null}
</button>
)
})}
</div>
)}
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={newLinearProjectSubmitting}
className="flex items-center gap-1.5 rounded-md border border-border/80 bg-muted/15 px-2 py-1 text-xs text-foreground/80 transition-colors hover:bg-muted/50 active:bg-muted disabled:opacity-50"
>
<Tag className="size-3.5 text-muted-foreground/70" />
<span>
{newLinearProjectLabelIds.length === 0
? 'Labels'
: `${newLinearProjectLabelIds.length} label${newLinearProjectLabelIds.length > 1 ? 's' : ''}`}
</span>
<ChevronDown className="size-3 text-muted-foreground/70" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-1">
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Labels
</div>
{newLinearProjectLabels.loading ? (
<div className="flex items-center justify-center p-4">
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="max-h-64 overflow-y-auto scrollbar-sleek">
{newLinearProjectLabels.data.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">No labels</div>
) : (
newLinearProjectLabels.data.map((label) => {
const selected = newLinearProjectLabelIds.includes(label.id)
return (
<button
key={label.id}
type="button"
onClick={() =>
setNewLinearProjectLabelIds((current) =>
selected
? current.filter((id) => id !== label.id)
: [...current, label.id]
)
}
className={cn(
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted',
selected
? 'bg-muted font-medium text-foreground'
: 'text-foreground/80'
)}
>
<span className="flex min-w-0 items-center gap-2">
<span
className="size-2 flex-none rounded-full bg-muted-foreground/40"
style={label.color ? { backgroundColor: label.color } : undefined}
/>
<span className="truncate">{label.name}</span>
</span>
{selected ? <Check className="size-3 flex-none" /> : null}
</button>
)
})
)}
</div>
)}
</PopoverContent>
</Popover>
<label className="flex cursor-pointer items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2 py-1 text-xs text-foreground transition-colors hover:bg-muted/50 has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50">
<Clock3 className="size-3.5 shrink-0 text-muted-foreground" />
<span className="shrink-0 text-muted-foreground">Start</span>
<input
type="date"
value={newLinearProjectStartDate}
onChange={(event) => setNewLinearProjectStartDate(event.target.value)}
disabled={newLinearProjectSubmitting}
className="h-5 min-w-[6.75rem] cursor-pointer border-none bg-transparent p-0 text-xs text-foreground outline-none disabled:cursor-not-allowed"
aria-label="Start date"
/>
</label>
<label className="flex cursor-pointer items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2 py-1 text-xs text-foreground transition-colors hover:bg-muted/50 has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50">
<Clock3 className="size-3.5 shrink-0 text-muted-foreground" />
<span className="shrink-0 text-muted-foreground">Target</span>
<input
type="date"
value={newLinearProjectTargetDate}
onChange={(event) => setNewLinearProjectTargetDate(event.target.value)}
disabled={newLinearProjectSubmitting}
className="h-5 min-w-[6.75rem] cursor-pointer border-none bg-transparent p-0 text-xs text-foreground outline-none disabled:cursor-not-allowed"
aria-label="Target date"
/>
</label>
</div>
<div className="border-t border-border/40 pt-4">
<textarea
value={newLinearProjectContent}
onChange={(event) => setNewLinearProjectContent(event.target.value)}
placeholder="Write a description, project brief, or collect ideas..."
rows={8}
disabled={newLinearProjectSubmitting}
className="max-h-72 min-h-40 w-full min-w-0 resize-none overflow-y-auto border-none bg-transparent p-0 text-sm text-foreground outline-none placeholder:text-muted-foreground/45 scrollbar-sleek focus:outline-none focus:ring-0 focus-visible:ring-0"
/>
</div>
<p className="text-[10px] text-muted-foreground">{submitShortcutLabel} to submit.</p>
</div>
<DialogFooter className="border-t border-border/60 bg-muted/10 px-5 py-3">
<Button
variant="outline"
onClick={() => setNewLinearProjectOpen(false)}
disabled={newLinearProjectSubmitting}
>
Cancel
</Button>
<Button
onClick={() => void handleCreateNewLinearProject()}
disabled={
!newLinearProjectTargetTeam ||
!newLinearProjectName.trim() ||
newLinearProjectSubmitting
}
>
{newLinearProjectSubmitting ? (
<>
<LoaderCircle className="size-4 animate-spin" />
Creating...
</>
) : (
'Create project'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={newLinearIssueOpen}
onOpenChange={(open) => {
@@ -20,7 +20,6 @@ import { Progress } from '@/components/ui/progress'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type {
LinearCustomViewModel,
LinearCustomViewSummary,
LinearProjectDetail,
LinearProjectSummary,
@@ -56,7 +55,6 @@ type LinearProjectTableProps = {
type LinearCustomViewTableProps = {
views: LinearCustomViewSummary[]
model: LinearCustomViewModel
loading: boolean
hasError?: boolean
selectedViewId?: string | null
@@ -388,7 +386,6 @@ export function LinearProjectTable({
export function LinearCustomViewTable({
views,
model,
loading,
hasError,
selectedViewId,
@@ -420,7 +417,7 @@ export function LinearCustomViewTable({
return (
<div className="px-4 py-10 text-center">
<p className="text-sm font-medium text-foreground">
{hasError ? `Unable to load ${model} views` : `No ${model} views found`}
{hasError ? 'Unable to load views' : 'No views found'}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{hasError
@@ -3,6 +3,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
linearCreateIssue,
linearCreateProject,
linearCreateSubIssue,
linearGetCustomView,
linearGetProject,
@@ -30,6 +31,7 @@ const linearStatusLocal = vi.fn()
const linearSearchIssuesLocal = vi.fn()
const linearListIssuesLocal = vi.fn()
const linearCreateIssueLocal = vi.fn()
const linearCreateProjectLocal = vi.fn()
const linearUpdateIssueLocal = vi.fn()
const linearListTeamsLocal = vi.fn()
const linearListProjectsLocal = vi.fn()
@@ -49,6 +51,7 @@ beforeEach(() => {
linearSearchIssuesLocal.mockReset()
linearListIssuesLocal.mockReset()
linearCreateIssueLocal.mockReset()
linearCreateProjectLocal.mockReset()
linearUpdateIssueLocal.mockReset()
linearListTeamsLocal.mockReset()
linearListProjectsLocal.mockReset()
@@ -70,6 +73,7 @@ beforeEach(() => {
searchIssues: linearSearchIssuesLocal,
listIssues: linearListIssuesLocal,
createIssue: linearCreateIssueLocal,
createProject: linearCreateProjectLocal,
updateIssue: linearUpdateIssueLocal,
listTeams: linearListTeamsLocal,
listProjects: linearListProjectsLocal,
@@ -97,6 +101,7 @@ describe('runtime linear client', () => {
title: 'Child task',
url: 'https://linear.app/ENG-2'
})
linearCreateProjectLocal.mockResolvedValue({ ok: true, project: { id: 'project-1' } })
await expect(linearStatus({ activeRuntimeEnvironmentId: null })).resolves.toEqual({
connected: false,
@@ -112,6 +117,10 @@ describe('runtime linear client', () => {
{ activeRuntimeEnvironmentId: null },
{ parentIssueId: 'issue-1', teamId: 'team-1', title: 'Child task' }
)
await linearCreateProject(
{ activeRuntimeEnvironmentId: null },
{ name: 'Roadmap', teamIds: ['team-1'], workspaceId: 'workspace-1' }
)
expect(linearStatusLocal).toHaveBeenCalled()
expect(linearSearchIssuesLocal).toHaveBeenCalledWith({
@@ -129,6 +138,11 @@ describe('runtime linear client', () => {
teamId: 'team-1',
title: 'Child task'
})
expect(linearCreateProjectLocal).toHaveBeenCalledWith({
name: 'Roadmap',
teamIds: ['team-1'],
workspaceId: 'workspace-1'
})
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
@@ -370,6 +384,12 @@ describe('runtime linear client', () => {
result: { items: [{ id: 'project-1' }] },
_meta: { runtimeId: 'runtime-1' }
})
.mockResolvedValueOnce({
id: 'rpc-create-project',
ok: true,
result: { ok: true, project: { id: 'project-2' } },
_meta: { runtimeId: 'runtime-1' }
})
.mockResolvedValueOnce({
id: 'rpc-select',
ok: true,
@@ -399,6 +419,16 @@ describe('runtime linear client', () => {
)
await linearListTeams({ activeRuntimeEnvironmentId: 'env-1' }, 'all')
await linearListProjects({ activeRuntimeEnvironmentId: 'env-1' }, 'roadmap', 10, 'workspace-1')
await linearCreateProject(
{ activeRuntimeEnvironmentId: 'env-1' },
{
name: 'Roadmap',
description: 'Summary',
teamIds: ['team-1'],
workspaceId: 'workspace-1',
priority: 2
}
)
await linearSelectWorkspace({ activeRuntimeEnvironmentId: 'env-1' }, 'workspace-1')
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, {
@@ -442,6 +472,18 @@ describe('runtime linear client', () => {
timeoutMs: 30_000
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(6, {
selector: 'env-1',
method: 'linear.createProject',
params: {
name: 'Roadmap',
description: 'Summary',
teamIds: ['team-1'],
workspaceId: 'workspace-1',
priority: 2
},
timeoutMs: 30_000
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(7, {
selector: 'env-1',
method: 'linear.selectWorkspace',
params: { workspaceId: 'workspace-1' },
@@ -31,6 +31,9 @@ export type LinearConnectResult = { ok: true; viewer: LinearViewer } | { ok: fal
export type LinearCreateIssueResult =
| { ok: true; id: string; identifier: string; title: string; url: string }
| { ok: false; error: string }
export type LinearCreateProjectResult =
| { ok: true; project: LinearProjectDetail }
| { ok: false; error: string }
export type LinearMutationResult = { ok: true } | { ok: false; error: string }
export type LinearCommentResult = { ok: true; id: string } | { ok: false; error: string }
export type LinearReadOptions = { force?: boolean }
@@ -323,6 +326,30 @@ export async function linearListProjects(
: { items: [] }
}
export async function linearCreateProject(
settings: RuntimeLinearSettings,
args: {
name: string
description?: string
content?: string
teamIds: string[]
workspaceId?: string
leadId?: string | null
memberIds?: string[]
labelIds?: string[]
priority?: number
startDate?: string
targetDate?: string
}
): Promise<LinearCreateProjectResult> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCreateProjectResult>(target, 'linear.createProject', args, {
timeoutMs: 30_000
})
: window.api.linear.createProject(args)
}
export async function linearGetProject(
settings: RuntimeLinearSettings,
id: string,