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): 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): 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, ): 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): 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, ): GitlabProjectLocator | null { const validation = validateProjectLocator(params) if (!validation.locator || validation.errors.length > 0) return null return validation.locator } function validateProjectLocator( params: Record, 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, 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://///-/${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 }