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): 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): boolean { return params.dryRun === true } export function isConfirmed(params: Record): 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) }