import { linearFetch, resolveLinearAuth, type ResolvedLinearAuth } from './auth.ts' import type { LinearAuthInput, JsonRecord } from './types.ts' import { requireString } from './types.ts' import { nextStepForScope, scopeForOperation, type LinearOperation, } from './setup.ts' export class LinearApiError extends Error { readonly status: number readonly operation: LinearOperation readonly details: unknown readonly missingScope: string | null constructor( message: string, options: { status: number operation: LinearOperation details: unknown missingScope: string | null }, ) { super(message) this.name = 'LinearApiError' this.status = options.status this.operation = options.operation this.details = options.details this.missingScope = options.missingScope } } export type LinearGraphqlResponse = { data: T errors?: Array } export type LinearGraphqlError = { message?: string path?: Array extensions?: { code?: string type?: string userError?: boolean [key: string]: unknown } } export type LinearGraphqlInput = LinearAuthInput & { query: string variables?: JsonRecord operation?: LinearOperation dryRun?: boolean confirm?: boolean } function graphqlErrors(body: unknown): Array { if (!body || typeof body !== 'object') return [] const errors = (body as { errors?: unknown }).errors if (!Array.isArray(errors)) return [] return errors as Array } function looksLikeInsufficientScope( status: number, errors: Array, ): boolean { if (status === 401 || status === 403) return true return errors.some((error) => { const code = error.extensions?.code?.toUpperCase?.() ?? '' const message = (error.message ?? '').toLowerCase() return ( code === 'FORBIDDEN' || code === 'AUTHENTICATION_ERROR' || code === 'INSUFFICIENT_SCOPES' || message.includes('insufficient') || message.includes("don't have permission") || message.includes('do not have permission') || message.includes('missing scope') || message.includes('not authorized') ) }) } function inferOperation(query: string, explicit?: LinearOperation): LinearOperation { if (explicit) return explicit if (/\bissueCreate\b/.test(query)) return 'issueCreate' if (/\bissueUpdate\b/.test(query)) return 'issueUpdate' if (/\bcommentCreate\b/.test(query)) return 'commentCreate' if (/\bprojectCreate\b/.test(query)) return 'projectCreate' if (/\bmutation\b/i.test(query)) return 'unknownMutation' return 'query' } function isReadOnlyQuery(query: string, operation: LinearOperation): boolean { return operation === 'query' && !/\bmutation\b/i.test(query) } export async function linearGraphql( input: LinearGraphqlInput, ): Promise> { const query = requireString(input.query, 'query') const operation = inferOperation(query, input.operation) const auth = await resolveLinearAuth(input) return linearGraphqlWithAuth(auth, { query, variables: input.variables, operation, }) } export async function linearGraphqlWithAuth( auth: ResolvedLinearAuth, input: { query: string variables?: JsonRecord operation: LinearOperation }, ): Promise> { const headers = new Headers({ Accept: 'application/json', 'Content-Type': 'application/json', 'User-Agent': 'kody-linear/1.0', }) const response = await linearFetch(auth, { method: 'POST', headers, body: JSON.stringify({ query: input.query, variables: input.variables ?? {}, }), }) const text = await response.text() let parsed: unknown = text try { parsed = text ? JSON.parse(text) : null } catch { parsed = text } const errors = graphqlErrors(parsed) if (!response.ok || looksLikeInsufficientScope(response.status, errors)) { const missingScope = scopeForOperation(input.operation) const firstMessage = errors[0]?.message || (typeof parsed === 'string' ? parsed.slice(0, 400) : response.statusText) throw new LinearApiError( [ `Linear ${input.operation} failed (${response.status}): ${firstMessage}.`, `This call needs the "${missingScope}" permission.`, nextStepForScope(missingScope, { authMode: auth.mode, integrationName: auth.integrationName, secretName: auth.secretName, }), ].join(' '), { status: response.status, operation: input.operation, details: parsed, missingScope, }, ) } if (errors.length > 0) { const first = errors[0] throw new LinearApiError( `Linear GraphQL error: ${first.message || 'unknown error'}`, { status: response.status, operation: input.operation, details: parsed, missingScope: null, }, ) } const data = parsed && typeof parsed === 'object' ? (parsed as { data?: T }).data : undefined if (data === undefined) { throw new LinearApiError('Linear GraphQL response did not include data.', { status: response.status, operation: input.operation, details: parsed, missingScope: null, }) } return { data, errors: errors.length ? errors : undefined } } export { inferOperation, isReadOnlyQuery }