1036 lines
26 KiB
TypeScript
1036 lines
26 KiB
TypeScript
/* 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 {
|
|
LINEAR_ISSUE_API_PAGE_SIZE_MAX,
|
|
clampLinearIssueListLimit
|
|
} from '../../shared/linear-issue-read-limits'
|
|
import {
|
|
acquire,
|
|
clearToken,
|
|
getClients,
|
|
isAuthError,
|
|
release,
|
|
type LinearClientForWorkspace
|
|
} from './client'
|
|
|
|
type LinearRawVariables = Record<string, unknown>
|
|
|
|
type PageInfoNode = {
|
|
hasNextPage?: boolean | null
|
|
endCursor?: string | 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,
|
|
$after: String,
|
|
$orderBy: PaginationOrderBy
|
|
) {
|
|
project(id: $id) {
|
|
issues(first: $first, after: $after, orderBy: $orderBy) {
|
|
nodes {
|
|
${ORCA_ISSUE_FIELDS}
|
|
}
|
|
pageInfo {
|
|
hasNextPage
|
|
endCursor
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
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,
|
|
$after: String,
|
|
$orderBy: PaginationOrderBy
|
|
) {
|
|
customView(id: $id) {
|
|
id
|
|
modelName
|
|
issues(first: $first, after: $after, orderBy: $orderBy) {
|
|
nodes {
|
|
${ORCA_ISSUE_FIELDS}
|
|
}
|
|
pageInfo {
|
|
hasNextPage
|
|
endCursor
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
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>, force = false): Promise<T> {
|
|
const existing = inFlight.get(key) as Promise<T> | undefined
|
|
if (existing && !force) {
|
|
return existing
|
|
}
|
|
const promise = load().finally(() => {
|
|
if (inFlight.get(key) === promise) {
|
|
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,
|
|
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 readIssueConnectionPages(
|
|
entry: LinearClientForWorkspace,
|
|
limit: number,
|
|
loadConnection: (variables: {
|
|
first: number
|
|
after?: string
|
|
}) => Promise<LinearConnection<LinearIssueNode> | null | undefined>
|
|
): Promise<LinearCollectionResult<LinearIssue>> {
|
|
const items: LinearIssue[] = []
|
|
let after: string | undefined
|
|
let hasMore = false
|
|
|
|
while (items.length < limit) {
|
|
// Why: Linear returns issue connections in pages of up to 50; expanded
|
|
// Orca reads must follow cursors to show more than one backend page.
|
|
const first = Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, limit - items.length)
|
|
const connection = await loadConnection(after ? { first, after } : { first })
|
|
const nodes = connection?.nodes ?? []
|
|
items.push(...nodes.map((issue) => mapIssueForWorkspace(entry, issue)))
|
|
hasMore = Boolean(connection?.pageInfo?.hasNextPage)
|
|
|
|
const nextCursor = connection?.pageInfo?.endCursor ?? undefined
|
|
if (!hasMore || !nextCursor || nextCursor === after || nodes.length === 0) {
|
|
break
|
|
}
|
|
after = nextCursor
|
|
}
|
|
|
|
return { items, hasMore }
|
|
}
|
|
|
|
async function readCollection<T>(
|
|
key: string,
|
|
workspaceId: LinearWorkspaceSelection | null | undefined,
|
|
load: (entry: LinearClientForWorkspace) => Promise<LinearCollectionResult<T>>,
|
|
force = false
|
|
): 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)
|
|
}
|
|
},
|
|
force
|
|
)
|
|
}
|
|
|
|
async function readConcreteCollection<T>(
|
|
key: string,
|
|
workspaceId: LinearConcreteWorkspaceId,
|
|
load: (entry: LinearClientForWorkspace) => Promise<LinearCollectionResult<T>>,
|
|
force = false
|
|
): Promise<LinearCollectionResult<T>> {
|
|
const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId)
|
|
return readCollection(key, concreteWorkspaceId, load, force)
|
|
}
|
|
|
|
export async function listProjects(
|
|
query: string | undefined,
|
|
limit = 20,
|
|
workspaceId?: LinearWorkspaceSelection | null,
|
|
force = false
|
|
): Promise<LinearCollectionResult<LinearProjectSummary>> {
|
|
const first = clampLimit(limit)
|
|
const trimmed = query?.trim()
|
|
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
|
|
}
|
|
},
|
|
force
|
|
)
|
|
}
|
|
|
|
export async function getProject(
|
|
id: string,
|
|
workspaceId: LinearConcreteWorkspaceId,
|
|
force = false
|
|
): 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()
|
|
}
|
|
},
|
|
force
|
|
)
|
|
}
|
|
|
|
export async function listProjectIssues(
|
|
projectId: string,
|
|
limit = 20,
|
|
workspaceId: LinearConcreteWorkspaceId,
|
|
force = false
|
|
): Promise<LinearCollectionResult<LinearIssue>> {
|
|
const id = projectId.trim()
|
|
if (!id) {
|
|
throw new Error('Project ID is required')
|
|
}
|
|
const first = clampLinearIssueListLimit(limit)
|
|
const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId)
|
|
return readConcreteCollection(
|
|
`listProjectIssues:${concreteWorkspaceId}:${id}:${first}`,
|
|
concreteWorkspaceId,
|
|
async (entry) => {
|
|
return readIssueConnectionPages(entry, first, async (page) => {
|
|
const result = await entry.client.client.rawRequest<
|
|
ProjectIssueConnectionResponse,
|
|
LinearRawVariables
|
|
>(PROJECT_ISSUES_QUERY, { id, ...page, orderBy: 'updatedAt' })
|
|
const project = result.data?.project
|
|
if (!project) {
|
|
throw new Error('Project was not found')
|
|
}
|
|
return project.issues
|
|
})
|
|
},
|
|
force
|
|
)
|
|
}
|
|
|
|
export async function listCustomViews(
|
|
model: LinearCustomViewModel,
|
|
limit = 20,
|
|
workspaceId?: LinearWorkspaceSelection | null,
|
|
force = false
|
|
): 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
|
|
}
|
|
},
|
|
force
|
|
)
|
|
}
|
|
|
|
export async function getCustomView(
|
|
viewId: string,
|
|
model: LinearCustomViewModel,
|
|
workspaceId: LinearConcreteWorkspaceId,
|
|
force = false
|
|
): 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()
|
|
}
|
|
},
|
|
force
|
|
)
|
|
}
|
|
|
|
export async function listCustomViewIssues(
|
|
viewId: string,
|
|
limit = 20,
|
|
workspaceId: LinearConcreteWorkspaceId,
|
|
force = false
|
|
): Promise<LinearCollectionResult<LinearIssue>> {
|
|
const id = viewId.trim()
|
|
if (!id) {
|
|
throw new Error('Custom view ID is required')
|
|
}
|
|
const first = clampLinearIssueListLimit(limit)
|
|
const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId)
|
|
return readConcreteCollection(
|
|
`listCustomViewIssues:${concreteWorkspaceId}:${id}:${first}`,
|
|
concreteWorkspaceId,
|
|
async (entry) => {
|
|
return readIssueConnectionPages(entry, first, async (page) => {
|
|
const result = await entry.client.client.rawRequest<
|
|
CustomViewConnectionResponse,
|
|
LinearRawVariables
|
|
>(CUSTOM_VIEW_ISSUES_QUERY, { id, ...page, orderBy: 'updatedAt' })
|
|
const view = result.data?.customView
|
|
if (mapCustomViewModel(view?.modelName) !== 'issue') {
|
|
throw new Error('Custom view does not contain issues')
|
|
}
|
|
return view?.issues
|
|
})
|
|
},
|
|
force
|
|
)
|
|
}
|
|
|
|
export async function listCustomViewProjects(
|
|
viewId: string,
|
|
limit = 20,
|
|
workspaceId: LinearConcreteWorkspaceId,
|
|
force = false
|
|
): 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
|
|
}
|
|
},
|
|
force
|
|
)
|
|
}
|