Skip to content

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

Package listing

@kentcdodds/github

src/pr/pr-input.ts

196 lines · 4.9 KB · TypeScript
export type PrLocator = {
	owner: string
	repo: string
	prNumber: number
}

export type ReviewStatus = 'draft' | 'ready' | 'toggle'

type PrLocatorValidation = {
	locator: PrLocator | null
	errors: Array<string>
}

type ReviewStatusValidation = {
	status: ReviewStatus | null
	errors: Array<string>
}

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

export function normalizeSetPrReviewStatusInput(
	params: Record<string, unknown>,
) {
	const locatorValidation = validatePrLocator(params)
	const statusValidation = validateReviewStatus(params)
	const errors = [
		...statusValidation.errors,
		...locatorValidation.errors,
	]

	if (
		!locatorValidation.locator ||
		!statusValidation.status ||
		errors.length > 0
	) {
		throw new Error(errors.join('; '))
	}

	return {
		...locatorValidation.locator,
		status: statusValidation.status,
	}
}

function validatePrLocator(
	params: Record<string, unknown>,
): PrLocatorValidation {
	const errors: Array<string> = []
	const prUrlInput = params.prUrl
	const hasPrUrlInput = prUrlInput !== undefined
	const hasStructuredInput =
		params.owner !== undefined ||
		params.repo !== undefined ||
		params.prNumber !== undefined ||
		params.number !== undefined

	let urlLocator: PrLocator | null = null
	if (hasPrUrlInput) {
		if (typeof prUrlInput !== 'string' || prUrlInput.trim().length === 0) {
			errors.push('prUrl must be a non-empty GitHub pull request URL')
		} else {
			urlLocator = parsePrUrl(prUrlInput.trim(), errors)
		}
	}

	const structuredLocator = hasStructuredInput
		? validateStructuredLocator(params, errors)
		: null

	if (urlLocator && structuredLocator) {
		errors.push('Provide either prUrl or owner + repo + prNumber, not both')
	}

	if (!hasPrUrlInput && !hasStructuredInput) {
		errors.push(
			'Provide either prUrl or owner + repo + prNumber (number is accepted as an alias for prNumber)',
		)
	}

	return {
		locator: urlLocator ?? structuredLocator,
		errors,
	}
}

function validateStructuredLocator(
	params: Record<string, unknown>,
	errors: Array<string>,
): PrLocator | null {
	const owner = readNonEmptyString(params.owner, 'owner', errors)
	const repo = readNonEmptyString(params.repo, 'repo', errors)
	const prNumber = readPrNumber(params, errors)

	if (!owner || !repo || !prNumber) return null

	return { owner, repo, prNumber }
}

function readNonEmptyString(
	value: unknown,
	fieldName: string,
	errors: Array<string>,
) {
	if (typeof value !== 'string' || value.trim().length === 0) {
		errors.push(`${fieldName} must be a non-empty string`)
		return null
	}
	return value.trim()
}

function readPrNumber(
	params: Record<string, unknown>,
	errors: Array<string>,
) {
	const hasPrNumber = params.prNumber !== undefined
	const hasNumberAlias = params.number !== undefined
	const rawPrNumber = hasPrNumber ? params.prNumber : params.number

	if (!hasPrNumber && !hasNumberAlias) {
		errors.push('prNumber is required (number is accepted as an alias)')
		return null
	}

	const prNumber = Number(rawPrNumber)
	if (!Number.isInteger(prNumber) || prNumber <= 0) {
		errors.push('prNumber must be a positive integer')
		return null
	}

	if (hasPrNumber && hasNumberAlias && Number(params.number) !== prNumber) {
		errors.push('prNumber and number aliases must match when both are provided')
		return null
	}

	return prNumber
}

function validateReviewStatus(
	params: Record<string, unknown>,
): ReviewStatusValidation {
	const errors: Array<string> = []
	const hasStatus = params.status !== undefined
	const hasReadyAlias = params.ready !== undefined
	let status: ReviewStatus | null = null
	let readyAliasStatus: ReviewStatus | null = null

	if (hasStatus) {
		if (params.status === 'draft' || params.status === 'ready' || params.status === 'toggle') {
			status = params.status
		} else {
			errors.push('status must be "draft", "ready", or "toggle"')
		}
	}

	if (hasReadyAlias) {
		if (typeof params.ready === 'boolean') {
			readyAliasStatus = params.ready ? 'ready' : 'draft'
		} else {
			errors.push('ready must be a boolean when provided')
		}
	}

	if (status && readyAliasStatus && status !== readyAliasStatus) {
		errors.push(
			'status and ready disagree; use status: "ready" with ready: true or status: "draft" with ready: false',
		)
	}

	if (!hasStatus && !hasReadyAlias) {
		errors.push('status must be "draft" or "ready"')
	}

	return {
		status: status ?? readyAliasStatus,
		errors,
	}
}

function parsePrUrl(prUrl: string, errors: Array<string>): PrLocator | null {
	const match = prUrl.match(
		/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/,
	)
	if (!match) {
		errors.push('prUrl must match https://github.com/{owner}/{repo}/pull/{number}')
		return null
	}
	return { owner: match[1], repo: match[2], prNumber: Number(match[3]) }
}