← Public packages
@kentcdodds/ship-pr
Progressive ship-pr loop for agents: PR status, Bugbot, review sort, 7-day decisions, merge, deploy, wrap-up.
src/review-sort.ts
87 lines · 3.4 KB · TypeScriptimport { 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 },
}
}