Add Linear project and custom view browsing (#3966)
* Add Linear project and custom view browsing * fix: address review findings
This commit is contained in:
parent
dbfb947faf
commit
ab3e1b8bc5
|
|
@ -1,3 +1,5 @@
|
|||
/* eslint-disable max-lines -- Why: Linear IPC validates one namespace in one
|
||||
registration boundary so local and SSH runtime schemas can stay mirrored. */
|
||||
import { ipcMain } from 'electron'
|
||||
import { connect, disconnect, getStatus, selectWorkspace, testConnection } from '../linear/client'
|
||||
import { _resetPreflightCache } from './preflight'
|
||||
|
|
@ -10,10 +12,22 @@ import {
|
|||
addIssueComment,
|
||||
getIssueComments
|
||||
} from '../linear/issues'
|
||||
import { listProjects } from '../linear/projects'
|
||||
import {
|
||||
getCustomView,
|
||||
getProject,
|
||||
listCustomViewIssues,
|
||||
listCustomViewProjects,
|
||||
listCustomViews,
|
||||
listProjectIssues,
|
||||
listProjects
|
||||
} from '../linear/projects'
|
||||
import { listTeams, getTeamStates, getTeamLabels, getTeamMembers } from '../linear/teams'
|
||||
import type { LinearListFilter } from '../linear/issues'
|
||||
import type { LinearIssueUpdate, LinearWorkspaceSelection } from '../../shared/types'
|
||||
import type {
|
||||
LinearCustomViewModel,
|
||||
LinearIssueUpdate,
|
||||
LinearWorkspaceSelection
|
||||
} from '../../shared/types'
|
||||
|
||||
const VALID_FILTERS = new Set<LinearListFilter>(['assigned', 'created', 'all', 'completed'])
|
||||
|
||||
|
|
@ -26,6 +40,21 @@ function normalizeWorkspaceSelection(value: unknown): LinearWorkspaceSelection |
|
|||
return workspaceId as LinearWorkspaceSelection | undefined
|
||||
}
|
||||
|
||||
function normalizeConcreteWorkspaceId(value: unknown): string {
|
||||
const workspaceId = normalizeWorkspaceId(value)
|
||||
if (!workspaceId || workspaceId === 'all') {
|
||||
throw new Error('Concrete Linear workspace ID is required')
|
||||
}
|
||||
return workspaceId
|
||||
}
|
||||
|
||||
function normalizeCustomViewModel(value: unknown): LinearCustomViewModel {
|
||||
if (value !== 'issue' && value !== 'project') {
|
||||
throw new Error('Custom view model is required')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function registerLinearHandlers(): void {
|
||||
ipcMain.handle('linear:connect', async (_event, args: { apiKey: string }) => {
|
||||
if (typeof args?.apiKey !== 'string' || !args.apiKey.trim()) {
|
||||
|
|
@ -244,6 +273,97 @@ export function registerLinearHandlers(): void {
|
|||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'linear:getProject',
|
||||
async (_event, args: { id: string; workspaceId?: string }) => {
|
||||
if (typeof args?.id !== 'string' || !args.id.trim()) {
|
||||
throw new Error('Project ID is required')
|
||||
}
|
||||
return getProject(args.id.trim(), normalizeConcreteWorkspaceId(args.workspaceId))
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'linear:listProjectIssues',
|
||||
async (_event, args: { projectId: string; limit?: number; workspaceId?: string }) => {
|
||||
if (typeof args?.projectId !== 'string' || !args.projectId.trim()) {
|
||||
throw new Error('Project ID is required')
|
||||
}
|
||||
const limit = Math.min(Math.max(1, args?.limit ?? 20), 50)
|
||||
return listProjectIssues(
|
||||
args.projectId.trim(),
|
||||
limit,
|
||||
normalizeConcreteWorkspaceId(args.workspaceId)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'linear:listCustomViews',
|
||||
async (
|
||||
_event,
|
||||
args?: {
|
||||
model?: LinearCustomViewModel
|
||||
limit?: number
|
||||
workspaceId?: LinearWorkspaceSelection
|
||||
}
|
||||
) => {
|
||||
const limit = Math.min(Math.max(1, args?.limit ?? 20), 50)
|
||||
return listCustomViews(
|
||||
normalizeCustomViewModel(args?.model),
|
||||
limit,
|
||||
normalizeWorkspaceSelection(args?.workspaceId)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'linear:getCustomView',
|
||||
async (
|
||||
_event,
|
||||
args: { viewId: string; model?: LinearCustomViewModel; workspaceId?: string }
|
||||
) => {
|
||||
if (typeof args?.viewId !== 'string' || !args.viewId.trim()) {
|
||||
throw new Error('Custom view ID is required')
|
||||
}
|
||||
return getCustomView(
|
||||
args.viewId.trim(),
|
||||
normalizeCustomViewModel(args.model),
|
||||
normalizeConcreteWorkspaceId(args.workspaceId)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'linear:listCustomViewIssues',
|
||||
async (_event, args: { viewId: string; limit?: number; workspaceId?: string }) => {
|
||||
if (typeof args?.viewId !== 'string' || !args.viewId.trim()) {
|
||||
throw new Error('Custom view ID is required')
|
||||
}
|
||||
const limit = Math.min(Math.max(1, args?.limit ?? 20), 50)
|
||||
return listCustomViewIssues(
|
||||
args.viewId.trim(),
|
||||
limit,
|
||||
normalizeConcreteWorkspaceId(args.workspaceId)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'linear:listCustomViewProjects',
|
||||
async (_event, args: { viewId: string; limit?: number; workspaceId?: string }) => {
|
||||
if (typeof args?.viewId !== 'string' || !args.viewId.trim()) {
|
||||
throw new Error('Custom view ID is required')
|
||||
}
|
||||
const limit = Math.min(Math.max(1, args?.limit ?? 20), 50)
|
||||
return listCustomViewProjects(
|
||||
args.viewId.trim(),
|
||||
limit,
|
||||
normalizeConcreteWorkspaceId(args.workspaceId)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'linear:teamStates',
|
||||
async (_event, args: { teamId: string; workspaceId?: string }) => {
|
||||
|
|
|
|||
|
|
@ -1,53 +1,951 @@
|
|||
import type { Project, ProjectSearchResult } from '@linear/sdk'
|
||||
import type { LinearProjectSummary, LinearWorkspaceSelection } from '../../shared/types'
|
||||
import { acquire, clearToken, getClients, isAuthError, release } from './client'
|
||||
/* eslint-disable max-lines -- Why: Linear project and custom-view reads share
|
||||
raw GraphQL selection sets, workspace fan-out, and partial-failure mapping. */
|
||||
import type {
|
||||
LinearCollectionResult,
|
||||
LinearConcreteWorkspaceId,
|
||||
LinearCustomViewModel,
|
||||
LinearCustomViewSummary,
|
||||
LinearIssue,
|
||||
LinearProjectDetail,
|
||||
LinearProjectMemberSummary,
|
||||
LinearProjectSummary,
|
||||
LinearWorkspaceError,
|
||||
LinearWorkspaceSelection
|
||||
} from '../../shared/types'
|
||||
import {
|
||||
acquire,
|
||||
clearToken,
|
||||
getClients,
|
||||
isAuthError,
|
||||
release,
|
||||
type LinearClientForWorkspace
|
||||
} from './client'
|
||||
|
||||
function mapLinearProject(project: Project | ProjectSearchResult): LinearProjectSummary {
|
||||
type LinearRawVariables = Record<string, unknown>
|
||||
|
||||
type PageInfoNode = {
|
||||
hasNextPage?: boolean | null
|
||||
}
|
||||
|
||||
type LinearConnection<T> = {
|
||||
nodes?: T[] | null
|
||||
pageInfo?: PageInfoNode | null
|
||||
}
|
||||
|
||||
type LinearUserNode = {
|
||||
id: string
|
||||
displayName?: string | null
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
type LinearProjectNode = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
content?: string | null
|
||||
url?: string | null
|
||||
color?: string | null
|
||||
icon?: string | null
|
||||
health?: string | null
|
||||
priority?: number | null
|
||||
priorityLabel?: string | null
|
||||
progress?: number | null
|
||||
scope?: number | null
|
||||
issueCountHistory?: number[] | null
|
||||
completedIssueCountHistory?: number[] | null
|
||||
startDate?: string | null
|
||||
targetDate?: string | null
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
completedAt?: string | null
|
||||
canceledAt?: string | null
|
||||
startedAt?: string | null
|
||||
status?: {
|
||||
id: string
|
||||
name?: string | null
|
||||
type?: string | null
|
||||
color?: string | null
|
||||
} | null
|
||||
lead?: LinearUserNode | null
|
||||
members?: LinearConnection<LinearUserNode> | null
|
||||
teams?: LinearConnection<{ id: string; name?: string | null; key?: string | null }> | null
|
||||
labels?: LinearConnection<{ id: string; name?: string | null; color?: string | null }> | null
|
||||
projectMilestones?: LinearConnection<{
|
||||
id: string
|
||||
name?: string | null
|
||||
status?: string | null
|
||||
targetDate?: string | null
|
||||
progress?: number | null
|
||||
}> | null
|
||||
externalLinks?: LinearConnection<{
|
||||
id: string
|
||||
label?: string | null
|
||||
url?: string | null
|
||||
}> | null
|
||||
lastUpdate?: {
|
||||
id: string
|
||||
body?: string | null
|
||||
health?: string | null
|
||||
url?: string | null
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
user?: LinearUserNode | null
|
||||
} | null
|
||||
}
|
||||
|
||||
type LinearIssueNode = {
|
||||
id: string
|
||||
identifier: string
|
||||
title: string
|
||||
description?: string | null
|
||||
url: string
|
||||
estimate?: number | null
|
||||
priority: number
|
||||
updatedAt: string
|
||||
labelIds?: string[] | null
|
||||
state?: {
|
||||
name?: string | null
|
||||
type?: string | null
|
||||
color?: string | null
|
||||
} | null
|
||||
team?: {
|
||||
id?: string | null
|
||||
name?: string | null
|
||||
key?: string | null
|
||||
} | null
|
||||
assignee?: LinearUserNode | null
|
||||
labels?: LinearConnection<{ id: string; name: string }> | null
|
||||
}
|
||||
|
||||
type LinearCustomViewNode = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
modelName?: string | null
|
||||
color?: string | null
|
||||
icon?: string | null
|
||||
shared?: boolean | null
|
||||
slugId?: string | null
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
team?: { id: string; name?: string | null; key?: string | null } | null
|
||||
owner?: LinearUserNode | null
|
||||
creator?: LinearUserNode | null
|
||||
}
|
||||
|
||||
type ProjectConnectionResponse = {
|
||||
projects?: LinearConnection<LinearProjectNode> | null
|
||||
searchProjects?: LinearConnection<LinearProjectNode> | null
|
||||
project?: LinearProjectNode | null
|
||||
}
|
||||
|
||||
type ProjectIssueConnectionResponse = {
|
||||
project?: {
|
||||
issues?: LinearConnection<LinearIssueNode> | null
|
||||
} | null
|
||||
}
|
||||
|
||||
type CustomViewConnectionResponse = {
|
||||
customViews?: LinearConnection<LinearCustomViewNode> | null
|
||||
customView?:
|
||||
| (LinearCustomViewNode & {
|
||||
issues?: LinearConnection<LinearIssueNode> | null
|
||||
projects?: LinearConnection<LinearProjectNode> | null
|
||||
})
|
||||
| null
|
||||
}
|
||||
|
||||
const ORCA_PROJECT_FIELDS = `
|
||||
id
|
||||
name
|
||||
description
|
||||
content
|
||||
url
|
||||
color
|
||||
icon
|
||||
health
|
||||
priority
|
||||
priorityLabel
|
||||
progress
|
||||
scope
|
||||
issueCountHistory
|
||||
completedIssueCountHistory
|
||||
startDate
|
||||
targetDate
|
||||
createdAt
|
||||
updatedAt
|
||||
completedAt
|
||||
canceledAt
|
||||
startedAt
|
||||
status {
|
||||
id
|
||||
name
|
||||
type
|
||||
color
|
||||
}
|
||||
lead {
|
||||
id
|
||||
displayName
|
||||
avatarUrl
|
||||
}
|
||||
members(first: 10) {
|
||||
nodes {
|
||||
id
|
||||
displayName
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
teams(first: 10) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
key
|
||||
}
|
||||
}
|
||||
labels(first: 20) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
color
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const ORCA_PROJECT_DETAIL_FIELDS = `
|
||||
${ORCA_PROJECT_FIELDS}
|
||||
projectMilestones(first: 20) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
status
|
||||
targetDate
|
||||
progress
|
||||
}
|
||||
}
|
||||
externalLinks(first: 20) {
|
||||
nodes {
|
||||
id
|
||||
label
|
||||
url
|
||||
}
|
||||
}
|
||||
lastUpdate {
|
||||
id
|
||||
body
|
||||
health
|
||||
url
|
||||
createdAt
|
||||
updatedAt
|
||||
user {
|
||||
id
|
||||
displayName
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const ORCA_ISSUE_FIELDS = `
|
||||
id
|
||||
identifier
|
||||
title
|
||||
description
|
||||
url
|
||||
priority
|
||||
estimate
|
||||
updatedAt
|
||||
labelIds
|
||||
state {
|
||||
name
|
||||
type
|
||||
color
|
||||
}
|
||||
team {
|
||||
id
|
||||
name
|
||||
key
|
||||
}
|
||||
assignee {
|
||||
id
|
||||
displayName
|
||||
avatarUrl
|
||||
}
|
||||
labels(first: 50) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const PROJECTS_QUERY = `
|
||||
query OrcaLinearProjects($first: Int, $filter: ProjectFilter, $orderBy: PaginationOrderBy) {
|
||||
projects(first: $first, filter: $filter, orderBy: $orderBy) {
|
||||
nodes {
|
||||
${ORCA_PROJECT_FIELDS}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const SEARCH_PROJECTS_QUERY = `
|
||||
query OrcaLinearProjectSearch($term: String!, $first: Int) {
|
||||
searchProjects(term: $term, first: $first) {
|
||||
nodes {
|
||||
${ORCA_PROJECT_FIELDS}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const PROJECT_QUERY = `
|
||||
query OrcaLinearProject($id: String!) {
|
||||
project(id: $id) {
|
||||
${ORCA_PROJECT_DETAIL_FIELDS}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const PROJECT_ISSUES_QUERY = `
|
||||
query OrcaLinearProjectIssues($id: String!, $first: Int, $orderBy: PaginationOrderBy) {
|
||||
project(id: $id) {
|
||||
issues(first: $first, orderBy: $orderBy) {
|
||||
nodes {
|
||||
${ORCA_ISSUE_FIELDS}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const CUSTOM_VIEWS_QUERY = `
|
||||
query OrcaLinearCustomViews(
|
||||
$first: Int,
|
||||
$filter: CustomViewFilter,
|
||||
$orderBy: PaginationOrderBy
|
||||
) {
|
||||
customViews(first: $first, filter: $filter, orderBy: $orderBy) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
description
|
||||
modelName
|
||||
color
|
||||
icon
|
||||
shared
|
||||
slugId
|
||||
createdAt
|
||||
updatedAt
|
||||
team {
|
||||
id
|
||||
name
|
||||
key
|
||||
}
|
||||
owner {
|
||||
id
|
||||
displayName
|
||||
avatarUrl
|
||||
}
|
||||
creator {
|
||||
id
|
||||
displayName
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const CUSTOM_VIEW_QUERY = `
|
||||
query OrcaLinearCustomView($id: String!) {
|
||||
customView(id: $id) {
|
||||
id
|
||||
name
|
||||
description
|
||||
modelName
|
||||
color
|
||||
icon
|
||||
shared
|
||||
slugId
|
||||
createdAt
|
||||
updatedAt
|
||||
team {
|
||||
id
|
||||
name
|
||||
key
|
||||
}
|
||||
owner {
|
||||
id
|
||||
displayName
|
||||
avatarUrl
|
||||
}
|
||||
creator {
|
||||
id
|
||||
displayName
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const CUSTOM_VIEW_ISSUES_QUERY = `
|
||||
query OrcaLinearCustomViewIssues($id: String!, $first: Int, $orderBy: PaginationOrderBy) {
|
||||
customView(id: $id) {
|
||||
id
|
||||
modelName
|
||||
issues(first: $first, orderBy: $orderBy) {
|
||||
nodes {
|
||||
${ORCA_ISSUE_FIELDS}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const CUSTOM_VIEW_PROJECTS_QUERY = `
|
||||
query OrcaLinearCustomViewProjects($id: String!, $first: Int, $orderBy: PaginationOrderBy) {
|
||||
customView(id: $id) {
|
||||
id
|
||||
modelName
|
||||
projects(first: $first, orderBy: $orderBy) {
|
||||
nodes {
|
||||
${ORCA_PROJECT_FIELDS}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const inFlight = new Map<string, Promise<unknown>>()
|
||||
|
||||
function clampLimit(limit = 20): number {
|
||||
return Math.min(Math.max(1, Math.floor(limit)), 50)
|
||||
}
|
||||
|
||||
function coalesce<T>(key: string, load: () => Promise<T>): Promise<T> {
|
||||
const existing = inFlight.get(key) as Promise<T> | undefined
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const promise = load().finally(() => inFlight.delete(key))
|
||||
inFlight.set(key, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
function normalizeConcreteWorkspaceId(workspaceId: unknown): LinearConcreteWorkspaceId {
|
||||
if (typeof workspaceId !== 'string' || !workspaceId.trim() || workspaceId === 'all') {
|
||||
throw new Error('Concrete Linear workspace ID is required')
|
||||
}
|
||||
return workspaceId.trim()
|
||||
}
|
||||
|
||||
function workspaceError(entry: LinearClientForWorkspace, error: unknown): LinearWorkspaceError {
|
||||
if (isAuthError(error)) {
|
||||
return {
|
||||
workspaceId: entry.workspace.id,
|
||||
workspaceName: entry.workspace.organizationName,
|
||||
type: 'auth',
|
||||
message: 'Linear authentication expired for this workspace.'
|
||||
}
|
||||
}
|
||||
|
||||
const record = error as { name?: string; message?: string; status?: number; response?: unknown }
|
||||
const message = record.message || 'Linear request failed.'
|
||||
const status =
|
||||
typeof record.status === 'number'
|
||||
? record.status
|
||||
: typeof (record.response as { status?: unknown } | undefined)?.status === 'number'
|
||||
? ((record.response as { status: number }).status as number)
|
||||
: undefined
|
||||
const name = record.name ?? ''
|
||||
|
||||
if (status === 429 || /rate/i.test(name)) {
|
||||
return {
|
||||
workspaceId: entry.workspace.id,
|
||||
workspaceName: entry.workspace.organizationName,
|
||||
type: 'rate_limited',
|
||||
message
|
||||
}
|
||||
}
|
||||
if ((typeof status === 'number' && status >= 500) || /network/i.test(name)) {
|
||||
return {
|
||||
workspaceId: entry.workspace.id,
|
||||
workspaceName: entry.workspace.organizationName,
|
||||
type: 'network',
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
workspaceId: entry.workspace.id,
|
||||
workspaceName: entry.workspace.organizationName,
|
||||
type: 'unknown',
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
function shouldFailWholeRequest(selection: LinearWorkspaceSelection | null | undefined): boolean {
|
||||
return selection !== 'all'
|
||||
}
|
||||
|
||||
function lastNumericValue(values?: number[] | null): number | undefined {
|
||||
const last = values?.at(-1)
|
||||
return typeof last === 'number' ? last : undefined
|
||||
}
|
||||
|
||||
function mapUser(user?: LinearUserNode | null): LinearProjectMemberSummary | undefined {
|
||||
if (!user?.id) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
id: user.id,
|
||||
displayName: user.displayName ?? '',
|
||||
avatarUrl: user.avatarUrl ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
function mapProjectForWorkspace(
|
||||
entry: LinearClientForWorkspace,
|
||||
project: LinearProjectNode
|
||||
): LinearProjectSummary {
|
||||
return {
|
||||
id: project.id,
|
||||
workspaceId: entry.workspace.id,
|
||||
workspaceName: entry.workspace.organizationName,
|
||||
name: project.name,
|
||||
url: project.url ?? undefined,
|
||||
color: project.color ?? undefined
|
||||
color: project.color ?? undefined,
|
||||
icon: project.icon ?? undefined,
|
||||
description: project.description ?? undefined,
|
||||
content: project.content ?? undefined,
|
||||
status: project.status
|
||||
? {
|
||||
id: project.status.id,
|
||||
name: project.status.name ?? '',
|
||||
type: project.status.type ?? undefined,
|
||||
color: project.status.color ?? undefined
|
||||
}
|
||||
: undefined,
|
||||
health: project.health ?? null,
|
||||
priority: project.priority ?? null,
|
||||
priorityLabel: project.priorityLabel ?? null,
|
||||
lead: mapUser(project.lead),
|
||||
members: project.members?.nodes
|
||||
?.map(mapUser)
|
||||
.filter((user): user is LinearProjectMemberSummary => !!user),
|
||||
teams: project.teams?.nodes?.map((team) => ({
|
||||
id: team.id,
|
||||
name: team.name ?? '',
|
||||
key: team.key ?? undefined
|
||||
})),
|
||||
labels: project.labels?.nodes?.map((label) => ({
|
||||
id: label.id,
|
||||
name: label.name ?? '',
|
||||
color: label.color ?? undefined
|
||||
})),
|
||||
startDate: project.startDate ?? null,
|
||||
targetDate: project.targetDate ?? null,
|
||||
createdAt: project.createdAt ?? undefined,
|
||||
updatedAt: project.updatedAt ?? undefined,
|
||||
completedAt: project.completedAt ?? null,
|
||||
canceledAt: project.canceledAt ?? null,
|
||||
startedAt: project.startedAt ?? null,
|
||||
progress: project.progress ?? null,
|
||||
scope: project.scope ?? null,
|
||||
issueCount: lastNumericValue(project.issueCountHistory),
|
||||
completedIssueCount: lastNumericValue(project.completedIssueCountHistory)
|
||||
}
|
||||
}
|
||||
|
||||
function mapProjectDetailForWorkspace(
|
||||
entry: LinearClientForWorkspace,
|
||||
project: LinearProjectNode
|
||||
): LinearProjectDetail {
|
||||
return {
|
||||
...mapProjectForWorkspace(entry, project),
|
||||
milestones: project.projectMilestones?.nodes?.map((milestone) => ({
|
||||
id: milestone.id,
|
||||
name: milestone.name ?? '',
|
||||
status: milestone.status ?? undefined,
|
||||
targetDate: milestone.targetDate ?? null,
|
||||
progress: milestone.progress ?? null
|
||||
})),
|
||||
resources: project.externalLinks?.nodes
|
||||
?.filter((link) => link.url)
|
||||
.map((link) => ({
|
||||
id: link.id,
|
||||
title: link.label || link.url || 'Link',
|
||||
url: link.url!,
|
||||
type: 'link'
|
||||
})),
|
||||
latestUpdate: project.lastUpdate
|
||||
? {
|
||||
id: project.lastUpdate.id,
|
||||
body: project.lastUpdate.body ?? undefined,
|
||||
health: project.lastUpdate.health ?? null,
|
||||
url: project.lastUpdate.url ?? undefined,
|
||||
createdAt: project.lastUpdate.createdAt ?? undefined,
|
||||
updatedAt: project.lastUpdate.updatedAt ?? undefined,
|
||||
user: mapUser(project.lastUpdate.user)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
function mapIssueForWorkspace(
|
||||
entry: LinearClientForWorkspace,
|
||||
issue: LinearIssueNode
|
||||
): LinearIssue {
|
||||
const labelNodes = issue.labels?.nodes ?? []
|
||||
return {
|
||||
id: issue.id,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
description: issue.description ?? undefined,
|
||||
url: issue.url,
|
||||
state: {
|
||||
name: issue.state?.name ?? '',
|
||||
type: issue.state?.type ?? '',
|
||||
color: issue.state?.color ?? ''
|
||||
},
|
||||
team: {
|
||||
id: issue.team?.id ?? '',
|
||||
name: issue.team?.name ?? '',
|
||||
key: issue.team?.key ?? ''
|
||||
},
|
||||
labels: labelNodes.map((label) => label.name),
|
||||
labelIds: issue.labelIds ?? labelNodes.map((label) => label.id),
|
||||
assignee: mapUser(issue.assignee),
|
||||
estimate: issue.estimate ?? null,
|
||||
priority: issue.priority,
|
||||
updatedAt: issue.updatedAt,
|
||||
workspaceId: entry.workspace.id,
|
||||
workspaceName: entry.workspace.organizationName
|
||||
}
|
||||
}
|
||||
|
||||
function mapCustomViewModel(modelName?: string | null): LinearCustomViewModel | null {
|
||||
const normalized = modelName?.toLowerCase()
|
||||
if (normalized === 'issue') {
|
||||
return 'issue'
|
||||
}
|
||||
if (normalized === 'project') {
|
||||
return 'project'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function mapCustomViewForWorkspace(
|
||||
entry: LinearClientForWorkspace,
|
||||
view: LinearCustomViewNode
|
||||
): LinearCustomViewSummary | null {
|
||||
const model = mapCustomViewModel(view.modelName)
|
||||
if (!model) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id: view.id,
|
||||
workspaceId: entry.workspace.id,
|
||||
workspaceName: entry.workspace.organizationName,
|
||||
name: view.name,
|
||||
description: view.description ?? undefined,
|
||||
model,
|
||||
url: view.slugId
|
||||
? `https://linear.app/${entry.workspace.organizationUrlKey}/view/${view.slugId}`
|
||||
: undefined,
|
||||
color: view.color ?? undefined,
|
||||
icon: view.icon ?? undefined,
|
||||
shared: view.shared ?? undefined,
|
||||
team: view.team
|
||||
? {
|
||||
id: view.team.id,
|
||||
name: view.team.name ?? undefined,
|
||||
key: view.team.key ?? undefined
|
||||
}
|
||||
: undefined,
|
||||
owner: mapUser(view.owner),
|
||||
creator: mapUser(view.creator),
|
||||
createdAt: view.createdAt ?? undefined,
|
||||
updatedAt: view.updatedAt ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function readCollection<T>(
|
||||
key: string,
|
||||
workspaceId: LinearWorkspaceSelection | null | undefined,
|
||||
load: (entry: LinearClientForWorkspace) => Promise<LinearCollectionResult<T>>
|
||||
): Promise<LinearCollectionResult<T>> {
|
||||
return coalesce(key, async () => {
|
||||
const entries = getClients(workspaceId)
|
||||
if (entries.length === 0) {
|
||||
return { items: [] }
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
await acquire()
|
||||
try {
|
||||
return await load(entry)
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
} else {
|
||||
console.warn('[linear] project/view read failed:', error)
|
||||
}
|
||||
if (shouldFailWholeRequest(workspaceId)) {
|
||||
throw error
|
||||
}
|
||||
return { items: [], errors: [workspaceError(entry, error)] }
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
items: results.flatMap((result) => result.items),
|
||||
errors: results.flatMap((result) => result.errors ?? []).length
|
||||
? results.flatMap((result) => result.errors ?? [])
|
||||
: undefined,
|
||||
hasMore: results.some((result) => result.hasMore)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function readConcreteCollection<T>(
|
||||
key: string,
|
||||
workspaceId: LinearConcreteWorkspaceId,
|
||||
load: (entry: LinearClientForWorkspace) => Promise<LinearCollectionResult<T>>
|
||||
): Promise<LinearCollectionResult<T>> {
|
||||
const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId)
|
||||
return readCollection(key, concreteWorkspaceId, load)
|
||||
}
|
||||
|
||||
export async function listProjects(
|
||||
query: string | undefined,
|
||||
limit = 20,
|
||||
workspaceId?: LinearWorkspaceSelection | null
|
||||
): Promise<LinearProjectSummary[]> {
|
||||
): Promise<LinearCollectionResult<LinearProjectSummary>> {
|
||||
const first = clampLimit(limit)
|
||||
const trimmed = query?.trim()
|
||||
|
||||
const entries = getClients(workspaceId)
|
||||
if (entries.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
await acquire()
|
||||
try {
|
||||
if (!trimmed) {
|
||||
const connection = await entry.client.projects({ first: limit })
|
||||
return connection.nodes.map(mapLinearProject)
|
||||
}
|
||||
const connection = await entry.client.searchProjects(trimmed, { first: limit })
|
||||
return connection.nodes.map(mapLinearProject)
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
if (workspaceId !== 'all') {
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
console.warn('[linear] listProjects failed:', error)
|
||||
}
|
||||
return []
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return results.flat().slice(0, limit)
|
||||
const key = `listProjects:${workspaceId ?? 'default'}:${trimmed ?? ''}:${first}`
|
||||
return readCollection(key, workspaceId, async (entry) => {
|
||||
const variables = trimmed ? { term: trimmed, first } : { first, orderBy: 'updatedAt' }
|
||||
const result = await entry.client.client.rawRequest<
|
||||
ProjectConnectionResponse,
|
||||
LinearRawVariables
|
||||
>(trimmed ? SEARCH_PROJECTS_QUERY : PROJECTS_QUERY, variables)
|
||||
const connection = trimmed ? result.data?.searchProjects : result.data?.projects
|
||||
return {
|
||||
items: (connection?.nodes ?? []).map((project) => mapProjectForWorkspace(entry, project)),
|
||||
hasMore: !!connection?.pageInfo?.hasNextPage
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function getProject(
|
||||
id: string,
|
||||
workspaceId: LinearConcreteWorkspaceId
|
||||
): Promise<LinearProjectDetail | null> {
|
||||
const projectId = id.trim()
|
||||
const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId)
|
||||
if (!projectId) {
|
||||
throw new Error('Project ID is required')
|
||||
}
|
||||
const key = `getProject:${concreteWorkspaceId}:${projectId}`
|
||||
return coalesce(key, async () => {
|
||||
const entries = getClients(concreteWorkspaceId)
|
||||
const entry = entries[0]
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
const result = await entry.client.client.rawRequest<
|
||||
ProjectConnectionResponse,
|
||||
LinearRawVariables
|
||||
>(PROJECT_QUERY, { id: projectId })
|
||||
return result.data?.project ? mapProjectDetailForWorkspace(entry, result.data.project) : null
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function listProjectIssues(
|
||||
projectId: string,
|
||||
limit = 20,
|
||||
workspaceId: LinearConcreteWorkspaceId
|
||||
): Promise<LinearCollectionResult<LinearIssue>> {
|
||||
const id = projectId.trim()
|
||||
if (!id) {
|
||||
throw new Error('Project ID is required')
|
||||
}
|
||||
const first = clampLimit(limit)
|
||||
const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId)
|
||||
return readConcreteCollection(
|
||||
`listProjectIssues:${concreteWorkspaceId}:${id}:${first}`,
|
||||
concreteWorkspaceId,
|
||||
async (entry) => {
|
||||
const result = await entry.client.client.rawRequest<
|
||||
ProjectIssueConnectionResponse,
|
||||
LinearRawVariables
|
||||
>(PROJECT_ISSUES_QUERY, { id, first, orderBy: 'updatedAt' })
|
||||
const project = result.data?.project
|
||||
if (!project) {
|
||||
throw new Error('Project was not found')
|
||||
}
|
||||
const connection = project.issues
|
||||
return {
|
||||
items: (connection?.nodes ?? []).map((issue) => mapIssueForWorkspace(entry, issue)),
|
||||
hasMore: !!connection?.pageInfo?.hasNextPage
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function listCustomViews(
|
||||
model: LinearCustomViewModel,
|
||||
limit = 20,
|
||||
workspaceId?: LinearWorkspaceSelection | null
|
||||
): Promise<LinearCollectionResult<LinearCustomViewSummary>> {
|
||||
const first = clampLimit(limit)
|
||||
const key = `listCustomViews:${workspaceId ?? 'default'}:${model}:${first}`
|
||||
const filter = { modelName: { eq: model === 'project' ? 'Project' : 'Issue' } }
|
||||
return readCollection(key, workspaceId, async (entry) => {
|
||||
const result = await entry.client.client.rawRequest<
|
||||
CustomViewConnectionResponse,
|
||||
LinearRawVariables
|
||||
>(CUSTOM_VIEWS_QUERY, { first, filter, orderBy: 'updatedAt' })
|
||||
const connection = result.data?.customViews
|
||||
return {
|
||||
items: (connection?.nodes ?? [])
|
||||
.map((view) => mapCustomViewForWorkspace(entry, view))
|
||||
.filter((view): view is LinearCustomViewSummary => !!view && view.model === model),
|
||||
hasMore: !!connection?.pageInfo?.hasNextPage
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function getCustomView(
|
||||
viewId: string,
|
||||
model: LinearCustomViewModel,
|
||||
workspaceId: LinearConcreteWorkspaceId
|
||||
): Promise<LinearCustomViewSummary | null> {
|
||||
const id = viewId.trim()
|
||||
const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId)
|
||||
if (!id) {
|
||||
throw new Error('Custom view ID is required')
|
||||
}
|
||||
const key = `getCustomView:${concreteWorkspaceId}:${model}:${id}`
|
||||
return coalesce(key, async () => {
|
||||
const entries = getClients(concreteWorkspaceId)
|
||||
const entry = entries[0]
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
const result = await entry.client.client.rawRequest<
|
||||
CustomViewConnectionResponse,
|
||||
LinearRawVariables
|
||||
>(CUSTOM_VIEW_QUERY, { id })
|
||||
const view = result.data?.customView
|
||||
const mapped = view ? mapCustomViewForWorkspace(entry, view) : null
|
||||
return mapped?.model === model ? mapped : null
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function listCustomViewIssues(
|
||||
viewId: string,
|
||||
limit = 20,
|
||||
workspaceId: LinearConcreteWorkspaceId
|
||||
): Promise<LinearCollectionResult<LinearIssue>> {
|
||||
const id = viewId.trim()
|
||||
if (!id) {
|
||||
throw new Error('Custom view ID is required')
|
||||
}
|
||||
const first = clampLimit(limit)
|
||||
const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId)
|
||||
return readConcreteCollection(
|
||||
`listCustomViewIssues:${concreteWorkspaceId}:${id}:${first}`,
|
||||
concreteWorkspaceId,
|
||||
async (entry) => {
|
||||
const result = await entry.client.client.rawRequest<
|
||||
CustomViewConnectionResponse,
|
||||
LinearRawVariables
|
||||
>(CUSTOM_VIEW_ISSUES_QUERY, { id, first, orderBy: 'updatedAt' })
|
||||
const view = result.data?.customView
|
||||
if (mapCustomViewModel(view?.modelName) !== 'issue') {
|
||||
throw new Error('Custom view does not contain issues')
|
||||
}
|
||||
const connection = view?.issues
|
||||
return {
|
||||
items: (connection?.nodes ?? []).map((issue) => mapIssueForWorkspace(entry, issue)),
|
||||
hasMore: !!connection?.pageInfo?.hasNextPage
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function listCustomViewProjects(
|
||||
viewId: string,
|
||||
limit = 20,
|
||||
workspaceId: LinearConcreteWorkspaceId
|
||||
): Promise<LinearCollectionResult<LinearProjectSummary>> {
|
||||
const id = viewId.trim()
|
||||
if (!id) {
|
||||
throw new Error('Custom view ID is required')
|
||||
}
|
||||
const first = clampLimit(limit)
|
||||
const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId)
|
||||
return readConcreteCollection(
|
||||
`listCustomViewProjects:${concreteWorkspaceId}:${id}:${first}`,
|
||||
concreteWorkspaceId,
|
||||
async (entry) => {
|
||||
const result = await entry.client.client.rawRequest<
|
||||
CustomViewConnectionResponse,
|
||||
LinearRawVariables
|
||||
>(CUSTOM_VIEW_PROJECTS_QUERY, { id, first, orderBy: 'updatedAt' })
|
||||
const view = result.data?.customView
|
||||
if (mapCustomViewModel(view?.modelName) !== 'project') {
|
||||
throw new Error('Custom view does not contain projects')
|
||||
}
|
||||
const connection = view?.projects
|
||||
return {
|
||||
items: (connection?.nodes ?? []).map((project) => mapProjectForWorkspace(entry, project)),
|
||||
hasMore: !!connection?.pageInfo?.hasNextPage
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import type {
|
|||
WorktreeBaseStatusEvent,
|
||||
WorktreeRemoteBranchConflictEvent,
|
||||
WorktreeStartupLaunch,
|
||||
LinearCustomViewModel,
|
||||
LinearIssueUpdate,
|
||||
LinearWorkspaceSelection,
|
||||
NestedRepoScanResult,
|
||||
|
|
@ -253,7 +254,15 @@ import {
|
|||
updateIssue as updateLinearIssue,
|
||||
type LinearListFilter
|
||||
} from '../linear/issues'
|
||||
import { listProjects as listLinearProjects } from '../linear/projects'
|
||||
import {
|
||||
getCustomView as getLinearCustomView,
|
||||
getProject as getLinearProject,
|
||||
listCustomViewIssues as listLinearCustomViewIssues,
|
||||
listCustomViewProjects as listLinearCustomViewProjects,
|
||||
listCustomViews as listLinearCustomViews,
|
||||
listProjectIssues as listLinearProjectIssues,
|
||||
listProjects as listLinearProjects
|
||||
} from '../linear/projects'
|
||||
import {
|
||||
getTeamLabels as getLinearTeamLabels,
|
||||
getTeamMembers as getLinearTeamMembers,
|
||||
|
|
@ -12149,6 +12158,50 @@ export class OrcaRuntimeService {
|
|||
return listLinearProjects(query, Math.min(Math.max(1, limit), 50), workspaceId)
|
||||
}
|
||||
|
||||
linearGetProject(id: string, workspaceId: string): ReturnType<typeof getLinearProject> {
|
||||
return getLinearProject(id, workspaceId)
|
||||
}
|
||||
|
||||
linearListProjectIssues(
|
||||
projectId: string,
|
||||
limit = 20,
|
||||
workspaceId: string
|
||||
): ReturnType<typeof listLinearProjectIssues> {
|
||||
return listLinearProjectIssues(projectId, Math.min(Math.max(1, limit), 50), workspaceId)
|
||||
}
|
||||
|
||||
linearListCustomViews(
|
||||
model: LinearCustomViewModel,
|
||||
limit = 20,
|
||||
workspaceId?: LinearWorkspaceSelection
|
||||
): ReturnType<typeof listLinearCustomViews> {
|
||||
return listLinearCustomViews(model, Math.min(Math.max(1, limit), 50), workspaceId)
|
||||
}
|
||||
|
||||
linearGetCustomView(
|
||||
viewId: string,
|
||||
model: LinearCustomViewModel,
|
||||
workspaceId: string
|
||||
): ReturnType<typeof getLinearCustomView> {
|
||||
return getLinearCustomView(viewId, model, workspaceId)
|
||||
}
|
||||
|
||||
linearListCustomViewIssues(
|
||||
viewId: string,
|
||||
limit = 20,
|
||||
workspaceId: string
|
||||
): ReturnType<typeof listLinearCustomViewIssues> {
|
||||
return listLinearCustomViewIssues(viewId, Math.min(Math.max(1, limit), 50), workspaceId)
|
||||
}
|
||||
|
||||
linearListCustomViewProjects(
|
||||
viewId: string,
|
||||
limit = 20,
|
||||
workspaceId: string
|
||||
): ReturnType<typeof listLinearCustomViewProjects> {
|
||||
return listLinearCustomViewProjects(viewId, Math.min(Math.max(1, limit), 50), workspaceId)
|
||||
}
|
||||
|
||||
linearTeamStates(teamId: string, workspaceId?: string): ReturnType<typeof getLinearTeamStates> {
|
||||
return getLinearTeamStates(teamId, workspaceId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -155,8 +155,14 @@ describe('linear RPC methods', () => {
|
|||
it('routes Linear metadata requests to the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
linearGetCustomView: vi.fn().mockResolvedValue({ id: 'view-1' }),
|
||||
linearGetProject: vi.fn().mockResolvedValue({ id: 'project-1' }),
|
||||
linearListCustomViewIssues: vi.fn().mockResolvedValue({ items: [{ id: 'issue-1' }] }),
|
||||
linearListCustomViewProjects: vi.fn().mockResolvedValue({ items: [{ id: 'project-2' }] }),
|
||||
linearListCustomViews: vi.fn().mockResolvedValue({ items: [{ id: 'view-1' }] }),
|
||||
linearListProjectIssues: vi.fn().mockResolvedValue({ items: [{ id: 'issue-2' }] }),
|
||||
linearListTeams: vi.fn().mockResolvedValue([{ id: 'team-1' }]),
|
||||
linearListProjects: vi.fn().mockResolvedValue([{ id: 'project-1' }]),
|
||||
linearListProjects: vi.fn().mockResolvedValue({ items: [{ id: 'project-1' }] }),
|
||||
linearTeamStates: vi.fn().mockResolvedValue([{ id: 'state-1' }]),
|
||||
linearTeamLabels: vi.fn().mockResolvedValue([{ id: 'label-1' }]),
|
||||
linearTeamMembers: vi.fn().mockResolvedValue([{ id: 'member-1' }])
|
||||
|
|
@ -165,6 +171,44 @@ describe('linear RPC methods', () => {
|
|||
|
||||
await dispatcher.dispatch(makeRequest('linear.listTeams', { workspaceId: 'all' }))
|
||||
await dispatcher.dispatch(makeRequest('linear.listProjects', { query: 'roadmap', limit: 5 }))
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('linear.getProject', { id: 'project-1', workspaceId: 'workspace-1' })
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('linear.listProjectIssues', {
|
||||
projectId: 'project-1',
|
||||
limit: 10,
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('linear.listCustomViews', {
|
||||
model: 'project',
|
||||
limit: 10,
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('linear.getCustomView', {
|
||||
viewId: 'view-1',
|
||||
model: 'project',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('linear.listCustomViewIssues', {
|
||||
viewId: 'view-1',
|
||||
limit: 10,
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('linear.listCustomViewProjects', {
|
||||
viewId: 'view-2',
|
||||
limit: 10,
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('linear.teamStates', { teamId: 'team-1', workspaceId: 'workspace-1' })
|
||||
)
|
||||
|
|
@ -177,6 +221,12 @@ describe('linear RPC methods', () => {
|
|||
|
||||
expect(runtime.linearListTeams).toHaveBeenCalledWith('all')
|
||||
expect(runtime.linearListProjects).toHaveBeenCalledWith('roadmap', 5, undefined)
|
||||
expect(runtime.linearGetProject).toHaveBeenCalledWith('project-1', 'workspace-1')
|
||||
expect(runtime.linearListProjectIssues).toHaveBeenCalledWith('project-1', 10, 'workspace-1')
|
||||
expect(runtime.linearListCustomViews).toHaveBeenCalledWith('project', 10, 'workspace-1')
|
||||
expect(runtime.linearGetCustomView).toHaveBeenCalledWith('view-1', 'project', 'workspace-1')
|
||||
expect(runtime.linearListCustomViewIssues).toHaveBeenCalledWith('view-1', 10, 'workspace-1')
|
||||
expect(runtime.linearListCustomViewProjects).toHaveBeenCalledWith('view-2', 10, 'workspace-1')
|
||||
expect(runtime.linearTeamStates).toHaveBeenCalledWith('team-1', 'workspace-1')
|
||||
expect(runtime.linearTeamLabels).toHaveBeenCalledWith('team-1', 'workspace-1')
|
||||
expect(runtime.linearTeamMembers).toHaveBeenCalledWith('team-1', 'workspace-1')
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { defineMethod, type RpcMethod } from '../core'
|
|||
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
|
||||
|
||||
const VALID_FILTERS = ['assigned', 'created', 'all', 'completed'] as const
|
||||
const VALID_CUSTOM_VIEW_MODELS = ['issue', 'project'] as const
|
||||
const LinearPriority = z.number().int().min(0).max(4).optional()
|
||||
const LinearLabelIds = z.array(requiredString('Invalid label ID')).optional()
|
||||
|
||||
|
|
@ -16,6 +17,11 @@ const WorkspaceSelection = z
|
|||
})
|
||||
.optional()
|
||||
|
||||
const ConcreteWorkspaceId = requiredString('Concrete Linear workspace ID is required').refine(
|
||||
(value) => value !== 'all',
|
||||
'Concrete Linear workspace ID is required'
|
||||
)
|
||||
|
||||
const SelectWorkspace = z.object({
|
||||
workspaceId: requiredString('Workspace ID is required')
|
||||
})
|
||||
|
|
@ -66,6 +72,35 @@ const ListProjects = z
|
|||
})
|
||||
.optional()
|
||||
|
||||
const ProjectId = z.object({
|
||||
id: requiredString('Project ID is required'),
|
||||
workspaceId: ConcreteWorkspaceId
|
||||
})
|
||||
|
||||
const ProjectIssues = z.object({
|
||||
projectId: requiredString('Project ID is required'),
|
||||
limit: OptionalFiniteNumber,
|
||||
workspaceId: ConcreteWorkspaceId
|
||||
})
|
||||
|
||||
const ListCustomViews = z.object({
|
||||
model: z.enum(VALID_CUSTOM_VIEW_MODELS),
|
||||
limit: OptionalFiniteNumber,
|
||||
workspaceId: OptionalString
|
||||
})
|
||||
|
||||
const CustomViewId = z.object({
|
||||
viewId: requiredString('Custom view ID is required'),
|
||||
model: z.enum(VALID_CUSTOM_VIEW_MODELS),
|
||||
workspaceId: ConcreteWorkspaceId
|
||||
})
|
||||
|
||||
const CustomViewContents = z.object({
|
||||
viewId: requiredString('Custom view ID is required'),
|
||||
limit: OptionalFiniteNumber,
|
||||
workspaceId: ConcreteWorkspaceId
|
||||
})
|
||||
|
||||
const TeamId = z.object({
|
||||
teamId: requiredString('Team ID is required'),
|
||||
workspaceId: OptionalString
|
||||
|
|
@ -181,6 +216,54 @@ export const LINEAR_METHODS: RpcMethod[] = [
|
|||
handler: async (params, { runtime }) =>
|
||||
runtime.linearListProjects(params?.query, params?.limit, params?.workspaceId)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.getProject',
|
||||
params: ProjectId,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.linearGetProject(params.id.trim(), params.workspaceId.trim())
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.listProjectIssues',
|
||||
params: ProjectIssues,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.linearListProjectIssues(
|
||||
params.projectId.trim(),
|
||||
params.limit,
|
||||
params.workspaceId.trim()
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.listCustomViews',
|
||||
params: ListCustomViews,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.linearListCustomViews(params.model, params.limit, params.workspaceId)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.getCustomView',
|
||||
params: CustomViewId,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.linearGetCustomView(params.viewId.trim(), params.model, params.workspaceId.trim())
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.listCustomViewIssues',
|
||||
params: CustomViewContents,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.linearListCustomViewIssues(
|
||||
params.viewId.trim(),
|
||||
params.limit,
|
||||
params.workspaceId.trim()
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.listCustomViewProjects',
|
||||
params: CustomViewContents,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.linearListCustomViewProjects(
|
||||
params.viewId.trim(),
|
||||
params.limit,
|
||||
params.workspaceId.trim()
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.teamStates',
|
||||
params: TeamId,
|
||||
|
|
|
|||
|
|
@ -218,12 +218,18 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
|||
'host.pwsh.isAvailable',
|
||||
'host.wsl.isAvailable',
|
||||
'host.wsl.listDistros',
|
||||
'linear.getCustomView',
|
||||
'linear.getIssue',
|
||||
'linear.getProject',
|
||||
'linear.addIssueComment',
|
||||
'linear.connect',
|
||||
'linear.createIssue',
|
||||
'linear.issueComments',
|
||||
'linear.listCustomViewIssues',
|
||||
'linear.listCustomViewProjects',
|
||||
'linear.listCustomViews',
|
||||
'linear.listIssues',
|
||||
'linear.listProjectIssues',
|
||||
'linear.listProjects',
|
||||
'linear.teamLabels',
|
||||
'linear.teamMembers',
|
||||
|
|
|
|||
|
|
@ -61,7 +61,10 @@ import type {
|
|||
ListWorkItemsResult,
|
||||
IssueInfo,
|
||||
LinearViewer,
|
||||
LinearCollectionResult,
|
||||
LinearConnectionStatus,
|
||||
LinearCustomViewModel,
|
||||
LinearCustomViewSummary,
|
||||
LinearWorkspaceSelection,
|
||||
LinearIssue,
|
||||
LinearIssueUpdate,
|
||||
|
|
@ -69,6 +72,7 @@ import type {
|
|||
LinearWorkflowState,
|
||||
LinearLabel,
|
||||
LinearMember,
|
||||
LinearProjectDetail,
|
||||
LinearProjectSummary,
|
||||
LinearTeam,
|
||||
MarkdownDocument,
|
||||
|
|
@ -1292,7 +1296,33 @@ export type PreloadApi = {
|
|||
query?: string
|
||||
limit?: number
|
||||
workspaceId?: LinearWorkspaceSelection
|
||||
}) => Promise<LinearProjectSummary[]>
|
||||
}) => Promise<LinearCollectionResult<LinearProjectSummary>>
|
||||
getProject: (args: { id: string; workspaceId: string }) => Promise<LinearProjectDetail | null>
|
||||
listProjectIssues: (args: {
|
||||
projectId: string
|
||||
limit?: number
|
||||
workspaceId: string
|
||||
}) => Promise<LinearCollectionResult<LinearIssue>>
|
||||
listCustomViews: (args: {
|
||||
model: LinearCustomViewModel
|
||||
limit?: number
|
||||
workspaceId?: LinearWorkspaceSelection
|
||||
}) => Promise<LinearCollectionResult<LinearCustomViewSummary>>
|
||||
getCustomView: (args: {
|
||||
viewId: string
|
||||
model: LinearCustomViewModel
|
||||
workspaceId: string
|
||||
}) => Promise<LinearCustomViewSummary | null>
|
||||
listCustomViewIssues: (args: {
|
||||
viewId: string
|
||||
limit?: number
|
||||
workspaceId: string
|
||||
}) => Promise<LinearCollectionResult<LinearIssue>>
|
||||
listCustomViewProjects: (args: {
|
||||
viewId: string
|
||||
limit?: number
|
||||
workspaceId: string
|
||||
}) => Promise<LinearCollectionResult<LinearProjectSummary>>
|
||||
teamStates: (args: { teamId: string; workspaceId?: string }) => Promise<LinearWorkflowState[]>
|
||||
teamLabels: (args: { teamId: string; workspaceId?: string }) => Promise<LinearLabel[]>
|
||||
teamMembers: (args: { teamId: string; workspaceId?: string }) => Promise<LinearMember[]>
|
||||
|
|
|
|||
|
|
@ -1227,7 +1227,40 @@ const api = {
|
|||
query?: string
|
||||
limit?: number
|
||||
workspaceId?: string
|
||||
}): Promise<unknown[]> => ipcRenderer.invoke('linear:listProjects', args),
|
||||
}): Promise<unknown> => ipcRenderer.invoke('linear:listProjects', args),
|
||||
|
||||
getProject: (args: { id: string; workspaceId: string }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('linear:getProject', args),
|
||||
|
||||
listProjectIssues: (args: {
|
||||
projectId: string
|
||||
limit?: number
|
||||
workspaceId: string
|
||||
}): Promise<unknown> => ipcRenderer.invoke('linear:listProjectIssues', args),
|
||||
|
||||
listCustomViews: (args: {
|
||||
model: string
|
||||
limit?: number
|
||||
workspaceId?: string
|
||||
}): Promise<unknown> => ipcRenderer.invoke('linear:listCustomViews', args),
|
||||
|
||||
getCustomView: (args: {
|
||||
viewId: string
|
||||
model: string
|
||||
workspaceId: string
|
||||
}): Promise<unknown> => ipcRenderer.invoke('linear:getCustomView', args),
|
||||
|
||||
listCustomViewIssues: (args: {
|
||||
viewId: string
|
||||
limit?: number
|
||||
workspaceId: string
|
||||
}): Promise<unknown> => ipcRenderer.invoke('linear:listCustomViewIssues', args),
|
||||
|
||||
listCustomViewProjects: (args: {
|
||||
viewId: string
|
||||
limit?: number
|
||||
workspaceId: string
|
||||
}): Promise<unknown> => ipcRenderer.invoke('linear:listCustomViewProjects', args),
|
||||
|
||||
teamStates: (args: { teamId: string; workspaceId?: string }): Promise<unknown[]> =>
|
||||
ipcRenderer.invoke('linear:teamStates', args),
|
||||
|
|
|
|||
|
|
@ -272,7 +272,7 @@ function LinearIssueSidebarProjectCard({
|
|||
void linearListProjects(settings, query, 20, issue.workspaceId)
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setProjects(result)
|
||||
setProjects(result.items)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,712 @@
|
|||
/* eslint-disable max-lines -- Why: project/view tables and project overview
|
||||
share compact Linear metadata presentation rules for the Tasks Linear surface. */
|
||||
import React from 'react'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
CalendarDays,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
FolderKanban,
|
||||
Layers3,
|
||||
RefreshCw,
|
||||
UserRound
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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,
|
||||
LinearWorkspaceError
|
||||
} from '../../../shared/types'
|
||||
|
||||
type LinearProjectLike = LinearProjectSummary & {
|
||||
content?: string
|
||||
summary?: string
|
||||
description?: string
|
||||
status?: unknown
|
||||
health?: unknown
|
||||
lead?: unknown
|
||||
members?: unknown[]
|
||||
teams?: unknown[]
|
||||
labels?: unknown[]
|
||||
milestones?: unknown[]
|
||||
resources?: unknown[]
|
||||
latestUpdate?: unknown
|
||||
lastUpdate?: unknown
|
||||
}
|
||||
|
||||
type LinearProjectTableProps = {
|
||||
projects: LinearProjectSummary[]
|
||||
loading: boolean
|
||||
hasError?: boolean
|
||||
selectedProjectId?: string | null
|
||||
workspaceSelection?: string | null
|
||||
onSelectProject: (project: LinearProjectSummary) => void
|
||||
onOpenProject: (project: LinearProjectSummary) => void
|
||||
onUseProjectIssues?: (project: LinearProjectSummary) => void
|
||||
}
|
||||
|
||||
type LinearCustomViewTableProps = {
|
||||
views: LinearCustomViewSummary[]
|
||||
model: LinearCustomViewModel
|
||||
loading: boolean
|
||||
hasError?: boolean
|
||||
selectedViewId?: string | null
|
||||
workspaceSelection?: string | null
|
||||
onSelectView: (view: LinearCustomViewSummary) => void
|
||||
onOpenView: (view: LinearCustomViewSummary) => void
|
||||
}
|
||||
|
||||
type LinearProjectOverviewProps = {
|
||||
project: LinearProjectDetail | LinearProjectSummary | null
|
||||
loading: boolean
|
||||
error?: string | null
|
||||
onBack: () => void
|
||||
onOpenProject: (project: LinearProjectSummary) => void
|
||||
onRefresh: () => void
|
||||
onOpenIssues?: () => void
|
||||
}
|
||||
|
||||
type LinearCollectionNoticeProps = {
|
||||
errors?: LinearWorkspaceError[]
|
||||
hasMore?: boolean
|
||||
count: number
|
||||
label: string
|
||||
}
|
||||
|
||||
function textFromUnknown(value: unknown): string | null {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim()
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
for (const key of ['name', 'label', 'displayName', 'title', 'status', 'body']) {
|
||||
const text = record[key]
|
||||
if (typeof text === 'string' && text.trim()) {
|
||||
return text.trim()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function dateLabel(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return 'None'
|
||||
}
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString()
|
||||
}
|
||||
|
||||
function priorityLabel(priority: unknown, fallback: unknown): string {
|
||||
const fromFallback = textFromUnknown(fallback)
|
||||
if (fromFallback) {
|
||||
return fromFallback
|
||||
}
|
||||
if (typeof priority === 'number') {
|
||||
return priority === 0 ? 'None' : `P${priority}`
|
||||
}
|
||||
return textFromUnknown(priority) ?? 'None'
|
||||
}
|
||||
|
||||
function projectProgress(project: LinearProjectLike): number | null {
|
||||
const progress = typeof project.progress === 'number' ? project.progress : null
|
||||
if (progress === null || !Number.isFinite(progress)) {
|
||||
return null
|
||||
}
|
||||
return progress <= 1 ? Math.round(progress * 100) : Math.round(progress)
|
||||
}
|
||||
|
||||
function listLabels(values: unknown[] | undefined, limit: number): string[] {
|
||||
if (!Array.isArray(values)) {
|
||||
return []
|
||||
}
|
||||
return values
|
||||
.map((value) => textFromUnknown(value))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
function workspaceLabel(
|
||||
workspaceSelection: string | null | undefined,
|
||||
workspaceName?: string
|
||||
): string | null {
|
||||
return workspaceSelection === 'all' && workspaceName ? workspaceName : null
|
||||
}
|
||||
|
||||
function ProjectColorMark({ project }: { project: LinearProjectSummary }): React.JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className="size-2.5 shrink-0 rounded-sm border border-border/50 bg-muted"
|
||||
style={project.color ? { backgroundColor: project.color } : undefined}
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectStatusBadge({ project }: { project: LinearProjectLike }): React.JSX.Element {
|
||||
const label = textFromUnknown(project.status) ?? 'Backlog'
|
||||
return (
|
||||
<Badge variant="outline" className="max-w-full truncate text-[11px] font-medium">
|
||||
{label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export function LinearCollectionNotice({
|
||||
errors,
|
||||
hasMore,
|
||||
count,
|
||||
label
|
||||
}: LinearCollectionNoticeProps): React.JSX.Element | null {
|
||||
if (!hasMore && (!errors || errors.length === 0)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-none flex-col gap-2 border-t border-border/50 bg-muted/25 px-3 py-2 text-xs text-muted-foreground">
|
||||
{errors && errors.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{errors.map((error) => (
|
||||
<Badge key={`${error.workspaceId}-${error.type}`} variant="outline">
|
||||
{error.workspaceName ?? error.workspaceId}: {error.message}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{hasMore ? (
|
||||
<div>
|
||||
Showing first {count} {label}. Search or open Linear for the full set.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LinearProjectTable({
|
||||
projects,
|
||||
loading,
|
||||
hasError,
|
||||
selectedProjectId,
|
||||
workspaceSelection,
|
||||
onSelectProject,
|
||||
onOpenProject,
|
||||
onUseProjectIssues
|
||||
}: LinearProjectTableProps): React.JSX.Element {
|
||||
if (loading && projects.length === 0) {
|
||||
return (
|
||||
<div className="divide-y divide-border/50">
|
||||
{Array.from({ length: 10 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="grid gap-3 px-3 py-3 md:grid-cols-[minmax(180px,1.5fr)_110px_100px_90px_120px_110px_80px_70px]"
|
||||
>
|
||||
<div className="h-4 w-4/5 animate-pulse rounded bg-muted/70" />
|
||||
<div className="h-4 w-20 animate-pulse rounded bg-muted/60" />
|
||||
<div className="h-4 w-16 animate-pulse rounded bg-muted/60" />
|
||||
<div className="h-4 w-16 animate-pulse rounded bg-muted/60" />
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted/60" />
|
||||
<div className="h-4 w-20 animate-pulse rounded bg-muted/60" />
|
||||
<div className="h-4 w-10 animate-pulse rounded bg-muted/60" />
|
||||
<div />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<div className="px-4 py-10 text-center">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{hasError ? 'Unable to load Linear projects' : 'No Linear projects found'}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{hasError ? 'Review the workspace error below, then refresh.' : 'Try search or refresh.'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-[820px] divide-y divide-border/50">
|
||||
{projects.map((project) => {
|
||||
const projectLike = project as LinearProjectLike
|
||||
const selected = project.id === selectedProjectId
|
||||
const labels = listLabels(projectLike.labels, 2)
|
||||
const workspace = workspaceLabel(workspaceSelection, project.workspaceName)
|
||||
const progress = projectProgress(projectLike)
|
||||
return (
|
||||
<div
|
||||
key={`${project.workspaceId ?? 'workspace'}-${project.id}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
data-current={selected ? 'true' : undefined}
|
||||
onClick={() => onSelectProject(project)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) {
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onSelectProject(project)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'group/row grid min-h-12 cursor-pointer grid-cols-[minmax(180px,1.5fr)_110px_100px_90px_120px_110px_80px_70px] items-center gap-3 px-3 py-2 text-left transition hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||
selected && 'bg-accent'
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ProjectColorMark project={project} />
|
||||
<span className="min-w-0 truncate text-[13px] font-medium text-foreground">
|
||||
{project.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
{workspace ? <span className="truncate">{workspace}</span> : null}
|
||||
{labels.map((label) => (
|
||||
<Badge key={label} variant="outline" className="px-1.5 py-0 text-[10px]">
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<ProjectStatusBadge project={projectLike} />
|
||||
</div>
|
||||
<span className="truncate text-[12px] text-muted-foreground">
|
||||
{textFromUnknown(projectLike.health) ?? 'None'}
|
||||
</span>
|
||||
<span className="truncate text-[12px] text-muted-foreground">
|
||||
{priorityLabel(projectLike.priority, projectLike.priorityLabel)}
|
||||
</span>
|
||||
<span className="truncate text-[12px] text-muted-foreground">
|
||||
{textFromUnknown(projectLike.lead) ?? 'Unassigned'}
|
||||
</span>
|
||||
<span className="truncate text-[12px] text-muted-foreground">
|
||||
{dateLabel(project.targetDate)}
|
||||
</span>
|
||||
<span className="text-[12px] text-muted-foreground">
|
||||
{typeof project.issueCount === 'number'
|
||||
? project.issueCount
|
||||
: typeof project.scope === 'number'
|
||||
? project.scope
|
||||
: progress !== null
|
||||
? `${progress}%`
|
||||
: '-'}
|
||||
</span>
|
||||
<div className="flex items-center justify-end gap-1 md:opacity-0 md:transition-opacity md:group-hover/row:opacity-100 md:group-focus-within/row:opacity-100">
|
||||
{onUseProjectIssues ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onUseProjectIssues(project)
|
||||
}}
|
||||
aria-label={`Open ${project.name} issues`}
|
||||
>
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Issues
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onOpenProject(project)
|
||||
}}
|
||||
aria-label={`Open ${project.name} in Linear`}
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Open in Linear
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LinearCustomViewTable({
|
||||
views,
|
||||
model,
|
||||
loading,
|
||||
hasError,
|
||||
selectedViewId,
|
||||
workspaceSelection,
|
||||
onSelectView,
|
||||
onOpenView
|
||||
}: LinearCustomViewTableProps): React.JSX.Element {
|
||||
if (loading && views.length === 0) {
|
||||
return (
|
||||
<div className="divide-y divide-border/50">
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="grid gap-3 px-3 py-3 md:grid-cols-[minmax(220px,1.5fr)_120px_120px_120px_130px_60px]"
|
||||
>
|
||||
<div className="h-4 w-4/5 animate-pulse rounded bg-muted/70" />
|
||||
<div className="h-4 w-20 animate-pulse rounded bg-muted/60" />
|
||||
<div className="h-4 w-20 animate-pulse rounded bg-muted/60" />
|
||||
<div className="h-4 w-20 animate-pulse rounded bg-muted/60" />
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted/60" />
|
||||
<div />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (views.length === 0) {
|
||||
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`}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{hasError
|
||||
? 'Review the workspace error below, then refresh.'
|
||||
: 'Create or save views in Linear, then refresh.'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-[680px] divide-y divide-border/50">
|
||||
{views.map((view) => {
|
||||
const selected = view.id === selectedViewId
|
||||
const workspace = workspaceLabel(workspaceSelection, view.workspaceName)
|
||||
return (
|
||||
<div
|
||||
key={`${view.workspaceId ?? 'workspace'}-${view.id}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
data-current={selected ? 'true' : undefined}
|
||||
onClick={() => onSelectView(view)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) {
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onSelectView(view)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'group/row grid min-h-12 cursor-pointer grid-cols-[minmax(220px,1.5fr)_120px_120px_120px_130px_60px] items-center gap-3 px-3 py-2 text-left transition hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||
selected && 'bg-accent'
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Layers3 className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 truncate text-[13px] font-medium text-foreground">
|
||||
{view.name}
|
||||
</span>
|
||||
</div>
|
||||
{view.description || workspace ? (
|
||||
<div className="mt-1 truncate text-[11px] text-muted-foreground">
|
||||
{workspace ? `${workspace}${view.description ? ' · ' : ''}` : null}
|
||||
{view.description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Badge variant="outline" className="w-fit capitalize">
|
||||
{view.model}
|
||||
</Badge>
|
||||
<span className="truncate text-[12px] text-muted-foreground">
|
||||
{view.shared ? 'Shared' : 'Private'}
|
||||
</span>
|
||||
<span className="truncate text-[12px] text-muted-foreground">
|
||||
{textFromUnknown(view.owner ?? view.creator) ?? 'Unknown'}
|
||||
</span>
|
||||
<span className="truncate text-[12px] text-muted-foreground">
|
||||
{view.updatedAt ? dateLabel(view.updatedAt) : 'Unknown'}
|
||||
</span>
|
||||
<div className="flex justify-end md:opacity-0 md:transition-opacity md:group-hover/row:opacity-100 md:group-focus-within/row:opacity-100">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onOpenView(view)
|
||||
}}
|
||||
aria-label={`Open ${view.name} in Linear`}
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Open in Linear
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LinearProjectOverview({
|
||||
project,
|
||||
loading,
|
||||
error,
|
||||
onBack,
|
||||
onOpenProject,
|
||||
onRefresh,
|
||||
onOpenIssues
|
||||
}: LinearProjectOverviewProps): React.JSX.Element {
|
||||
const projectLike = project as LinearProjectLike | null
|
||||
const progress = projectLike ? projectProgress(projectLike) : null
|
||||
const teams = listLabels(projectLike?.teams, 4)
|
||||
const labels = listLabels(projectLike?.labels, 4)
|
||||
const members = listLabels(projectLike?.members, 4)
|
||||
const milestones = listLabels(projectLike?.milestones, 4)
|
||||
const resources = listLabels(projectLike?.resources, 4)
|
||||
const latestUpdate = textFromUnknown(projectLike?.latestUpdate ?? projectLike?.lastUpdate)
|
||||
const body = projectLike?.content || projectLike?.description || projectLike?.summary || ''
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex h-10 flex-none items-center justify-between gap-3 border-b border-border/50 bg-muted/35 px-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Button variant="ghost" size="icon-xs" onClick={onBack} aria-label="Back to projects">
|
||||
<ArrowLeft className="size-3.5" />
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13px] font-medium text-foreground">
|
||||
{project?.name ?? 'Project'}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">
|
||||
{project?.workspaceName
|
||||
? `Linear / Projects / ${project.workspaceName}`
|
||||
: 'Linear / Projects'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{onOpenIssues ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={onOpenIssues}
|
||||
className="gap-1 border-border/50 bg-background/70"
|
||||
>
|
||||
<Layers3 className="size-3.5" />
|
||||
Issues
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className="gap-1 border-border/50 bg-background/70"
|
||||
>
|
||||
<RefreshCw className={cn('size-3.5', loading && 'animate-spin')} />
|
||||
Refresh
|
||||
</Button>
|
||||
{project ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => onOpenProject(project)}
|
||||
className="gap-1 border-border/50 bg-background/70"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
Linear
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4 scrollbar-sleek">
|
||||
{error ? (
|
||||
<div className="mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{loading && !project ? (
|
||||
<div className="space-y-3">
|
||||
<div className="h-5 w-1/3 animate-pulse rounded bg-muted/70" />
|
||||
<div className="h-24 animate-pulse rounded-md bg-muted/50" />
|
||||
<div className="h-40 animate-pulse rounded-md bg-muted/50" />
|
||||
</div>
|
||||
) : projectLike ? (
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_280px]">
|
||||
<div className="min-w-0 space-y-4">
|
||||
<section className="rounded-md border border-border/50 bg-muted/20 p-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ProjectColorMark project={projectLike} />
|
||||
<h2 className="min-w-0 truncate text-base font-semibold text-foreground">
|
||||
{projectLike.name}
|
||||
</h2>
|
||||
</div>
|
||||
{body ? (
|
||||
<p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">
|
||||
{body}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-muted-foreground">No project description.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{progress !== null ? (
|
||||
<section className="rounded-md border border-border/50 bg-muted/20 p-4">
|
||||
<div className="mb-2 flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-foreground">Progress</span>
|
||||
<span className="text-muted-foreground">{progress}%</span>
|
||||
</div>
|
||||
<Progress value={Math.max(0, Math.min(100, progress))} />
|
||||
{typeof projectLike.scope === 'number' ? (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
{projectLike.scope} scoped issues
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{milestones.length > 0 || resources.length > 0 || latestUpdate ? (
|
||||
<section className="rounded-md border border-border/50 bg-muted/20 p-4">
|
||||
<h3 className="text-sm font-medium text-foreground">Planning</h3>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-3">
|
||||
<MetadataList
|
||||
icon={<FolderKanban className="size-3.5" />}
|
||||
label="Milestones"
|
||||
items={milestones}
|
||||
/>
|
||||
<MetadataList
|
||||
icon={<FileText className="size-3.5" />}
|
||||
label="Resources"
|
||||
items={resources}
|
||||
/>
|
||||
<MetadataList
|
||||
icon={<RefreshCw className="size-3.5" />}
|
||||
label="Latest update"
|
||||
items={latestUpdate ? [latestUpdate] : []}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<aside className="min-w-0 space-y-3">
|
||||
<PropertyRow
|
||||
label="Status"
|
||||
value={textFromUnknown(projectLike.status) ?? 'Backlog'}
|
||||
/>
|
||||
<PropertyRow label="Health" value={textFromUnknown(projectLike.health) ?? 'None'} />
|
||||
<PropertyRow
|
||||
label="Priority"
|
||||
value={priorityLabel(projectLike.priority, projectLike.priorityLabel)}
|
||||
/>
|
||||
<PropertyRow
|
||||
label="Lead"
|
||||
value={textFromUnknown(projectLike.lead) ?? 'Unassigned'}
|
||||
icon={<UserRound className="size-3.5" />}
|
||||
/>
|
||||
<PropertyRow
|
||||
label="Start"
|
||||
value={dateLabel(projectLike.startDate)}
|
||||
icon={<CalendarDays className="size-3.5" />}
|
||||
/>
|
||||
<PropertyRow
|
||||
label="Target"
|
||||
value={dateLabel(projectLike.targetDate)}
|
||||
icon={<CalendarDays className="size-3.5" />}
|
||||
/>
|
||||
<MetadataList label="Teams" items={teams} />
|
||||
<MetadataList label="Members" items={members} />
|
||||
<MetadataList label="Labels" items={labels} />
|
||||
</aside>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
Select a project to view its overview.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PropertyRow({
|
||||
label,
|
||||
value,
|
||||
icon
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="rounded-md border border-border/50 bg-muted/20 px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-sm text-foreground">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MetadataList({
|
||||
icon,
|
||||
label,
|
||||
items
|
||||
}: {
|
||||
icon?: React.ReactNode
|
||||
label: string
|
||||
items: string[]
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="rounded-md border border-border/50 bg-muted/20 px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
{items.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{items.map((item) => (
|
||||
<Badge key={item} variant="outline" className="max-w-full truncate">
|
||||
{item}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 text-sm text-muted-foreground">None</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,15 @@
|
|||
/* eslint-disable max-lines -- Why: runtime Linear routing cases stay together
|
||||
so local preload fallback and SSH runtime transport parity are reviewed as one boundary. */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
linearCreateIssue,
|
||||
linearCreateSubIssue,
|
||||
linearGetCustomView,
|
||||
linearGetProject,
|
||||
linearListCustomViewIssues,
|
||||
linearListCustomViewProjects,
|
||||
linearListCustomViews,
|
||||
linearListProjectIssues,
|
||||
linearListProjects,
|
||||
linearListTeams,
|
||||
linearSearchIssues,
|
||||
|
|
@ -23,6 +31,12 @@ const linearCreateIssueLocal = vi.fn()
|
|||
const linearUpdateIssueLocal = vi.fn()
|
||||
const linearListTeamsLocal = vi.fn()
|
||||
const linearListProjectsLocal = vi.fn()
|
||||
const linearGetCustomViewLocal = vi.fn()
|
||||
const linearGetProjectLocal = vi.fn()
|
||||
const linearListProjectIssuesLocal = vi.fn()
|
||||
const linearListCustomViewsLocal = vi.fn()
|
||||
const linearListCustomViewIssuesLocal = vi.fn()
|
||||
const linearListCustomViewProjectsLocal = vi.fn()
|
||||
const linearSelectWorkspaceLocal = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -35,6 +49,12 @@ beforeEach(() => {
|
|||
linearUpdateIssueLocal.mockReset()
|
||||
linearListTeamsLocal.mockReset()
|
||||
linearListProjectsLocal.mockReset()
|
||||
linearGetCustomViewLocal.mockReset()
|
||||
linearGetProjectLocal.mockReset()
|
||||
linearListProjectIssuesLocal.mockReset()
|
||||
linearListCustomViewsLocal.mockReset()
|
||||
linearListCustomViewIssuesLocal.mockReset()
|
||||
linearListCustomViewProjectsLocal.mockReset()
|
||||
linearSelectWorkspaceLocal.mockReset()
|
||||
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
|
||||
return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
|
||||
|
|
@ -49,6 +69,12 @@ beforeEach(() => {
|
|||
updateIssue: linearUpdateIssueLocal,
|
||||
listTeams: linearListTeamsLocal,
|
||||
listProjects: linearListProjectsLocal,
|
||||
getCustomView: linearGetCustomViewLocal,
|
||||
getProject: linearGetProjectLocal,
|
||||
listProjectIssues: linearListProjectIssuesLocal,
|
||||
listCustomViews: linearListCustomViewsLocal,
|
||||
listCustomViewIssues: linearListCustomViewIssuesLocal,
|
||||
listCustomViewProjects: linearListCustomViewProjectsLocal,
|
||||
selectWorkspace: linearSelectWorkspaceLocal
|
||||
}
|
||||
}
|
||||
|
|
@ -98,7 +124,7 @@ describe('runtime linear client', () => {
|
|||
|
||||
await expect(
|
||||
linearListProjects({ activeRuntimeEnvironmentId: null }, 'roadmap', 10, 'workspace-1')
|
||||
).resolves.toEqual([])
|
||||
).resolves.toEqual({ items: [] })
|
||||
})
|
||||
|
||||
it('routes Linear reads through the selected runtime environment', async () => {
|
||||
|
|
@ -176,7 +202,7 @@ describe('runtime linear client', () => {
|
|||
.mockResolvedValueOnce({
|
||||
id: 'rpc-projects',
|
||||
ok: true,
|
||||
result: [{ id: 'project-1' }],
|
||||
result: { items: [{ id: 'project-1' }] },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
|
|
@ -257,4 +283,119 @@ describe('runtime linear client', () => {
|
|||
timeoutMs: 15_000
|
||||
})
|
||||
})
|
||||
|
||||
it('routes Linear project and custom-view reads through the selected runtime environment', async () => {
|
||||
runtimeEnvironmentCall
|
||||
.mockResolvedValueOnce({
|
||||
id: 'rpc-project',
|
||||
ok: true,
|
||||
result: { id: 'project-1' },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'rpc-project-issues',
|
||||
ok: true,
|
||||
result: { items: [{ id: 'issue-1' }] },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'rpc-views',
|
||||
ok: true,
|
||||
result: { items: [{ id: 'view-1' }] },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'rpc-view',
|
||||
ok: true,
|
||||
result: { id: 'view-1' },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'rpc-view-issues',
|
||||
ok: true,
|
||||
result: { items: [{ id: 'issue-2' }] },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'rpc-view-projects',
|
||||
ok: true,
|
||||
result: { items: [{ id: 'project-2' }] },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
|
||||
await linearGetProject({ activeRuntimeEnvironmentId: 'env-1' }, 'project-1', 'workspace-1')
|
||||
await linearListProjectIssues(
|
||||
{ activeRuntimeEnvironmentId: 'env-1' },
|
||||
'project-1',
|
||||
10,
|
||||
'workspace-1'
|
||||
)
|
||||
await linearListCustomViews(
|
||||
{ activeRuntimeEnvironmentId: 'env-1' },
|
||||
'project',
|
||||
10,
|
||||
'workspace-1'
|
||||
)
|
||||
await linearGetCustomView(
|
||||
{ activeRuntimeEnvironmentId: 'env-1' },
|
||||
'view-1',
|
||||
'project',
|
||||
'workspace-1'
|
||||
)
|
||||
await linearListCustomViewIssues(
|
||||
{ activeRuntimeEnvironmentId: 'env-1' },
|
||||
'view-1',
|
||||
10,
|
||||
'workspace-1'
|
||||
)
|
||||
await linearListCustomViewProjects(
|
||||
{ activeRuntimeEnvironmentId: 'env-1' },
|
||||
'view-2',
|
||||
10,
|
||||
'workspace-1'
|
||||
)
|
||||
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, {
|
||||
selector: 'env-1',
|
||||
method: 'linear.getProject',
|
||||
params: { id: 'project-1', workspaceId: 'workspace-1' },
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, {
|
||||
selector: 'env-1',
|
||||
method: 'linear.listProjectIssues',
|
||||
params: { projectId: 'project-1', limit: 10, workspaceId: 'workspace-1' },
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, {
|
||||
selector: 'env-1',
|
||||
method: 'linear.listCustomViews',
|
||||
params: { model: 'project', limit: 10, workspaceId: 'workspace-1' },
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, {
|
||||
selector: 'env-1',
|
||||
method: 'linear.getCustomView',
|
||||
params: { viewId: 'view-1', model: 'project', workspaceId: 'workspace-1' },
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, {
|
||||
selector: 'env-1',
|
||||
method: 'linear.listCustomViewIssues',
|
||||
params: { viewId: 'view-1', limit: 10, workspaceId: 'workspace-1' },
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(6, {
|
||||
selector: 'env-1',
|
||||
method: 'linear.listCustomViewProjects',
|
||||
params: { viewId: 'view-2', limit: 10, workspaceId: 'workspace-1' },
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
expect(linearGetProjectLocal).not.toHaveBeenCalled()
|
||||
expect(linearGetCustomViewLocal).not.toHaveBeenCalled()
|
||||
expect(linearListProjectIssuesLocal).not.toHaveBeenCalled()
|
||||
expect(linearListCustomViewsLocal).not.toHaveBeenCalled()
|
||||
expect(linearListCustomViewIssuesLocal).not.toHaveBeenCalled()
|
||||
expect(linearListCustomViewProjectsLocal).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,11 +4,15 @@
|
|||
import type {
|
||||
GlobalSettings,
|
||||
LinearComment,
|
||||
LinearCollectionResult,
|
||||
LinearConnectionStatus,
|
||||
LinearCustomViewModel,
|
||||
LinearCustomViewSummary,
|
||||
LinearIssue,
|
||||
LinearIssueUpdate,
|
||||
LinearLabel,
|
||||
LinearMember,
|
||||
LinearProjectDetail,
|
||||
LinearProjectSummary,
|
||||
LinearTeam,
|
||||
LinearViewer,
|
||||
|
|
@ -268,10 +272,10 @@ export async function linearListProjects(
|
|||
query?: string,
|
||||
limit?: number,
|
||||
workspaceId?: LinearWorkspaceSelection | null
|
||||
): Promise<LinearProjectSummary[]> {
|
||||
): Promise<LinearCollectionResult<LinearProjectSummary>> {
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
return target.kind === 'environment'
|
||||
? callRuntimeRpc<LinearProjectSummary[]>(
|
||||
? callRuntimeRpc<LinearCollectionResult<LinearProjectSummary>>(
|
||||
target,
|
||||
'linear.listProjects',
|
||||
{ query, limit, workspaceId: workspaceId ?? undefined },
|
||||
|
|
@ -279,7 +283,108 @@ export async function linearListProjects(
|
|||
)
|
||||
: typeof window.api.linear.listProjects === 'function'
|
||||
? window.api.linear.listProjects({ query, limit, workspaceId: workspaceId ?? undefined })
|
||||
: []
|
||||
: { items: [] }
|
||||
}
|
||||
|
||||
export async function linearGetProject(
|
||||
settings: RuntimeLinearSettings,
|
||||
id: string,
|
||||
workspaceId: string
|
||||
): Promise<LinearProjectDetail | null> {
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
return target.kind === 'environment'
|
||||
? callRuntimeRpc<LinearProjectDetail | null>(
|
||||
target,
|
||||
'linear.getProject',
|
||||
{ id, workspaceId },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
: window.api.linear.getProject({ id, workspaceId })
|
||||
}
|
||||
|
||||
export async function linearListProjectIssues(
|
||||
settings: RuntimeLinearSettings,
|
||||
projectId: string,
|
||||
limit: number | undefined,
|
||||
workspaceId: string
|
||||
): Promise<LinearCollectionResult<LinearIssue>> {
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
return target.kind === 'environment'
|
||||
? callRuntimeRpc<LinearCollectionResult<LinearIssue>>(
|
||||
target,
|
||||
'linear.listProjectIssues',
|
||||
{ projectId, limit, workspaceId },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
: window.api.linear.listProjectIssues({ projectId, limit, workspaceId })
|
||||
}
|
||||
|
||||
export async function linearListCustomViews(
|
||||
settings: RuntimeLinearSettings,
|
||||
model: LinearCustomViewModel,
|
||||
limit?: number,
|
||||
workspaceId?: LinearWorkspaceSelection | null
|
||||
): Promise<LinearCollectionResult<LinearCustomViewSummary>> {
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
return target.kind === 'environment'
|
||||
? callRuntimeRpc<LinearCollectionResult<LinearCustomViewSummary>>(
|
||||
target,
|
||||
'linear.listCustomViews',
|
||||
{ model, limit, workspaceId: workspaceId ?? undefined },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
: window.api.linear.listCustomViews({ model, limit, workspaceId: workspaceId ?? undefined })
|
||||
}
|
||||
|
||||
export async function linearGetCustomView(
|
||||
settings: RuntimeLinearSettings,
|
||||
viewId: string,
|
||||
model: LinearCustomViewModel,
|
||||
workspaceId: string
|
||||
): Promise<LinearCustomViewSummary | null> {
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
return target.kind === 'environment'
|
||||
? callRuntimeRpc<LinearCustomViewSummary | null>(
|
||||
target,
|
||||
'linear.getCustomView',
|
||||
{ viewId, model, workspaceId },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
: window.api.linear.getCustomView({ viewId, model, workspaceId })
|
||||
}
|
||||
|
||||
export async function linearListCustomViewIssues(
|
||||
settings: RuntimeLinearSettings,
|
||||
viewId: string,
|
||||
limit: number | undefined,
|
||||
workspaceId: string
|
||||
): Promise<LinearCollectionResult<LinearIssue>> {
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
return target.kind === 'environment'
|
||||
? callRuntimeRpc<LinearCollectionResult<LinearIssue>>(
|
||||
target,
|
||||
'linear.listCustomViewIssues',
|
||||
{ viewId, limit, workspaceId },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
: window.api.linear.listCustomViewIssues({ viewId, limit, workspaceId })
|
||||
}
|
||||
|
||||
export async function linearListCustomViewProjects(
|
||||
settings: RuntimeLinearSettings,
|
||||
viewId: string,
|
||||
limit: number | undefined,
|
||||
workspaceId: string
|
||||
): Promise<LinearCollectionResult<LinearProjectSummary>> {
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
return target.kind === 'environment'
|
||||
? callRuntimeRpc<LinearCollectionResult<LinearProjectSummary>>(
|
||||
target,
|
||||
'linear.listCustomViewProjects',
|
||||
{ viewId, limit, workspaceId },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
: window.api.linear.listCustomViewProjects({ viewId, limit, workspaceId })
|
||||
}
|
||||
|
||||
export async function linearTeamStates(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,16 @@
|
|||
/* eslint-disable max-lines -- Why: Linear cache and fallback tests share one
|
||||
mocked slice harness, keeping failure-mode coverage easy to compare. */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { create } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import type { LinearConnectionStatus, LinearIssue, LinearViewer } from '../../../../shared/types'
|
||||
import type {
|
||||
LinearConnectionStatus,
|
||||
LinearIssue,
|
||||
LinearProjectDetail,
|
||||
LinearProjectSummary,
|
||||
LinearTeam,
|
||||
LinearViewer
|
||||
} from '../../../../shared/types'
|
||||
import { createLinearSlice } from './linear'
|
||||
|
||||
const linearStatus = vi.fn()
|
||||
|
|
@ -11,14 +20,28 @@ const linearListIssues = vi.fn()
|
|||
const linearSearchIssues = vi.fn()
|
||||
const linearListTeams = vi.fn()
|
||||
const linearGetIssue = vi.fn()
|
||||
const linearListProjects = vi.fn()
|
||||
const linearGetCustomView = vi.fn()
|
||||
const linearGetProject = vi.fn()
|
||||
const linearListProjectIssues = vi.fn()
|
||||
const linearListCustomViews = vi.fn()
|
||||
const linearListCustomViewIssues = vi.fn()
|
||||
const linearListCustomViewProjects = vi.fn()
|
||||
const linearTestConnection = vi.fn()
|
||||
|
||||
vi.mock('@/runtime/runtime-linear-client', () => ({
|
||||
linearConnect: (...args: unknown[]) => linearConnect(...args),
|
||||
linearDisconnect: (...args: unknown[]) => linearDisconnect(...args),
|
||||
linearDisconnectWorkspace: vi.fn(),
|
||||
linearGetCustomView: (...args: unknown[]) => linearGetCustomView(...args),
|
||||
linearGetProject: (...args: unknown[]) => linearGetProject(...args),
|
||||
linearGetIssue: (...args: unknown[]) => linearGetIssue(...args),
|
||||
linearListCustomViewIssues: (...args: unknown[]) => linearListCustomViewIssues(...args),
|
||||
linearListCustomViewProjects: (...args: unknown[]) => linearListCustomViewProjects(...args),
|
||||
linearListCustomViews: (...args: unknown[]) => linearListCustomViews(...args),
|
||||
linearListIssues: (...args: unknown[]) => linearListIssues(...args),
|
||||
linearListProjectIssues: (...args: unknown[]) => linearListProjectIssues(...args),
|
||||
linearListProjects: (...args: unknown[]) => linearListProjects(...args),
|
||||
linearListTeams: (...args: unknown[]) => linearListTeams(...args),
|
||||
linearSearchIssues: (...args: unknown[]) => linearSearchIssues(...args),
|
||||
linearSelectWorkspace: vi.fn(),
|
||||
|
|
@ -55,6 +78,14 @@ function issue(id: string): LinearIssue {
|
|||
}
|
||||
}
|
||||
|
||||
function team(id: string): LinearTeam {
|
||||
return { id, name: id, key: id, workspaceId: 'workspace-1', workspaceName: 'Workspace' }
|
||||
}
|
||||
|
||||
function project(id: string): LinearProjectSummary {
|
||||
return { id, name: id, workspaceId: 'workspace-1', workspaceName: 'Workspace' }
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((res) => {
|
||||
|
|
@ -161,6 +192,195 @@ describe('createLinearSlice caching', () => {
|
|||
).resolves.toMatchObject([{ id: 'LIN-CACHED' }])
|
||||
})
|
||||
|
||||
it('surfaces scoped project issue failures alongside cached rows', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
linearProjectIssueCache: {
|
||||
'workspace-1::project-issues::project-1::20': {
|
||||
data: { items: [issue('LIN-CACHED')] },
|
||||
fetchedAt: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
linearListProjectIssues.mockRejectedValueOnce(new Error('network down'))
|
||||
|
||||
await expect(
|
||||
store.getState().listLinearProjectIssues('project-1', 'workspace-1', 20, { force: true })
|
||||
).resolves.toMatchObject({
|
||||
items: [{ id: 'LIN-CACHED' }],
|
||||
errors: [{ workspaceId: 'workspace-1', type: 'unknown', message: 'network down' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces scoped custom-view project failures alongside cached rows', async () => {
|
||||
const store = createTestStore()
|
||||
const rateLimitError = Object.assign(new Error('slow down'), { status: 429 })
|
||||
store.setState({
|
||||
linearCustomViewProjectCache: {
|
||||
'workspace-1::custom-view-projects::view-1::20': {
|
||||
data: { items: [project('project-cached')] },
|
||||
fetchedAt: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
linearListCustomViewProjects.mockRejectedValueOnce(rateLimitError)
|
||||
|
||||
await expect(
|
||||
store.getState().listLinearCustomViewProjects('view-1', 'workspace-1', 20, {
|
||||
force: true
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
items: [{ id: 'project-cached' }],
|
||||
errors: [{ workspaceId: 'workspace-1', type: 'rate_limited', message: 'slow down' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces top-level project list failures alongside cached rows', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'workspace-1' },
|
||||
linearProjectCache: {
|
||||
'workspace-1::projects::::20': {
|
||||
data: { items: [project('project-cached')] },
|
||||
fetchedAt: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
linearListProjects.mockRejectedValueOnce(new Error('network down'))
|
||||
|
||||
await expect(
|
||||
store.getState().listLinearProjects(undefined, 20, undefined, { force: true })
|
||||
).resolves.toMatchObject({
|
||||
items: [{ id: 'project-cached' }],
|
||||
errors: [{ workspaceId: 'workspace-1', type: 'unknown', message: 'network down' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces top-level custom-view failures alongside cached rows', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'workspace-1' },
|
||||
linearCustomViewCache: {
|
||||
'workspace-1::custom-views::project::20': {
|
||||
data: {
|
||||
items: [{ id: 'view-cached', name: 'Cached view', model: 'project' }]
|
||||
},
|
||||
fetchedAt: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
linearListCustomViews.mockRejectedValueOnce(new Error('network down'))
|
||||
|
||||
await expect(
|
||||
store.getState().listLinearCustomViews('project', 20, undefined, { force: true })
|
||||
).resolves.toMatchObject({
|
||||
items: [{ id: 'view-cached' }],
|
||||
errors: [{ workspaceId: 'workspace-1', type: 'unknown', message: 'network down' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('fetches custom views by exact id for saved-context restore', async () => {
|
||||
const store = createTestStore()
|
||||
linearGetCustomView.mockResolvedValueOnce({
|
||||
id: 'view-1',
|
||||
name: 'Burn views',
|
||||
model: 'project',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
|
||||
await expect(
|
||||
store.getState().fetchLinearCustomView('view-1', 'workspace-1', 'project', { force: true })
|
||||
).resolves.toMatchObject({ id: 'view-1' })
|
||||
|
||||
expect(linearGetCustomView).toHaveBeenCalledWith(null, 'view-1', 'project', 'workspace-1')
|
||||
})
|
||||
|
||||
it('fails forced exact custom-view validation instead of reopening stale cache', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
linearCustomViewDetailCache: {
|
||||
'workspace-1::custom-view-detail::project::view-1': {
|
||||
data: {
|
||||
id: 'view-1',
|
||||
name: 'Stale view',
|
||||
model: 'project',
|
||||
workspaceId: 'workspace-1'
|
||||
},
|
||||
fetchedAt: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
linearGetCustomView.mockRejectedValueOnce(new Error('network down'))
|
||||
|
||||
await expect(
|
||||
store.getState().fetchLinearCustomView('view-1', 'workspace-1', 'project', { force: true })
|
||||
).rejects.toThrow('network down')
|
||||
})
|
||||
|
||||
it('prevents stale detail reads from overwriting forced refresh caches', async () => {
|
||||
const store = createTestStore()
|
||||
const staleProject = deferred<LinearProjectDetail | null>()
|
||||
const freshProject = deferred<LinearProjectDetail | null>()
|
||||
const staleView = deferred<{
|
||||
id: string
|
||||
name: string
|
||||
model: 'project'
|
||||
workspaceId: string
|
||||
}>()
|
||||
const freshView = deferred<{
|
||||
id: string
|
||||
name: string
|
||||
model: 'project'
|
||||
workspaceId: string
|
||||
}>()
|
||||
linearGetProject
|
||||
.mockReturnValueOnce(staleProject.promise)
|
||||
.mockReturnValueOnce(freshProject.promise)
|
||||
linearGetCustomView
|
||||
.mockReturnValueOnce(staleView.promise)
|
||||
.mockReturnValueOnce(freshView.promise)
|
||||
|
||||
const staleProjectPromise = store.getState().fetchLinearProject('project-1', 'workspace-1')
|
||||
const freshProjectPromise = store
|
||||
.getState()
|
||||
.fetchLinearProject('project-1', 'workspace-1', { force: true })
|
||||
const staleViewPromise = store
|
||||
.getState()
|
||||
.fetchLinearCustomView('view-1', 'workspace-1', 'project')
|
||||
const freshViewPromise = store
|
||||
.getState()
|
||||
.fetchLinearCustomView('view-1', 'workspace-1', 'project', { force: true })
|
||||
|
||||
freshProject.resolve({ ...project('project-1'), name: 'Fresh project' })
|
||||
freshView.resolve({
|
||||
id: 'view-1',
|
||||
name: 'Fresh view',
|
||||
model: 'project',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
await freshProjectPromise
|
||||
await freshViewPromise
|
||||
|
||||
staleProject.resolve({ ...project('project-1'), name: 'Stale project' })
|
||||
staleView.resolve({
|
||||
id: 'view-1',
|
||||
name: 'Stale view',
|
||||
model: 'project',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
await staleProjectPromise
|
||||
await staleViewPromise
|
||||
|
||||
expect(
|
||||
store.getState().linearProjectDetailCache['workspace-1::project-detail::project-1'].data?.name
|
||||
).toBe('Fresh project')
|
||||
expect(
|
||||
store.getState().linearCustomViewDetailCache[
|
||||
'workspace-1::custom-view-detail::project::view-1'
|
||||
].data?.name
|
||||
).toBe('Fresh view')
|
||||
})
|
||||
|
||||
it('preserves cached search rows when forced revalidation fails transiently', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
|
|
@ -189,6 +409,78 @@ describe('createLinearSlice caching', () => {
|
|||
store.getState().getCachedLinearIssues({ kind: 'list', filter: 'all', limit: 36 })
|
||||
).toEqual([issue('LIN-1')])
|
||||
})
|
||||
|
||||
it('keeps literal search queries separate from list cache keys', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'workspace-1' },
|
||||
linearSearchCache: {
|
||||
'workspace-1::list::all::36': { data: [issue('LIST')], fetchedAt: Date.now() }
|
||||
}
|
||||
})
|
||||
linearSearchIssues.mockResolvedValueOnce([issue('SEARCH')])
|
||||
|
||||
await expect(store.getState().searchLinearIssues('list::all', 36)).resolves.toMatchObject([
|
||||
{ id: 'SEARCH' }
|
||||
])
|
||||
|
||||
expect(linearSearchIssues).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
store.getState().getCachedLinearIssues({ kind: 'search', query: 'list::all', limit: 36 })
|
||||
).toMatchObject([{ id: 'SEARCH' }])
|
||||
expect(
|
||||
store.getState().getCachedLinearIssues({ kind: 'list', filter: 'all', limit: 36 })
|
||||
).toMatchObject([{ id: 'LIST' }])
|
||||
})
|
||||
|
||||
it('caches teams by workspace and dedupes fresh reads', async () => {
|
||||
const store = createTestStore()
|
||||
linearListTeams.mockResolvedValueOnce([team('team-1')])
|
||||
|
||||
await expect(store.getState().listLinearTeams('workspace-1')).resolves.toMatchObject([
|
||||
{ id: 'team-1' }
|
||||
])
|
||||
await expect(store.getState().listLinearTeams('workspace-1')).resolves.toMatchObject([
|
||||
{ id: 'team-1' }
|
||||
])
|
||||
|
||||
expect(linearListTeams).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().getCachedLinearTeams('workspace-1')).toMatchObject([{ id: 'team-1' }])
|
||||
})
|
||||
|
||||
it('patches issue-cache entries keyed by workspace-qualified ids', () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
linearIssueCache: {
|
||||
'workspace-1::issue-id': { data: issue('issue-id'), fetchedAt: Date.now() }
|
||||
},
|
||||
linearProjectIssueCache: {
|
||||
'workspace-1::project-issues::project-1::20': {
|
||||
data: { items: [issue('issue-id')] },
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
},
|
||||
linearCustomViewIssueCache: {
|
||||
'workspace-1::custom-view-issues::view-1::20': {
|
||||
data: { items: [issue('issue-id')] },
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
store.getState().patchLinearIssue('issue-id', { title: 'Updated' })
|
||||
|
||||
expect(store.getState().linearIssueCache['workspace-1::issue-id'].data?.title).toBe('Updated')
|
||||
expect(store.getState().linearIssueCache['workspace-1::issue-id'].fetchedAt).toBe(0)
|
||||
expect(
|
||||
store.getState().linearProjectIssueCache['workspace-1::project-issues::project-1::20'].data
|
||||
?.items[0]?.title
|
||||
).toBe('Updated')
|
||||
expect(
|
||||
store.getState().linearCustomViewIssueCache['workspace-1::custom-view-issues::view-1::20']
|
||||
.data?.items[0]?.title
|
||||
).toBe('Updated')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createLinearSlice', () => {
|
||||
|
|
|
|||
|
|
@ -6,9 +6,15 @@ import type { AppState } from '../types'
|
|||
import type {
|
||||
LinearViewer,
|
||||
LinearConnectionStatus,
|
||||
LinearCollectionResult,
|
||||
LinearCustomViewModel,
|
||||
LinearCustomViewSummary,
|
||||
LinearIssue,
|
||||
LinearProjectDetail,
|
||||
LinearProjectSummary,
|
||||
LinearTeam,
|
||||
LinearWorkspace,
|
||||
LinearWorkspaceError,
|
||||
LinearWorkspaceSelection
|
||||
} from '../../../../shared/types'
|
||||
import type { CacheEntry } from './github'
|
||||
|
|
@ -17,8 +23,15 @@ import {
|
|||
linearConnect,
|
||||
linearDisconnect,
|
||||
linearDisconnectWorkspace,
|
||||
linearGetCustomView,
|
||||
linearGetProject,
|
||||
linearGetIssue,
|
||||
linearListCustomViewIssues,
|
||||
linearListCustomViewProjects,
|
||||
linearListCustomViews,
|
||||
linearListIssues,
|
||||
linearListProjectIssues,
|
||||
linearListProjects,
|
||||
linearListTeams,
|
||||
linearSearchIssues,
|
||||
linearSelectWorkspace,
|
||||
|
|
@ -60,12 +73,46 @@ type InflightLinearIssueRequest = {
|
|||
generation: number
|
||||
}
|
||||
|
||||
function workspaceErrorType(error: unknown): LinearWorkspaceError['type'] {
|
||||
const record = error as { name?: string; message?: string; status?: number; response?: unknown }
|
||||
const message = record.message ?? String(error)
|
||||
const status =
|
||||
typeof record.status === 'number'
|
||||
? record.status
|
||||
: typeof (record.response as { status?: unknown } | undefined)?.status === 'number'
|
||||
? ((record.response as { status: number }).status as number)
|
||||
: undefined
|
||||
if (looksLikeAuthError(error)) {
|
||||
return 'auth'
|
||||
}
|
||||
if (status === 429 || /rate/i.test(record.name ?? '')) {
|
||||
return 'rate_limited'
|
||||
}
|
||||
if ((typeof status === 'number' && status >= 500) || /network/i.test(record.name ?? message)) {
|
||||
return 'network'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function workspaceErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
const inflightIssueRequests = new Map<string, InflightLinearIssueRequest>()
|
||||
type InflightLinearListRequest = {
|
||||
promise: Promise<LinearIssue[]>
|
||||
force: boolean
|
||||
generation: number
|
||||
}
|
||||
type InflightLinearCollectionRequest<T> = {
|
||||
promise: Promise<LinearCollectionResult<T>>
|
||||
force: boolean
|
||||
generation: number
|
||||
}
|
||||
type InflightLinearDetailRequest<T> = {
|
||||
promise: Promise<T>
|
||||
force: boolean
|
||||
}
|
||||
|
||||
const inflightSearchRequests = new Map<string, InflightLinearListRequest>()
|
||||
const inflightListRequests = new Map<string, InflightLinearListRequest>()
|
||||
|
|
@ -76,6 +123,31 @@ type InflightLinearTeamRequest = {
|
|||
}
|
||||
|
||||
const inflightTeamRequests = new Map<string, InflightLinearTeamRequest>()
|
||||
const inflightProjectRequests = new Map<
|
||||
string,
|
||||
InflightLinearCollectionRequest<LinearProjectSummary>
|
||||
>()
|
||||
const inflightProjectDetailRequests = new Map<
|
||||
string,
|
||||
InflightLinearDetailRequest<LinearProjectDetail | null>
|
||||
>()
|
||||
const inflightProjectIssueRequests = new Map<string, InflightLinearCollectionRequest<LinearIssue>>()
|
||||
const inflightCustomViewRequests = new Map<
|
||||
string,
|
||||
InflightLinearCollectionRequest<LinearCustomViewSummary>
|
||||
>()
|
||||
const inflightCustomViewDetailRequests = new Map<
|
||||
string,
|
||||
InflightLinearDetailRequest<LinearCustomViewSummary | null>
|
||||
>()
|
||||
const inflightCustomViewIssueRequests = new Map<
|
||||
string,
|
||||
InflightLinearCollectionRequest<LinearIssue>
|
||||
>()
|
||||
const inflightCustomViewProjectRequests = new Map<
|
||||
string,
|
||||
InflightLinearCollectionRequest<LinearProjectSummary>
|
||||
>()
|
||||
let inflightStatusRequest: Promise<void> | null = null
|
||||
let linearStatusReadGeneration = 0
|
||||
let linearMutationGeneration = 0
|
||||
|
|
@ -140,6 +212,13 @@ function clearLinearRequestMaps(): void {
|
|||
inflightSearchRequests.clear()
|
||||
inflightListRequests.clear()
|
||||
inflightTeamRequests.clear()
|
||||
inflightProjectRequests.clear()
|
||||
inflightProjectDetailRequests.clear()
|
||||
inflightProjectIssueRequests.clear()
|
||||
inflightCustomViewRequests.clear()
|
||||
inflightCustomViewDetailRequests.clear()
|
||||
inflightCustomViewIssueRequests.clear()
|
||||
inflightCustomViewProjectRequests.clear()
|
||||
}
|
||||
|
||||
function invalidateLinearCaches(): void {
|
||||
|
|
@ -154,6 +233,66 @@ function shouldRefreshStatusAfterRead(
|
|||
return workspaceId === 'all'
|
||||
}
|
||||
|
||||
function linearCollectionCacheKey(
|
||||
workspaceId: LinearWorkspaceSelection | null | undefined,
|
||||
mode: string,
|
||||
...parts: (string | number | null | undefined)[]
|
||||
): string {
|
||||
return [workspaceId ?? 'default', mode, ...parts.map((part) => part ?? '')].join('::')
|
||||
}
|
||||
|
||||
function emptyLinearCollection<T>(): LinearCollectionResult<T> {
|
||||
return { items: [] }
|
||||
}
|
||||
|
||||
function collectionWithWorkspaceError<T>(
|
||||
fallback: LinearCollectionResult<T>,
|
||||
workspaceId: string,
|
||||
error: unknown
|
||||
): LinearCollectionResult<T> {
|
||||
const existingErrors = (fallback.errors ?? []).filter((item) => item.workspaceId !== workspaceId)
|
||||
return {
|
||||
...fallback,
|
||||
errors: [
|
||||
...existingErrors,
|
||||
{
|
||||
workspaceId,
|
||||
type: workspaceErrorType(error),
|
||||
message: workspaceErrorMessage(error) || 'Linear request failed.'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function patchLinearIssueCollectionCache(
|
||||
cache: Record<string, CacheEntry<LinearCollectionResult<LinearIssue>>>,
|
||||
issueId: string,
|
||||
patch: Partial<LinearIssue>
|
||||
): {
|
||||
cache: Record<string, CacheEntry<LinearCollectionResult<LinearIssue>>>
|
||||
changed: boolean
|
||||
} {
|
||||
let changed = false
|
||||
const nextCache = { ...cache }
|
||||
for (const [key, entry] of Object.entries(nextCache)) {
|
||||
if (!entry?.data) {
|
||||
continue
|
||||
}
|
||||
const idx = entry.data.items.findIndex((item) => item.id === issueId)
|
||||
if (idx === -1) {
|
||||
continue
|
||||
}
|
||||
const updatedItems = [...entry.data.items]
|
||||
updatedItems[idx] = { ...updatedItems[idx], ...patch }
|
||||
nextCache[key] = {
|
||||
...entry,
|
||||
data: { ...entry.data, items: updatedItems }
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
return { cache: nextCache, changed }
|
||||
}
|
||||
|
||||
type LinearIssueReadArgs =
|
||||
| { kind: 'search'; query: string; limit?: number }
|
||||
| { kind: 'list'; filter?: 'assigned' | 'created' | 'all' | 'completed'; limit?: number }
|
||||
|
|
@ -176,6 +315,16 @@ export type LinearSlice = {
|
|||
linearIssueCache: Record<string, CacheEntry<LinearIssue>>
|
||||
linearSearchCache: Record<string, CacheEntry<LinearIssue[]>>
|
||||
linearTeamCache: Record<string, CacheEntry<LinearTeam[]>>
|
||||
linearProjectCache: Record<string, CacheEntry<LinearCollectionResult<LinearProjectSummary>>>
|
||||
linearProjectDetailCache: Record<string, CacheEntry<LinearProjectDetail | null>>
|
||||
linearProjectIssueCache: Record<string, CacheEntry<LinearCollectionResult<LinearIssue>>>
|
||||
linearCustomViewCache: Record<string, CacheEntry<LinearCollectionResult<LinearCustomViewSummary>>>
|
||||
linearCustomViewDetailCache: Record<string, CacheEntry<LinearCustomViewSummary | null>>
|
||||
linearCustomViewIssueCache: Record<string, CacheEntry<LinearCollectionResult<LinearIssue>>>
|
||||
linearCustomViewProjectCache: Record<
|
||||
string,
|
||||
CacheEntry<LinearCollectionResult<LinearProjectSummary>>
|
||||
>
|
||||
|
||||
checkLinearConnection: (force?: boolean) => Promise<void>
|
||||
connectLinear: (
|
||||
|
|
@ -205,6 +354,57 @@ export type LinearSlice = {
|
|||
workspaceId?: LinearWorkspaceSelection | null,
|
||||
options?: LinearFetchOptions
|
||||
) => Promise<LinearTeam[]>
|
||||
getCachedLinearProjects: (
|
||||
query?: string,
|
||||
limit?: number,
|
||||
workspaceId?: LinearWorkspaceSelection | null
|
||||
) => LinearCollectionResult<LinearProjectSummary> | null
|
||||
listLinearProjects: (
|
||||
query?: string,
|
||||
limit?: number,
|
||||
workspaceId?: LinearWorkspaceSelection | null,
|
||||
options?: LinearFetchOptions
|
||||
) => Promise<LinearCollectionResult<LinearProjectSummary>>
|
||||
fetchLinearProject: (
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
options?: LinearFetchOptions
|
||||
) => Promise<LinearProjectDetail | null>
|
||||
listLinearProjectIssues: (
|
||||
projectId: string,
|
||||
workspaceId: string,
|
||||
limit?: number,
|
||||
options?: LinearFetchOptions
|
||||
) => Promise<LinearCollectionResult<LinearIssue>>
|
||||
getCachedLinearCustomViews: (
|
||||
model: LinearCustomViewModel,
|
||||
limit?: number,
|
||||
workspaceId?: LinearWorkspaceSelection | null
|
||||
) => LinearCollectionResult<LinearCustomViewSummary> | null
|
||||
listLinearCustomViews: (
|
||||
model: LinearCustomViewModel,
|
||||
limit?: number,
|
||||
workspaceId?: LinearWorkspaceSelection | null,
|
||||
options?: LinearFetchOptions
|
||||
) => Promise<LinearCollectionResult<LinearCustomViewSummary>>
|
||||
fetchLinearCustomView: (
|
||||
viewId: string,
|
||||
workspaceId: string,
|
||||
model: LinearCustomViewModel,
|
||||
options?: LinearFetchOptions
|
||||
) => Promise<LinearCustomViewSummary | null>
|
||||
listLinearCustomViewIssues: (
|
||||
viewId: string,
|
||||
workspaceId: string,
|
||||
limit?: number,
|
||||
options?: LinearFetchOptions
|
||||
) => Promise<LinearCollectionResult<LinearIssue>>
|
||||
listLinearCustomViewProjects: (
|
||||
viewId: string,
|
||||
workspaceId: string,
|
||||
limit?: number,
|
||||
options?: LinearFetchOptions
|
||||
) => Promise<LinearCollectionResult<LinearProjectSummary>>
|
||||
patchLinearIssue: (issueId: string, patch: Partial<LinearIssue>) => void
|
||||
}
|
||||
|
||||
|
|
@ -214,6 +414,13 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
linearIssueCache: {},
|
||||
linearSearchCache: {},
|
||||
linearTeamCache: {},
|
||||
linearProjectCache: {},
|
||||
linearProjectDetailCache: {},
|
||||
linearProjectIssueCache: {},
|
||||
linearCustomViewCache: {},
|
||||
linearCustomViewDetailCache: {},
|
||||
linearCustomViewIssueCache: {},
|
||||
linearCustomViewProjectCache: {},
|
||||
|
||||
checkLinearConnection: async (force = false) => {
|
||||
if (inflightStatusRequest && !force) {
|
||||
|
|
@ -241,6 +448,13 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
linearIssueCache: {},
|
||||
linearSearchCache: {},
|
||||
linearTeamCache: {},
|
||||
linearProjectCache: {},
|
||||
linearProjectDetailCache: {},
|
||||
linearProjectIssueCache: {},
|
||||
linearCustomViewCache: {},
|
||||
linearCustomViewDetailCache: {},
|
||||
linearCustomViewIssueCache: {},
|
||||
linearCustomViewProjectCache: {},
|
||||
linearStatusChecked: true
|
||||
})
|
||||
} else if (!get().linearStatusChecked) {
|
||||
|
|
@ -255,7 +469,21 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
return
|
||||
}
|
||||
if (get().linearStatus.connected) {
|
||||
set({ linearStatus: { connected: false, viewer: null }, linearStatusChecked: true })
|
||||
invalidateLinearCaches()
|
||||
set({
|
||||
linearStatus: { connected: false, viewer: null },
|
||||
linearIssueCache: {},
|
||||
linearSearchCache: {},
|
||||
linearTeamCache: {},
|
||||
linearProjectCache: {},
|
||||
linearProjectDetailCache: {},
|
||||
linearProjectIssueCache: {},
|
||||
linearCustomViewCache: {},
|
||||
linearCustomViewDetailCache: {},
|
||||
linearCustomViewIssueCache: {},
|
||||
linearCustomViewProjectCache: {},
|
||||
linearStatusChecked: true
|
||||
})
|
||||
} else if (!get().linearStatusChecked) {
|
||||
set({ linearStatusChecked: true })
|
||||
}
|
||||
|
|
@ -285,6 +513,13 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
linearIssueCache: {},
|
||||
linearSearchCache: {},
|
||||
linearTeamCache: {},
|
||||
linearProjectCache: {},
|
||||
linearProjectDetailCache: {},
|
||||
linearProjectIssueCache: {},
|
||||
linearCustomViewCache: {},
|
||||
linearCustomViewDetailCache: {},
|
||||
linearCustomViewIssueCache: {},
|
||||
linearCustomViewProjectCache: {},
|
||||
linearStatusChecked: true
|
||||
})
|
||||
} else {
|
||||
|
|
@ -307,7 +542,14 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
set({
|
||||
linearIssueCache: {},
|
||||
linearSearchCache: {},
|
||||
linearTeamCache: {}
|
||||
linearTeamCache: {},
|
||||
linearProjectCache: {},
|
||||
linearProjectDetailCache: {},
|
||||
linearProjectIssueCache: {},
|
||||
linearCustomViewCache: {},
|
||||
linearCustomViewDetailCache: {},
|
||||
linearCustomViewIssueCache: {},
|
||||
linearCustomViewProjectCache: {}
|
||||
})
|
||||
const status = await linearStatus(get().settings)
|
||||
if (!isCurrentLinearMutation(requestGeneration)) {
|
||||
|
|
@ -337,6 +579,13 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
linearIssueCache: {},
|
||||
linearSearchCache: {},
|
||||
linearTeamCache: {},
|
||||
linearProjectCache: {},
|
||||
linearProjectDetailCache: {},
|
||||
linearProjectIssueCache: {},
|
||||
linearCustomViewCache: {},
|
||||
linearCustomViewDetailCache: {},
|
||||
linearCustomViewIssueCache: {},
|
||||
linearCustomViewProjectCache: {},
|
||||
linearStatusChecked: true
|
||||
})
|
||||
},
|
||||
|
|
@ -353,6 +602,13 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
linearIssueCache: {},
|
||||
linearSearchCache: {},
|
||||
linearTeamCache: {},
|
||||
linearProjectCache: {},
|
||||
linearProjectDetailCache: {},
|
||||
linearProjectIssueCache: {},
|
||||
linearCustomViewCache: {},
|
||||
linearCustomViewDetailCache: {},
|
||||
linearCustomViewIssueCache: {},
|
||||
linearCustomViewProjectCache: {},
|
||||
linearStatusChecked: true
|
||||
})
|
||||
},
|
||||
|
|
@ -370,6 +626,13 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
linearIssueCache: {},
|
||||
linearSearchCache: {},
|
||||
linearTeamCache: {},
|
||||
linearProjectCache: {},
|
||||
linearProjectDetailCache: {},
|
||||
linearProjectIssueCache: {},
|
||||
linearCustomViewCache: {},
|
||||
linearCustomViewDetailCache: {},
|
||||
linearCustomViewIssueCache: {},
|
||||
linearCustomViewProjectCache: {},
|
||||
linearStatusChecked: true
|
||||
})
|
||||
},
|
||||
|
|
@ -632,6 +895,380 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
return promise
|
||||
},
|
||||
|
||||
getCachedLinearProjects: (query, limit = 20, workspaceId) => {
|
||||
const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus)
|
||||
const cacheKey = linearCollectionCacheKey(resolvedWorkspaceId, 'projects', query?.trim(), limit)
|
||||
return get().linearProjectCache[cacheKey]?.data ?? null
|
||||
},
|
||||
|
||||
listLinearProjects: async (query, limit = 20, workspaceId, options) => {
|
||||
const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus)
|
||||
const trimmed = query?.trim() || undefined
|
||||
const cacheKey = linearCollectionCacheKey(resolvedWorkspaceId, 'projects', trimmed, limit)
|
||||
const cached = get().linearProjectCache[cacheKey]
|
||||
if (!options?.force && isFresh(cached)) {
|
||||
return cached.data ?? emptyLinearCollection<LinearProjectSummary>()
|
||||
}
|
||||
|
||||
const inflight = inflightProjectRequests.get(cacheKey)
|
||||
if (inflight && (!options?.force || inflight.force)) {
|
||||
return inflight.promise
|
||||
}
|
||||
|
||||
let entry: InflightLinearCollectionRequest<LinearProjectSummary>
|
||||
const requestCacheGeneration = linearCacheGeneration
|
||||
const promise = linearListProjects(get().settings, trimmed, limit, resolvedWorkspaceId)
|
||||
.then((result) => {
|
||||
if (
|
||||
inflightProjectRequests.get(cacheKey) === entry &&
|
||||
requestCacheGeneration === linearCacheGeneration
|
||||
) {
|
||||
set((s) => ({
|
||||
linearProjectCache: evictStaleEntries({
|
||||
...s.linearProjectCache,
|
||||
[cacheKey]: { data: result, fetchedAt: Date.now() }
|
||||
})
|
||||
}))
|
||||
}
|
||||
return result
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[linear] listLinearProjects failed:', error)
|
||||
if (looksLikeAuthError(error)) {
|
||||
set({ linearStatus: { connected: false, viewer: null } })
|
||||
}
|
||||
const fallback =
|
||||
get().linearProjectCache[cacheKey]?.data ?? emptyLinearCollection<LinearProjectSummary>()
|
||||
return collectionWithWorkspaceError(fallback, resolvedWorkspaceId ?? 'default', error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (inflightProjectRequests.get(cacheKey) === entry) {
|
||||
inflightProjectRequests.delete(cacheKey)
|
||||
}
|
||||
if (
|
||||
shouldRefreshStatusAfterRead(resolvedWorkspaceId) &&
|
||||
requestCacheGeneration === linearCacheGeneration
|
||||
) {
|
||||
void get().checkLinearConnection(true)
|
||||
}
|
||||
})
|
||||
|
||||
entry = { promise, force: Boolean(options?.force), generation: requestCacheGeneration }
|
||||
inflightProjectRequests.set(cacheKey, entry)
|
||||
return promise
|
||||
},
|
||||
|
||||
fetchLinearProject: async (id, workspaceId, options) => {
|
||||
const cacheKey = linearCollectionCacheKey(workspaceId, 'project-detail', id)
|
||||
const cached = get().linearProjectDetailCache[cacheKey]
|
||||
if (!options?.force && isFresh(cached)) {
|
||||
return cached.data
|
||||
}
|
||||
|
||||
const inflight = inflightProjectDetailRequests.get(cacheKey)
|
||||
if (inflight && (!options?.force || inflight.force)) {
|
||||
return inflight.promise
|
||||
}
|
||||
|
||||
let entry: InflightLinearDetailRequest<LinearProjectDetail | null>
|
||||
const promise = linearGetProject(get().settings, id, workspaceId)
|
||||
.then((project) => {
|
||||
if (inflightProjectDetailRequests.get(cacheKey) === entry) {
|
||||
set((s) => ({
|
||||
linearProjectDetailCache: evictStaleEntries({
|
||||
...s.linearProjectDetailCache,
|
||||
[cacheKey]: { data: project, fetchedAt: Date.now() }
|
||||
})
|
||||
}))
|
||||
}
|
||||
return project
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[linear] fetchLinearProject failed:', error)
|
||||
if (looksLikeAuthError(error)) {
|
||||
set({ linearStatus: { connected: false, viewer: null } })
|
||||
}
|
||||
if (options?.force) {
|
||||
throw error
|
||||
}
|
||||
const cachedResult = get().linearProjectDetailCache[cacheKey]
|
||||
if (cachedResult) {
|
||||
return cachedResult.data
|
||||
}
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
if (inflightProjectDetailRequests.get(cacheKey) === entry) {
|
||||
inflightProjectDetailRequests.delete(cacheKey)
|
||||
}
|
||||
})
|
||||
|
||||
entry = { promise, force: Boolean(options?.force) }
|
||||
inflightProjectDetailRequests.set(cacheKey, entry)
|
||||
return promise
|
||||
},
|
||||
|
||||
listLinearProjectIssues: async (projectId, workspaceId, limit = 20, options) => {
|
||||
const cacheKey = linearCollectionCacheKey(workspaceId, 'project-issues', projectId, limit)
|
||||
const cached = get().linearProjectIssueCache[cacheKey]
|
||||
if (!options?.force && isFresh(cached)) {
|
||||
return cached.data ?? emptyLinearCollection<LinearIssue>()
|
||||
}
|
||||
|
||||
const inflight = inflightProjectIssueRequests.get(cacheKey)
|
||||
if (inflight && (!options?.force || inflight.force)) {
|
||||
return inflight.promise
|
||||
}
|
||||
|
||||
let entry: InflightLinearCollectionRequest<LinearIssue>
|
||||
const requestCacheGeneration = linearCacheGeneration
|
||||
const promise = linearListProjectIssues(get().settings, projectId, limit, workspaceId)
|
||||
.then((result) => {
|
||||
if (
|
||||
inflightProjectIssueRequests.get(cacheKey) === entry &&
|
||||
requestCacheGeneration === linearCacheGeneration
|
||||
) {
|
||||
set((s) => ({
|
||||
linearProjectIssueCache: evictStaleEntries({
|
||||
...s.linearProjectIssueCache,
|
||||
[cacheKey]: { data: result, fetchedAt: Date.now() }
|
||||
})
|
||||
}))
|
||||
}
|
||||
return result
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[linear] listLinearProjectIssues failed:', error)
|
||||
if (looksLikeAuthError(error)) {
|
||||
set({ linearStatus: { connected: false, viewer: null } })
|
||||
}
|
||||
const fallback =
|
||||
get().linearProjectIssueCache[cacheKey]?.data ?? emptyLinearCollection<LinearIssue>()
|
||||
return collectionWithWorkspaceError(fallback, workspaceId, error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (inflightProjectIssueRequests.get(cacheKey) === entry) {
|
||||
inflightProjectIssueRequests.delete(cacheKey)
|
||||
}
|
||||
})
|
||||
|
||||
entry = { promise, force: Boolean(options?.force), generation: requestCacheGeneration }
|
||||
inflightProjectIssueRequests.set(cacheKey, entry)
|
||||
return promise
|
||||
},
|
||||
|
||||
getCachedLinearCustomViews: (model, limit = 20, workspaceId) => {
|
||||
const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus)
|
||||
const cacheKey = linearCollectionCacheKey(resolvedWorkspaceId, 'custom-views', model, limit)
|
||||
return get().linearCustomViewCache[cacheKey]?.data ?? null
|
||||
},
|
||||
|
||||
listLinearCustomViews: async (model, limit = 20, workspaceId, options) => {
|
||||
const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus)
|
||||
const cacheKey = linearCollectionCacheKey(resolvedWorkspaceId, 'custom-views', model, limit)
|
||||
const cached = get().linearCustomViewCache[cacheKey]
|
||||
if (!options?.force && isFresh(cached)) {
|
||||
return cached.data ?? emptyLinearCollection<LinearCustomViewSummary>()
|
||||
}
|
||||
|
||||
const inflight = inflightCustomViewRequests.get(cacheKey)
|
||||
if (inflight && (!options?.force || inflight.force)) {
|
||||
return inflight.promise
|
||||
}
|
||||
|
||||
let entry: InflightLinearCollectionRequest<LinearCustomViewSummary>
|
||||
const requestCacheGeneration = linearCacheGeneration
|
||||
const promise = linearListCustomViews(get().settings, model, limit, resolvedWorkspaceId)
|
||||
.then((result) => {
|
||||
if (
|
||||
inflightCustomViewRequests.get(cacheKey) === entry &&
|
||||
requestCacheGeneration === linearCacheGeneration
|
||||
) {
|
||||
set((s) => ({
|
||||
linearCustomViewCache: evictStaleEntries({
|
||||
...s.linearCustomViewCache,
|
||||
[cacheKey]: { data: result, fetchedAt: Date.now() }
|
||||
})
|
||||
}))
|
||||
}
|
||||
return result
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[linear] listLinearCustomViews failed:', error)
|
||||
if (looksLikeAuthError(error)) {
|
||||
set({ linearStatus: { connected: false, viewer: null } })
|
||||
}
|
||||
const fallback =
|
||||
get().linearCustomViewCache[cacheKey]?.data ??
|
||||
emptyLinearCollection<LinearCustomViewSummary>()
|
||||
return collectionWithWorkspaceError(fallback, resolvedWorkspaceId ?? 'default', error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (inflightCustomViewRequests.get(cacheKey) === entry) {
|
||||
inflightCustomViewRequests.delete(cacheKey)
|
||||
}
|
||||
if (
|
||||
shouldRefreshStatusAfterRead(resolvedWorkspaceId) &&
|
||||
requestCacheGeneration === linearCacheGeneration
|
||||
) {
|
||||
void get().checkLinearConnection(true)
|
||||
}
|
||||
})
|
||||
|
||||
entry = { promise, force: Boolean(options?.force), generation: requestCacheGeneration }
|
||||
inflightCustomViewRequests.set(cacheKey, entry)
|
||||
return promise
|
||||
},
|
||||
|
||||
fetchLinearCustomView: async (viewId, workspaceId, model, options) => {
|
||||
const cacheKey = linearCollectionCacheKey(workspaceId, 'custom-view-detail', model, viewId)
|
||||
const cached = get().linearCustomViewDetailCache[cacheKey]
|
||||
if (!options?.force && isFresh(cached)) {
|
||||
return cached.data
|
||||
}
|
||||
|
||||
const inflight = inflightCustomViewDetailRequests.get(cacheKey)
|
||||
if (inflight && (!options?.force || inflight.force)) {
|
||||
return inflight.promise
|
||||
}
|
||||
|
||||
let entry: InflightLinearDetailRequest<LinearCustomViewSummary | null>
|
||||
const promise = linearGetCustomView(get().settings, viewId, model, workspaceId)
|
||||
.then((view) => {
|
||||
if (inflightCustomViewDetailRequests.get(cacheKey) === entry) {
|
||||
set((s) => ({
|
||||
linearCustomViewDetailCache: evictStaleEntries({
|
||||
...s.linearCustomViewDetailCache,
|
||||
[cacheKey]: { data: view, fetchedAt: Date.now() }
|
||||
})
|
||||
}))
|
||||
}
|
||||
return view
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[linear] fetchLinearCustomView failed:', error)
|
||||
if (looksLikeAuthError(error)) {
|
||||
set({ linearStatus: { connected: false, viewer: null } })
|
||||
}
|
||||
if (options?.force) {
|
||||
throw error
|
||||
}
|
||||
const cachedResult = get().linearCustomViewDetailCache[cacheKey]
|
||||
if (cachedResult) {
|
||||
return cachedResult.data
|
||||
}
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
if (inflightCustomViewDetailRequests.get(cacheKey) === entry) {
|
||||
inflightCustomViewDetailRequests.delete(cacheKey)
|
||||
}
|
||||
})
|
||||
|
||||
entry = { promise, force: Boolean(options?.force) }
|
||||
inflightCustomViewDetailRequests.set(cacheKey, entry)
|
||||
return promise
|
||||
},
|
||||
|
||||
listLinearCustomViewIssues: async (viewId, workspaceId, limit = 20, options) => {
|
||||
const cacheKey = linearCollectionCacheKey(workspaceId, 'custom-view-issues', viewId, limit)
|
||||
const cached = get().linearCustomViewIssueCache[cacheKey]
|
||||
if (!options?.force && isFresh(cached)) {
|
||||
return cached.data ?? emptyLinearCollection<LinearIssue>()
|
||||
}
|
||||
|
||||
const inflight = inflightCustomViewIssueRequests.get(cacheKey)
|
||||
if (inflight && (!options?.force || inflight.force)) {
|
||||
return inflight.promise
|
||||
}
|
||||
|
||||
let entry: InflightLinearCollectionRequest<LinearIssue>
|
||||
const requestCacheGeneration = linearCacheGeneration
|
||||
const promise = linearListCustomViewIssues(get().settings, viewId, limit, workspaceId)
|
||||
.then((result) => {
|
||||
if (
|
||||
inflightCustomViewIssueRequests.get(cacheKey) === entry &&
|
||||
requestCacheGeneration === linearCacheGeneration
|
||||
) {
|
||||
set((s) => ({
|
||||
linearCustomViewIssueCache: evictStaleEntries({
|
||||
...s.linearCustomViewIssueCache,
|
||||
[cacheKey]: { data: result, fetchedAt: Date.now() }
|
||||
})
|
||||
}))
|
||||
}
|
||||
return result
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[linear] listLinearCustomViewIssues failed:', error)
|
||||
if (looksLikeAuthError(error)) {
|
||||
set({ linearStatus: { connected: false, viewer: null } })
|
||||
}
|
||||
const fallback =
|
||||
get().linearCustomViewIssueCache[cacheKey]?.data ?? emptyLinearCollection<LinearIssue>()
|
||||
return collectionWithWorkspaceError(fallback, workspaceId, error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (inflightCustomViewIssueRequests.get(cacheKey) === entry) {
|
||||
inflightCustomViewIssueRequests.delete(cacheKey)
|
||||
}
|
||||
})
|
||||
|
||||
entry = { promise, force: Boolean(options?.force), generation: requestCacheGeneration }
|
||||
inflightCustomViewIssueRequests.set(cacheKey, entry)
|
||||
return promise
|
||||
},
|
||||
|
||||
listLinearCustomViewProjects: async (viewId, workspaceId, limit = 20, options) => {
|
||||
const cacheKey = linearCollectionCacheKey(workspaceId, 'custom-view-projects', viewId, limit)
|
||||
const cached = get().linearCustomViewProjectCache[cacheKey]
|
||||
if (!options?.force && isFresh(cached)) {
|
||||
return cached.data ?? emptyLinearCollection<LinearProjectSummary>()
|
||||
}
|
||||
|
||||
const inflight = inflightCustomViewProjectRequests.get(cacheKey)
|
||||
if (inflight && (!options?.force || inflight.force)) {
|
||||
return inflight.promise
|
||||
}
|
||||
|
||||
let entry: InflightLinearCollectionRequest<LinearProjectSummary>
|
||||
const requestCacheGeneration = linearCacheGeneration
|
||||
const promise = linearListCustomViewProjects(get().settings, viewId, limit, workspaceId)
|
||||
.then((result) => {
|
||||
if (
|
||||
inflightCustomViewProjectRequests.get(cacheKey) === entry &&
|
||||
requestCacheGeneration === linearCacheGeneration
|
||||
) {
|
||||
set((s) => ({
|
||||
linearCustomViewProjectCache: evictStaleEntries({
|
||||
...s.linearCustomViewProjectCache,
|
||||
[cacheKey]: { data: result, fetchedAt: Date.now() }
|
||||
})
|
||||
}))
|
||||
}
|
||||
return result
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[linear] listLinearCustomViewProjects failed:', error)
|
||||
if (looksLikeAuthError(error)) {
|
||||
set({ linearStatus: { connected: false, viewer: null } })
|
||||
}
|
||||
const fallback =
|
||||
get().linearCustomViewProjectCache[cacheKey]?.data ??
|
||||
emptyLinearCollection<LinearProjectSummary>()
|
||||
return collectionWithWorkspaceError(fallback, workspaceId, error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (inflightCustomViewProjectRequests.get(cacheKey) === entry) {
|
||||
inflightCustomViewProjectRequests.delete(cacheKey)
|
||||
}
|
||||
})
|
||||
|
||||
entry = { promise, force: Boolean(options?.force), generation: requestCacheGeneration }
|
||||
inflightCustomViewProjectRequests.set(cacheKey, entry)
|
||||
return promise
|
||||
},
|
||||
|
||||
patchLinearIssue: (issueId, patch) => {
|
||||
set((s) => {
|
||||
let changed = false
|
||||
|
|
@ -667,7 +1304,36 @@ export const createLinearSlice: StateCreator<AppState, [], [], LinearSlice> = (s
|
|||
changed = true
|
||||
}
|
||||
|
||||
return changed ? { linearIssueCache: nextIssueCache, linearSearchCache: nextSearchCache } : {}
|
||||
const nextProjectIssueCache = patchLinearIssueCollectionCache(
|
||||
s.linearProjectIssueCache,
|
||||
issueId,
|
||||
patch
|
||||
)
|
||||
if (nextProjectIssueCache.changed) {
|
||||
changed = true
|
||||
}
|
||||
|
||||
const nextCustomViewIssueCache = patchLinearIssueCollectionCache(
|
||||
s.linearCustomViewIssueCache,
|
||||
issueId,
|
||||
patch
|
||||
)
|
||||
if (nextCustomViewIssueCache.changed) {
|
||||
changed = true
|
||||
}
|
||||
|
||||
return changed
|
||||
? {
|
||||
linearIssueCache: nextIssueCache,
|
||||
linearSearchCache: nextSearchCache,
|
||||
linearProjectIssueCache: nextProjectIssueCache.changed
|
||||
? nextProjectIssueCache.cache
|
||||
: s.linearProjectIssueCache,
|
||||
linearCustomViewIssueCache: nextCustomViewIssueCache.changed
|
||||
? nextCustomViewIssueCache.cache
|
||||
: s.linearCustomViewIssueCache
|
||||
}
|
||||
: {}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -122,7 +122,14 @@ function runtimeScopedStateReset(): Partial<AppState> {
|
|||
linearStatusChecked: false,
|
||||
linearIssueCache: {},
|
||||
linearSearchCache: {},
|
||||
linearTeamCache: {}
|
||||
linearTeamCache: {},
|
||||
linearProjectCache: {},
|
||||
linearProjectDetailCache: {},
|
||||
linearProjectIssueCache: {},
|
||||
linearCustomViewCache: {},
|
||||
linearCustomViewDetailCache: {},
|
||||
linearCustomViewIssueCache: {},
|
||||
linearCustomViewProjectCache: {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -219,6 +219,11 @@ const VALID_LINEAR_PRESETS = new Set<NonNullable<TaskResumeState['linearPreset']
|
|||
'all',
|
||||
'completed'
|
||||
])
|
||||
const VALID_LINEAR_MODES = new Set<NonNullable<TaskResumeState['linearMode']>>([
|
||||
'issues',
|
||||
'projects',
|
||||
'views'
|
||||
])
|
||||
|
||||
function filterTrustedOrcaHooksToValidRepos(
|
||||
trust: PersistedTrustedOrcaHooks,
|
||||
|
|
@ -340,9 +345,33 @@ function sanitizeTaskResumeState(value: unknown): TaskResumeState | undefined {
|
|||
) {
|
||||
next.linearPreset = input.linearPreset as NonNullable<TaskResumeState['linearPreset']>
|
||||
}
|
||||
if (
|
||||
typeof input.linearMode === 'string' &&
|
||||
VALID_LINEAR_MODES.has(input.linearMode as NonNullable<TaskResumeState['linearMode']>)
|
||||
) {
|
||||
next.linearMode = input.linearMode as NonNullable<TaskResumeState['linearMode']>
|
||||
}
|
||||
if (typeof input.linearQuery === 'string') {
|
||||
next.linearQuery = input.linearQuery
|
||||
}
|
||||
if (input.linearContext && typeof input.linearContext === 'object') {
|
||||
const context = input.linearContext as Record<string, unknown>
|
||||
if (
|
||||
(context.kind === 'project' || context.kind === 'view') &&
|
||||
typeof context.id === 'string' &&
|
||||
context.id.trim() &&
|
||||
typeof context.workspaceId === 'string' &&
|
||||
context.workspaceId.trim() &&
|
||||
context.workspaceId !== 'all'
|
||||
) {
|
||||
next.linearContext = {
|
||||
kind: context.kind,
|
||||
id: context.id,
|
||||
workspaceId: context.workspaceId,
|
||||
model: context.model === 'issue' || context.model === 'project' ? context.model : undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(next).length > 0 ? next : undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1066,6 +1066,21 @@ export type LinearWorkspace = LinearViewer & {
|
|||
}
|
||||
|
||||
export type LinearWorkspaceSelection = string | 'all'
|
||||
export type LinearWorkspaceSelector = LinearWorkspaceSelection | undefined
|
||||
export type LinearConcreteWorkspaceId = string
|
||||
|
||||
export type LinearWorkspaceError = {
|
||||
workspaceId: string
|
||||
workspaceName?: string
|
||||
type: 'auth' | 'rate_limited' | 'network' | 'unknown'
|
||||
message: string
|
||||
}
|
||||
|
||||
export type LinearCollectionResult<T> = {
|
||||
items: T[]
|
||||
errors?: LinearWorkspaceError[]
|
||||
hasMore?: boolean
|
||||
}
|
||||
|
||||
export type LinearConnectionStatus = {
|
||||
connected: boolean
|
||||
|
|
@ -1109,9 +1124,109 @@ export type LinearIssue = {
|
|||
|
||||
export type LinearProjectSummary = {
|
||||
id: string
|
||||
workspaceId?: string
|
||||
workspaceName?: string
|
||||
name: string
|
||||
url?: string
|
||||
color?: string
|
||||
icon?: string
|
||||
description?: string
|
||||
content?: string
|
||||
status?: LinearProjectStatusSummary
|
||||
health?: string | null
|
||||
priority?: number | null
|
||||
priorityLabel?: string | null
|
||||
lead?: LinearProjectMemberSummary
|
||||
members?: LinearProjectMemberSummary[]
|
||||
teams?: {
|
||||
id: string
|
||||
name: string
|
||||
key?: string
|
||||
}[]
|
||||
labels?: {
|
||||
id: string
|
||||
name: string
|
||||
color?: string
|
||||
}[]
|
||||
startDate?: string | null
|
||||
targetDate?: string | null
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
completedAt?: string | null
|
||||
canceledAt?: string | null
|
||||
startedAt?: string | null
|
||||
progress?: number | null
|
||||
scope?: number | null
|
||||
issueCount?: number
|
||||
completedIssueCount?: number
|
||||
}
|
||||
|
||||
export type LinearProjectStatusSummary = {
|
||||
id: string
|
||||
name: string
|
||||
type?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export type LinearProjectMemberSummary = {
|
||||
id: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
export type LinearProjectMilestoneSummary = {
|
||||
id: string
|
||||
name: string
|
||||
status?: string
|
||||
targetDate?: string | null
|
||||
progress?: number | null
|
||||
}
|
||||
|
||||
export type LinearProjectResourceSummary = {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
export type LinearProjectUpdateSummary = {
|
||||
id: string
|
||||
body?: string
|
||||
health?: string | null
|
||||
url?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
user?: LinearProjectMemberSummary
|
||||
}
|
||||
|
||||
export type LinearProjectDetail = LinearProjectSummary & {
|
||||
milestones?: LinearProjectMilestoneSummary[]
|
||||
resources?: LinearProjectResourceSummary[]
|
||||
latestUpdate?: LinearProjectUpdateSummary
|
||||
}
|
||||
|
||||
export type LinearCustomViewModel = 'issue' | 'project'
|
||||
|
||||
export type LinearCustomViewSummary = {
|
||||
id: string
|
||||
workspaceId?: string
|
||||
workspaceName?: string
|
||||
name: string
|
||||
description?: string
|
||||
model: LinearCustomViewModel
|
||||
url?: string
|
||||
color?: string
|
||||
icon?: string
|
||||
shared?: boolean
|
||||
team?: {
|
||||
id: string
|
||||
name?: string
|
||||
key?: string
|
||||
}
|
||||
owner?: LinearProjectMemberSummary
|
||||
creator?: LinearProjectMemberSummary
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export type LinearIssueChildSummary = {
|
||||
|
|
@ -2250,8 +2365,15 @@ export type TaskResumeState = {
|
|||
githubItemsPreset?: TaskViewPresetId | null
|
||||
githubItemsQuery?: string
|
||||
githubProjectHiddenFieldIdsByView?: Record<string, string[]>
|
||||
linearMode?: 'issues' | 'projects' | 'views'
|
||||
linearPreset?: 'assigned' | 'created' | 'all' | 'completed'
|
||||
linearQuery?: string
|
||||
linearContext?: {
|
||||
kind: 'project' | 'view'
|
||||
id: string
|
||||
workspaceId: LinearConcreteWorkspaceId
|
||||
model?: LinearCustomViewModel
|
||||
}
|
||||
}
|
||||
|
||||
export type RightSidebarTab = 'explorer' | 'search' | 'source-control' | 'checks' | 'ports'
|
||||
|
|
|
|||
Loading…
Reference in New Issue