import type { GitlabAuthInput, GitlabAuthLaneInfo, GitlabResolvedAuth } from './types.ts'
export const DEFAULT_GITLAB_API_BASE_URL = 'https://gitlab.com/api/v4'
export const DEFAULT_GITLAB_INSTANCE_URL = 'https://gitlab.com'
export const DEFAULT_GITLAB_API_HOST = 'gitlab.com'
export const DEFAULT_GITLAB_INTEGRATION_NAME = 'gitlab'
export const DEFAULT_GITLAB_SECRET_NAME = 'gitlabAccessToken'
export const GITLAB_OAUTH_AUTHORIZE_URL = 'https://gitlab.com/oauth/authorize'
export const GITLAB_OAUTH_TOKEN_URL = 'https://gitlab.com/oauth/token'
export const GITLAB_APPLICATIONS_URL = 'https://gitlab.com/-/profile/applications'
export const GITLAB_PAT_CREATE_URL =
'https://gitlab.com/-/user_settings/personal_access_tokens?scopes=api'
export const SUGGESTED_GITLAB_OAUTH_SCOPES = ['api'] as const
export const READ_ONLY_GITLAB_OAUTH_SCOPES = ['read_api', 'read_user'] as const
export const GITLAB_OAUTH_CONNECT_URL = buildOauthConnectUrl({
provider: DEFAULT_GITLAB_INTEGRATION_NAME,
})
export const GITLAB_PAT_SETUP_URL = buildPatSetupUrl(DEFAULT_GITLAB_SECRET_NAME)
const gitlabAuthLanes = [
{
lane: 'oauth',
default: true,
integrationName: DEFAULT_GITLAB_INTEGRATION_NAME,
connectUrl: GITLAB_OAUTH_CONNECT_URL,
useWhen:
'Bring-your-own GitLab OAuth application. Pass a distinct integrationName (gitlab-work, …) for each extra connected identity.',
avoidWhen: 'You only have a personal access token and have not connected OAuth.',
mutationGuidance:
'Mutating helpers require dryRun: true to preview, then confirm: true to apply.',
},
{
lane: 'pat',
default: false,
secretName: DEFAULT_GITLAB_SECRET_NAME,
secretSetupUrl: GITLAB_PAT_SETUP_URL,
useWhen:
'Personal access tokens. Pass secretName (default gitlabAccessToken) and do not pass integrationName.',
avoidWhen: 'A saved GitLab OAuth integration already covers the work.',
mutationGuidance:
'Mutating helpers require dryRun: true to preview, then confirm: true to apply. Grant only the PAT scopes the mutation needs.',
},
] as const satisfies readonly GitlabAuthLaneInfo[]
/**
* Return the supported GitLab auth lanes and selection guidance.
*/
export function accounts(): readonly GitlabAuthLaneInfo[] {
return gitlabAuthLanes
}
/**
* Resolve OAuth vs PAT credentials and the GitLab API base URL.
*/
export function resolveGitlabAuth(input: GitlabAuthInput = {}): GitlabResolvedAuth {
const apiBaseUrl = resolveApiBaseUrl(input)
const apiHost = hostFromUrl(apiBaseUrl)
const secretName = trimToUndefined(input.secretName)
if (secretName) {
return {
mode: 'pat',
integrationName: null,
secretName,
label: secretName,
apiBaseUrl,
apiHost,
}
}
const integrationName =
trimToUndefined(input.integrationName) ??
trimToUndefined(input.account) ??
DEFAULT_GITLAB_INTEGRATION_NAME
return {
mode: 'oauth',
integrationName,
secretName: null,
label: integrationName,
apiBaseUrl,
apiHost,
}
}
export function resolveApiBaseUrl(input: GitlabAuthInput = {}): string {
const apiBaseUrl = trimToUndefined(input.apiBaseUrl)
if (apiBaseUrl) return normalizeApiBaseUrl(apiBaseUrl)
const instanceUrl = trimToUndefined(input.instanceUrl)
if (instanceUrl) return normalizeApiBaseUrl(new URL('/api/v4', instanceUrl).href)
return DEFAULT_GITLAB_API_BASE_URL
}
export function normalizeApiBaseUrl(value: string): string {
let parsed: URL
try {
parsed = new URL(value)
} catch {
throw new Error(
`apiBaseUrl / instanceUrl must be an absolute https URL (received ${JSON.stringify(value)}).`,
)
}
if (parsed.protocol !== 'https:') {
throw new Error('GitLab helpers only accept https API bases.')
}
parsed.hash = ''
parsed.search = ''
const path = parsed.pathname.replace(/\/+$/, '')
if (path === '' || path === '/') {
parsed.pathname = '/api/v4'
} else if (path === '/api') {
parsed.pathname = '/api/v4'
} else if (!path.endsWith('/api/v4')) {
throw new Error(
'apiBaseUrl must be a GitLab origin or end with /api or /api/v4 (for example https://gitlab.example.com/api/v4).',
)
} else {
parsed.pathname = path
}
return parsed.href.replace(/\/+$/, '')
}
export function hostFromUrl(value: string): string {
return new URL(value).host
}
export function buildOauthConnectUrl(options: {
provider?: string
instanceUrl?: string
scopes?: readonly string[]
}): string {
const provider = trimToUndefined(options.provider) ?? DEFAULT_GITLAB_INTEGRATION_NAME
const instanceUrl = (trimToUndefined(options.instanceUrl) ?? DEFAULT_GITLAB_INSTANCE_URL).replace(
/\/+$/,
'',
)
const scopes = options.scopes ?? SUGGESTED_GITLAB_OAUTH_SCOPES
const authorizeUrl = `${instanceUrl}/oauth/authorize`
const tokenUrl = `${instanceUrl}/oauth/token`
const apiBaseUrl = `${instanceUrl}/api/v4`
const host = hostFromUrl(instanceUrl)
const params = new URLSearchParams({
provider,
authorizeUrl,
tokenUrl,
flow: 'confidential',
scopes: scopes.join(' '),
allowedHosts: host,
apiBaseUrl,
dashboardUrl: `${instanceUrl}/-/profile/applications`,
})
return `https://kody.codes/connect/oauth?${params.toString()}`
}
export function buildPatSetupUrl(
secretName = DEFAULT_GITLAB_SECRET_NAME,
apiHost = DEFAULT_GITLAB_API_HOST,
): string {
const params = new URLSearchParams({
name: secretName,
description: 'GitLab personal access token',
allowedHosts: apiHost,
scope: 'user',
})
return `https://kody.codes/account/secrets/new?${params.toString()}`
}
export function pickAuthInput(params: Record<string, unknown>): GitlabAuthInput {
return {
integrationName: readOptionalString(params.integrationName),
secretName: readOptionalString(params.secretName),
account: readOptionalString(params.account),
apiBaseUrl: readOptionalString(params.apiBaseUrl),
instanceUrl: readOptionalString(params.instanceUrl),
}
}
export function isDryRun(params: Record<string, unknown>): boolean {
return params.dryRun === true
}
export function isConfirmed(params: Record<string, unknown>): boolean {
return params.confirm === true
}
export function trimToUndefined(value: string | undefined): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : undefined
}
export function readOptionalString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined
}
export function stringifyError(error: unknown): string {
if (error instanceof Error) return error.message
return String(error)
}