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