From 8889f7c3798fcbe3b2f0aabe87998b2ccc39ef98 Mon Sep 17 00:00:00 2001 From: Matt Galligan Date: Sat, 21 Feb 2026 21:49:48 -0500 Subject: [PATCH] feat(core): extract PR mutation handlers to core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three transport-agnostic PR mutation handlers: - approveHandler: submit APPROVE review via REST API - rejectHandler: submit REQUEST_CHANGES review via REST API - editHandler: polymorphic — edit PR metadata (title, body, base, labels, milestone) or edit a comment body; routes by classifyId() All validate repo format, authenticate via detectAuth, and return Result. No transport-specific code. 🤘🏻 In-collaboration-with: [Claude Code](https://claude.com/claude-code) --- packages/core/src/handlers/approve.ts | 90 ++++++++++ packages/core/src/handlers/edit.ts | 234 ++++++++++++++++++++++++++ packages/core/src/handlers/index.ts | 15 ++ packages/core/src/handlers/reject.ts | 91 ++++++++++ 4 files changed, 430 insertions(+) create mode 100644 packages/core/src/handlers/approve.ts create mode 100644 packages/core/src/handlers/edit.ts create mode 100644 packages/core/src/handlers/reject.ts diff --git a/packages/core/src/handlers/approve.ts b/packages/core/src/handlers/approve.ts new file mode 100644 index 0000000..52bf42b --- /dev/null +++ b/packages/core/src/handlers/approve.ts @@ -0,0 +1,90 @@ +/** + * Handler for approving a pull request. + * + * Authenticates, submits an APPROVE review on the PR, and returns the + * review metadata. Optionally includes a review comment body. + */ + +import { AuthError, Result, ValidationError } from "@outfitter/contracts"; + +import { detectAuth } from "../auth"; +import type { HandlerContext } from "./types"; + +// ============================================================================= +// Types +// ============================================================================= + +/** Input parameters for the approve handler. */ +export interface ApproveInput { + /** PR number to approve */ + pr: number; + /** Repository (owner/repo) */ + repo: string; + /** Optional review comment body */ + body?: string | undefined; +} + +/** Structured output from the approve handler. */ +export interface ApproveOutput { + /** Review node ID */ + id: string; + /** URL to the review */ + url?: string | undefined; +} + +// ============================================================================= +// Handler +// ============================================================================= + +/** + * Approve a pull request. + * + * Submits an APPROVE review via the GitHub REST API. + * + * @param input - PR number, repo, optional body + * @param ctx - Handler context with config, db, logger + * @returns Result with review ID and URL on success + */ +export async function approveHandler( + input: ApproveInput, + ctx: HandlerContext +): Promise> { + const authResult = await detectAuth(ctx.config.github_token); + if (authResult.isErr()) { + return Result.err( + new AuthError({ message: authResult.error.message }) + ); + } + + const { GitHubClient } = await import("../github"); + const client = new GitHubClient(authResult.value.token); + + const parts = input.repo.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return Result.err( + new ValidationError({ message: `Invalid repo format: ${input.repo}` }) + ); + } + const [owner, repo] = parts; + const reviewResult = await client.addReview( + owner, + repo, + input.pr, + "approve", + input.body + ); + + if (reviewResult.isErr()) { + return Result.err(reviewResult.error); + } + + const review = reviewResult.value; + if (!review) { + return Result.ok({ id: "" }); + } + + return Result.ok({ + id: review.id, + ...(review.url && { url: review.url }), + }); +} diff --git a/packages/core/src/handlers/edit.ts b/packages/core/src/handlers/edit.ts new file mode 100644 index 0000000..2a058f6 --- /dev/null +++ b/packages/core/src/handlers/edit.ts @@ -0,0 +1,234 @@ +/** + * Handler for editing a pull request or comment. + * + * Polymorphic: edits PR metadata (title, body, base, labels, milestone) + * when given a PR number, or edits a comment body when given a comment ID. + */ + +import { + AuthError, + NotFoundError, + Result, + ValidationError, +} from "@outfitter/contracts"; + +import { detectAuth } from "../auth"; +import type { GitHubClient } from "../github"; +import { getEntry } from "../repository"; +import { classifyId, normalizeShortId, resolveShortId } from "../short-id"; +import type { HandlerContext } from "./types"; + +// ============================================================================= +// Types +// ============================================================================= + +/** Input parameters for the edit handler. */ +export interface EditInput { + /** Target ID — PR number, short ID (@abc), or comment ID */ + id: string; + /** Repository (owner/repo) */ + repo: string; + /** New PR title (PR edit only) */ + title?: string | undefined; + /** New PR/comment body */ + body?: string | undefined; + /** New base branch (PR edit only) */ + base?: string | undefined; + /** Labels to add (PR edit only) */ + addLabels?: string[] | undefined; + /** Labels to remove (PR edit only) */ + removeLabels?: string[] | undefined; + /** Milestone name (PR edit only) */ + milestone?: string | undefined; +} + +/** Structured output from the edit handler. */ +export interface EditOutput { + /** What was edited */ + type: "pr" | "comment"; + /** Whether the edit succeeded */ + ok: boolean; +} + +// ============================================================================= +// Handler +// ============================================================================= + +/** + * Edit a pull request or comment. + * + * When the ID resolves to a PR number, edits PR metadata via the REST API. + * When the ID resolves to a comment, edits the comment body. + * + * @param input - Target ID, repo, and fields to update + * @param ctx - Handler context with config, db, logger + * @returns Result indicating what was edited + */ +export async function editHandler( + input: EditInput, + ctx: HandlerContext +): Promise> { + const authResult = await detectAuth(ctx.config.github_token); + if (authResult.isErr()) { + return Result.err( + new AuthError({ message: authResult.error.message }) + ); + } + + const { GitHubClient } = await import("../github"); + const client = new GitHubClient(authResult.value.token); + + const parts = input.repo.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return Result.err( + new ValidationError({ message: `Invalid repo format: ${input.repo}` }) + ); + } + const [owner, repo] = parts; + + // Determine if this is a PR number or a comment ID + const idType = classifyId(input.id); + + if (idType === "pr_number") { + return editPr(client, owner, repo, Number(input.id), input); + } + + // Resolve short ID to comment ID + let commentId = input.id; + if (idType === "short_id") { + const normalized = normalizeShortId(input.id); + const resolved = resolveShortId(normalized); + if (!resolved) { + return Result.err( + new NotFoundError({ + message: `Could not resolve short ID: ${input.id}`, + resourceType: "entry", + resourceId: input.id, + }) + ); + } + commentId = resolved.fullId; + } + + return editComment(client, owner, repo, commentId, input, ctx); +} + +async function editPr( + client: GitHubClient, + owner: string, + repo: string, + prNumber: number, + input: EditInput +): Promise> { + const updates: { title?: string; body?: string; base?: string } = {}; + if (input.title) { + updates.title = input.title; + } + if (input.body) { + updates.body = input.body; + } + if (input.base) { + updates.base = input.base; + } + + if (Object.keys(updates).length === 0 && !input.addLabels && !input.removeLabels && !input.milestone) { + return Result.err( + new ValidationError({ message: "No fields to update" }) + ); + } + + if (Object.keys(updates).length > 0) { + const editResult = await client.editPullRequest(owner, repo, prNumber, updates); + if (editResult.isErr()) { + return Result.err(editResult.error); + } + } + + // Handle labels + if (input.addLabels?.length) { + const addResult = await client.addLabels(owner, repo, prNumber, input.addLabels); + if (addResult.isErr()) { + return Result.err(addResult.error); + } + } + if (input.removeLabels?.length) { + const rmResult = await client.removeLabels(owner, repo, prNumber, input.removeLabels); + if (rmResult.isErr()) { + return Result.err(rmResult.error); + } + } + + // Handle milestone + if (input.milestone) { + const msResult = await client.setMilestone(owner, repo, prNumber, input.milestone); + if (msResult.isErr()) { + return Result.err(msResult.error); + } + } + + return Result.ok({ type: "pr", ok: true }); +} + +async function editComment( + client: GitHubClient, + owner: string, + repo: string, + commentId: string, + input: EditInput, + ctx: HandlerContext +): Promise> { + if (!input.body) { + return Result.err( + new ValidationError({ message: "Comment edit requires a body" }) + ); + } + + // Look up the entry to find the numeric comment ID for the REST API + const entry = getEntry(ctx.db, commentId, `${owner}/${repo}`); + if (!entry) { + return Result.err( + new NotFoundError({ + message: `Entry not found: ${commentId}`, + resourceType: "entry", + resourceId: commentId, + }) + ); + } + + // Issue comments use numeric IDs from the REST API + // The entry.id is the GraphQL node ID; we need the REST numeric ID + // For now, use the GraphQL mutation approach via editIssueComment + // which accepts the REST numeric comment ID + const numericId = extractNumericId(entry.id); + if (!numericId) { + return Result.err( + new ValidationError({ + message: `Cannot determine numeric ID for comment: ${entry.id}`, + }) + ); + } + + const editResult = await client.editIssueComment( + owner, + repo, + numericId, + input.body + ); + if (editResult.isErr()) { + return Result.err(editResult.error); + } + + return Result.ok({ type: "comment", ok: true }); +} + +/** Extract numeric ID from various GitHub ID formats. */ +function extractNumericId(id: string): number | null { + // Try direct numeric + const num = Number(id); + if (!Number.isNaN(num) && num > 0) { + return num; + } + // Try extracting trailing number from node ID patterns + const match = id.match(/(\d+)$/); + return match ? Number(match[1]) : null; +} diff --git a/packages/core/src/handlers/index.ts b/packages/core/src/handlers/index.ts index 7cc83d9..564704e 100644 --- a/packages/core/src/handlers/index.ts +++ b/packages/core/src/handlers/index.ts @@ -4,6 +4,11 @@ export { type AckItemResult, type AckOutput, } from "./ack"; +export { + approveHandler, + type ApproveInput, + type ApproveOutput, +} from "./approve"; export { closeHandler, type CloseInput, @@ -20,11 +25,21 @@ export { type DoctorInput, type DoctorOutput, } from "./doctor"; +export { + editHandler, + type EditInput, + type EditOutput, +} from "./edit"; export { queryHandler, type QueryInput, type QueryOutput, } from "./query"; +export { + rejectHandler, + type RejectInput, + type RejectOutput, +} from "./reject"; export { replyHandler, type ReplyInput, diff --git a/packages/core/src/handlers/reject.ts b/packages/core/src/handlers/reject.ts new file mode 100644 index 0000000..11143cb --- /dev/null +++ b/packages/core/src/handlers/reject.ts @@ -0,0 +1,91 @@ +/** + * Handler for requesting changes on a pull request. + * + * Authenticates, submits a REQUEST_CHANGES review on the PR, and returns + * the review metadata. + */ + +import { AuthError, Result, ValidationError } from "@outfitter/contracts"; + +import { detectAuth } from "../auth"; +import type { HandlerContext } from "./types"; + +// ============================================================================= +// Types +// ============================================================================= + +/** Input parameters for the reject handler. */ +export interface RejectInput { + /** PR number to request changes on */ + pr: number; + /** Repository (owner/repo) */ + repo: string; + /** Review comment body (required for REQUEST_CHANGES) */ + body: string; +} + +/** Structured output from the reject handler. */ +export interface RejectOutput { + /** Review node ID */ + id: string; + /** URL to the review */ + url?: string | undefined; +} + +// ============================================================================= +// Handler +// ============================================================================= + +/** + * Request changes on a pull request. + * + * Submits a REQUEST_CHANGES review via the GitHub REST API. A body is + * required by GitHub for change-request reviews. + * + * @param input - PR number, repo, body + * @param ctx - Handler context with config, db, logger + * @returns Result with review ID and URL on success + */ +export async function rejectHandler( + input: RejectInput, + ctx: HandlerContext +): Promise> { + const authResult = await detectAuth(ctx.config.github_token); + if (authResult.isErr()) { + return Result.err( + new AuthError({ message: authResult.error.message }) + ); + } + + const { GitHubClient } = await import("../github"); + const client = new GitHubClient(authResult.value.token); + + const parts = input.repo.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return Result.err( + new ValidationError({ message: `Invalid repo format: ${input.repo}` }) + ); + } + const [owner, repo] = parts; + const reviewResult = await client.addReview( + owner, + repo, + input.pr, + "request-changes", + input.body + ); + + if (reviewResult.isErr()) { + return Result.err(reviewResult.error); + } + + const review = reviewResult.value; + if (!review) { + return Result.ok({ id: "" }); + } + + return Result.ok({ + id: review.id, + ...(review.url && { url: review.url }), + }); +}