Skip to content

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

Package listing

@kody/gitlab

src/locators.ts

327 lines · 10.7 KB · TypeScript
import type {
	GitlabIssueLocator,
	GitlabMergeRequestLocator,
	GitlabPipelineLocator,
	GitlabProjectLocator,
} from './types.ts'

const ISSUE_PATH = /\/-\/issues\/(\d+)(?:\/|$)/
const MERGE_REQUEST_PATH = /\/-\/merge_requests\/(\d+)(?:\/|$)/
const PIPELINE_PATH = /\/-\/pipelines\/(\d+)(?:\/|$)/

/**
 * Encode a numeric project id or `group/project` path for `/projects/:id`.
 */
export function encodeProjectId(project: string | number): string {
	const value = String(project).trim()
	if (!value) {
		throw new Error('project must be a numeric id or namespace/project path')
	}
	if (/^\d+$/.test(value)) return value
	if (/%2[fF]/.test(value)) return value
	return encodeURIComponent(value)
}

export function decodeProjectPath(projectId: string): string {
	try {
		return decodeURIComponent(projectId)
	} catch {
		return projectId
	}
}

export function normalizeProjectLocator(params: Record<string, unknown>): GitlabProjectLocator {
	const validation = validateProjectLocator(params)
	if (!validation.locator || validation.errors.length > 0) {
		throw new Error(validation.errors.join('; '))
	}
	return validation.locator
}

export function normalizeIssueLocator(params: Record<string, unknown>): GitlabIssueLocator {
	const project = validateProjectLocator(params, { optionalWhenUrl: true })
	const fromUrl = readResourceUrl(params, 'issueUrl', ISSUE_PATH, 'issues')
	const issueIid = readPositiveInt(params.issueIid ?? params.iid ?? params.issue_iid, 'issueIid')
	const errors = [...project.errors, ...fromUrl.errors, ...issueIid.errors]

	if (fromUrl.locator && project.explicit && project.locator) {
		errors.push('Provide either issueUrl or project + issueIid, not both')
	}
	if (fromUrl.locator && issueIid.value && fromUrl.locator.iid !== issueIid.value) {
		errors.push('issueUrl iid and issueIid must match when both are provided')
	}
	if (!fromUrl.locator && !project.locator) {
		errors.push(
			'Provide issueUrl or a project locator (project, projectId, projectPath, or projectUrl) plus issueIid',
		)
	}
	if (!fromUrl.locator && !issueIid.value) {
		errors.push('issueIid is required when issueUrl is omitted (iid is accepted as an alias)')
	}
	if (errors.length > 0 || (!fromUrl.locator && !project.locator) || !(fromUrl.locator?.iid ?? issueIid.value)) {
		throw new Error(errors.join('; ') || 'Invalid GitLab issue locator')
	}

	const locator = fromUrl.locator
		? {
				projectId: encodeProjectId(fromUrl.locator.projectPath),
				projectPath: fromUrl.locator.projectPath,
				issueIid: fromUrl.locator.iid,
			}
		: {
				projectId: project.locator!.projectId,
				projectPath: project.locator!.projectPath,
				issueIid: issueIid.value!,
			}
	return locator
}

