Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 51 additions & 13 deletions packages/cli/src/commands/rca/run.ts
Original file line number Diff line number Diff line change
@@ -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'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

worth noting the js imports ? in an agents.md

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's been an ask from @sorccu until we make the big major upgrade. That's how I understood it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I don't think this will stay like this for long

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

import * as api from '../../rest/api'
import { NotFoundError, InadequateEntitlementsError } from '../../rest/errors'
import { formatRcaPending, formatRcaCompleted } from '../../formatters/rca'
Expand All @@ -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 <value> | -te <value>] [-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',
Expand All @@ -27,20 +33,51 @@ export default class RcaRun extends AuthCommand {
}

async run (): Promise<void> {
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
Expand Down Expand Up @@ -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
}
Expand Down
45 changes: 43 additions & 2 deletions packages/cli/src/formatters/__tests__/rca.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down
42 changes: 32 additions & 10 deletions packages/cli/src/formatters/rca.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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')
}

Expand Down
54 changes: 54 additions & 0 deletions packages/cli/src/helpers/__tests__/flags.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
32 changes: 32 additions & 0 deletions packages/cli/src/helpers/flags.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/rest/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/rest/rca.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ class Rca {
)
}

triggerTestSessionErrorGroup (testSessionErrorGroupId: string) {
return this.api.post<TriggerRcaResponse>(
`/v1/root-cause-analyses/test-session-error-groups/${testSessionErrorGroupId}`,
)
}

get (id: string) {
return this.api.get<RootCauseAnalysis>(`/v1/root-cause-analyses/${id}`)
}
Expand Down
26 changes: 26 additions & 0 deletions packages/cli/src/rest/test-session-error-groups.ts
Original file line number Diff line number Diff line change
@@ -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<TestSessionErrorGroup>(`/v1/test-session-error-groups/${id}`)
}
}

export default TestSessionErrorGroups
Loading