diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index a418ba7c..bc5f413f 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -152,6 +152,7 @@ ${colors.bold('OPTIONS:')} --no-color Disable colored output --no-cache Bypass cache for this request --refresh Force refresh cached data + --dry-run Show what would happen without making changes -p, --page Page number for pagination -s, --size Page size (default: 100) --sort Sort by field (prefix with - for descending) diff --git a/packages/cli/src/commands/attachments/handlers.ts b/packages/cli/src/commands/attachments/handlers.ts index 2feda05c..037d07cf 100644 --- a/packages/cli/src/commands/attachments/handlers.ts +++ b/packages/cli/src/commands/attachments/handlers.ts @@ -20,6 +20,7 @@ import { createRenderContext, humanAttachmentDetailRenderer, } from '../../renderers/index.js'; +import { isDryRun, handleDryRunOutput } from '../../utils/dry-run.js'; import { parseFilters } from '../../utils/parse-filters.js'; function parseListOptions(ctx: CommandContext): ListAttachmentsOptions { @@ -105,6 +106,20 @@ export async function attachmentsDelete(args: string[], ctx: CommandContext): Pr await runCommand(async () => { const execCtx = fromCommandContext(ctx); + + if (isDryRun(ctx)) { + handleDryRunOutput( + { + action: 'delete', + resource: 'attachment', + resourceId: id, + }, + ctx, + spinner, + ); + return; + } + await deleteAttachment({ id }, execCtx); spinner.succeed(); diff --git a/packages/cli/src/commands/bookings/handlers.ts b/packages/cli/src/commands/bookings/handlers.ts index c792ca66..06bc6080 100644 --- a/packages/cli/src/commands/bookings/handlers.ts +++ b/packages/cli/src/commands/bookings/handlers.ts @@ -19,6 +19,7 @@ import { handleError, exitWithValidationError, runCommand } from '../../error-ha import { ValidationError } from '../../errors.js'; import { render, createRenderContext, humanBookingDetailRenderer } from '../../renderers/index.js'; import { colors } from '../../utils/colors.js'; +import { isDryRun, handleDryRunOutput } from '../../utils/dry-run.js'; import { parseFilters } from '../../utils/parse-filters.js'; function formatDuration(minutes: number): string { @@ -161,23 +162,43 @@ export async function bookingsAdd(ctx: CommandContext): Promise { await runCommand(async () => { const execCtx = fromCommandContext(ctx); - const result = await createBooking( - { - personId, - serviceId: ctx.options.service ? String(ctx.options.service) : undefined, - eventId: ctx.options.event ? String(ctx.options.event) : undefined, - startedOn: String(ctx.options.from), - endedOn: String(ctx.options.to), - time: ctx.options.time ? parseInt(String(ctx.options.time)) : undefined, - totalTime: ctx.options['total-time'] - ? parseInt(String(ctx.options['total-time'])) - : undefined, - percentage: ctx.options.percentage ? parseInt(String(ctx.options.percentage)) : undefined, - draft: ctx.options.tentative === true, - note: ctx.options.note ? String(ctx.options.note) : undefined, - }, - execCtx, - ); + const payload = { + personId, + serviceId: ctx.options.service ? String(ctx.options.service) : undefined, + eventId: ctx.options.event ? String(ctx.options.event) : undefined, + startedOn: String(ctx.options.from), + endedOn: String(ctx.options.to), + time: ctx.options.time ? parseInt(String(ctx.options.time)) : undefined, + totalTime: ctx.options['total-time'] + ? parseInt(String(ctx.options['total-time'])) + : undefined, + percentage: ctx.options.percentage ? parseInt(String(ctx.options.percentage)) : undefined, + draft: ctx.options.tentative === true, + note: ctx.options.note ? String(ctx.options.note) : undefined, + }; + + if (isDryRun(ctx)) { + const target = ctx.options.service + ? `service ${payload.serviceId}` + : ctx.options.event + ? `event ${payload.eventId}` + : 'unknown'; + const period = `${payload.startedOn} → ${payload.endedOn}`; + + handleDryRunOutput( + { + action: 'create', + resource: 'booking', + payload, + description: `Booking for ${target} (${period})`, + }, + ctx, + spinner, + ); + return; + } + + const result = await createBooking(payload, execCtx); spinner.succeed(); @@ -214,25 +235,36 @@ export async function bookingsUpdate(args: string[], ctx: CommandContext): Promi if (ctx.options.confirm !== undefined) draft = false; else if (ctx.options.tentative !== undefined) draft = ctx.options.tentative === true; - const result = await updateBooking( - { - id, - startedOn: ctx.options.from !== undefined ? String(ctx.options.from) : undefined, - endedOn: ctx.options.to !== undefined ? String(ctx.options.to) : undefined, - time: ctx.options.time !== undefined ? parseInt(String(ctx.options.time)) : undefined, - totalTime: - ctx.options['total-time'] !== undefined - ? parseInt(String(ctx.options['total-time'])) - : undefined, - percentage: - ctx.options.percentage !== undefined - ? parseInt(String(ctx.options.percentage)) - : undefined, - draft, - note: ctx.options.note !== undefined ? String(ctx.options.note) : undefined, - }, - execCtx, - ); + const payload = { + id, + startedOn: ctx.options.from !== undefined ? String(ctx.options.from) : undefined, + endedOn: ctx.options.to !== undefined ? String(ctx.options.to) : undefined, + time: ctx.options.time !== undefined ? parseInt(String(ctx.options.time)) : undefined, + totalTime: + ctx.options['total-time'] !== undefined + ? parseInt(String(ctx.options['total-time'])) + : undefined, + percentage: + ctx.options.percentage !== undefined ? parseInt(String(ctx.options.percentage)) : undefined, + draft, + note: ctx.options.note !== undefined ? String(ctx.options.note) : undefined, + }; + + if (isDryRun(ctx)) { + handleDryRunOutput( + { + action: 'update', + resource: 'booking', + resourceId: id, + payload, + }, + ctx, + spinner, + ); + return; + } + + const result = await updateBooking(payload, execCtx); spinner.succeed(); diff --git a/packages/cli/src/commands/comments/handlers.ts b/packages/cli/src/commands/comments/handlers.ts index c3386537..8de1f86b 100644 --- a/packages/cli/src/commands/comments/handlers.ts +++ b/packages/cli/src/commands/comments/handlers.ts @@ -19,6 +19,7 @@ import { handleError, exitWithValidationError, runCommand } from '../../error-ha import { ValidationError } from '../../errors.js'; import { render, createRenderContext, humanCommentDetailRenderer } from '../../renderers/index.js'; import { colors } from '../../utils/colors.js'; +import { isDryRun, handleDryRunOutput } from '../../utils/dry-run.js'; import { parseFilters } from '../../utils/parse-filters.js'; function parseListOptions(ctx: CommandContext): ListCommentsOptions { @@ -153,19 +154,53 @@ export async function commentsAdd(ctx: CommandContext): Promise { await runCommand(async () => { const execCtx = fromCommandContext(ctx); - const result = await createComment( - { - body: String(ctx.options.body), - hidden: ctx.options.hidden === true ? true : undefined, - taskId: ctx.options.task ? String(ctx.options.task) : undefined, - dealId: ctx.options.deal ? String(ctx.options.deal) : undefined, - companyId: ctx.options.company ? String(ctx.options.company) : undefined, - invoiceId: ctx.options.invoice ? String(ctx.options.invoice) : undefined, - personId: ctx.options.person ? String(ctx.options.person) : undefined, - discussionId: ctx.options.discussion ? String(ctx.options.discussion) : undefined, - }, - execCtx, - ); + const payload = { + body: String(ctx.options.body), + hidden: ctx.options.hidden === true ? true : undefined, + taskId: ctx.options.task ? String(ctx.options.task) : undefined, + dealId: ctx.options.deal ? String(ctx.options.deal) : undefined, + companyId: ctx.options.company ? String(ctx.options.company) : undefined, + invoiceId: ctx.options.invoice ? String(ctx.options.invoice) : undefined, + personId: ctx.options.person ? String(ctx.options.person) : undefined, + discussionId: ctx.options.discussion ? String(ctx.options.discussion) : undefined, + }; + + if (isDryRun(ctx)) { + const parentType = ctx.options.task + ? 'task' + : ctx.options.deal + ? 'deal' + : ctx.options.company + ? 'company' + : ctx.options.invoice + ? 'invoice' + : ctx.options.person + ? 'person' + : ctx.options.discussion + ? 'discussion' + : 'unknown'; + const parentId = + ctx.options.task || + ctx.options.deal || + ctx.options.company || + ctx.options.invoice || + ctx.options.person || + ctx.options.discussion; + + handleDryRunOutput( + { + action: 'create', + resource: 'comment', + payload, + description: `Comment on ${parentType} ${parentId}`, + }, + ctx, + spinner, + ); + return; + } + + const result = await createComment(payload, execCtx); spinner.succeed(); @@ -213,14 +248,27 @@ export async function commentsUpdate(args: string[], ctx: CommandContext): Promi await runCommand(async () => { const execCtx = fromCommandContext(ctx); - const result = await updateComment( - { - id, - body: hasBody ? String(ctx.options.body) : undefined, - hidden, - }, - execCtx, - ); + const payload = { + id, + body: hasBody ? String(ctx.options.body) : undefined, + hidden, + }; + + if (isDryRun(ctx)) { + handleDryRunOutput( + { + action: 'update', + resource: 'comment', + resourceId: id, + payload, + }, + ctx, + spinner, + ); + return; + } + + const result = await updateComment(payload, execCtx); spinner.succeed(); diff --git a/packages/cli/src/commands/companies/handlers.ts b/packages/cli/src/commands/companies/handlers.ts index d978ffbe..26923730 100644 --- a/packages/cli/src/commands/companies/handlers.ts +++ b/packages/cli/src/commands/companies/handlers.ts @@ -19,6 +19,7 @@ import { handleError, exitWithValidationError, runCommand } from '../../error-ha import { ValidationError } from '../../errors.js'; import { render, createRenderContext, humanCompanyDetailRenderer } from '../../renderers/index.js'; import { colors } from '../../utils/colors.js'; +import { isDryRun, handleDryRunOutput } from '../../utils/dry-run.js'; import { parseFilters } from '../../utils/parse-filters.js'; export async function companiesList(ctx: CommandContext): Promise { @@ -103,18 +104,31 @@ export async function companiesAdd(ctx: CommandContext): Promise { await runCommand(async () => { const execCtx = fromCommandContext(ctx); - const result = await createCompany( - { - name: String(ctx.options.name), - billingName: ctx.options['billing-name'] ? String(ctx.options['billing-name']) : undefined, - vat: ctx.options.vat ? String(ctx.options.vat) : undefined, - defaultCurrency: ctx.options.currency ? String(ctx.options.currency) : undefined, - companyCode: ctx.options.code ? String(ctx.options.code) : undefined, - domain: ctx.options.domain ? String(ctx.options.domain) : undefined, - dueDays: ctx.options['due-days'] ? parseInt(String(ctx.options['due-days'])) : undefined, - }, - execCtx, - ); + const payload = { + name: String(ctx.options.name), + billingName: ctx.options['billing-name'] ? String(ctx.options['billing-name']) : undefined, + vat: ctx.options.vat ? String(ctx.options.vat) : undefined, + defaultCurrency: ctx.options.currency ? String(ctx.options.currency) : undefined, + companyCode: ctx.options.code ? String(ctx.options.code) : undefined, + domain: ctx.options.domain ? String(ctx.options.domain) : undefined, + dueDays: ctx.options['due-days'] ? parseInt(String(ctx.options['due-days'])) : undefined, + }; + + if (isDryRun(ctx)) { + handleDryRunOutput( + { + action: 'create', + resource: 'company', + payload, + description: `Company "${payload.name}"`, + }, + ctx, + spinner, + ); + return; + } + + const result = await createCompany(payload, execCtx); spinner.succeed(); @@ -144,28 +158,51 @@ export async function companiesUpdate(args: string[], ctx: CommandContext): Prom await runCommand(async () => { const execCtx = fromCommandContext(ctx); + const payload = { + id, + name: ctx.options.name !== undefined ? String(ctx.options.name) : undefined, + billingName: + ctx.options['billing-name'] !== undefined ? String(ctx.options['billing-name']) : undefined, + vat: ctx.options.vat !== undefined ? String(ctx.options.vat) : undefined, + defaultCurrency: + ctx.options.currency !== undefined ? String(ctx.options.currency) : undefined, + companyCode: ctx.options.code !== undefined ? String(ctx.options.code) : undefined, + domain: ctx.options.domain !== undefined ? String(ctx.options.domain) : undefined, + dueDays: + ctx.options['due-days'] !== undefined + ? parseInt(String(ctx.options['due-days'])) + : undefined, + }; + + // Check for validation before dry-run to ensure consistent behavior + const hasUpdates = Object.entries(payload).some( + ([key, value]) => key !== 'id' && value !== undefined, + ); + if (!hasUpdates) { + spinner.fail(); + throw ValidationError.invalid( + 'options', + {}, + 'No updates specified. Use --name, --billing-name, --vat, --currency, etc.', + ); + } - try { - const result = await updateCompany( + if (isDryRun(ctx)) { + handleDryRunOutput( { - id, - name: ctx.options.name !== undefined ? String(ctx.options.name) : undefined, - billingName: - ctx.options['billing-name'] !== undefined - ? String(ctx.options['billing-name']) - : undefined, - vat: ctx.options.vat !== undefined ? String(ctx.options.vat) : undefined, - defaultCurrency: - ctx.options.currency !== undefined ? String(ctx.options.currency) : undefined, - companyCode: ctx.options.code !== undefined ? String(ctx.options.code) : undefined, - domain: ctx.options.domain !== undefined ? String(ctx.options.domain) : undefined, - dueDays: - ctx.options['due-days'] !== undefined - ? parseInt(String(ctx.options['due-days'])) - : undefined, + action: 'update', + resource: 'company', + resourceId: id, + payload, }, - execCtx, + ctx, + spinner, ); + return; + } + + try { + const result = await updateCompany(payload, execCtx); spinner.succeed(); diff --git a/packages/cli/src/commands/deals/handlers.ts b/packages/cli/src/commands/deals/handlers.ts index 2f2c4e2e..59006e84 100644 --- a/packages/cli/src/commands/deals/handlers.ts +++ b/packages/cli/src/commands/deals/handlers.ts @@ -20,6 +20,7 @@ import { handleError, exitWithValidationError, runCommand } from '../../error-ha import { ValidationError } from '../../errors.js'; import { render, createRenderContext, humanDealDetailRenderer } from '../../renderers/index.js'; import { colors } from '../../utils/colors.js'; +import { isDryRun, handleDryRunOutput } from '../../utils/dry-run.js'; import { parseFilters } from '../../utils/parse-filters.js'; function parseListOptions(ctx: CommandContext): ListDealsOptions { @@ -121,16 +122,31 @@ export async function dealsAdd(ctx: CommandContext): Promise { await runCommand(async () => { const execCtx = fromCommandContext(ctx); - const result = await createDeal( - { - name: String(ctx.options.name), - companyId: String(ctx.options.company), - date: ctx.options.date ? String(ctx.options.date) : undefined, - budget: ctx.options.budget === true, - responsibleId: ctx.options.responsible ? String(ctx.options.responsible) : undefined, - }, - execCtx, - ); + const payload = { + name: String(ctx.options.name), + companyId: String(ctx.options.company), + date: ctx.options.date ? String(ctx.options.date) : undefined, + budget: ctx.options.budget === true, + responsibleId: ctx.options.responsible ? String(ctx.options.responsible) : undefined, + }; + + if (isDryRun(ctx)) { + const type = payload.budget ? 'budget' : 'deal'; + + handleDryRunOutput( + { + action: 'create', + resource: type, + payload, + description: `${type.charAt(0).toUpperCase() + type.slice(1)} "${payload.name}" for company ${payload.companyId}`, + }, + ctx, + spinner, + ); + return; + } + + const result = await createDeal(payload, execCtx); spinner.succeed(); @@ -160,21 +176,45 @@ export async function dealsUpdate(args: string[], ctx: CommandContext): Promise< await runCommand(async () => { const execCtx = fromCommandContext(ctx); + const payload = { + id, + name: ctx.options.name !== undefined ? String(ctx.options.name) : undefined, + date: ctx.options.date !== undefined ? String(ctx.options.date) : undefined, + endDate: ctx.options['end-date'] !== undefined ? String(ctx.options['end-date']) : undefined, + responsibleId: + ctx.options.responsible !== undefined ? String(ctx.options.responsible) : undefined, + dealStatusId: ctx.options.status !== undefined ? String(ctx.options.status) : undefined, + }; + + // Check for validation before dry-run to ensure consistent behavior + const hasUpdates = Object.entries(payload).some( + ([key, value]) => key !== 'id' && value !== undefined, + ); + if (!hasUpdates) { + spinner.fail(); + throw ValidationError.invalid( + 'options', + {}, + 'No updates specified. Use --name, --date, --end-date, --responsible, --status, etc.', + ); + } - try { - const result = await updateDeal( + if (isDryRun(ctx)) { + handleDryRunOutput( { - id, - name: ctx.options.name !== undefined ? String(ctx.options.name) : undefined, - date: ctx.options.date !== undefined ? String(ctx.options.date) : undefined, - endDate: - ctx.options['end-date'] !== undefined ? String(ctx.options['end-date']) : undefined, - responsibleId: - ctx.options.responsible !== undefined ? String(ctx.options.responsible) : undefined, - dealStatusId: ctx.options.status !== undefined ? String(ctx.options.status) : undefined, + action: 'update', + resource: 'deal', + resourceId: id, + payload, }, - execCtx, + ctx, + spinner, ); + return; + } + + try { + const result = await updateDeal(payload, execCtx); spinner.succeed(); diff --git a/packages/cli/src/commands/discussions/handlers.ts b/packages/cli/src/commands/discussions/handlers.ts index deef0d7b..31291c47 100644 --- a/packages/cli/src/commands/discussions/handlers.ts +++ b/packages/cli/src/commands/discussions/handlers.ts @@ -27,6 +27,7 @@ import { humanDiscussionDetailRenderer, } from '../../renderers/index.js'; import { colors } from '../../utils/colors.js'; +import { isDryRun, handleDryRunOutput } from '../../utils/dry-run.js'; import { parseFilters } from '../../utils/parse-filters.js'; function parseListOptions(ctx: CommandContext): ListDiscussionsOptions { @@ -111,14 +112,27 @@ export async function discussionsAdd(ctx: CommandContext): Promise { await runCommand(async () => { const execCtx = fromCommandContext(ctx); - const result = await createDiscussion( - { - body: String(ctx.options.body), - pageId: String(ctx.options['page-id']), - title: ctx.options.title ? String(ctx.options.title) : undefined, - }, - execCtx, - ); + const payload = { + body: String(ctx.options.body), + pageId: String(ctx.options['page-id']), + title: ctx.options.title ? String(ctx.options.title) : undefined, + }; + + if (isDryRun(ctx)) { + handleDryRunOutput( + { + action: 'create', + resource: 'discussion', + payload, + description: `Discussion on page ${payload.pageId}${payload.title ? ` ("${payload.title}")` : ''}`, + }, + ctx, + spinner, + ); + return; + } + + const result = await createDiscussion(payload, execCtx); spinner.succeed(); @@ -147,16 +161,35 @@ export async function discussionsUpdate(args: string[], ctx: CommandContext): Pr await runCommand(async () => { const execCtx = fromCommandContext(ctx); + const payload = { + id, + title: ctx.options.title !== undefined ? String(ctx.options.title) : undefined, + body: ctx.options.body !== undefined ? String(ctx.options.body) : undefined, + }; + + // Check for validation before dry-run to ensure consistent behavior + const hasUpdates = payload.title !== undefined || payload.body !== undefined; + if (!hasUpdates) { + spinner.fail(); + throw ValidationError.invalid('options', {}, 'No updates specified. Use --title or --body.'); + } - try { - const result = await updateDiscussion( + if (isDryRun(ctx)) { + handleDryRunOutput( { - id, - title: ctx.options.title !== undefined ? String(ctx.options.title) : undefined, - body: ctx.options.body !== undefined ? String(ctx.options.body) : undefined, + action: 'update', + resource: 'discussion', + resourceId: id, + payload, }, - execCtx, + ctx, + spinner, ); + return; + } + + try { + const result = await updateDiscussion(payload, execCtx); spinner.succeed(); @@ -192,6 +225,20 @@ export async function discussionsDelete(args: string[], ctx: CommandContext): Pr await runCommand(async () => { const execCtx = fromCommandContext(ctx); + + if (isDryRun(ctx)) { + handleDryRunOutput( + { + action: 'delete', + resource: 'discussion', + resourceId: id, + }, + ctx, + spinner, + ); + return; + } + await deleteDiscussion({ id }, execCtx); spinner.succeed(); @@ -214,6 +261,20 @@ export async function discussionsResolve(args: string[], ctx: CommandContext): P await runCommand(async () => { const execCtx = fromCommandContext(ctx); + + if (isDryRun(ctx)) { + handleDryRunOutput( + { + action: 'resolve', + resource: 'discussion', + resourceId: id, + }, + ctx, + spinner, + ); + return; + } + const result = await resolveDiscussion({ id }, execCtx); spinner.succeed(); @@ -236,6 +297,20 @@ export async function discussionsReopen(args: string[], ctx: CommandContext): Pr await runCommand(async () => { const execCtx = fromCommandContext(ctx); + + if (isDryRun(ctx)) { + handleDryRunOutput( + { + action: 'reopen', + resource: 'discussion', + resourceId: id, + }, + ctx, + spinner, + ); + return; + } + const result = await reopenDiscussion({ id }, execCtx); spinner.succeed(); diff --git a/packages/cli/src/commands/pages/handlers.ts b/packages/cli/src/commands/pages/handlers.ts index c6b17fc9..98c373b8 100644 --- a/packages/cli/src/commands/pages/handlers.ts +++ b/packages/cli/src/commands/pages/handlers.ts @@ -21,6 +21,7 @@ import { handleError, exitWithValidationError, runCommand } from '../../error-ha import { ValidationError } from '../../errors.js'; import { render, createRenderContext, humanPageDetailRenderer } from '../../renderers/index.js'; import { colors } from '../../utils/colors.js'; +import { isDryRun, handleDryRunOutput } from '../../utils/dry-run.js'; import { parseFilters } from '../../utils/parse-filters.js'; function parseListOptions(ctx: CommandContext): ListPagesOptions { @@ -104,15 +105,28 @@ export async function pagesAdd(ctx: CommandContext): Promise { await runCommand(async () => { const execCtx = fromCommandContext(ctx); - const result = await createPage( - { - title: String(ctx.options.title), - projectId: String(ctx.options.project), - body: ctx.options.body ? String(ctx.options.body) : undefined, - parentPageId: ctx.options['parent-page'] ? String(ctx.options['parent-page']) : undefined, - }, - execCtx, - ); + const payload = { + title: String(ctx.options.title), + projectId: String(ctx.options.project), + body: ctx.options.body ? String(ctx.options.body) : undefined, + parentPageId: ctx.options['parent-page'] ? String(ctx.options['parent-page']) : undefined, + }; + + if (isDryRun(ctx)) { + handleDryRunOutput( + { + action: 'create', + resource: 'page', + payload, + description: `"${payload.title}" in project ${payload.projectId}`, + }, + ctx, + spinner, + ); + return; + } + + const result = await createPage(payload, execCtx); spinner.succeed(); @@ -138,16 +152,35 @@ export async function pagesUpdate(args: string[], ctx: CommandContext): Promise< await runCommand(async () => { const execCtx = fromCommandContext(ctx); + const payload = { + id, + title: ctx.options.title !== undefined ? String(ctx.options.title) : undefined, + body: ctx.options.body !== undefined ? String(ctx.options.body) : undefined, + }; + + // Check for validation before dry-run to ensure consistent behavior + const hasUpdates = payload.title !== undefined || payload.body !== undefined; + if (!hasUpdates) { + spinner.fail(); + throw ValidationError.invalid('options', {}, 'No updates specified. Use --title or --body.'); + } - try { - const result = await updatePage( + if (isDryRun(ctx)) { + handleDryRunOutput( { - id, - title: ctx.options.title !== undefined ? String(ctx.options.title) : undefined, - body: ctx.options.body !== undefined ? String(ctx.options.body) : undefined, + action: 'update', + resource: 'page', + resourceId: id, + payload, }, - execCtx, + ctx, + spinner, ); + return; + } + + try { + const result = await updatePage(payload, execCtx); spinner.succeed(); @@ -183,6 +216,20 @@ export async function pagesDelete(args: string[], ctx: CommandContext): Promise< await runCommand(async () => { const execCtx = fromCommandContext(ctx); + + if (isDryRun(ctx)) { + handleDryRunOutput( + { + action: 'delete', + resource: 'page', + resourceId: id, + }, + ctx, + spinner, + ); + return; + } + await deletePage({ id }, execCtx); spinner.succeed(); diff --git a/packages/cli/src/commands/tasks/handlers.ts b/packages/cli/src/commands/tasks/handlers.ts index 1ab8909d..369eca58 100644 --- a/packages/cli/src/commands/tasks/handlers.ts +++ b/packages/cli/src/commands/tasks/handlers.ts @@ -25,6 +25,7 @@ import { formatTime, } from '../../renderers/index.js'; import { colors } from '../../utils/colors.js'; +import { isDryRun, handleDryRunOutput } from '../../utils/dry-run.js'; import { parseFilters } from '../../utils/parse-filters.js'; export function getIncludedResource( @@ -172,21 +173,34 @@ export async function tasksAdd(ctx: CommandContext): Promise { await runCommand(async () => { const execCtx = fromCommandContext(ctx); - const result = await createTask( - { - title: String(ctx.options.title), - projectId: String(ctx.options.project), - taskListId: String(ctx.options['task-list']), - assigneeId: ctx.options.assignee ? String(ctx.options.assignee) : undefined, - description: ctx.options.description ? String(ctx.options.description) : undefined, - dueDate: ctx.options['due-date'] ? String(ctx.options['due-date']) : undefined, - startDate: ctx.options['start-date'] ? String(ctx.options['start-date']) : undefined, - initialEstimate: ctx.options.estimate ? parseInt(String(ctx.options.estimate)) : undefined, - workflowStatusId: ctx.options.status ? String(ctx.options.status) : undefined, - isPrivate: ctx.options.private === true, - }, - execCtx, - ); + const payload = { + title: String(ctx.options.title), + projectId: String(ctx.options.project), + taskListId: String(ctx.options['task-list']), + assigneeId: ctx.options.assignee ? String(ctx.options.assignee) : undefined, + description: ctx.options.description ? String(ctx.options.description) : undefined, + dueDate: ctx.options['due-date'] ? String(ctx.options['due-date']) : undefined, + startDate: ctx.options['start-date'] ? String(ctx.options['start-date']) : undefined, + initialEstimate: ctx.options.estimate ? parseInt(String(ctx.options.estimate)) : undefined, + workflowStatusId: ctx.options.status ? String(ctx.options.status) : undefined, + isPrivate: ctx.options.private === true, + }; + + if (isDryRun(ctx)) { + handleDryRunOutput( + { + action: 'create', + resource: 'task', + payload, + description: `"${payload.title}" in project ${payload.projectId}`, + }, + ctx, + spinner, + ); + return; + } + + const result = await createTask(payload, execCtx); spinner.succeed(); @@ -218,27 +232,50 @@ export async function tasksUpdate(args: string[], ctx: CommandContext): Promise< await runCommand(async () => { const execCtx = fromCommandContext(ctx); + const payload = { + id, + title: ctx.options.title !== undefined ? String(ctx.options.title) : undefined, + description: + ctx.options.description !== undefined ? String(ctx.options.description) : undefined, + dueDate: ctx.options['due-date'] !== undefined ? String(ctx.options['due-date']) : undefined, + startDate: + ctx.options['start-date'] !== undefined ? String(ctx.options['start-date']) : undefined, + initialEstimate: + ctx.options.estimate !== undefined ? parseInt(String(ctx.options.estimate)) : undefined, + isPrivate: ctx.options.private !== undefined ? ctx.options.private === true : undefined, + assigneeId: ctx.options.assignee !== undefined ? String(ctx.options.assignee) : undefined, + workflowStatusId: ctx.options.status !== undefined ? String(ctx.options.status) : undefined, + }; + + // Check for validation before dry-run to ensure consistent behavior + const hasUpdates = Object.entries(payload).some( + ([key, value]) => key !== 'id' && value !== undefined, + ); + if (!hasUpdates) { + spinner.fail(); + throw ValidationError.invalid( + 'options', + {}, + 'No updates specified. Use --title, --description, --due-date, --assignee, --status, etc.', + ); + } - try { - const result = await updateTask( + if (isDryRun(ctx)) { + handleDryRunOutput( { - id, - title: ctx.options.title !== undefined ? String(ctx.options.title) : undefined, - description: - ctx.options.description !== undefined ? String(ctx.options.description) : undefined, - dueDate: - ctx.options['due-date'] !== undefined ? String(ctx.options['due-date']) : undefined, - startDate: - ctx.options['start-date'] !== undefined ? String(ctx.options['start-date']) : undefined, - initialEstimate: - ctx.options.estimate !== undefined ? parseInt(String(ctx.options.estimate)) : undefined, - isPrivate: ctx.options.private !== undefined ? ctx.options.private === true : undefined, - assigneeId: ctx.options.assignee !== undefined ? String(ctx.options.assignee) : undefined, - workflowStatusId: - ctx.options.status !== undefined ? String(ctx.options.status) : undefined, + action: 'update', + resource: 'task', + resourceId: id, + payload, }, - execCtx, + ctx, + spinner, ); + return; + } + + try { + const result = await updateTask(payload, execCtx); spinner.succeed(); diff --git a/packages/cli/src/commands/time/handlers.dry-run.test.ts b/packages/cli/src/commands/time/handlers.dry-run.test.ts new file mode 100644 index 00000000..5ac2df36 --- /dev/null +++ b/packages/cli/src/commands/time/handlers.dry-run.test.ts @@ -0,0 +1,218 @@ +/** + * Tests for dry-run functionality in time handlers + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { createTestContext } from '../../context.js'; +import { timeAdd, timeUpdate, timeDelete } from './handlers.js'; + +describe('time handlers dry-run', () => { + let consoleSpy: ReturnType; + let processExitSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit() was called'); + }); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + processExitSpy.mockRestore(); + }); + + describe('timeAdd', () => { + it('shows dry-run output without making API call', async () => { + const mockApi = { + // createTimeEntry should not be called in dry-run mode + }; + + const mockFormatter = { + output: vi.fn(), + success: vi.fn(), + error: vi.fn(), + }; + + const ctx = createTestContext({ + api: mockApi as any, + formatter: mockFormatter as any, + options: { + 'dry-run': true, + format: 'human', + service: '123', + time: '480', + person: '456', + note: 'Test work', + }, + config: { userId: '456', apiToken: 'test', organizationId: '789', baseUrl: 'test' }, + }); + + await timeAdd(ctx); + + // Should show dry-run output but not call API + const logCalls = consoleSpy.mock.calls.flat(); + expect(logCalls.some((call) => String(call).includes('DRY RUN MODE'))).toBe(true); + expect(logCalls.some((call) => String(call).includes('Create time entry'))).toBe(true); + expect(logCalls.some((call) => String(call).includes('8h 0m for service 123'))).toBe(true); + expect(logCalls.some((call) => String(call).includes('No changes made'))).toBe(true); + expect(mockFormatter.success).not.toHaveBeenCalled(); + }); + + it('outputs JSON format in dry-run mode', async () => { + const mockApi = {}; + const mockFormatter = { output: vi.fn() }; + + const ctx = createTestContext({ + api: mockApi as any, + formatter: mockFormatter as any, + options: { + 'dry-run': true, + format: 'json', + service: '123', + time: '480', + person: '456', + }, + config: { userId: '456', apiToken: 'test', organizationId: '789', baseUrl: 'test' }, + }); + + await timeAdd(ctx); + + expect(mockFormatter.output).toHaveBeenCalledWith({ + dry_run: true, + action: 'create', + resource: 'time entry', + resource_id: undefined, + payload: { + personId: '456', + serviceId: '123', + time: 480, + date: undefined, + note: undefined, + }, + }); + }); + + it('still validates required fields in dry-run mode', async () => { + const mockFormatter = { error: vi.fn() }; + + const ctx = createTestContext({ + formatter: mockFormatter as any, + options: { + 'dry-run': true, + // Missing required fields + }, + config: { apiToken: 'test', organizationId: '789', baseUrl: 'test' }, + }); + + try { + await timeAdd(ctx); + } catch (error) { + // Expect process.exit to be called due to validation error + expect(String(error)).toContain('process.exit() was called'); + } + + // Should still validate and show error, not dry-run output + expect(mockFormatter.error).toHaveBeenCalled(); + const logCalls = consoleSpy.mock.calls.flat(); + expect(logCalls.some((call) => String(call).includes('DRY RUN MODE'))).toBe(false); + }); + }); + + describe('timeUpdate', () => { + it('shows dry-run output for update operation', async () => { + const mockApi = {}; + const mockFormatter = { output: vi.fn() }; + + const ctx = createTestContext({ + api: mockApi as any, + formatter: mockFormatter as any, + options: { + 'dry-run': true, + format: 'human', + time: '240', + note: 'Updated work', + }, + }); + + await timeUpdate(['123'], ctx); + + const logCalls = consoleSpy.mock.calls.flat(); + expect(logCalls.some((call) => String(call).includes('DRY RUN MODE'))).toBe(true); + expect(logCalls.some((call) => String(call).includes('Update time entry 123'))).toBe(true); + expect(logCalls.some((call) => String(call).includes('No changes made'))).toBe(true); + }); + + it('validates no updates provided even in dry-run mode', async () => { + const mockFormatter = { error: vi.fn() }; + + const ctx = createTestContext({ + formatter: mockFormatter as any, + options: { + 'dry-run': true, + // No update fields provided + }, + }); + + try { + await timeUpdate(['123'], ctx); + } catch (error) { + // Expect process.exit to be called due to validation error + expect(String(error)).toContain('process.exit() was called'); + } + + // Should still validate and show error + expect(mockFormatter.error).toHaveBeenCalled(); + const logCalls = consoleSpy.mock.calls.flat(); + expect(logCalls.some((call) => String(call).includes('DRY RUN MODE'))).toBe(false); + }); + }); + + describe('timeDelete', () => { + it('shows dry-run output for delete operation', async () => { + const mockApi = {}; + const mockFormatter = { output: vi.fn() }; + + const ctx = createTestContext({ + api: mockApi as any, + formatter: mockFormatter as any, + options: { + 'dry-run': true, + format: 'human', + }, + }); + + await timeDelete(['456'], ctx); + + const logCalls = consoleSpy.mock.calls.flat(); + expect(logCalls.some((call) => String(call).includes('DRY RUN MODE'))).toBe(true); + expect(logCalls.some((call) => String(call).includes('Delete time entry 456'))).toBe(true); + expect(logCalls.some((call) => String(call).includes('No changes made'))).toBe(true); + }); + + it('outputs JSON format for delete in dry-run mode', async () => { + const mockApi = {}; + const mockFormatter = { output: vi.fn() }; + + const ctx = createTestContext({ + api: mockApi as any, + formatter: mockFormatter as any, + options: { + 'dry-run': true, + format: 'json', + }, + }); + + await timeDelete(['456'], ctx); + + expect(mockFormatter.output).toHaveBeenCalledWith({ + dry_run: true, + action: 'delete', + resource: 'time entry', + resource_id: '456', + payload: {}, + }); + }); + }); +}); diff --git a/packages/cli/src/commands/time/handlers.ts b/packages/cli/src/commands/time/handlers.ts index caaa7dd8..e3a6b5a1 100644 --- a/packages/cli/src/commands/time/handlers.ts +++ b/packages/cli/src/commands/time/handlers.ts @@ -34,6 +34,7 @@ import { } from '../../renderers/index.js'; import { colors } from '../../utils/colors.js'; import { parseDate, parseDateRange } from '../../utils/date.js'; +import { isDryRun, handleDryRunOutput } from '../../utils/dry-run.js'; import { parseFilters } from '../../utils/parse-filters.js'; /** @@ -202,16 +203,32 @@ export async function timeAdd(ctx: CommandContext): Promise { await runCommand(async () => { const execCtx = fromCommandContext(ctx); - const result = await createTimeEntry( - { - personId, - serviceId: String(ctx.options.service), - time: parseInt(String(ctx.options.time)), - date: ctx.options.date ? String(ctx.options.date) : undefined, - note: ctx.options.note ? String(ctx.options.note) : undefined, - }, - execCtx, - ); + const timeValue = parseInt(String(ctx.options.time)); + const payload = { + personId, + serviceId: String(ctx.options.service), + time: timeValue, + date: ctx.options.date ? String(ctx.options.date) : undefined, + note: ctx.options.note ? String(ctx.options.note) : undefined, + }; + + if (isDryRun(ctx)) { + const hours = Math.floor(timeValue / 60); + const minutes = timeValue % 60; + handleDryRunOutput( + { + action: 'create', + resource: 'time entry', + payload, + description: `${hours}h ${minutes}m for service ${payload.serviceId}${payload.note ? ` (${payload.note})` : ''}`, + }, + ctx, + spinner, + ); + return; + } + + const result = await createTimeEntry(payload, execCtx); spinner.succeed(); @@ -252,17 +269,41 @@ export async function timeUpdate(args: string[], ctx: CommandContext): Promise { const execCtx = fromCommandContext(ctx); + const payload = { + id, + time: ctx.options.time ? parseInt(String(ctx.options.time)) : undefined, + note: ctx.options.note ? String(ctx.options.note) : undefined, + date: ctx.options.date ? String(ctx.options.date) : undefined, + }; + + // Check for validation before dry-run to ensure consistent behavior + const hasUpdates = + payload.time !== undefined || payload.note !== undefined || payload.date !== undefined; + if (!hasUpdates) { + spinner.fail(); + throw ValidationError.invalid( + 'options', + {}, + 'No updates specified. Use --time, --note, or --date', + ); + } - try { - const result = await updateTimeEntry( + if (isDryRun(ctx)) { + handleDryRunOutput( { - id, - time: ctx.options.time ? parseInt(String(ctx.options.time)) : undefined, - note: ctx.options.note ? String(ctx.options.note) : undefined, - date: ctx.options.date ? String(ctx.options.date) : undefined, + action: 'update', + resource: 'time entry', + resourceId: id, + payload, }, - execCtx, + ctx, + spinner, ); + return; + } + + try { + const result = await updateTimeEntry(payload, execCtx); spinner.succeed(); @@ -301,6 +342,20 @@ export async function timeDelete(args: string[], ctx: CommandContext): Promise { const execCtx = fromCommandContext(ctx); + + if (isDryRun(ctx)) { + handleDryRunOutput( + { + action: 'delete', + resource: 'time entry', + resourceId: id, + }, + ctx, + spinner, + ); + return; + } + await deleteTimeEntry({ id }, execCtx); spinner.succeed(); diff --git a/packages/cli/src/commands/timers/handlers.ts b/packages/cli/src/commands/timers/handlers.ts index ad272d56..b74e4c2f 100644 --- a/packages/cli/src/commands/timers/handlers.ts +++ b/packages/cli/src/commands/timers/handlers.ts @@ -19,6 +19,7 @@ import { handleError, exitWithValidationError, runCommand } from '../../error-ha import { ValidationError } from '../../errors.js'; import { render, createRenderContext, humanTimerDetailRenderer } from '../../renderers/index.js'; import { colors } from '../../utils/colors.js'; +import { isDryRun, handleDryRunOutput } from '../../utils/dry-run.js'; import { parseFilters } from '../../utils/parse-filters.js'; function parseListOptions(ctx: CommandContext): ListTimersOptions { @@ -122,13 +123,32 @@ export async function timersStart(ctx: CommandContext): Promise { await runCommand(async () => { const execCtx = fromCommandContext(ctx); - const result = await startTimer( - { - serviceId: ctx.options.service ? String(ctx.options.service) : undefined, - timeEntryId: ctx.options['time-entry'] ? String(ctx.options['time-entry']) : undefined, - }, - execCtx, - ); + const payload = { + serviceId: ctx.options.service ? String(ctx.options.service) : undefined, + timeEntryId: ctx.options['time-entry'] ? String(ctx.options['time-entry']) : undefined, + }; + + if (isDryRun(ctx)) { + const target = ctx.options.service + ? `service ${payload.serviceId}` + : ctx.options['time-entry'] + ? `time entry ${payload.timeEntryId}` + : 'unknown'; + + handleDryRunOutput( + { + action: 'start', + resource: 'timer', + payload, + description: `Start timer for ${target}`, + }, + ctx, + spinner, + ); + return; + } + + const result = await startTimer(payload, execCtx); spinner.succeed(); @@ -154,6 +174,20 @@ export async function timersStop(args: string[], ctx: CommandContext): Promise { const execCtx = fromCommandContext(ctx); + + if (isDryRun(ctx)) { + handleDryRunOutput( + { + action: 'stop', + resource: 'timer', + resourceId: id, + }, + ctx, + spinner, + ); + return; + } + const result = await stopTimer({ id }, execCtx); spinner.succeed(); diff --git a/packages/cli/src/utils/dry-run.test.ts b/packages/cli/src/utils/dry-run.test.ts new file mode 100644 index 00000000..88bfce99 --- /dev/null +++ b/packages/cli/src/utils/dry-run.test.ts @@ -0,0 +1,199 @@ +/** + * Tests for dry-run utilities + */ + +import { describe, it, expect, vi } from 'vitest'; + +import type { CommandContext } from '../context.js'; + +import { isDryRun, handleDryRunOutput } from './dry-run.js'; + +describe('dry-run utilities', () => { + describe('isDryRun', () => { + it('returns true when --dry-run option is set', () => { + const ctx = { + options: { 'dry-run': true }, + } as CommandContext; + + expect(isDryRun(ctx)).toBe(true); + }); + + it('returns false when --dry-run option is not set', () => { + const ctx = { + options: {}, + } as CommandContext; + + expect(isDryRun(ctx)).toBe(false); + }); + + it('returns false when --dry-run option is false', () => { + const ctx = { + options: { 'dry-run': false }, + } as CommandContext; + + expect(isDryRun(ctx)).toBe(false); + }); + }); + + describe('handleDryRunOutput', () => { + it('outputs JSON format correctly', () => { + const mockFormatter = { output: vi.fn() }; + const mockSpinner = { succeed: vi.fn(), fail: vi.fn() }; + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const ctx = { + options: { format: 'json' }, + formatter: mockFormatter, + } as unknown as CommandContext; + + handleDryRunOutput( + { + action: 'create', + resource: 'task', + payload: { title: 'Test task' }, + }, + ctx, + mockSpinner as any, + ); + + expect(mockSpinner.succeed).toHaveBeenCalled(); + expect(mockFormatter.output).toHaveBeenCalledWith({ + dry_run: true, + action: 'create', + resource: 'task', + resource_id: undefined, + payload: { title: 'Test task' }, + }); + expect(consoleSpy).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('outputs human format correctly for create action', () => { + const mockFormatter = { output: vi.fn() }; + const mockSpinner = { succeed: vi.fn(), fail: vi.fn() }; + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const ctx = { + options: { format: 'human' }, + formatter: mockFormatter, + } as unknown as CommandContext; + + handleDryRunOutput( + { + action: 'create', + resource: 'task', + payload: { title: 'Test task', projectId: '123' }, + description: 'Create new task', + }, + ctx, + mockSpinner as any, + ); + + expect(mockSpinner.succeed).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('DRY RUN MODE')); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Action:'), 'Create task'); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Description:'), + 'Create new task', + ); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No changes made.')); + expect(mockFormatter.output).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('outputs human format correctly for update action with resource ID', () => { + const mockFormatter = { output: vi.fn() }; + const mockSpinner = { succeed: vi.fn(), fail: vi.fn() }; + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const ctx = { + options: { format: 'human' }, + formatter: mockFormatter, + } as unknown as CommandContext; + + handleDryRunOutput( + { + action: 'update', + resource: 'task', + resourceId: '456', + payload: { title: 'Updated task' }, + }, + ctx, + mockSpinner as any, + ); + + expect(mockSpinner.succeed).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Action:'), + 'Update task 456', + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Payload:'), + expect.stringContaining('title'), + ); + + consoleSpy.mockRestore(); + }); + + it('outputs human format correctly for delete action', () => { + const mockFormatter = { output: vi.fn() }; + const mockSpinner = { succeed: vi.fn(), fail: vi.fn() }; + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const ctx = { + options: { format: 'human' }, + formatter: mockFormatter, + } as unknown as CommandContext; + + handleDryRunOutput( + { + action: 'delete', + resource: 'task', + resourceId: '789', + }, + ctx, + mockSpinner as any, + ); + + expect(mockSpinner.succeed).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Action:'), + 'Delete task 789', + ); + + consoleSpy.mockRestore(); + }); + + it('handles empty payload correctly', () => { + const mockFormatter = { output: vi.fn() }; + const mockSpinner = { succeed: vi.fn(), fail: vi.fn() }; + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const ctx = { + options: { format: 'human' }, + formatter: mockFormatter, + } as unknown as CommandContext; + + handleDryRunOutput( + { + action: 'start', + resource: 'timer', + }, + ctx, + mockSpinner as any, + ); + + expect(mockSpinner.succeed).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Action:'), 'Start timer'); + // Should not log payload for empty payload + expect(consoleSpy).not.toHaveBeenCalledWith( + expect.stringContaining('Payload:'), + expect.anything(), + ); + + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/packages/cli/src/utils/dry-run.ts b/packages/cli/src/utils/dry-run.ts new file mode 100644 index 00000000..172cc9c0 --- /dev/null +++ b/packages/cli/src/utils/dry-run.ts @@ -0,0 +1,94 @@ +/** + * Dry-run utilities for CLI commands + * + * When --dry-run is passed, validate all inputs normally but skip the API call + * and instead show what would happen. + */ + +import type { CommandContext } from '../context.js'; +import type { OutputFormat } from '../types.js'; + +import { colors } from './colors.js'; + +export interface DryRunInfo { + action: string; + resource: string; + resourceId?: string; + payload?: Record; + description?: string; +} + +/** + * Check if dry-run mode is enabled + */ +export function isDryRun(ctx: CommandContext): boolean { + return ctx.options['dry-run'] === true; +} + +/** + * Handle dry-run output - show what would happen without making changes + */ +export function handleDryRunOutput( + info: DryRunInfo, + ctx: CommandContext, + spinner: { succeed: () => void; fail: () => void }, +): void { + spinner.succeed(); + + const format = (ctx.options.format || ctx.options.f || 'human') as OutputFormat; + + if (format === 'json') { + ctx.formatter.output({ + dry_run: true, + action: info.action, + resource: info.resource, + resource_id: info.resourceId, + payload: info.payload || {}, + }); + } else { + const actionText = getActionText(info.action); + console.log(colors.yellow('DRY RUN MODE')); + console.log(); + + if (info.resourceId) { + console.log(colors.cyan('Action:'), `${actionText} ${info.resource} ${info.resourceId}`); + } else { + console.log(colors.cyan('Action:'), `${actionText} ${info.resource}`); + } + + if (info.payload && Object.keys(info.payload).length > 0) { + console.log(colors.cyan('Payload:'), JSON.stringify(info.payload, null, 2)); + } + + if (info.description) { + console.log(colors.cyan('Description:'), info.description); + } + + console.log(); + console.log(colors.gray('No changes made.')); + } +} + +/** + * Get human-readable action text + */ +function getActionText(action: string): string { + switch (action) { + case 'create': + return 'Create'; + case 'update': + return 'Update'; + case 'delete': + return 'Delete'; + case 'start': + return 'Start'; + case 'stop': + return 'Stop'; + case 'resolve': + return 'Resolve'; + case 'reopen': + return 'Reopen'; + default: + return action.charAt(0).toUpperCase() + action.slice(1); + } +}