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
}