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 extends Error { readonly response: GitlabResponse constructor(response: GitlabResponse) { 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( options: GitlabRequestOptions, ): Promise> { 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(text, fetchResponse.headers) const response: GitlabResponse = { 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( options: GitlabPaginateOptions, ): Promise> { const auth = resolveGitlabAuth(options) const maxPages = options.maxPages ?? 20 const items: TItem[] = [] let nextPath: string | null = options.path let pages = 0 let lastResponse: GitlabResponse | null = null while (nextPath && pages < maxPages) { const response = await request({ 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 { 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 { 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(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, 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): 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 }