Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/linear

src/client.ts

192 lines · 5.1 KB · TypeScript
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<T> = {
	data: T
	errors?: Array<LinearGraphqlError>
}

export type LinearGraphqlError = {
	message?: string
	path?: Array<string | number>
	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<LinearGraphqlError> {
	if (!body || typeof body !== 'object') return []
	const errors = (body as { errors?: unknown }).errors
	if (!Array.isArray(errors)) return []
	return errors as Array<LinearGraphqlError>
}

function looksLikeInsufficientScope(
	status: number,
	errors: Array<LinearGraphqlError>,
): 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<T = JsonRecord>(
	input: LinearGraphqlInput,
): Promise<LinearGraphqlResponse<T>> {
	const query = requireString(input.query, 'query')
	const operation = inferOperation(query, input.operation)
	const auth = await resolveLinearAuth(input)
	return linearGraphqlWithAuth<T>(auth, {
		query,
		variables: input.variables,
		operation,
	})
}

export async function linearGraphqlWithAuth<T = JsonRecord>(
	auth: ResolvedLinearAuth,
	input: {
		query: string
		variables?: JsonRecord
		operation: LinearOperation
	},
): Promise<LinearGraphqlResponse<T>> {
	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 }