import type { LinearClient } from '@linear/sdk' import { loadLinearSdk } from './linear-sdk' import { acquire, release } from './linear-request-concurrency' import { clearToken } from './linear-token-store' import { isAuthError, type LinearClientForWorkspace } from './client' import type { LinearAttachmentByUuidResponse, LinearCommentByUuidResponse, LinearIssueByUuidResponse } from './linear-issue-query-documents' export type LinearWriteFailureKind = 'duplicate_id' | 'failed' | 'network' | 'unconfirmed' export class LinearWriteFailure extends Error { readonly kind: LinearWriteFailureKind readonly cause: unknown constructor(kind: LinearWriteFailureKind, message: string, cause?: unknown) { super(message) this.name = 'LinearWriteFailure' this.kind = kind this.cause = cause } } export type LinearIssueWriteRecord = { id: string identifier: string title: string description?: string | null url: string team: { id: string; key: string; name: string } state: { id: string; name: string } | null parent: { id: string; identifier: string } | null project?: { id: string; name: string } | null assignee?: { id: string; displayName: string } | null priority?: number | null estimate?: number | null dueDate?: string | null labelIds?: string[] | null labels?: { id: string; name: string }[] } export type LinearCommentWriteRecord = { id: string url: string | null body: string issue: { id: string; identifier: string; url: string } parentId: string | null threadRootId: string | null } export type LinearAttachmentWriteRecord = { id: string title: string url: string issue: { id: string; identifier: string; url: string } } export function linearWriteMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } function isDuplicateIdError(error: unknown): boolean { const message = linearWriteMessage(error).toLowerCase() return ( message.includes('duplicate') || message.includes('already exists') || message.includes('already in use') || message.includes('id has already') ) } function errorCauseCode(error: unknown): string { if (!error || typeof error !== 'object') { return '' } const cause = (error as { cause?: unknown }).cause if (!cause || typeof cause !== 'object') { return '' } const code = (cause as { code?: unknown }).code return typeof code === 'string' ? code.toLowerCase() : '' } export function classifyLinearWriteFailure(error: unknown): LinearWriteFailure { if (error instanceof LinearWriteFailure) { return error } if (isDuplicateIdError(error)) { return new LinearWriteFailure('duplicate_id', linearWriteMessage(error), error) } const message = linearWriteMessage(error) const lower = message.toLowerCase() const code = errorCauseCode(error) if ( lower.includes('enotfound') || lower.includes('econnrefused') || code === 'enotfound' || code === 'econnrefused' ) { return new LinearWriteFailure('network', message, error) } if ( lower.includes('abort') || lower.includes('timeout') || lower.includes('timed out') || lower.includes('network') || lower.includes('econnreset') || lower.includes('fetch failed') || lower.includes('socket') ) { return new LinearWriteFailure('unconfirmed', message, error) } return new LinearWriteFailure('failed', message, error) } export async function runLinearWrite( entry: LinearClientForWorkspace, signal: AbortSignal | undefined, write: (client: LinearClient) => Promise, beforeInvoke?: () => void ): Promise { await acquire(signal) try { signal?.throwIfAborted() const client = signal ? new (loadLinearSdk().LinearClient)({ apiKey: entry.apiKey, signal }) : entry.client beforeInvoke?.() return await write(client) } catch (error) { if (error instanceof LinearWriteFailure) { throw error } if (isAuthError(error)) { clearToken(entry.workspace.id) throw error } throw classifyLinearWriteFailure(error) } finally { release() } } export async function runLinearLookup( entry: LinearClientForWorkspace, lookup: () => Promise, signal?: AbortSignal ): Promise { await acquire(signal) try { signal?.throwIfAborted() return await lookup() } catch (error) { if (isAuthError(error)) { clearToken(entry.workspace.id) throw error } if (isLinearLookupMiss(error)) { return null } throw error } finally { release() } } export function isLinearLookupMiss(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error) // Why: Linear throws for direct entity lookups that miss; write-id probes // need the same null shape as GraphQL nullable data, not a failed write. return message.includes('Entity not found:') && message.includes('Could not find referenced') } export async function confirmLinearWrite( message: string, readback: () => Promise ): Promise { try { return await readback() } catch (error) { throw new LinearWriteFailure('unconfirmed', message, error) } } export function mapRawCommentWriteRecord( comment: NonNullable ): LinearCommentWriteRecord | null { const issue = comment.issue if (!issue?.id || !issue.identifier || !issue.url) { return null } const parentId = comment.parent?.id ?? null return { id: comment.id, url: comment.url ?? null, body: comment.body ?? '', issue: { id: issue.id, identifier: issue.identifier, url: issue.url }, parentId, threadRootId: parentId ?? comment.id } } export function mapRawAttachmentWriteRecord( attachment: NonNullable ): LinearAttachmentWriteRecord | null { const issue = attachment.issue if (!issue?.id || !issue.identifier || !issue.url || !attachment.url) { return null } return { id: attachment.id, title: attachment.title ?? attachment.url, url: attachment.url, issue: { id: issue.id, identifier: issue.identifier, url: issue.url } } } export function mapRawIssueWriteRecord( issue: NonNullable ): LinearIssueWriteRecord { return { ...issue, labels: issue.labels?.nodes ?? [] } }