diff --git a/packages/cli/src/commands/rca/run.ts b/packages/cli/src/commands/rca/run.ts index e5acfaf68..f6fc48d5c 100644 --- a/packages/cli/src/commands/rca/run.ts +++ b/packages/cli/src/commands/rca/run.ts @@ -1,7 +1,7 @@ import { Flags } from '@oclif/core' import chalk from 'chalk' import { AuthCommand } from '../authCommand' -import { outputFlag } from '../../helpers/flags' +import { normalizeFlagAliases, outputFlag } from '../../helpers/flags.js' import * as api from '../../rest/api' import { NotFoundError, InadequateEntitlementsError } from '../../rest/errors' import { formatRcaPending, formatRcaCompleted } from '../../formatters/rca' @@ -10,13 +10,19 @@ export default class RcaRun extends AuthCommand { static hidden = false static readOnly = false static idempotent = false - static description = 'Trigger a root cause analysis for an error group.' + static description = 'Trigger a root cause analysis for a check or test session error group.' + static usage = 'rca run [-e | -te ] [-w] [-o detail|json|md]' static flags = { 'error-group': Flags.string({ char: 'e', description: 'The error group ID to analyze.', - required: true, + exactlyOne: ['error-group', 'test-session-error-group'], + exclusive: ['test-session-error-group'], + }), + 'test-session-error-group': Flags.string({ + description: 'The test session error group ID to analyze.', + helpLabel: '-te, --test-session-error-group', }), 'watch': Flags.boolean({ char: 'w', @@ -27,20 +33,51 @@ export default class RcaRun extends AuthCommand { } async run (): Promise { - const { flags } = await this.parse(RcaRun) + const { flags } = await this.parse(RcaRun, normalizeFlagAliases(this.argv, [ + { alias: '-te', flag: '--test-session-error-group' }, + ])) this.style.outputFormat = flags.output + const source = flags['test-session-error-group'] + ? { + type: 'test-session-error-group' as const, + id: flags['test-session-error-group'], + } + : { + type: 'error-group' as const, + id: flags['error-group']!, + } + try { - // Fetch the error group to get the checkId for navigation hints - const { data: errorGroup } = await api.errorGroups.get(flags['error-group']) + let pendingInfo + let rcaId: string - // Trigger the RCA - const { data: { id: rcaId } } = await api.rca.trigger(flags['error-group']) + if (source.type === 'error-group') { + // Fetch the error group to get the checkId for navigation hints + const { data: errorGroup } = await api.errorGroups.get(source.id) - const pendingInfo = { - rcaId, - errorGroupId: flags['error-group'], - checkId: errorGroup.checkId, + const response = await api.rca.trigger(source.id) + rcaId = response.data.id + pendingInfo = { + rcaId, + source: { + type: 'error-group' as const, + errorGroupId: source.id, + checkId: errorGroup.checkId, + }, + } + } else { + await api.testSessionErrorGroups.get(source.id) + + const response = await api.rca.triggerTestSessionErrorGroup(source.id) + rcaId = response.data.id + pendingInfo = { + rcaId, + source: { + type: 'test-session-error-group' as const, + testSessionErrorGroupId: source.id, + }, + } } // If not watching, show pending state and exit @@ -73,7 +110,8 @@ export default class RcaRun extends AuthCommand { return } if (err instanceof NotFoundError) { - this.style.shortError(`Error group not found: ${flags['error-group']}`) + const label = source.type === 'error-group' ? 'Error group' : 'Test session error group' + this.style.shortError(`${label} not found: ${source.id}`) process.exitCode = 1 return } diff --git a/packages/cli/src/formatters/__tests__/rca.spec.ts b/packages/cli/src/formatters/__tests__/rca.spec.ts index 3f8ba6990..7fd9b2ff8 100644 --- a/packages/cli/src/formatters/__tests__/rca.spec.ts +++ b/packages/cli/src/formatters/__tests__/rca.spec.ts @@ -130,8 +130,11 @@ describe('transformErrorGroupForJson', () => { describe('formatRcaPending', () => { const pendingInfo = { rcaId: 'rca-123', - errorGroupId: 'eg-456', - checkId: 'check-789', + source: { + type: 'error-group' as const, + errorGroupId: 'eg-456', + checkId: 'check-789', + }, } it('renders pending state in terminal', () => { @@ -158,6 +161,44 @@ describe('formatRcaPending', () => { expect(result).toContain('pending') expect(result).toContain('rca-123') }) + + it('renders test session error group pending state', () => { + const result = stripAnsi(formatRcaPending({ + rcaId: 'rca-456', + source: { + type: 'test-session-error-group', + testSessionErrorGroupId: 'tseg-123', + }, + }, 'terminal')) + + expect(result).toContain('Test session error group:') + expect(result).toContain('tseg-123') + expect(result).toContain('checkly rca get rca-456 --watch') + expect(result).not.toContain('checkly checks get') + + const lines = result.split('\n') + const rcaLine = lines.find(line => line.includes('RCA ID:'))! + const groupLine = lines.find(line => line.includes('Test session error group:'))! + const statusLine = lines.find(line => line.includes('Status:'))! + + expect(rcaLine.indexOf('rca-456')).toBe(groupLine.indexOf('tseg-123')) + expect(statusLine.indexOf('pending')).toBe(groupLine.indexOf('tseg-123')) + }) + + it('renders test session error group pending state in json', () => { + const result = formatRcaPending({ + rcaId: 'rca-456', + source: { + type: 'test-session-error-group', + testSessionErrorGroupId: 'tseg-123', + }, + }, 'json') + + const parsed = JSON.parse(result) + expect(parsed.id).toBe('rca-456') + expect(parsed.status).toBe('pending') + expect(parsed.testSessionErrorGroupId).toBe('tseg-123') + }) }) describe('formatRcaCompleted', () => { diff --git a/packages/cli/src/formatters/rca.ts b/packages/cli/src/formatters/rca.ts index 3f015d0b4..da7512bc6 100644 --- a/packages/cli/src/formatters/rca.ts +++ b/packages/cli/src/formatters/rca.ts @@ -118,40 +118,62 @@ export interface ErrorGroupJsonOutput { export interface RcaPendingInfo { rcaId: string - errorGroupId: string - checkId: string + source: { + type: 'error-group' + errorGroupId: string + checkId: string + } | { + type: 'test-session-error-group' + testSessionErrorGroupId: string + } } export function formatRcaPending (info: RcaPendingInfo, format: OutputFormat | 'json'): string { if (format === 'json') { - return JSON.stringify({ + const output: Record = { id: info.rcaId, status: 'pending', - errorGroupId: info.errorGroupId, - }, null, 2) + } + if (info.source.type === 'error-group') { + output.errorGroupId = info.source.errorGroupId + } else { + output.testSessionErrorGroupId = info.source.testSessionErrorGroupId + } + return JSON.stringify(output, null, 2) } if (format === 'md') { + const sourceLabel = info.source.type === 'error-group' ? 'Error group' : 'Test session error group' + const sourceId = info.source.type === 'error-group' + ? info.source.errorGroupId + : info.source.testSessionErrorGroupId return [ '# Root Cause Analysis', '', '| Field | Value |', '| --- | --- |', `| RCA ID | ${info.rcaId} |`, - `| Error group | ${info.errorGroupId} |`, + `| ${sourceLabel} | ${sourceId} |`, '| Status | pending |', ].join('\n') } const lines: string[] = [] + const pendingLabelWidth = 'Test session error group:'.length + 2 lines.push(chalk.bold('Root cause analysis triggered.')) lines.push('') - lines.push(`${label('RCA ID:')}${info.rcaId}`) - lines.push(`${label('Error group:')}${info.errorGroupId}`) - lines.push(`${label('Status:')}${chalk.yellow('pending')}`) + lines.push(`${label('RCA ID:', pendingLabelWidth)}${info.rcaId}`) + if (info.source.type === 'error-group') { + lines.push(`${label('Error group:', pendingLabelWidth)}${info.source.errorGroupId}`) + } else { + lines.push(`${label('Test session error group:', pendingLabelWidth)}${info.source.testSessionErrorGroupId}`) + } + lines.push(`${label('Status:', pendingLabelWidth)}${chalk.yellow('pending')}`) lines.push('') lines.push(` ${chalk.dim('Watch progress:')} checkly rca get ${info.rcaId} --watch`) - lines.push(` ${chalk.dim('View result:')} checkly checks get ${info.checkId} --error-group ${info.errorGroupId}`) + if (info.source.type === 'error-group') { + lines.push(` ${chalk.dim('View result:')} checkly checks get ${info.source.checkId} --error-group ${info.source.errorGroupId}`) + } return lines.join('\n') } diff --git a/packages/cli/src/helpers/__tests__/flags.spec.ts b/packages/cli/src/helpers/__tests__/flags.spec.ts new file mode 100644 index 000000000..cc4a8ac9d --- /dev/null +++ b/packages/cli/src/helpers/__tests__/flags.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { normalizeFlagAliases } from '../flags.js' + +describe('normalizeFlagAliases', () => { + const aliases = [ + { alias: '-te', flag: '--test-session-error-group' }, + { alias: '-eg', flag: '--error-group' }, + ] + + it('normalizes alias flags with separate values', () => { + expect(normalizeFlagAliases(['-te', 'tseg-123'], aliases)).toEqual([ + '--test-session-error-group', + 'tseg-123', + ]) + }) + + it('normalizes alias flags with equals values', () => { + expect(normalizeFlagAliases(['-te=tseg=123'], aliases)).toEqual([ + '--test-session-error-group=tseg=123', + ]) + }) + + it('normalizes multiple aliases', () => { + expect(normalizeFlagAliases(['-eg', 'eg-123', '-te=tseg-123'], aliases)).toEqual([ + '--error-group', + 'eg-123', + '--test-session-error-group=tseg-123', + ]) + }) + + it('leaves arguments after -- unchanged', () => { + expect(normalizeFlagAliases(['-te', 'tseg-123', '--', '-eg'], aliases)).toEqual([ + '--test-session-error-group', + 'tseg-123', + '--', + '-eg', + ]) + }) + + it('leaves non-flag-shaped arguments unchanged', () => { + expect(normalizeFlagAliases(['value=with-equals', '--test-session-error-group=tseg-123'], aliases)).toEqual([ + 'value=with-equals', + '--test-session-error-group=tseg-123', + ]) + }) + + it('returns a copy when no aliases are configured', () => { + const argv = ['-te', 'tseg-123'] + const normalized = normalizeFlagAliases(argv, []) + + expect(normalized).toEqual(argv) + expect(normalized).not.toBe(argv) + }) +}) diff --git a/packages/cli/src/helpers/flags.ts b/packages/cli/src/helpers/flags.ts index 82899c46d..a5ab64bd2 100644 --- a/packages/cli/src/helpers/flags.ts +++ b/packages/cli/src/helpers/flags.ts @@ -1,5 +1,37 @@ import { Flags } from '@oclif/core' +export interface FlagAlias { + alias: string + flag: string +} + +export function normalizeFlagAliases (argv: string[], aliases: FlagAlias[]): string[] { + if (aliases.length === 0) { + return [...argv] + } + + const flagByAlias = new Map(aliases.map(({ alias, flag }) => [alias, flag])) + const passthroughIndex = argv.indexOf('--') + const argsToNormalize = passthroughIndex === -1 ? argv : argv.slice(0, passthroughIndex) + const passthroughArgs = passthroughIndex === -1 ? [] : argv.slice(passthroughIndex) + + return [ + ...argsToNormalize.map(arg => { + const match = arg.match(/^(-[a-z0-9-]+)(?:=(.*))?$/i) + const alias = match?.[1] + const flag = alias ? flagByAlias.get(alias) : undefined + + if (!flag) { + return arg + } + + const value = match?.[2] + return value === undefined ? flag : `${flag}=${value}` + }), + ...passthroughArgs, + ] +} + export function outputFlag (opts: { default: string, options?: string[] }) { return Flags.string({ char: 'o', diff --git a/packages/cli/src/rest/api.ts b/packages/cli/src/rest/api.ts index 4ca1eb075..df7f2f9dc 100644 --- a/packages/cli/src/rest/api.ts +++ b/packages/cli/src/rest/api.ts @@ -18,6 +18,7 @@ import CheckStatuses from './check-statuses' import CheckResults from './check-results' import CheckGroups from './check-groups' import ErrorGroups from './error-groups' +import TestSessionErrorGroups from './test-session-error-groups.js' import StatusPages from './status-pages' import Incidents from './incidents' import Analytics from './analytics' @@ -122,6 +123,7 @@ export const checkStatuses = new CheckStatuses(api) export const checkResults = new CheckResults(api) export const checkGroups = new CheckGroups(api) export const errorGroups = new ErrorGroups(api) +export const testSessionErrorGroups = new TestSessionErrorGroups(api) export const statusPages = new StatusPages(api) export const incidents = new Incidents(api) export const analytics = new Analytics(api) diff --git a/packages/cli/src/rest/rca.ts b/packages/cli/src/rest/rca.ts index eb2762607..cbd797a3a 100644 --- a/packages/cli/src/rest/rca.ts +++ b/packages/cli/src/rest/rca.ts @@ -19,6 +19,12 @@ class Rca { ) } + triggerTestSessionErrorGroup (testSessionErrorGroupId: string) { + return this.api.post( + `/v1/root-cause-analyses/test-session-error-groups/${testSessionErrorGroupId}`, + ) + } + get (id: string) { return this.api.get(`/v1/root-cause-analyses/${id}`) } diff --git a/packages/cli/src/rest/test-session-error-groups.ts b/packages/cli/src/rest/test-session-error-groups.ts new file mode 100644 index 000000000..8d68e954c --- /dev/null +++ b/packages/cli/src/rest/test-session-error-groups.ts @@ -0,0 +1,26 @@ +import type { AxiosInstance } from 'axios' + +export interface TestSessionErrorGroup { + id: string + projectId: string + environments: string[] + errorHash: string + rawErrorMessage: string | null + cleanedErrorMessage: string + firstSeen: string + lastSeen: string + archivedUntilNextEvent: boolean +} + +class TestSessionErrorGroups { + api: AxiosInstance + constructor (api: AxiosInstance) { + this.api = api + } + + get (id: string) { + return this.api.get(`/v1/test-session-error-groups/${id}`) + } +} + +export default TestSessionErrorGroups