Skip to content

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

Package listing

@kody/gitlab

src/core.ts

414 lines · 11.8 KB · TypeScript
import { createAuthenticatedFetch } from 'kody:runtime'
import {
	DEFAULT_GITLAB_INTEGRATION_NAME,
	DEFAULT_GITLAB_SECRET_NAME,
	GITLAB_APPLICATIONS_URL,
	GITLAB_OAUTH_CONNECT_URL,
	GITLAB_PAT_SETUP_URL,
	buildOauthConnectUrl,
	buildPatSetupUrl,
	resolveGitlabAuth,
	stringifyError,
} from './auth.ts'
import type {
	GitlabAuthInput,
	GitlabCurrentUser,
	GitlabHeaders,
	GitlabPaginateOptions,
	GitlabPaginationResult,
	GitlabQuery,
	GitlabRequestOptions,
	GitlabResolvedAuth,
	GitlabResponse,
} from './types.ts'

export {
	DEFAULT_GITLAB_API_BASE_URL,
	DEFAULT_GITLAB_API_HOST,
	DEFAULT_GITLAB_INSTANCE_URL,
	DEFAULT_GITLAB_INTEGRATION_NAME,
	DEFAULT_GITLAB_SECRET_NAME,
	GITLAB_APPLICATIONS_URL,
	GITLAB_OAUTH_AUTHORIZE_URL,
	GITLAB_OAUTH_CONNECT_URL,
	GITLAB_OAUTH_TOKEN_URL,
	GITLAB_PAT_CREATE_URL,
	GITLAB_PAT_SETUP_URL,
	READ_ONLY_GITLAB_OAUTH_SCOPES,
	SUGGESTED_GITLAB_OAUTH_SCOPES,
	accounts,
	buildOauthConnectUrl,
	buildPatSetupUrl,
	isConfirmed,
	isDryRun,
	pickAuthInput,
	resolveApiBaseUrl,
	resolveGitlabAuth,
} from './auth.ts'

export {
	decodeProjectPath,
	encodeProjectId,
	normalizeIssueLocator,
	normalizeMergeRequestLocator,
	normalizePipelineLocator,
	normalizeProjectLocator,
} from './locators.ts'

const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])

const selectedHeaderNames = [
	'link',
	'x-next-page',
	'x-page',
	'x-per-page',
	'x-prev-page',
	'x-total',
	'x-total-pages',
	'x-request-id',
	'ratelimit-limit',
	'ratelimit-remaining',
	'ratelimit-reset',
] as const

export class GitlabRequestError<TData = unknown> extends Error {
	readonly response: GitlabResponse<TData>

	constructor(response: GitlabResponse<TData>) {
		super(buildErrorMessage(response))
		this.name = 'GitlabRequestError'
		this.response = response
	}
}

/**
 * Make an authenticated GitLab REST API request.
 *
 * Mutating methods require `dryRun` or `confirm`.
 */
export async function request<TData = unknown>(
	options: GitlabRequestOptions,
): Promise<GitlabResponse<TData>> {
	if (typeof options?.path !== 'string' || options.path.length === 0) {
		throw new Error(
			'GitLab request requires a non-empty string "path" param (for example "/projects" or "/user").',
		)
	}
	const auth = resolveGitlabAuth(options)
	const url = buildUrl(auth.apiBaseUrl, options.path, options.query)
	const method = (options.method ?? (options.body === undefined ? 'GET' : 'POST')).toUpperCase()
	const mutating = isMutatingMethod(method)
	assertMutationGuard(mutating, options.dryRun, options.confirm, `${method} ${url}`)

	if (options.dryRun && mutating) {
		return {
			auth,
			url,
			ok: true,
			status: 0,
			statusText: 'dry-run',
			headers: {},
			data: {
				dryRun: true,
				method,
				path: options.path,
				wouldRequest: true,
			} as TData,
			text: '',
			dryRun: true,
		}
	}

	const headers = new Headers(options.headers)
	headers.set('Accept', headers.get('Accept') ?? 'application/json')
	headers.set('User-Agent', headers.get('User-Agent') ?? 'kody-gitlab')

	const init: RequestInit = { method, headers }
	if (options.body !== undefined) {
		if (!headers.has('Content-Type')) {
			headers.set('Content-Type', 'application/json')
		}
		init.body = typeof options.body === 'string' ? options.body : JSON.stringify(options.body)
	}

	const fetchResponse = await gitlabFetch(url, init, auth)
	const text = await fetchResponse.text()
	const data = parseResponseBody<TData>(text, fetchResponse.headers)
	const response: GitlabResponse<TData> = {
		auth,
		url,
		ok: fetchResponse.ok,
		status: fetchResponse.status,
		statusText: fetchResponse.statusText,
		headers: collectHeaders(fetchResponse.headers),
		data,
		text,
	}

	if (!response.ok && options.throwOnError) {
		throw new GitlabRequestError(response)
	}

	return response
}

