Skip to content

src/reply-review.ts

78 lines · 2.9 KB · TypeScript
Raw
import { fetchSnapshot, replyToReviewComment, resolveReviewThread } from './github.ts'
import {
	asRecord,
	locatorKeys,
	optionalBoolean,
	parseLocator,
	rejectUnknownKeys,
	requiredString,
} from './input.ts'

const NAME = 'ship-pr/reply-review'
const allowed = [...locatorKeys, 'commentId', 'body', 'resolve', 'dryRun']

export type ReplyReviewInput = {
	prUrl?: string
	owner?: string
	repo?: string
	prNumber?: number
	/** Review comment id in the thread (tick lists the root `commentId`). */
	commentId: number
	/**
	 * Reply text. Cite the fixing commit ("Fixed in abc1234: …") or the wontfix reasoning.
	 * Posted with headers-only secret resolution, so quoting placeholder syntax is sent as written.
	 */
	body: string
	/** Also resolve the review thread (kody-bot). */
	resolve?: boolean
	/** Preview without posting. */
	dryRun?: boolean
}

/**
 * Reply to a pull-request review thread as kody-bot, optionally resolving it.
 * Use instead of `gh api` (Cloud Agent `gh` cannot post review replies).
 * A reply citing a fixing commit ("Fixed in abc1234") or "wontfix" moves a
 * sorted Bugbot/Devin/Seer finding to `alreadyAddressed` on the next tick.
 *
 * @param input - PR locator, `commentId`, `body`, optional `resolve`, optional `dryRun`.
 * @returns `{ replied, url, resolved, threadId, dryRun }`
 *
 * @example
 * import replyReview from 'kody:@kentcdodds/ship-pr/reply-review'
 * await replyReview({
 *   prUrl: 'https://github.com/kentcdodds/kody/pull/123',
 *   commentId: 4185778067,
 *   body: 'Fixed in 688e5da51: builtins are excluded from the gate.',
 *   resolve: true,
 * })
 * // => { replied: true, url: 'https://github.com/…#discussion_r…', resolved: true }
 */
export default async function replyReview(rawInput: ReplyReviewInput) {
	const input = asRecord(rawInput, NAME)
	rejectUnknownKeys(input, allowed, NAME)
	const locator = parseLocator(input, NAME)
	const commentId = Number(input.commentId)
	if (!Number.isInteger(commentId) || commentId <= 0) {
		throw new Error(`${NAME}: commentId must be the numeric review comment id.`)
	}
	const body = requiredString(input, 'body', NAME)
	const resolve = optionalBoolean(input, 'resolve', NAME) ?? false
	const dryRun = optionalBoolean(input, 'dryRun', NAME) ?? false

	let threadId: string | null = null
	if (resolve || dryRun) {
		const snapshot = await fetchSnapshot(locator)
		const thread = snapshot.threads.find((t) => t.comments.some((c) => c.id === commentId))
		threadId = thread?.id ?? null
		if (!thread) {
			throw new Error(`${NAME}: no review thread on ${locator.prUrl} contains comment ${commentId}.`)
		}
	}
	if (dryRun) {
		return { replied: false, dryRun: true, prUrl: locator.prUrl, commentId, threadId, body, resolve, account: 'bot' }
	}
	const reply = await replyToReviewComment(locator, commentId, body)
	const resolved = resolve && threadId ? await resolveReviewThread(threadId) : false
	return { replied: true, dryRun: false, url: reply.url, commentId, threadId, resolved }
}