diff --git a/apps/cli/src/commands/approve.ts b/apps/cli/src/commands/approve.ts index f9dab1b..edb28fe 100644 --- a/apps/cli/src/commands/approve.ts +++ b/apps/cli/src/commands/approve.ts @@ -5,16 +5,17 @@ * equivalent to `fw pr review --approve`. */ +import { exitWithError } from "@outfitter/cli/output"; import { - type GitHubClient, + approveHandler, + getDatabase, loadConfig, - type FirewatchConfig, } from "@outfitter/firewatch-core"; +import { silentLogger } from "@outfitter/firewatch-shared"; import { Command, Option } from "commander"; -import { createAuthenticatedClient } from "../auth-client"; import { applyCommonOptions } from "../query-helpers"; -import { parseRepoInput, parsePrNumber, resolveRepoOrThrow } from "../repo"; +import { parsePrNumber, resolveRepoOrThrow } from "../repo"; import { outputStructured } from "../utils/json"; import { shouldOutputJson } from "../utils/tty"; @@ -27,77 +28,46 @@ export interface ApproveCommandOptions { noColor?: boolean; } -interface ApproveContext { - client: GitHubClient; - config: FirewatchConfig; - repo: string; - owner: string; - name: string; - outputJson: boolean; -} - -async function createContext( - options: ApproveCommandOptions -): Promise { - const config = await loadConfig(); - const repo = await resolveRepoOrThrow(options.repo); - const { owner, name } = parseRepoInput(repo); - - const { client } = await createAuthenticatedClient(config.github_token); - - return { - client, - config, - repo, - owner, - name, - outputJson: shouldOutputJson(options, config.output?.default_format), - }; -} - export async function approveAction( pr: number, options: ApproveCommandOptions ): Promise { applyCommonOptions(options); try { - const ctx = await createContext(options); + const config = await loadConfig(); + const db = getDatabase(); + const repo = await resolveRepoOrThrow(options.repo); + const outputJson = shouldOutputJson(options, config.output?.default_format); - const reviewResult = await ctx.client.addReview( - ctx.owner, - ctx.name, - pr, - "approve", - options.body + const result = await approveHandler( + { pr, repo, body: options.body }, + { config, db, logger: silentLogger } ); - if (reviewResult.isErr()) { - throw reviewResult.error; + + if (result.isErr()) { + exitWithError(result.error); } - const review = reviewResult.value; + const review = result.value; const payload = { ok: true, - repo: ctx.repo, + repo, pr, action: "approved", - ...(review?.id && { review_id: review.id }), - ...(review?.url && { url: review.url }), + ...(review.id && { review_id: review.id }), + ...(review.url && { url: review.url }), }; - if (ctx.outputJson) { + if (outputJson) { await outputStructured(payload, "jsonl"); } else { - console.log(`Approved ${ctx.repo}#${pr}.`); - if (review?.url) { + console.log(`Approved ${repo}#${pr}.`); + if (review.url) { console.log(review.url); } } } catch (error) { - console.error( - "Approve failed:", - error instanceof Error ? error.message : error - ); - process.exit(1); + exitWithError(error instanceof Error ? error : new Error(String(error))); } } diff --git a/apps/cli/src/commands/comment.ts b/apps/cli/src/commands/comment.ts index bbbd4f7..780445a 100644 --- a/apps/cli/src/commands/comment.ts +++ b/apps/cli/src/commands/comment.ts @@ -5,16 +5,17 @@ * equivalent to `fw pr comment`. */ +import { exitWithError } from "@outfitter/cli/output"; import { - type GitHubClient, + commentHandler, + getDatabase, loadConfig, - type FirewatchConfig, } from "@outfitter/firewatch-core"; +import { silentLogger } from "@outfitter/firewatch-shared"; import { Command, Option } from "commander"; -import { createAuthenticatedClient } from "../auth-client"; import { applyCommonOptions } from "../query-helpers"; -import { parseRepoInput, parsePrNumber, resolveRepoOrThrow } from "../repo"; +import { parsePrNumber, resolveRepoOrThrow } from "../repo"; import { outputStructured } from "../utils/json"; import { shouldOutputJson } from "../utils/tty"; @@ -26,34 +27,6 @@ export interface CommentCommandOptions { noColor?: boolean; } -interface CommentContext { - client: GitHubClient; - config: FirewatchConfig; - repo: string; - owner: string; - name: string; - outputJson: boolean; -} - -async function createContext( - options: CommentCommandOptions -): Promise { - const config = await loadConfig(); - const repo = await resolveRepoOrThrow(options.repo); - const { owner, name } = parseRepoInput(repo); - - const { client } = await createAuthenticatedClient(config.github_token); - - return { - client, - config, - repo, - owner, - name, - outputJson: shouldOutputJson(options, config.output?.default_format), - }; -} - export async function commentAction( pr: number, body: string, @@ -61,53 +34,44 @@ export async function commentAction( ): Promise { applyCommonOptions(options); if (!body.trim()) { - console.error("Comment body cannot be empty."); - process.exit(1); + exitWithError(new Error("Comment body cannot be empty.")); } try { - const ctx = await createContext(options); + const config = await loadConfig(); + const db = getDatabase(); + const repo = await resolveRepoOrThrow(options.repo); + const outputJson = shouldOutputJson(options, config.output?.default_format); - const prIdResult = await ctx.client.fetchPullRequestId( - ctx.owner, - ctx.name, - pr - ); - if (prIdResult.isErr()) { - throw prIdResult.error; - } - const commentResult = await ctx.client.addIssueComment( - prIdResult.value, - body + const result = await commentHandler( + { pr, body, repo }, + { config, db, logger: silentLogger } ); - if (commentResult.isErr()) { - throw commentResult.error; + + if (result.isErr()) { + exitWithError(result.error); } - const comment = commentResult.value; + const comment = result.value; const payload = { ok: true, - repo: ctx.repo, + repo, pr, action: "comment", id: comment.id, ...(comment.url && { url: comment.url }), }; - if (ctx.outputJson) { + if (outputJson) { await outputStructured(payload, "jsonl"); } else { - console.log(`Added comment to ${ctx.repo}#${pr}.`); + console.log(`Added comment to ${repo}#${pr}.`); if (comment.url) { console.log(comment.url); } } } catch (error) { - console.error( - "Comment failed:", - error instanceof Error ? error.message : error - ); - process.exit(1); + exitWithError(error instanceof Error ? error : new Error(String(error))); } } diff --git a/apps/cli/src/commands/doctor.ts b/apps/cli/src/commands/doctor.ts index 03c5cac..c681c77 100644 --- a/apps/cli/src/commands/doctor.ts +++ b/apps/cli/src/commands/doctor.ts @@ -1,3 +1,4 @@ +import { exitWithError } from "@outfitter/cli/output"; import { doctorHandler, getDatabase, @@ -36,8 +37,7 @@ export const doctorCommand = new Command("doctor") const result = await doctorHandler({ fix: options.fix }, ctx); if (result.isErr()) { - console.error("Doctor failed:", result.error.message); - process.exit(1); + exitWithError(result.error); } const output = result.value; @@ -91,10 +91,8 @@ export const doctorCommand = new Command("doctor") console.log(""); console.log(`${counts.ok} passed, ${counts.failed} failed`); } catch (error) { - console.error( - "Doctor failed:", - error instanceof Error ? error.message : error + exitWithError( + error instanceof Error ? error : new Error(String(error)) ); - process.exit(1); } }); diff --git a/apps/cli/src/commands/edit.ts b/apps/cli/src/commands/edit.ts index 88c896f..4b92ca7 100644 --- a/apps/cli/src/commands/edit.ts +++ b/apps/cli/src/commands/edit.ts @@ -6,6 +6,7 @@ * - `@abc12` -> Comment with short ID (Comment editing mode) * - `PRRC_...`, `IC_...` -> Comment by full ID (Comment editing mode) */ +import { exitWithError } from "@outfitter/cli/output"; import { type GitHubClient, buildShortIdCache, @@ -405,18 +406,19 @@ async function handlePrEdit( ): Promise { // Validate conflicting options if (options.draft && options.ready) { - console.error("Cannot use --draft and --ready together."); - process.exit(1); + exitWithError(new Error("Cannot use --draft and --ready together.")); } if (options.milestone && options.removeMilestone) { - console.error("Cannot use --milestone and --remove-milestone together."); - process.exit(1); + exitWithError( + new Error("Cannot use --milestone and --remove-milestone together.") + ); } if (!hasPrEdits(options)) { - console.error("No edits specified. Use --help to see available options."); - process.exit(1); + exitWithError( + new Error("No edits specified. Use --help to see available options.") + ); } // Apply all edits and collect results @@ -467,21 +469,20 @@ async function resolveCommentEntry( const [resolution] = await resolveBatchIds([idArg], ctx.repo); if (!resolution || resolution.type === "error") { - console.error( - `Could not resolve ID "${idArg}": ${resolution?.error ?? "Unknown error"}` + exitWithError( + new Error( + `Could not resolve ID "${idArg}": ${resolution?.error ?? "Unknown error"}` + ) ); - return null; } if (resolution.type === "pr") { // Should not happen since we already checked for PR numbers - console.error(`Expected comment ID, got PR number: ${idArg}`); - return null; + exitWithError(new Error(`Expected comment ID, got PR number: ${idArg}`)); } if (!resolution.entry) { - console.error(`Comment not found in cache: ${idArg}`); - return null; + exitWithError(new Error(`Comment not found in cache: ${idArg}`)); } const shortId = @@ -496,15 +497,16 @@ async function handleCommentEdit( options: EditOptions ): Promise { if (!hasCommentEdits(options)) { - console.error( - "No edits specified. Use --body or --delete for comments." + exitWithError( + new Error( + "No edits specified. Use --body or --delete for comments." + ) ); - process.exit(1); } const resolved = await resolveCommentEntry(ctx, idArg); if (!resolved) { - process.exit(1); + exitWithError(new Error(`Could not resolve comment: ${idArg}`)); } const { entry, shortId } = resolved; @@ -549,10 +551,11 @@ async function handleCommentDelete( }; if (ctx.outputJson) { await outputStructured(result, "jsonl"); - } else { - console.error(`Failed to delete comment ${displayId}: ${result.error}`); + process.exit(1); } - process.exit(1); + exitWithError( + new Error(`Failed to delete comment ${displayId}: ${result.error}`) + ); } try { @@ -587,10 +590,11 @@ async function handleCommentDelete( if (ctx.outputJson) { await outputStructured(result, "jsonl"); - } else { - console.error(`Failed to delete comment ${displayId}: ${result.error}`); + process.exit(1); } - process.exit(1); + exitWithError( + new Error(`Failed to delete comment ${displayId}: ${result.error}`) + ); } } @@ -612,10 +616,11 @@ async function handleCommentBodyUpdate( }; if (ctx.outputJson) { await outputStructured(result, "jsonl"); - } else { - console.error(`Failed to update comment ${displayId}: ${result.error}`); + process.exit(1); } - process.exit(1); + exitWithError( + new Error(`Failed to update comment ${displayId}: ${result.error}`) + ); } try { @@ -650,10 +655,11 @@ async function handleCommentBodyUpdate( if (ctx.outputJson) { await outputStructured(result, "jsonl"); - } else { - console.error(`Failed to update comment ${displayId}: ${result.error}`); + process.exit(1); } - process.exit(1); + exitWithError( + new Error(`Failed to update comment ${displayId}: ${result.error}`) + ); } } @@ -675,11 +681,7 @@ async function handleEdit(idArg: string, options: EditOptions): Promise { await handleCommentEdit(ctx, idArg, options); } } catch (error) { - console.error( - "Edit failed:", - error instanceof Error ? error.message : error - ); - process.exit(1); + exitWithError(error instanceof Error ? error : new Error(String(error))); } } diff --git a/apps/cli/src/commands/reject.ts b/apps/cli/src/commands/reject.ts index cb34c6a..13872ed 100644 --- a/apps/cli/src/commands/reject.ts +++ b/apps/cli/src/commands/reject.ts @@ -5,16 +5,17 @@ * equivalent to `fw pr review --request-changes --body `. */ +import { exitWithError } from "@outfitter/cli/output"; import { - type GitHubClient, + getDatabase, loadConfig, - type FirewatchConfig, + rejectHandler, } from "@outfitter/firewatch-core"; +import { silentLogger } from "@outfitter/firewatch-shared"; import { Command, Option } from "commander"; -import { createAuthenticatedClient } from "../auth-client"; import { applyCommonOptions } from "../query-helpers"; -import { parseRepoInput, parsePrNumber, resolveRepoOrThrow } from "../repo"; +import { parsePrNumber, resolveRepoOrThrow } from "../repo"; import { outputStructured } from "../utils/json"; import { shouldOutputJson } from "../utils/tty"; @@ -27,84 +28,54 @@ export interface RejectCommandOptions { noColor?: boolean; } -interface RejectContext { - client: GitHubClient; - config: FirewatchConfig; - repo: string; - owner: string; - name: string; - outputJson: boolean; -} - -async function createContext( - options: RejectCommandOptions -): Promise { - const config = await loadConfig(); - const repo = await resolveRepoOrThrow(options.repo); - const { owner, name } = parseRepoInput(repo); - - const { client } = await createAuthenticatedClient(config.github_token); - - return { - client, - config, - repo, - owner, - name, - outputJson: shouldOutputJson(options, config.output?.default_format), - }; -} - export async function rejectAction( pr: number, options: RejectCommandOptions ): Promise { applyCommonOptions(options); if (!options.body) { - console.error( - "Body is required for rejecting a PR. Use -b to provide a reason." + exitWithError( + new Error( + "Body is required for rejecting a PR. Use -b to provide a reason." + ) ); - process.exit(1); } try { - const ctx = await createContext(options); + const config = await loadConfig(); + const db = getDatabase(); + const repo = await resolveRepoOrThrow(options.repo); + const outputJson = shouldOutputJson(options, config.output?.default_format); - const reviewResult = await ctx.client.addReview( - ctx.owner, - ctx.name, - pr, - "request-changes", - options.body + const result = await rejectHandler( + { pr, repo, body: options.body }, + { config, db, logger: silentLogger } ); - if (reviewResult.isErr()) { - throw reviewResult.error; + + if (result.isErr()) { + exitWithError(result.error); } - const review = reviewResult.value; + const review = result.value; const payload = { ok: true, - repo: ctx.repo, + repo, pr, action: "changes_requested", - ...(review?.id && { review_id: review.id }), - ...(review?.url && { url: review.url }), + ...(review.id && { review_id: review.id }), + ...(review.url && { url: review.url }), }; - if (ctx.outputJson) { + if (outputJson) { await outputStructured(payload, "jsonl"); } else { - console.log(`Requested changes on ${ctx.repo}#${pr}.`); - if (review?.url) { + console.log(`Requested changes on ${repo}#${pr}.`); + if (review.url) { console.log(review.url); } } } catch (error) { - console.error( - "Reject failed:", - error instanceof Error ? error.message : error - ); - process.exit(1); + exitWithError(error instanceof Error ? error : new Error(String(error))); } } diff --git a/apps/cli/src/commands/reply.ts b/apps/cli/src/commands/reply.ts index 8d1ba44..20c3451 100644 --- a/apps/cli/src/commands/reply.ts +++ b/apps/cli/src/commands/reply.ts @@ -4,6 +4,7 @@ * Replaces `fw feedback reply` with direct `fw reply` access. */ +import { exitWithError } from "@outfitter/cli/output"; import { type GitHubClient, buildShortIdCache, @@ -228,10 +229,11 @@ export async function replyAction( applyCommonOptions(options); const body = bodyArg ?? options.body; if (!body) { - console.error( - "Reply body required. Use: fw reply or fw reply --body " + exitWithError( + new Error( + "Reply body required. Use: fw reply or fw reply --body " + ) ); - process.exit(1); } try { @@ -249,8 +251,7 @@ export async function replyAction( if (idType === "pr_number") { const prNum = Number.parseInt(id, 10); if (Number.isNaN(prNum)) { - console.error(`Invalid PR number: ${id}`); - process.exit(1); + exitWithError(new Error(`Invalid PR number: ${id}`)); } await addPrComment(ctx, prNum, body); return; @@ -259,8 +260,7 @@ export async function replyAction( // Comment ID: reply to the specific comment const resolved = await resolveCommentId(id, ctx.repo, entries); if (!resolved) { - console.error(`Comment ${id} not found.`); - process.exit(1); + exitWithError(new Error(`Comment ${id} not found.`)); } const { entry, shortId } = resolved; @@ -278,11 +278,7 @@ export async function replyAction( await addPrComment(ctx, entry.pr, body, { shortId, ghId: entry.id }); } } catch (error) { - console.error( - "Reply failed:", - error instanceof Error ? error.message : error - ); - process.exit(1); + exitWithError(error instanceof Error ? error : new Error(String(error))); } } diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index 25a16a6..0c9a60c 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -1,3 +1,4 @@ +import { exitWithError } from "@outfitter/cli/output"; import { getDatabase, loadConfig, @@ -128,8 +129,7 @@ export const statusCommand = new Command("status") ); if (result.isErr()) { - console.error("Status failed:", result.error.message); - process.exit(1); + exitWithError(result.error); } const output = result.value; @@ -146,10 +146,8 @@ export const statusCommand = new Command("status") printFullOutput(output); } catch (error) { - console.error( - "Status failed:", - error instanceof Error ? error.message : error + exitWithError( + error instanceof Error ? error : new Error(String(error)) ); - process.exit(1); } }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b379d90..ee692dd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -261,19 +261,34 @@ export { // Handlers export { + approveHandler, + commentHandler, doctorHandler, + editHandler, getCacheStats, queryHandler, + rejectHandler, + replyHandler, statusHandler, syncHandler, + type ApproveInput, + type ApproveOutput, type CacheStats, + type CommentInput, + type CommentOutput, type DoctorCheckResult, type DoctorInput, type DoctorOutput, + type EditInput, + type EditOutput, type Handler, type HandlerContext, type QueryInput, type QueryOutput, + type RejectInput, + type RejectOutput, + type ReplyInput, + type ReplyOutput, type StatusInput, type StatusOutput, type SyncInput,