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