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
}