/**
 * Follow GitLab REST Link / x-next-page pagination for endpoints that return JSON arrays.
 */
export async function paginate<TItem = unknown>(
	options: GitlabPaginateOptions,
): Promise<GitlabPaginationResult<TItem>> {
	const auth = resolveGitlabAuth(options)
	const maxPages = options.maxPages ?? 20
	const items: TItem[] = []
	let nextPath: string | null = options.path
	let pages = 0
	let lastResponse: GitlabResponse<unknown> | null = null

	while (nextPath && pages < maxPages) {
		const response = await request<unknown>({
			integrationName: options.integrationName,
			secretName: options.secretName,
			account: options.account,
			apiBaseUrl: options.apiBaseUrl,
			instanceUrl: options.instanceUrl,
			path: nextPath,
			method: options.method ?? 'GET',
			query: pages === 0 ? options.query : undefined,
			headers: options.headers,
			throwOnError: true,
		})

		if (!Array.isArray(response.data)) {
			throw new Error('GitLab pagination requires endpoints that return a JSON array.')
		}

		items.push(...(response.data as TItem[]))
		pages += 1
		lastResponse = response
		nextPath = getNextPath(response, auth.apiBaseUrl)
	}

	return { auth, items, pages, lastResponse }
}

/**
 * Fetch the authenticated GitLab user for the selected OAuth integration or PAT.
 */
export async function getCurrentUser(options: GitlabAuthInput = {}): Promise<GitlabCurrentUser> {
	const auth = resolveGitlabAuth(options)
	const response = await request<{
		id: number
		username: string
		name: string
		web_url: string
		state: string
	}>({
		...options,
		path: '/user',
		throwOnError: true,
	})

	if (!response.data) {
		throw new Error('GitLab current-user response did not include a JSON body.')
	}

	return {
		auth,
		id: response.data.id,
		username: response.data.username,
		name: response.data.name,
		webUrl: response.data.web_url,
		state: response.data.state,
	}
}

async function gitlabFetch(url: string, init: RequestInit, auth: GitlabResolvedAuth): Promise<Response> {
	switch (auth.mode) {
		case 'pat': {
			const headers = new Headers(init.headers)
			headers.set('PRIVATE-TOKEN', `{{secret:${auth.secretName}|scope=user}}`)
			try {
				return await fetch(url, { ...init, headers })
			} catch (error) {
				throw wrapPatSetupError(auth.secretName!, auth.apiHost, error)
			}
		}
		case 'oauth': {
			let authedFetch: typeof fetch
			try {
				authedFetch = await createAuthenticatedFetch(auth.integrationName!)
			} catch (error) {
				throw wrapOauthSetupError(auth.integrationName!, error)
			}
			return await authedFetch(url, init)
		}
		default: {
			const exhaustive: never = auth.mode
			throw new Error('Unsupported GitLab auth mode: ' + String(exhaustive))
		}
	}
}

function wrapOauthSetupError(integrationName: string, error: unknown): Error {
	const connectUrl = buildOauthConnectUrl({ provider: integrationName })
	return new Error(
		`GitLab OAuth integration "${integrationName}" is not connected or cannot be used (${stringifyError(error)}). ` +
			`There is no built-in GitLab OAuth app. Create an application at ${GITLAB_APPLICATIONS_URL} ` +
			`with redirect URI https://kody.codes/connect/oauth, then connect at ${connectUrl}. ` +
			`For a PAT instead, save ${DEFAULT_GITLAB_SECRET_NAME} at ${GITLAB_PAT_SETUP_URL} and pass secretName: "${DEFAULT_GITLAB_SECRET_NAME}".`,
	)
}

function wrapPatSetupError(secretName: string, apiHost: string, error: unknown): Error {
	const setupUrl = buildPatSetupUrl(secretName, apiHost)
	return new Error(
		`GitLab PAT secret "${secretName}" could not be used (${stringifyError(error)}). ` +
			`Save the token at ${setupUrl} and approve host ${apiHost}. ` +
			`Never paste the token into chat.`,
	)
}

function assertMutationGuard(
	mutating: boolean,
	dryRun: boolean | undefined,
	confirm: boolean | undefined,
	action: string,
): void {
	if (!mutating) return
	if (dryRun === true) return
	if (confirm === true) return
	throw new Error(
		`GitLab mutation "${action}" requires dryRun: true (preview, no GitLab write) or confirm: true (live write).`,
	)
}

