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 }, } }