export function normalizeMergeRequestLocator(
	params: Record<string, unknown>,
): GitlabMergeRequestLocator {
	const project = validateProjectLocator(params, { optionalWhenUrl: true })
	const fromUrl = readResourceUrl(
		params,
		'mergeRequestUrl',
		MERGE_REQUEST_PATH,
		'merge_requests',
	)
	const mergeRequestIid = readPositiveInt(
		params.mergeRequestIid ?? params.mrIid ?? params.iid ?? params.merge_request_iid,
		'mergeRequestIid',
	)
	const errors = [...project.errors, ...fromUrl.errors, ...mergeRequestIid.errors]

	if (fromUrl.locator && project.explicit && project.locator) {
		errors.push('Provide either mergeRequestUrl or project + mergeRequestIid, not both')
	}
	if (fromUrl.locator && mergeRequestIid.value && fromUrl.locator.iid !== mergeRequestIid.value) {
		errors.push('mergeRequestUrl iid and mergeRequestIid must match when both are provided')
	}
	if (!fromUrl.locator && !project.locator) {
		errors.push(
			'Provide mergeRequestUrl or a project locator plus mergeRequestIid (mrIid and iid are aliases)',
		)
	}
	if (!fromUrl.locator && !mergeRequestIid.value) {
		errors.push('mergeRequestIid is required when mergeRequestUrl is omitted')
	}
	if (
		errors.length > 0 ||
		(!fromUrl.locator && !project.locator) ||
		!(fromUrl.locator?.iid ?? mergeRequestIid.value)
	) {
		throw new Error(errors.join('; ') || 'Invalid GitLab merge request locator')
	}

	return fromUrl.locator
		? {
				projectId: encodeProjectId(fromUrl.locator.projectPath),
				projectPath: fromUrl.locator.projectPath,
				mergeRequestIid: fromUrl.locator.iid,
			}
		: {
				projectId: project.locator!.projectId,
				projectPath: project.locator!.projectPath,
				mergeRequestIid: mergeRequestIid.value!,
			}
}

export function normalizePipelineLocator(params: Record<string, unknown>): GitlabPipelineLocator {
	const project = validateProjectLocator(params, { optionalWhenUrl: true })
	const fromUrl = readResourceUrl(params, 'pipelineUrl', PIPELINE_PATH, 'pipelines')
	const pipelineId = readPositiveInt(params.pipelineId ?? params.pipeline_id, 'pipelineId')
	const errors = [...project.errors, ...fromUrl.errors, ...pipelineId.errors]

	if (fromUrl.locator && project.explicit && project.locator) {
		errors.push('Provide either pipelineUrl or project + pipelineId, not both')
	}
	if (fromUrl.locator && pipelineId.value && fromUrl.locator.iid !== pipelineId.value) {
		errors.push('pipelineUrl id and pipelineId must match when both are provided')
	}
	if (!fromUrl.locator && !project.locator) {
		errors.push('Provide pipelineUrl or a project locator plus pipelineId')
	}
	if (!fromUrl.locator && !pipelineId.value) {
		errors.push('pipelineId is required when pipelineUrl is omitted')
	}
	if (
		errors.length > 0 ||
		(!fromUrl.locator && !project.locator) ||
		!(fromUrl.locator?.iid ?? pipelineId.value)
	) {
		throw new Error(errors.join('; ') || 'Invalid GitLab pipeline locator')
	}

	return fromUrl.locator
		? {
				projectId: encodeProjectId(fromUrl.locator.projectPath),
				projectPath: fromUrl.locator.projectPath,
				pipelineId: fromUrl.locator.iid,
			}
		: {
				projectId: project.locator!.projectId,
				projectPath: project.locator!.projectPath,
				pipelineId: pipelineId.value!,
			}
}

export function tryNormalizeProjectLocator(
	params: Record<string, unknown>,
): GitlabProjectLocator | null {
	const validation = validateProjectLocator(params)
	if (!validation.locator || validation.errors.length > 0) return null
	return validation.locator
}

function validateProjectLocator(
	params: Record<string, unknown>,
	options: { optionalWhenUrl?: boolean } = {},
): {
	locator: GitlabProjectLocator | null
	errors: string[]
	explicit: boolean
} {
	const errors: string[] = []
	const projectUrl = readOptionalString(params.projectUrl)
	const project = params.project ?? params.projectId ?? params.projectPath ?? params.project_id
	const explicit =
		params.project !== undefined ||
		params.projectId !== undefined ||
		params.projectPath !== undefined ||
		params.project_id !== undefined ||
		params.projectUrl !== undefined

	let urlLocator: GitlabProjectLocator | null = null
	if (projectUrl !== undefined) {
		urlLocator = parseProjectUrl(projectUrl, errors)
	}

	let structured: GitlabProjectLocator | null = null
	if (project !== undefined) {
		if (typeof project === 'number' && Number.isInteger(project) && project > 0) {
			structured = { projectId: String(project), projectPath: String(project) }
		} else if (typeof project === 'string' && project.trim().length > 0) {
			const trimmed = project.trim()
			structured = {
				projectId: encodeProjectId(trimmed),
				projectPath: /^\d+$/.test(trimmed) ? trimmed : decodeProjectPath(trimmed),
			}
		} else {
			errors.push('project must be a positive integer id or namespace/project path')
		}
	}

	if (urlLocator && structured && urlLocator.projectPath !== structured.projectPath) {
		errors.push('projectUrl and project locators must refer to the same project')
	}

	if (!urlLocator && !structured && !options.optionalWhenUrl) {
		errors.push(
			'Provide project, projectId, projectPath, or projectUrl (namespace/project or numeric id)',
		)
	}

	return {
		locator: urlLocator ?? structured,
		errors,
		explicit,
	}
}

