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]) }
}