diff --git a/packages/core/src/handlers/ack.ts b/packages/core/src/handlers/ack.ts new file mode 100644 index 0000000..8a1bc51 --- /dev/null +++ b/packages/core/src/handlers/ack.ts @@ -0,0 +1,201 @@ +/** + * Handler for acknowledging feedback comments. + * + * Acknowledges one or more comments locally and optionally adds a thumbs-up + * reaction on GitHub. Supports undo via the undo flag. + */ + +import { NotFoundError, Result } from "@outfitter/contracts"; + +import { detectAuth } from "../auth"; +import { type AckRecord, addAcks, isAcked, removeAck } from "../ack"; +import { + batchAddReactions, + deduplicateByCommentId, + partitionResolutions, + resolveBatchIds, +} from "../batch"; +import { formatDisplayId } from "../render/ids"; +import { classifyId, generateShortId } from "../short-id"; +import type { HandlerContext } from "./types"; + +// ============================================================================= +// Types +// ============================================================================= + +/** Input parameters for the ack handler. */ +export interface AckInput { + /** IDs to acknowledge — short IDs, comment IDs, or PR numbers */ + ids: string[]; + /** Repository (owner/repo) */ + repo: string; + /** Add thumbs-up reaction on GitHub */ + react?: boolean | undefined; + /** Undo a previous ack */ + undo?: boolean | undefined; +} + +/** Per-ID result for ack operations. */ +export interface AckItemResult { + /** Short display ID */ + id: string; + /** Whether this operation succeeded */ + ok: boolean; + /** Error message if not ok */ + error?: string | undefined; +} + +/** Structured output from the ack handler. */ +export interface AckOutput { + /** Number of entries acknowledged (or un-acknowledged) */ + acked: number; + /** Number of reactions added */ + reactionsAdded: number; + /** Individual results per ID */ + results: AckItemResult[]; +} + +// ============================================================================= +// Handler +// ============================================================================= + +/** + * Acknowledge feedback comments, optionally adding GitHub reactions. + * + * Resolves each ID from the cache, checks ack status, and records acks locally. + * When react=true, attempts to add a thumbs-up reaction (requires auth). + * When undo=true, removes existing acks instead. + * + * @param input - Ack input including IDs, repo, and options + * @param ctx - Handler context with config, db, and logger + * @returns Result containing AckOutput on success + */ +export async function ackHandler( + input: AckInput, + ctx: HandlerContext +): Promise> { + if (input.ids.length === 0) { + return Result.err( + new NotFoundError({ + message: "At least one ID is required.", + resourceType: "comment", + resourceId: "", + }) + ); + } + + // Filter out PR numbers (not supported in ack) + const commentIds = input.ids.filter((id) => classifyId(id) !== "pr_number"); + if (commentIds.length === 0) { + return Result.err( + new NotFoundError({ + message: "No comment IDs provided. Use PR numbers with a dedicated bulk ack command.", + resourceType: "comment", + resourceId: input.ids[0] ?? "", + }) + ); + } + + // Resolve all comment IDs in a single batch + const resolutions = await resolveBatchIds(commentIds, input.repo); + const { comments, errors } = partitionResolutions(resolutions); + const uniqueComments = deduplicateByCommentId(comments); + + const itemResults: AckItemResult[] = []; + + // Collect error results + for (const e of errors) { + itemResults.push({ + id: e.id, + ok: false, + error: e.error ?? "Unknown error", + }); + } + + if (uniqueComments.length === 0) { + return Result.ok({ + acked: 0, + reactionsAdded: 0, + results: itemResults, + }); + } + + // Handle undo: remove acks + if (input.undo) { + let removed = 0; + for (const comment of uniqueComments) { + const count = await removeAck(comment.id, input.repo); + const shortId = comment.shortId ?? formatDisplayId(generateShortId(comment.id, input.repo)); + if (count > 0) { + removed++; + itemResults.push({ id: shortId, ok: true }); + } else { + itemResults.push({ id: shortId, ok: false, error: "Not previously acknowledged." }); + } + } + return Result.ok({ acked: removed, reactionsAdded: 0, results: itemResults }); + } + + // Setup GitHub client for reactions if requested + let client = null; + if (input.react) { + const authResult = await detectAuth(ctx.config.github_token); + if (authResult.isOk()) { + const { GitHubClient } = await import("../github"); + client = new GitHubClient(authResult.value.token); + } else { + ctx.logger.debug("No auth for reactions; acknowledging locally only."); + } + } + + // Check which are already acked + const ackChecks = await Promise.all( + uniqueComments.map(async (r) => { + const alreadyAcked = await isAcked(r.id, input.repo); + return { ...r, alreadyAcked }; + }) + ); + + const toAck = ackChecks.filter((r) => !r.alreadyAcked); + const alreadyAcked = ackChecks.filter((r) => r.alreadyAcked); + + // Add reactions in parallel for newly acked items + const reactionResults = + client && toAck.length > 0 + ? await batchAddReactions(toAck.map((r) => r.id), client) + : toAck.map((r) => ({ commentId: r.id, reactionAdded: false })); + + const reactionMap = new Map(reactionResults.map((r) => [r.commentId, r.reactionAdded])); + + // Build and store ack records + const ackRecords: AckRecord[] = toAck.map((r) => ({ + repo: input.repo, + pr: r.entry?.pr ?? 0, + comment_id: r.id, + acked_at: new Date().toISOString(), + ...(ctx.config.user?.github_username && { acked_by: ctx.config.user.github_username }), + reaction_added: reactionMap.get(r.id) ?? false, + })); + + if (ackRecords.length > 0) { + await addAcks(ackRecords); + } + + const reactionsAdded = reactionResults.filter((r) => r.reactionAdded).length; + + // Build per-ID results + for (const r of toAck) { + const shortId = r.shortId ?? formatDisplayId(generateShortId(r.id, input.repo)); + itemResults.push({ id: shortId, ok: true }); + } + for (const r of alreadyAcked) { + const shortId = r.shortId ?? formatDisplayId(generateShortId(r.id, input.repo)); + itemResults.push({ id: shortId, ok: true }); + } + + return Result.ok({ + acked: toAck.length, + reactionsAdded, + results: itemResults, + }); +} diff --git a/packages/core/src/handlers/close.ts b/packages/core/src/handlers/close.ts new file mode 100644 index 0000000..926bacc --- /dev/null +++ b/packages/core/src/handlers/close.ts @@ -0,0 +1,165 @@ +/** + * Handler for closing feedback: resolving review threads or closing PRs. + * + * Polymorphic: accepts a comment ID (resolves the thread) or a PR number + * (closes the PR via GitHub API). Returns structured output for formatting. + */ + +import { AuthError, NotFoundError, Result } from "@outfitter/contracts"; + +import { detectAuth } from "../auth"; +import { queryEntries } from "../query"; +import { formatDisplayId } from "../render/ids"; +import { + buildShortIdCache, + classifyId, + resolveShortId, +} from "../short-id"; +import type { HandlerContext } from "./types"; + +// ============================================================================= +// Types +// ============================================================================= + +/** Input parameters for the close handler. */ +export interface CloseInput { + /** ID to close — short ID, comment ID, or PR number */ + id: string; + /** Repository (owner/repo) */ + repo: string; + /** Comment body when closing (optional) */ + body?: string | undefined; +} + +/** Structured output from the close handler. */ +export interface CloseOutput { + /** What was closed */ + type: "thread" | "pr"; + /** Whether it was successfully closed */ + ok: boolean; + /** URL of the closed item */ + url?: string | undefined; +} + +// ============================================================================= +// Handler +// ============================================================================= + +/** + * Close a review thread or PR. + * + * When the ID is a PR number, closes the PR via GitHub API. + * When the ID is a comment ID (short or full), resolves the review thread + * (or acks if it's an issue comment which cannot be resolved). + * + * @param input - Close input including ID and repo + * @param ctx - Handler context with config, db, and logger + * @returns Result containing CloseOutput on success + */ +export async function closeHandler( + input: CloseInput, + ctx: HandlerContext +): Promise> { + const authResult = await detectAuth(ctx.config.github_token); + if (authResult.isErr()) { + return Result.err( + new AuthError({ message: `Authentication required to close: ${authResult.error.message}` }) + ); + } + + const { GitHubClient } = await import("../github"); + const client = new GitHubClient(authResult.value.token); + + const repoParts = input.repo.split("/"); + const owner = repoParts[0] ?? ""; + const name = repoParts[1] ?? ""; + + const idType = classifyId(input.id); + + // PR number: close the PR + if (idType === "pr_number") { + const prNum = Number.parseInt(input.id, 10); + const prIdResult = await client.fetchPullRequestId(owner, name, prNum); + if (prIdResult.isErr()) { + return Result.err( + new NotFoundError({ + message: `PR #${prNum} not found in ${input.repo}.`, + resourceType: "pull_request", + resourceId: String(prNum), + }) + ); + } + + const closeResult = await client.closePullRequest(prIdResult.value); + if (closeResult.isErr()) { + return Result.err(closeResult.error); + } + + ctx.logger.debug("PR closed", { pr: prNum, repo: input.repo }); + return Result.ok({ type: "pr", ok: true }); + } + + // Comment ID: resolve the review thread + const entries = await queryEntries({ filters: { repo: input.repo } }); + buildShortIdCache(entries); + + let commentId = input.id; + if (idType === "short_id") { + const mapping = resolveShortId(input.id); + if (!mapping) { + return Result.err( + new NotFoundError({ + message: `Short ID ${formatDisplayId(input.id)} not found in cache.`, + resourceType: "comment", + resourceId: input.id, + }) + ); + } + commentId = mapping.fullId; + } + + const entry = entries.find((e) => e.id === commentId); + if (!entry) { + return Result.err( + new NotFoundError({ + message: `Comment ${input.id} not found in cache.`, + resourceType: "comment", + resourceId: input.id, + }) + ); + } + + if (entry.subtype !== "review_comment") { + // Issue comments cannot be resolved — acknowledge with reaction instead + const reactionResult = await client.addReaction(commentId, "THUMBS_UP"); + if (reactionResult.isErr()) { + ctx.logger.debug("Reaction already exists or failed", { commentId }); + } + return Result.ok({ type: "thread", ok: true }); + } + + // Review comment: find and resolve the thread + const threadMapResult = await client.fetchReviewThreadMap(owner, name, entry.pr); + if (threadMapResult.isErr()) { + return Result.err(threadMapResult.error); + } + + const threadId = threadMapResult.value.get(commentId); + if (!threadId) { + return Result.err( + new NotFoundError({ + message: `No review thread found for comment ${input.id}.`, + resourceType: "thread", + resourceId: commentId, + }) + ); + } + + const resolveResult = await client.resolveReviewThread(threadId); + if (resolveResult.isErr()) { + return Result.err(resolveResult.error); + } + + ctx.logger.debug("Thread resolved", { threadId, repo: input.repo }); + return Result.ok({ type: "thread", ok: true }); +} diff --git a/packages/core/src/handlers/comment.ts b/packages/core/src/handlers/comment.ts new file mode 100644 index 0000000..5a6d582 --- /dev/null +++ b/packages/core/src/handlers/comment.ts @@ -0,0 +1,91 @@ +/** + * Handler for adding a PR-level comment. + * + * Resolves the PR node ID and posts a new issue comment on the PR. + * Returns structured output for CLI/MCP formatting. + */ + +import { AuthError, NotFoundError, Result } from "@outfitter/contracts"; + +import { detectAuth } from "../auth"; +import type { HandlerContext } from "./types"; + +// ============================================================================= +// Types +// ============================================================================= + +/** Input parameters for the comment handler. */ +export interface CommentInput { + /** PR number */ + pr: number; + /** Comment body text */ + body: string; + /** Repository (owner/repo) */ + repo: string; +} + +/** Structured output from the comment handler. */ +export interface CommentOutput { + /** The created comment's node ID */ + id: string; + /** URL to the comment */ + url?: string | undefined; +} + +// ============================================================================= +// Handler +// ============================================================================= + +/** + * Add a PR-level comment. + * + * Resolves the PR node ID via GitHub API and posts a new issue comment. + * Returns structured metadata about the created comment. + * + * @param input - Comment input including PR number, body, and repo + * @param ctx - Handler context with config, db, and logger + * @returns Result containing CommentOutput on success + */ +export async function commentHandler( + input: CommentInput, + ctx: HandlerContext +): Promise> { + const authResult = await detectAuth(ctx.config.github_token); + if (authResult.isErr()) { + return Result.err( + new AuthError({ message: `Authentication required to comment: ${authResult.error.message}` }) + ); + } + + const { GitHubClient } = await import("../github"); + const client = new GitHubClient(authResult.value.token); + + const repoParts = input.repo.split("/"); + const owner = repoParts[0] ?? ""; + const name = repoParts[1] ?? ""; + + const prIdResult = await client.fetchPullRequestId(owner, name, input.pr); + if (prIdResult.isErr()) { + return Result.err( + new NotFoundError({ + message: `PR #${input.pr} not found in ${input.repo}.`, + resourceType: "pull_request", + resourceId: String(input.pr), + }) + ); + } + + const commentResult = await client.addIssueComment(prIdResult.value, input.body); + if (commentResult.isErr()) { + return Result.err(commentResult.error); + } + + const comment = commentResult.value; + + ctx.logger.debug("Comment added", { pr: input.pr, repo: input.repo, id: comment.id }); + + return Result.ok({ + id: comment.id, + ...(comment.url && { url: comment.url }), + }); +} diff --git a/packages/core/src/handlers/index.ts b/packages/core/src/handlers/index.ts index 0a3f524..7cc83d9 100644 --- a/packages/core/src/handlers/index.ts +++ b/packages/core/src/handlers/index.ts @@ -1,3 +1,19 @@ +export { + ackHandler, + type AckInput, + type AckItemResult, + type AckOutput, +} from "./ack"; +export { + closeHandler, + type CloseInput, + type CloseOutput, +} from "./close"; +export { + commentHandler, + type CommentInput, + type CommentOutput, +} from "./comment"; export { doctorHandler, type DoctorCheckResult, @@ -9,6 +25,11 @@ export { type QueryInput, type QueryOutput, } from "./query"; +export { + replyHandler, + type ReplyInput, + type ReplyOutput, +} from "./reply"; export { getCacheStats, statusHandler, diff --git a/packages/core/src/handlers/reply.ts b/packages/core/src/handlers/reply.ts new file mode 100644 index 0000000..4a37b41 --- /dev/null +++ b/packages/core/src/handlers/reply.ts @@ -0,0 +1,161 @@ +/** + * Handler for replying to a review thread comment. + * + * Resolves the target ID, finds the thread, posts a reply, and optionally + * resolves the thread. Returns structured output for CLI/MCP formatting. + */ + +import { AuthError, NotFoundError, Result } from "@outfitter/contracts"; + +import { detectAuth } from "../auth"; +import { queryEntries } from "../query"; +import { formatDisplayId } from "../render/ids"; +import { + buildShortIdCache, + classifyId, + generateShortId, + resolveShortId, +} from "../short-id"; +import type { HandlerContext } from "./types"; + +// ============================================================================= +// Types +// ============================================================================= + +/** Input parameters for the reply handler. */ +export interface ReplyInput { + /** Thread ID to reply to — short ID (@abc), comment ID, or PR number */ + id: string; + /** Reply body text */ + body: string; + /** Repository (owner/repo) */ + repo: string; + /** Optionally resolve the thread after replying */ + resolve?: boolean | undefined; +} + +/** Structured output from the reply handler. */ +export interface ReplyOutput { + /** The created reply's node ID */ + id: string; + /** URL to the reply */ + url?: string | undefined; + /** Whether the thread was resolved */ + resolved?: boolean | undefined; +} + +// ============================================================================= +// Handler +// ============================================================================= + +/** + * Reply to a review thread comment. + * + * Resolves the ID (short → full), locates the review thread, posts the reply, + * and optionally resolves the thread. Returns a Result with reply metadata. + * + * @param input - Reply input including target ID, body, and repo + * @param ctx - Handler context with config, db, and logger + * @returns Result containing ReplyOutput on success + */ +export async function replyHandler( + input: ReplyInput, + ctx: HandlerContext +): Promise> { + const authResult = await detectAuth(ctx.config.github_token); + if (authResult.isErr()) { + return Result.err( + new AuthError({ message: `Authentication required to reply: ${authResult.error.message}` }) + ); + } + + const { GitHubClient } = await import("../github"); + const client = new GitHubClient(authResult.value.token); + + const idType = classifyId(input.id); + + if (idType === "pr_number") { + return Result.err( + new NotFoundError({ + message: "Use commentHandler to comment on a PR by number. replyHandler expects a comment ID.", + resourceType: "comment", + resourceId: input.id, + }) + ); + } + + // Load entries to resolve short ID + const entries = await queryEntries({ filters: { repo: input.repo } }); + buildShortIdCache(entries); + + let commentId = input.id; + if (idType === "short_id") { + const mapping = resolveShortId(input.id); + if (!mapping) { + return Result.err( + new NotFoundError({ + message: `Short ID ${formatDisplayId(input.id)} not found in cache.`, + resourceType: "comment", + resourceId: input.id, + }) + ); + } + commentId = mapping.fullId; + } + + const entry = entries.find((e) => e.id === commentId); + if (!entry) { + return Result.err( + new NotFoundError({ + message: `Comment ${input.id} not found in cache.`, + resourceType: "comment", + resourceId: input.id, + }) + ); + } + + const repoParts = input.repo.split("/"); + const owner = repoParts[0] ?? ""; + const name = repoParts[1] ?? ""; + + const threadMapResult = await client.fetchReviewThreadMap(owner, name, entry.pr); + if (threadMapResult.isErr()) { + return Result.err(threadMapResult.error); + } + + const threadId = threadMapResult.value.get(commentId); + if (!threadId) { + return Result.err( + new NotFoundError({ + message: `No review thread found for comment ${input.id}.`, + resourceType: "thread", + resourceId: commentId, + }) + ); + } + + const replyResult = await client.addReviewThreadReply(threadId, input.body); + if (replyResult.isErr()) { + return Result.err(replyResult.error); + } + + const reply = replyResult.value; + + const shortId = formatDisplayId(generateShortId(reply.id, input.repo)); + + if (input.resolve) { + const resolveResult = await client.resolveReviewThread(threadId); + if (resolveResult.isErr()) { + ctx.logger.warn("Failed to resolve thread after reply", { + threadId, + error: resolveResult.error.message, + }); + } + } + + return Result.ok({ + id: shortId, + ...(reply.url && { url: reply.url }), + ...(input.resolve && { resolved: true }), + }); +}