Skip to content

src/review-sort.ts

87 lines · 3.4 KB · TypeScript
Raw
import { fetchSnapshot, replyToReviewComment } from './github.ts'
import { asRecord, locatorKeys, optionalBoolean, parseLocator, rejectUnknownKeys } from './input.ts'
import { analyzeThreads } from './status.ts'
import { listDecisions, recordEvent } from './storage.ts'
import { invalidReplyBody, type ReviewBotKind } from './classify.ts'

const NAME = 'ship-pr/review-sort'
const allowed = [...locatorKeys, 'applyInvalidReplies']

export type ReviewSortInput = {
	prUrl?: string
	owner?: string
	repo?: string
	prNumber?: number
	/** Post the short kody-bot "invalid" reply on sorted invalid findings that lack one. Default false (classify only). */
	applyInvalidReplies?: boolean
}

/**
 * Review-bot sort for Bugbot / Devin / Seer threads (source of truth; replaces
 * the old in-repo `sort-review-bot-comments.mjs`). Classifies unresolved root
 * comments: `mustAddress` (valid + unsure, no addressing reply),
 * `alreadyAddressed` (human/kody-bot reply cites a fixing commit or wontfix),
 * `invalid` (nit / false positive / no-action). Resolved and decided threads are
 * hidden. CodeRabbit inline threads and humans are listed under `otherThreads`,
 * unclassified. CodeRabbit "Outside diff range" body findings are promoted into
 * `mustAddress` with `source: 'outside_diff'` and a `decideTarget`.
 * `./tick` already embeds this; call directly for the full list.
 *
 * @param input - PR locator plus optional `applyInvalidReplies`.
 * @returns `{ mustAddress, alreadyAddressed, invalid, otherThreads, replied, skippedAlreadyReplied, hidden }`
 *
 * @example
 * import reviewSort from 'kody:@kentcdodds/ship-pr/review-sort'
 * const sorted = await reviewSort({ prUrl: 'https://github.com/kentcdodds/kody/pull/123' })
 * // => { mustAddress: [{ commentId, bot: 'bugbot', verdict: 'valid', title, url }], invalid: [], ... }
 */
export default async function reviewSort(rawInput: ReviewSortInput) {
	const input = asRecord(rawInput, NAME)
	rejectUnknownKeys(input, allowed, NAME)
	const locator = parseLocator(input, NAME)
	const apply = optionalBoolean(input, 'applyInvalidReplies', NAME) ?? false
	const [snapshot, decisions] = await Promise.all([fetchSnapshot(locator), listDecisions(locator.key)])
	const sorted = analyzeThreads(snapshot, decisions)
	let replied = 0
	let skippedAlreadyReplied = 0
	const errors: Array<{ commentId: number; error: string }> = []
	for (const item of sorted.invalid) {
		if (item.invalidReplied) {
			skippedAlreadyReplied += 1
			continue
		}
		if (!apply) continue
		try {
			const reply = await replyToReviewComment(
				locator,
				item.commentId,
				invalidReplyBody({
					bot: item.bot as ReviewBotKind,
					authorLogin: item.author,
					commentId: item.commentId,
					bodyPreview: item.title,
					verdict: 'invalid',
					reason: item.reason ?? 'invalid',
				}),
			)
			await recordEvent(locator.key, 'invalid_reply', String(item.commentId), { url: reply.url })
			replied += 1
		} catch (error) {
			errors.push({ commentId: item.commentId, error: error instanceof Error ? error.message : String(error) })
		}
	}
	return {
		ok: errors.length === 0,
		prUrl: locator.prUrl,
		headSha: snapshot.headSha,
		applyInvalidReplies: apply,
		mustAddress: sorted.mustAddress,
		alreadyAddressed: sorted.alreadyAddressed,
		invalid: sorted.invalid,
		otherThreads: sorted.otherThreads,
		replied,
		skippedAlreadyReplied,
		...(errors.length ? { errors } : {}),
		hidden: { resolvedThreads: sorted.resolved, decidedThreads: sorted.decided },
	}
}