function isMutatingMethod(method: string): boolean {
	return !SAFE_HTTP_METHODS.has(method)
}

function buildUrl(apiBaseUrl: string, path: string, query?: GitlabQuery): string {
	let url = normalizeApiPath(apiBaseUrl, path)
	if (!query) return url

	const searchParams = new URLSearchParams()
	for (const [key, value] of Object.entries(query)) {
		if (value === null || value === undefined) continue
		searchParams.set(key, String(value))
	}

	const queryString = searchParams.toString()
	if (!queryString) return url

	url += url.includes('?') ? '&' : '?'
	return url + queryString
}

function normalizeApiPath(apiBaseUrl: string, path: string): string {
	if (path.startsWith(apiBaseUrl + '/')) return path
	if (path.startsWith('http://') || path.startsWith('https://')) {
		const host = new URL(path).host
		const allowedHost = new URL(apiBaseUrl).host
		if (host !== allowedHost) {
			throw new Error(
				`GitLab helpers only accept ${allowedHost} URLs or API paths for this apiBaseUrl.`,
			)
		}
		return path
	}
	return apiBaseUrl + (path.startsWith('/') ? path : '/' + path)
}

function parseResponseBody<TData>(text: string, headers: Headers): TData | null {
	if (!text) return null
	const contentType = headers.get('content-type') ?? ''
	if (contentType.includes('json') || text.startsWith('{') || text.startsWith('[')) {
		return JSON.parse(text) as TData
	}
	return text as TData
}

function collectHeaders(headers: Headers): GitlabHeaders {
	const collected: GitlabHeaders = {}
	for (const name of selectedHeaderNames) {
		const value = headers.get(name)
		if (value) collected[name] = value
	}
	return collected
}

function getNextPath(response: GitlabResponse<unknown>, apiBaseUrl: string): string | null {
	const fromLink = getNextLink(response.headers.link)
	if (fromLink) return fromLink
	const nextPage = response.headers['x-next-page']
	if (!nextPage) return null
	const current = new URL(response.url)
	current.searchParams.set('page', nextPage)
	if (current.href.startsWith(apiBaseUrl)) {
		return current.pathname + current.search
	}
	return current.href
}

function getNextLink(linkHeader: string | undefined): string | null {
	if (!linkHeader) return null
	for (const part of linkHeader.split(',')) {
		const match = part.match(/<([^>]+)>;\s*rel="next"/)
		if (match) return match[1] ?? null
	}
	return null
}

function buildErrorMessage(response: GitlabResponse<unknown>): string {
	const message = extractGitlabMessage(response.data)
	const nextStep = nextSetupStep(response.auth)
	if (response.status === 401 || response.status === 403) {
		return (
			`GitLab request failed with ${response.status}` +
			(message ? `: ${message}` : '') +
			`. ${nextStep}`
		)
	}
	return message
		? 'GitLab request failed with ' + response.status + ': ' + message
		: 'GitLab request failed with ' + response.status + ' ' + response.statusText
}

function nextSetupStep(auth: GitlabResolvedAuth): string {
	switch (auth.mode) {
		case 'oauth': {
			const provider = auth.integrationName ?? DEFAULT_GITLAB_INTEGRATION_NAME
			const connectUrl = buildOauthConnectUrl({ provider })
			return (
				`Next setup step: reconnect at ${connectUrl} and confirm the GitLab application still has the api or read_api scope. ` +
				`Default connect URL: ${GITLAB_OAUTH_CONNECT_URL}.`
			)
		}
		case 'pat': {
			const secretName = auth.secretName ?? DEFAULT_GITLAB_SECRET_NAME
			const setupUrl = buildPatSetupUrl(secretName, auth.apiHost)
			return (
				`Next setup step: grant api or read_api on a PAT at https://gitlab.com/-/user_settings/personal_access_tokens, ` +
				`then update the secret at ${setupUrl}.`
			)
		}
		default: {
			const exhaustive: never = auth.mode
			throw new Error('Unsupported GitLab auth mode: ' + String(exhaustive))
		}
	}
}

function extractGitlabMessage(data: unknown): string | null {
	if (!data || typeof data !== 'object') return null
	if ('message' in data) {
		const message = (data as { message?: unknown }).message
		if (typeof message === 'string') return message
		if (message && typeof message === 'object') return JSON.stringify(message)
	}
	if ('error' in data) {
		const error = (data as { error?: unknown }).error
		return typeof error === 'string' ? error : null
	}
	return null
}