Skip to content

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

Package listing

@kody/linear

src/setup.ts

129 lines · 4.4 KB · TypeScript
import type { LinearAuthMode } from './types.ts'
import { requireString } from './types.ts'

export const LINEAR_GRAPHQL_URL = 'https://api.linear.app/graphql'
export const LINEAR_AUTHORIZE_URL = 'https://linear.app/oauth/authorize'
export const LINEAR_TOKEN_URL = 'https://api.linear.app/oauth/token'
export const LINEAR_API_BASE_URL = 'https://api.linear.app'
export const LINEAR_REQUIRED_HOST = 'api.linear.app'
export const LINEAR_OAUTH_APP_URL = 'https://linear.app/settings/api/applications/new'
export const LINEAR_API_KEY_URL = 'https://linear.app/settings/account/security'
export const LINEAR_CALLBACK_URL = 'https://kody.codes/connect/oauth'
export const DEFAULT_INTEGRATION_NAME = 'linear'
export const DEFAULT_API_KEY_SECRET = 'linearApiKey'
export const DEFAULT_OAUTH_SCOPES = ['read', 'write'] as const

export const LINEAR_SCOPES = {
	read: 'Read access for the authorizing user. Always present on OAuth tokens.',
	write: 'Write access. Needed to update issues, projects, and most mutations.',
	'issues:create': 'Create issues and attachments without full write.',
	'comments:create': 'Create comments without full write.',
	'timeSchedule:write': 'Create and modify time schedules.',
	admin: 'Admin endpoints. Do not request unless the workflow truly needs it.',
} as const

export type LinearScope = keyof typeof LINEAR_SCOPES

export type LinearOperation =
	| 'query'
	| 'issueCreate'
	| 'issueUpdate'
	| 'commentCreate'
	| 'projectCreate'
	| 'unknownMutation'

export function oauthConnectUrl(provider: string = DEFAULT_INTEGRATION_NAME): string {
	const name = requireString(provider, 'provider')
	const params = new URLSearchParams({
		provider: name,
		authorizeUrl: LINEAR_AUTHORIZE_URL,
		tokenUrl: LINEAR_TOKEN_URL,
		apiBaseUrl: LINEAR_API_BASE_URL,
		scopes: DEFAULT_OAUTH_SCOPES.join(','),
		scopeSeparator: ',',
		flow: 'confidential',
		allowedHosts: LINEAR_REQUIRED_HOST,
		dashboardUrl: LINEAR_OAUTH_APP_URL,
	})
	return `https://kody.codes/connect/oauth?${params.toString()}`
}

export function apiKeySetupUrl(secretName: string = DEFAULT_API_KEY_SECRET): string {
	const name = requireString(secretName, 'secretName')
	const params = new URLSearchParams({
		name,
		description: 'Linear personal API key',
		allowedHosts: LINEAR_REQUIRED_HOST,
		scope: 'user',
	})
	return `https://kody.codes/account/secrets/new?${params.toString()}`
}

export function reconnectUrl(provider: string = DEFAULT_INTEGRATION_NAME): string {
	return `https://kody.codes/connect/oauth?provider=${encodeURIComponent(provider)}`
}

export function scopeForOperation(operation: LinearOperation): LinearScope {
	switch (operation) {
		case 'query':
			return 'read'
		case 'issueCreate':
			return 'issues:create'
		case 'commentCreate':
			return 'comments:create'
		case 'issueUpdate':
		case 'projectCreate':
			return 'write'
		case 'unknownMutation':
			return 'write'
		default: {
			const exhaustive: never = operation
			throw new Error(`Unsupported Linear operation: ${String(exhaustive)}`)
		}
	}
}

export function nextStepForScope(
	scope: LinearScope,
	options: {
		authMode: LinearAuthMode
		integrationName: string
		secretName: string
	},
): string {
	switch (options.authMode) {
		case 'oauth':
			return [
				`Reconnect the "${options.integrationName}" OAuth integration and include the "${scope}" scope.`,
				reconnectUrl(options.integrationName),
				`Full BYO connect URL (scopes ${DEFAULT_OAUTH_SCOPES.join(',')}): ${oauthConnectUrl(options.integrationName)}`,
			].join(' ')
		case 'api-key':
			return [
				`Create a Linear personal API key that includes the "${scope}" permission (or Write), then save it as ${options.secretName}.`,
				LINEAR_API_KEY_URL,
				apiKeySetupUrl(options.secretName),
			].join(' ')
		default: {
			const exhaustive: never = options.authMode
			throw new Error(`Unsupported Linear auth mode: ${String(exhaustive)}`)
		}
	}
}

export function missingCredentialsMessage(options: {
	integrationName: string
	secretName: string
}): string {
	return [
		'Linear credentials are missing.',
		'OAuth (recommended for shared/multi-account use): create an OAuth app at',
		LINEAR_OAUTH_APP_URL,
		`with redirect URI ${LINEAR_CALLBACK_URL}, then connect:`,
		oauthConnectUrl(options.integrationName),
		`Personal API key (fastest for one workspace): create a key at ${LINEAR_API_KEY_URL}`,
		'with Read plus the write permissions you need, then save it (do not paste the value in chat):',
		apiKeySetupUrl(options.secretName),
		`Required API host: ${LINEAR_REQUIRED_HOST}.`,
	].join(' ')
}