Skip to content

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

Package listing

@kentcdodds/github

src/pr/set-review-status.ts

103 lines · 3.3 KB · TypeScript
import { graphql } from '../core.ts'
import type { GitHubAccount } from '../types.ts'
import getPrInfo from './get-info.ts'
import { normalizePrLocator, normalizeSetPrReviewStatusInput } from './pr-input.ts'

const CONVERT_TO_DRAFT = `
	mutation ConvertToDraft($prId: ID!) {
		convertPullRequestToDraft(input: { pullRequestId: $prId }) {
			pullRequest { id number title isDraft }
		}
	}
`

const MARK_READY = `
	mutation MarkReadyForReview($prId: ID!) {
		markPullRequestReadyForReview(input: { pullRequestId: $prId }) {
			pullRequest { id number title isDraft }
		}
	}
`

/** Bound GraphQL review-status mutations; same hang class as merge. */
const DEFAULT_MUTATION_TIMEOUT_MS = 25_000

/**
 * Set a pull request to draft, ready-for-review, or toggle between them.
 *
 * The GraphQL mutation is bounded by `timeoutMs` (default 25000) so a stuck
 * GitHub mutation fails fast instead of hanging the execute sandbox.
 *
 * @param params - PR locator plus `status: 'draft' | 'ready' | 'toggle'`,
 *   optional `account`, and optional `timeoutMs`.
 * @returns Whether the status changed and the updated draft flag.
 * @example
 * import setPrReviewStatus from 'kody:@kentcdodds/github/pr/set-review-status'
 * const result = await setPrReviewStatus({ owner: 'kentcdodds', repo: 'kody', prNumber: 1, status: 'ready' })
 * // => { changed: true, draft: false, message: 'PR #1 is now ready for review.' }
 */
export default async function setPrReviewStatus(params: Record<string, unknown> = {}) {
	const account = (params.account as GitHubAccount | undefined) ?? 'bot'
	const timeoutMs = readTimeoutMs(params)

	if (params.status === 'toggle') {
		const locator = normalizePrLocator(params)
		const pr = await getPrInfo({ ...locator, account })
		const status = pr.draft ? 'ready' : 'draft'
		return setPrReviewStatus({ ...locator, account, status, timeoutMs })
	}

	const { status, ...locator } = normalizeSetPrReviewStatusInput(params)
	const pr = await getPrInfo({ ...locator, account })

	if (pr.state !== 'open') {
		throw new Error(
			`PR #${pr.number} is ${pr.state}, not open — cannot change review status.`,
		)
	}

	const alreadyCorrect =
		(status === 'draft' && pr.draft) || (status === 'ready' && !pr.draft)

	if (alreadyCorrect) {
		return {
			changed: false,
			prNumber: pr.number,
			title: pr.title,
			url: pr.url,
			draft: pr.draft,
			message: `PR #${pr.number} is already ${pr.draft ? 'a draft' : 'ready for review'}.`,
		}
	}

	const result = await graphql<Record<string, Record<string, { pullRequest: Record<string, unknown> }>>>({
		account,
		query: status === 'draft' ? CONVERT_TO_DRAFT : MARK_READY,
		variables: { prId: pr.nodeId },
		throwOnError: true,
		timeoutMs,
	})

	const updated =
		status === 'draft'
			? result.data!.convertPullRequestToDraft.pullRequest
			: result.data!.markPullRequestReadyForReview.pullRequest

	return {
		changed: true,
		prNumber: pr.number,
		title: pr.title,
		url: pr.url,
		draft: updated.isDraft as boolean,
		message: `PR #${pr.number} is now ${updated.isDraft ? 'a draft' : 'ready for review'}.`,
	}
}

function readTimeoutMs(params: Record<string, unknown>): number {
	if (params.timeoutMs === undefined) return DEFAULT_MUTATION_TIMEOUT_MS
	const value = Number(params.timeoutMs)
	if (!Number.isFinite(value) || value <= 0) {
		throw new Error('timeoutMs must be a positive number of milliseconds')
	}
	return value
}