← Public packages
@kentcdodds/ship-pr
Progressive ship-pr loop for agents: PR status, Bugbot, review sort, 7-day decisions, merge, deploy, wrap-up.
src/input.ts
161 lines · 4.9 KB · TypeScript/** Strict input helpers for agent-facing exports (reject unknown keys, one contract). */
export type PrLocator = {
owner: string
repo: string
prNumber: number
prUrl: string
/** Lowercased `owner/repo#n` storage key. */
key: string
}
export const locatorKeys = ['prUrl', 'owner', 'repo', 'prNumber'] as const
export type LocatorInput = {
prUrl?: string
owner?: string
repo?: string
prNumber?: number
}
export function asRecord(raw: unknown, exportName: string): Record<string, unknown> {
if (raw == null) return {}
if (typeof raw !== 'object' || Array.isArray(raw)) {
throw new Error(`${exportName}: pass a JSON object of params.`)
}
return raw as Record<string, unknown>
}
export function rejectUnknownKeys(
input: Record<string, unknown>,
allowed: ReadonlyArray<string>,
exportName: string,
) {
const unknown = Object.keys(input).filter(
(key) => !allowed.includes(key) && input[key] !== undefined,
)
if (unknown.length) {
throw new Error(
`${exportName}: unknown param(s) ${unknown.map((k) => JSON.stringify(k)).join(', ')}. Accepted: ${allowed.join(', ')}.`,
)
}
}
export function parseLocator(input: Record<string, unknown>, exportName: string): PrLocator {
const prUrl = typeof input.prUrl === 'string' ? input.prUrl.trim() : ''
if (prUrl) {
const match = prUrl.match(/github\.com\/([^/]+)\/([^/]+)\/(?:pull|issues)\/(\d+)/i)
if (!match) {
throw new Error(
`${exportName}: could not parse a GitHub PR URL from ${JSON.stringify(prUrl)}. Pass https://github.com/owner/repo/pull/123.`,
)
}
return makeLocator(match[1], match[2], Number(match[3]))
}
const owner = typeof input.owner === 'string' ? input.owner.trim() : ''
const repo = typeof input.repo === 'string' ? input.repo.trim() : ''
const prNumber = Number(input.prNumber)
if (!owner || !repo || !Number.isInteger(prNumber) || prNumber <= 0) {
throw new Error(`${exportName}: pass prUrl, or owner + repo + prNumber.`)
}
return makeLocator(owner, repo, prNumber)
}
export function makeLocator(owner: string, repo: string, prNumber: number): PrLocator {
return {
owner,
repo,
prNumber,
prUrl: `https://github.com/${owner}/${repo}/pull/${prNumber}`,
key: `${owner}/${repo}#${prNumber}`.toLowerCase(),
}
}
export function optionalString(
input: Record<string, unknown>,
key: string,
exportName: string,
): string | undefined {
const value = input[key]
if (value == null) return undefined
if (typeof value !== 'string') throw new Error(`${exportName}: ${key} must be a string.`)
const trimmed = value.trim()
return trimmed || undefined
}
export function requiredString(
input: Record<string, unknown>,
key: string,
exportName: string,
hint = '',
): string {
const value = optionalString(input, key, exportName)
if (!value) throw new Error(`${exportName}: ${key} is required.${hint ? ` ${hint}` : ''}`)
return value
}
export function optionalBoolean(
input: Record<string, unknown>,
key: string,
exportName: string,
): boolean | undefined {
const value = input[key]
if (value == null) return undefined
if (typeof value !== 'boolean') throw new Error(`${exportName}: ${key} must be true or false.`)
return value
}
export function oneOf<T extends string>(
input: Record<string, unknown>,
key: string,
values: ReadonlyArray<T>,
exportName: string,
required: true,
): T
export function oneOf<T extends string>(
input: Record<string, unknown>,
key: string,
values: ReadonlyArray<T>,
exportName: string,
required?: false,
): T | undefined
export function oneOf<T extends string>(
input: Record<string, unknown>,
key: string,
values: ReadonlyArray<T>,
exportName: string,
required = false,
): T | undefined {
const value = input[key]
if (value == null || value === '') {
if (required) throw new Error(`${exportName}: ${key} is required (${values.join(' | ')}).`)
return undefined
}
// Case-insensitive: 'medium' and 'Medium' both resolve to the canonical spelling.
const match = typeof value === 'string' ? values.find((v) => v.toLowerCase() === value.trim().toLowerCase()) : undefined
if (!match) {
throw new Error(`${exportName}: ${key} must be one of ${values.join(' | ')}; got ${JSON.stringify(value)}.`)
}
return match
}
/**
* Shell-safe example local CLI call for a package export.
*
* `@kodycodes/cli` rejects `--local` together with `--invoke` (`--invoke` runs
* in Kody cloud), so the local form is a one-line static-import passthrough via
* `--code` — the same thin passthrough `--invoke` would mint.
*/
export function exampleInvoke(
exportSubpath: string,
params: Record<string, unknown>,
purpose: string,
pkg = '@kentcdodds/ship-pr',
): { purpose: string; command: string } {
const ref = exportSubpath ? `kody:${pkg}/${exportSubpath.replace(/^\.\//, '')}` : `kody:${pkg}`
const json = JSON.stringify(params).replace(/'/g, `'\\''`)
return {
purpose,
command: `npx @kodycodes/cli execute --local --code 'import run from "${ref}"; export default (p) => run(p)' --params '${json}'`,
}
}