import type { LinearCollectionMeta, LinearIssueAttachment, LinearIssueChildNode, LinearIssueCommentNode, LinearIssueContextResult, LinearIssueInclude, LinearIssueRelation, LinearIssueRequest } from '../../shared/linear-agent-access' import { LINEAR_ATTACHMENTS_CAP, LINEAR_CHILDREN_NODE_CAP, LINEAR_COMMENTS_CAP, LINEAR_COMMENT_BODY_CAP, LINEAR_RELATIONS_CAP, clampLinearIssueDepth } from '../../shared/linear-agent-access' import type { ResolvedIssue } from './issue-context-client' import { getRequiredEntry, withLinearRead } from './issue-context-client' import { includeErrorCode } from './issue-context-errors' import { readConnectionPages } from './issue-context-pagination' import { ATTACHMENTS_QUERY, CHILDREN_QUERY, COMMENTS_QUERY, RELATIONS_QUERY, collectionMeta, mapIssue, type RawAttachmentsResponse, type RawChildrenResponse, type RawCommentsResponse, type RawRelationsResponse } from './issue-context-raw' export async function readOptionalIncludes( resolved: ResolvedIssue, request: LinearIssueRequest, result: LinearIssueContextResult, includeErrors: LinearIssueContextResult['meta']['includeErrors'], sections: LinearIssueContextResult['meta']['sections'] ): Promise { const includeTasks: [LinearIssueInclude, () => Promise][] = [] if (request.include.comments) { includeTasks.push(['comments', async () => assignComments(resolved, result, sections)]) } if (request.include.children) { includeTasks.push([ 'children', async () => assignChildren(resolved, request.depth, result, sections) ]) } if (request.include.attachments) { includeTasks.push(['attachments', async () => assignAttachments(resolved, result, sections)]) } if (request.include.relations) { includeTasks.push(['relations', async () => assignRelations(resolved, result, sections)]) } for (const [include, task] of includeTasks) { try { await task() } catch (error) { includeErrors.push({ include, code: includeErrorCode(error), message: error instanceof Error ? error.message : String(error) }) } } } async function assignComments( resolved: ResolvedIssue, result: LinearIssueContextResult, sections: LinearIssueContextResult['meta']['sections'] ): Promise { const read = await readComments(resolved) result.comments = read.items sections.comments = read.meta } async function assignChildren( resolved: ResolvedIssue, depth: number, result: LinearIssueContextResult, sections: LinearIssueContextResult['meta']['sections'] ): Promise { const read = await readChildren(resolved, clampLinearIssueDepth(depth)) result.children = read.items sections.children = read.meta } async function assignAttachments( resolved: ResolvedIssue, result: LinearIssueContextResult, sections: LinearIssueContextResult['meta']['sections'] ): Promise { const read = await readAttachments(resolved) result.attachments = read.items sections.attachments = read.meta } async function assignRelations( resolved: ResolvedIssue, result: LinearIssueContextResult, sections: LinearIssueContextResult['meta']['sections'] ): Promise { const read = await readRelations(resolved) result.relations = read.items sections.relations = read.meta } async function readComments(resolved: ResolvedIssue): Promise<{ items: LinearIssueCommentNode[] meta: LinearCollectionMeta }> { const entry = getRequiredEntry(resolved.workspace.id) const response = await readConnectionPages(LINEAR_COMMENTS_CAP, async (page) => { return await withLinearRead(entry, async () => { const raw = await entry.client.client.rawRequest< RawCommentsResponse, Record >(COMMENTS_QUERY, { id: resolved.issue.id, ...page }) return raw.data?.issue?.comments ?? null }) }) const nodes = response.nodes const items = nodes.slice(0, LINEAR_COMMENTS_CAP).map((comment) => { const body = comment.body ?? '' return { id: comment.id, body: body.slice(0, LINEAR_COMMENT_BODY_CAP), bodyTruncated: body.length > LINEAR_COMMENT_BODY_CAP, createdAt: comment.createdAt, updatedAt: comment.updatedAt, parentId: comment.parent?.id ?? null, user: comment.user ?? null } }) return { items, meta: collectionMeta(items.length, LINEAR_COMMENTS_CAP, response.hasMore) } } async function readChildren( resolved: ResolvedIssue, depth: number ): Promise<{ items: LinearIssueChildNode[]; meta: LinearCollectionMeta }> { if (depth <= 0) { return { items: [], meta: collectionMeta(0, LINEAR_CHILDREN_NODE_CAP, false) } } const entry = getRequiredEntry(resolved.workspace.id) let returned = 0 let capReached = false let depthReached = false const readLevel = async (issueId: string, level: number): Promise => { if (level > depth || returned >= LINEAR_CHILDREN_NODE_CAP) { depthReached = true return [] } const remaining = LINEAR_CHILDREN_NODE_CAP - returned const response = await readConnectionPages(remaining, async (page) => { return await withLinearRead(entry, async () => { const raw = await entry.client.client.rawRequest< RawChildrenResponse, Record >(CHILDREN_QUERY, { id: issueId, ...page }) return raw.data?.issue?.children ?? null }) }) const nodes = response.nodes if (response.hasMore || nodes.length > remaining) { capReached = true } const children = nodes.slice(0, remaining).map((node) => { returned += 1 return { raw: node, child: mapIssue(node) as LinearIssueChildNode } }) if (returned >= LINEAR_CHILDREN_NODE_CAP) { capReached = true } // Why: when the current level already exhausts the output cap, fetching // grandchildren would add latency without returning any additional nodes. const canReadNested = level < depth && returned < LINEAR_CHILDREN_NODE_CAP if (!canReadNested && level >= depth && children.length > 0) { depthReached = true } const mappedChildren: LinearIssueChildNode[] = [] for (const { raw, child } of children) { const nested = canReadNested ? await readLevel(raw.id, level + 1) : [] if (nested.length > 0) { child.children = nested } child.mayHaveMore = level >= depth || returned >= LINEAR_CHILDREN_NODE_CAP || response.hasMore mappedChildren.push(child) } return mappedChildren } const items = await readLevel(resolved.issue.id, 1) return { items, meta: { returned, cap: LINEAR_CHILDREN_NODE_CAP, capReached, mayHaveMore: capReached || depthReached } } } async function readAttachments( resolved: ResolvedIssue ): Promise<{ items: LinearIssueAttachment[]; meta: LinearCollectionMeta }> { const entry = getRequiredEntry(resolved.workspace.id) const response = await readConnectionPages(LINEAR_ATTACHMENTS_CAP, async (page) => { return await withLinearRead(entry, async () => { const raw = await entry.client.client.rawRequest< RawAttachmentsResponse, Record >(ATTACHMENTS_QUERY, { id: resolved.issue.id, ...page }) return raw.data?.issue?.attachments ?? null }) }) const items = response.nodes.slice(0, LINEAR_ATTACHMENTS_CAP).map((node) => ({ id: node.id, title: node.title, url: node.url, source: node.source, subtitle: node.subtitle, createdAt: node.createdAt, metadataOnly: true as const })) return { items, meta: collectionMeta(items.length, LINEAR_ATTACHMENTS_CAP, response.hasMore) } } async function readRelations( resolved: ResolvedIssue ): Promise<{ items: LinearIssueRelation[]; meta: LinearCollectionMeta }> { const entry = getRequiredEntry(resolved.workspace.id) const response = await readConnectionPages(LINEAR_RELATIONS_CAP, async (page) => { return await withLinearRead(entry, async () => { const raw = await entry.client.client.rawRequest< RawRelationsResponse, Record >(RELATIONS_QUERY, { id: resolved.issue.id, ...page }) return raw.data?.issue?.relations ?? null }) }) const items = response.nodes.slice(0, LINEAR_RELATIONS_CAP).map((node) => ({ id: node.id, type: node.type, relatedIssue: node.relatedIssue ? { id: node.relatedIssue.id, identifier: node.relatedIssue.identifier, title: node.relatedIssue.title, url: node.relatedIssue.url } : null })) return { items, meta: collectionMeta(items.length, LINEAR_RELATIONS_CAP, response.hasMore) } }