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 }