function parseProjectUrl(value: string, errors: string[]): GitlabProjectLocator | null {
	if (typeof value !== 'string' || value.trim().length === 0) {
		errors.push('projectUrl must be a non-empty https GitLab project URL')
		return null
	}
	let parsed: URL
	try {
		parsed = new URL(value.trim())
	} catch {
		errors.push('projectUrl must be an absolute URL')
		return null
	}
	if (parsed.protocol !== 'https:') {
		errors.push('projectUrl must be https')
		return null
	}
	if (parsed.hostname === 'github.com' || parsed.hostname.endsWith('.github.com')) {
		errors.push('projectUrl must be a GitLab URL, not GitHub')
		return null
	}
	const resource = stripResourceSuffix(parsed.pathname)
	const projectPath = resource.replace(/^\/+/, '').replace(/\/+$/, '').replace(/\.git$/, '')
	if (!projectPath || projectPath.includes(' ')) {
		errors.push('projectUrl must include a namespace/project path')
		return null
	}
	return {
		projectId: encodeProjectId(projectPath),
		projectPath,
	}
}

function stripResourceSuffix(pathname: string): string {
	return pathname
		.replace(/\/-\/(issues|merge_requests|pipelines|tree|blob|commits)\/.*$/, '')
		.replace(/\/-\/?$/, '')
}

function readResourceUrl(
	params: Record<string, unknown>,
	field: string,
	pattern: RegExp,
	kind: string,
): { locator: { projectPath: string; iid: number } | null; errors: string[] } {
	const value = params[field]
	if (value === undefined) return { locator: null, errors: [] }
	if (typeof value !== 'string' || value.trim().length === 0) {
		return { locator: null, errors: [`${field} must be a non-empty GitLab ${kind} URL`] }
	}
	let parsed: URL
	try {
		parsed = new URL(value.trim())
	} catch {
		return { locator: null, errors: [`${field} must be an absolute URL`] }
	}
	if (parsed.protocol !== 'https:') {
		return { locator: null, errors: [`${field} must be https`] }
	}
	if (parsed.hostname === 'github.com' || parsed.hostname.endsWith('.github.com')) {
		return { locator: null, errors: [`${field} must be a GitLab URL, not GitHub`] }
	}
	const match = parsed.pathname.match(pattern)
	if (!match) {
		return {
			locator: null,
			errors: [`${field} must match https://<gitlab-host>/<namespace>/<project>/-/${kind}/{id}`],
		}
	}
	const projectPath = parsed.pathname
		.slice(0, match.index)
		.replace(/^\/+/, '')
		.replace(/\/+$/, '')
	if (!projectPath) {
		return { locator: null, errors: [`${field} must include a namespace/project path`] }
	}
	return {
		locator: { projectPath, iid: Number(match[1]) },
		errors: [],
	}
}

function readPositiveInt(
	value: unknown,
	fieldName: string,
): { value: number | null; errors: string[] } {
	if (value === undefined) return { value: null, errors: [] }
	const parsed = Number(value)
	if (!Number.isInteger(parsed) || parsed <= 0) {
		return { value: null, errors: [`${fieldName} must be a positive integer`] }
	}
	return { value: parsed, errors: [] }
}

function readOptionalString(value: unknown): string | undefined {
	return typeof value === 'string' ? value : undefined
}