diff --git a/src/commands/analytics/cmd-analytics.ts b/src/commands/analytics/cmd-analytics.ts index 0b870727c..220cb93ce 100644 --- a/src/commands/analytics/cmd-analytics.ts +++ b/src/commands/analytics/cmd-analytics.ts @@ -5,10 +5,8 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { displayAnalytics } from './display-analytics' import constants from '../../constants' import { commonFlags, outputFlags } from '../../flags' -import { AuthError } from '../../utils/errors' import { meowOrExit } from '../../utils/meow-with-subcommands' import { getFlagListOutput } from '../../utils/output-formatting' -import { getDefaultToken } from '../../utils/sdk' import type { CliCommandConfig } from '../../utils/meow-with-subcommands' @@ -21,6 +19,19 @@ const config: CliCommandConfig = { flags: { ...commonFlags, ...outputFlags, + file: { + type: 'string', + shortFlag: 'f', + default: '-', + description: + 'Path to a local file to save the output. Only valid with --json/--markdown. Defaults to stdout.' + }, + repo: { + type: 'string', + shortFlag: 'r', + default: '', + description: 'Name of the repository. Only valid when scope=repo' + }, scope: { type: 'string', shortFlag: 's', @@ -33,18 +44,6 @@ const config: CliCommandConfig = { shortFlag: 't', default: 7, description: 'Time filter - either 7, 30 or 90, default: 7' - }, - repo: { - type: 'string', - shortFlag: 'r', - default: '', - description: 'Name of the repository' - }, - file: { - type: 'string', - shortFlag: 'f', - default: '', - description: 'Path to a local file to save the output' } }, help: (command, { flags }) => ` @@ -82,21 +81,32 @@ async function run( parentName }) - const { repo, scope, time } = cli.flags + const { file, json, markdown, repo, scope, time } = cli.flags const badScope = scope !== 'org' && scope !== 'repo' const badTime = time !== 7 && time !== 30 && time !== 90 const badRepo = scope === 'repo' && !repo + const badFile = file !== '-' && !json && !markdown + const badFlags = json && markdown - if (badScope || badTime || badRepo) { + if (badScope || badTime || badRepo || badFile || badFlags) { // Use exit status of 2 to indicate incorrect usage, generally invalid // options or missing arguments. // https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html process.exitCode = 2 - logger.error(`${colors.bgRed(colors.white('Input error'))}: Please provide the required fields:\n + logger.error( + `${colors.bgRed(colors.white('Input error'))}: Please provide the required fields:\n - Scope must be "repo" or "org" ${badScope ? colors.red('(bad!)') : colors.green('(ok)')}\n - The time filter must either be 7, 30 or 90 ${badTime ? colors.red('(bad!)') : colors.green('(ok)')}\n - - Repository name using --repo when scope is "repo" ${badRepo ? colors.red('(bad!)') : colors.green('(ok)')}\n`) + ${scope === 'repo' ? `- Repository name using --repo when scope is "repo" ${badRepo ? colors.red('(bad!)') : colors.green('(ok)')}` : ''}\n + ${badFlags ? `- The \`--json\` and \`--markdown\` flags can not be used at the same time ${badFlags ? colors.red('(bad!)') : colors.green('(ok)')}` : ''}\n + ${badFile ? `- The \`--file\` flag is only valid when using \`--json\` or \`--markdown\` ${badFile ? colors.red('(bad!)') : colors.green('(ok)')}` : ''}\n + ` + .trim() + .split('\n') + .filter(s => !!s.trim()) + .join('\n') + '\n' + ) return } @@ -105,19 +115,11 @@ async function run( return } - const apiToken = getDefaultToken() - if (!apiToken) { - throw new AuthError( - 'User must be authenticated to run this command. To log in, run the command `socket login` and enter your API token.' - ) - } - return await displayAnalytics({ - apiToken, scope, time, repo: String(repo || ''), - outputJson: Boolean(cli.flags['json']), - filePath: String(cli.flags['file'] || '') + outputKind: json ? 'json' : markdown ? 'markdown' : 'print', + filePath: String(file || '') }) } diff --git a/src/commands/analytics/display-analytics.ts b/src/commands/analytics/display-analytics.ts index 0a4df456a..92e5f690a 100644 --- a/src/commands/analytics/display-analytics.ts +++ b/src/commands/analytics/display-analytics.ts @@ -6,45 +6,32 @@ import contrib from 'blessed-contrib' import { logger } from '@socketsecurity/registry/lib/logger' +import { fetchOrgAnalyticsData } from './fetch-org-analytics' +import { fetchRepoAnalyticsData } from './fetch-repo-analytics' import constants from '../../constants' -import { handleApiCall, handleUnsuccessfulApiResponse } from '../../utils/api' -import { setupSdk } from '../../utils/sdk' +import { AuthError } from '../../utils/errors' +import { mdTableStringNumber } from '../../utils/markdown' +import { getDefaultToken } from '../../utils/sdk' -import type { Spinner } from '@socketsecurity/registry/lib/spinner' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' import type { Widgets } from 'blessed' // Note: Widgets does not seem to actually work as code :'( -type FormattedData = { - top_five_alert_types: { [key: string]: number } - total_critical_alerts: { [key: string]: number } - total_high_alerts: { [key: string]: number } - total_medium_alerts: { [key: string]: number } - total_low_alerts: { [key: string]: number } - total_critical_added: { [key: string]: number } - total_medium_added: { [key: string]: number } - total_low_added: { [key: string]: number } - total_high_added: { [key: string]: number } - total_critical_prevented: { [key: string]: number } - total_high_prevented: { [key: string]: number } - total_medium_prevented: { [key: string]: number } - total_low_prevented: { [key: string]: number } +interface FormattedData { + top_five_alert_types: Record + total_critical_alerts: Record + total_high_alerts: Record + total_medium_alerts: Record + total_low_alerts: Record + total_critical_added: Record + total_medium_added: Record + total_low_added: Record + total_high_added: Record + total_critical_prevented: Record + total_high_prevented: Record + total_medium_prevented: Record + total_low_prevented: Record } -// Note: This maps `new Date(date).getMonth()` to English three letters -export const Months = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec' -] as const - const METRICS = [ 'total_critical_alerts', 'total_high_alerts', @@ -60,10 +47,56 @@ const METRICS = [ 'total_low_prevented' ] as const +// Note: This maps `new Date(date).getMonth()` to English three letters +const Months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec' +] as const + export async function displayAnalytics({ + filePath, + outputKind, + repo, + scope, + time +}: { + scope: string + time: number + repo: string + outputKind: 'json' | 'markdown' | 'print' + filePath: string +}): Promise { + const apiToken = getDefaultToken() + if (!apiToken) { + throw new AuthError( + 'User must be authenticated to run this command. To log in, run the command `socket login` and enter your API token.' + ) + } + + await outputAnalyticsWithToken({ + apiToken, + filePath, + outputKind, + repo, + scope, + time + }) +} + +async function outputAnalyticsWithToken({ apiToken, filePath, - outputJson, + outputKind, repo, scope, time @@ -72,7 +105,7 @@ export async function displayAnalytics({ scope: string time: number repo: string - outputJson: boolean + outputKind: 'json' | 'markdown' | 'print' filePath: string }): Promise { // Lazily access constants.spinner. @@ -80,31 +113,129 @@ export async function displayAnalytics({ spinner.start('Fetching analytics data') - let data: undefined | { [key: string]: any }[] + let data: + | undefined + | SocketSdkReturnType<'getOrgAnalytics'>['data'] + | SocketSdkReturnType<'getRepoAnalytics'>['data'] if (scope === 'org') { data = await fetchOrgAnalyticsData(time, spinner, apiToken) } else if (repo) { data = await fetchRepoAnalyticsData(repo, time, spinner, apiToken) } - if (data) { - if (outputJson && !filePath) { - logger.log(data) - } else if (filePath) { + // A message should already have been printed if we have no data here + if (!data) return + + if (outputKind === 'json') { + let serialized = renderJson(data) + if (!serialized) return + + if (filePath && filePath !== '-') { try { - await fs.writeFile(filePath, JSON.stringify(data), 'utf8') + await fs.writeFile(filePath, serialized, 'utf8') logger.log(`Data successfully written to ${filePath}`) - } catch (e: any) { + } catch (e) { + logger.error('There was an error trying to write the json to disk') logger.error(e) + process.exitCode = 1 + } + } else { + logger.log(serialized) + } + } else { + const fdata = scope === 'org' ? formatDataOrg(data) : formatDataRepo(data) + + if (outputKind === 'markdown') { + const serialized = renderMarkdown(fdata, time, repo) + + if (filePath && filePath !== '-') { + try { + await fs.writeFile(filePath, serialized, 'utf8') + logger.log(`Data successfully written to ${filePath}`) + } catch (e) { + logger.error(e) + } + } else { + logger.log(serialized) } } else { - const fdata = - scope === 'org' ? formatData(data, 'org') : formatData(data, 'repo') displayAnalyticsScreen(fdata) } } } +function renderJson(data: unknown): string | undefined { + try { + return JSON.stringify(data, null, 2) + } catch (e) { + // This could be caused by circular references, which is an "us" problem + logger.error( + 'There was a problem converting the data set to JSON. Please try without --json or with --markdown' + ) + process.exitCode = 1 + return + } +} + +function renderMarkdown( + data: FormattedData, + days: number, + repoSlug: string +): string { + return ( + `# Socket Alert Analytics + +These are the Socket.dev stats are analytics for the ${repoSlug ? `${repoSlug} repo` : 'org'} of the past ${days} days + +` + + [ + [ + 'Total critical alerts', + mdTableStringNumber('Date', 'Counts', data['total_critical_alerts']) + ], + [ + 'Total high alerts', + mdTableStringNumber('Date', 'Counts', data['total_high_alerts']) + ], + [ + 'Total critical alerts added to the main branch', + mdTableStringNumber('Date', 'Counts', data['total_critical_added']) + ], + [ + 'Total high alerts added to the main branch', + mdTableStringNumber('Date', 'Counts', data['total_high_added']) + ], + [ + 'Total critical alerts prevented from the main branch', + mdTableStringNumber('Date', 'Counts', data['total_critical_prevented']) + ], + [ + 'Total high alerts prevented from the main branch', + mdTableStringNumber('Date', 'Counts', data['total_high_prevented']) + ], + [ + 'Total medium alerts prevented from the main branch', + mdTableStringNumber('Date', 'Counts', data['total_medium_prevented']) + ], + [ + 'Total low alerts prevented from the main branch', + mdTableStringNumber('Date', 'Counts', data['total_low_prevented']) + ] + ] + .map(([title, table]) => { + return ` +## ${title} + +${table} + `.trim() + }) + .join('\n\n') + + '\n\n## Top 5 alert types\n\n' + + mdTableStringNumber('Name', 'Counts', data['top_five_alert_types']) + + '\n' + ) +} + function displayAnalyticsScreen(data: FormattedData): void { const screen: Widgets.Screen = new ScreenWidget({}) const grid = new contrib.grid({ rows: 5, cols: 4, screen }) @@ -187,116 +318,86 @@ function displayAnalyticsScreen(data: FormattedData): void { screen.key(['escape', 'q', 'C-c'], () => process.exit(0)) } -async function fetchOrgAnalyticsData( - time: number, - spinner: Spinner, - apiToken: string -): Promise<{ [key: string]: any }[] | undefined> { - const socketSdk = await setupSdk(apiToken) - const result = await handleApiCall( - socketSdk.getOrgAnalytics(time.toString()), - 'fetching analytics data' - ) +function formatDataRepo( + data: SocketSdkReturnType<'getRepoAnalytics'>['data'] +): FormattedData { + const sortedTopFiveAlerts: Record = {} + const totalTopAlerts: Record = {} - if (result.success === false) { - handleUnsuccessfulApiResponse('getOrgAnalytics', result, spinner) - return undefined + const formattedData = {} as Omit + for (const metric of METRICS) { + formattedData[metric] = {} } - spinner.stop() - - if (!result.data.length) { - logger.log('No analytics data is available for this organization yet.') - return undefined + for (const entry of data) { + const topFiveAlertTypes = entry['top_five_alert_types'] + for (const type of Object.keys(topFiveAlertTypes)) { + const count = topFiveAlertTypes[type] ?? 0 + if (!totalTopAlerts[type]) { + totalTopAlerts[type] = count + } else if (count > (totalTopAlerts[type] ?? 0)) { + totalTopAlerts[type] = count + } + } } - - return result.data -} - -async function fetchRepoAnalyticsData( - repo: string, - time: number, - spinner: Spinner, - apiToken: string -): Promise<{ [key: string]: any }[] | undefined> { - const socketSdk = await setupSdk(apiToken) - const result = await handleApiCall( - socketSdk.getRepoAnalytics(repo, time.toString()), - 'fetching analytics data' - ) - - if (result.success === false) { - handleUnsuccessfulApiResponse('getRepoAnalytics', result, spinner) - return undefined + for (const entry of data) { + for (const metric of METRICS) { + formattedData[metric]![formatDate(entry['created_at'])] = entry[metric] + } } - spinner.stop() - - if (!result.data.length) { - logger.log('No analytics data is available for this organization yet.') - return undefined + const topFiveAlertEntries = Object.entries(totalTopAlerts) + .sort(([_keya, a], [_keyb, b]) => b - a) + .slice(0, 5) + for (const [key, value] of topFiveAlertEntries) { + sortedTopFiveAlerts[key] = value } - return result.data + return { + ...formattedData, + top_five_alert_types: sortedTopFiveAlerts + } } -function formatData( - data: { [key: string]: any }[], - scope: string +function formatDataOrg( + data: SocketSdkReturnType<'getOrgAnalytics'>['data'] ): FormattedData { - const formattedData = >{} - const sortedTopFiveAlerts: { [key: string]: number } = {} - const totalTopAlerts: { [key: string]: number } = {} + const sortedTopFiveAlerts: Record = {} + const totalTopAlerts: Record = {} + const formattedData = {} as Omit for (const metric of METRICS) { formattedData[metric] = {} } - if (scope === 'org') { - for (const entry of data) { - const topFiveAlertTypes = entry['top_five_alert_types'] - for (const type of Object.keys(topFiveAlertTypes)) { - const count = topFiveAlertTypes[type] ?? 0 - if (!totalTopAlerts[type]) { - totalTopAlerts[type] = count - } else { - totalTopAlerts[type] += count - } - } - } - for (const metric of METRICS) { - const formatted = formattedData[metric] - for (const entry of data) { - const date = formatDate(entry['created_at']) - if (!formatted[date]) { - formatted[date] = entry[metric]! - } else { - formatted[date] += entry[metric]! - } - } - } - } else if (scope === 'repo') { - for (const entry of data) { - const topFiveAlertTypes = entry['top_five_alert_types'] - for (const type of Object.keys(topFiveAlertTypes)) { - const count = topFiveAlertTypes[type] ?? 0 - if (!totalTopAlerts[type]) { - totalTopAlerts[type] = count - } else if (count > (totalTopAlerts[type] ?? 0)) { - totalTopAlerts[type] = count - } + + for (const entry of data) { + const topFiveAlertTypes = entry['top_five_alert_types'] + for (const type of Object.keys(topFiveAlertTypes)) { + const count = topFiveAlertTypes[type] ?? 0 + if (!totalTopAlerts[type]) { + totalTopAlerts[type] = count + } else { + totalTopAlerts[type] += count } } + } + + for (const metric of METRICS) { + const formatted = formattedData[metric] for (const entry of data) { - for (const metric of METRICS) { - formattedData[metric]![formatDate(entry['created_at'])] = entry[metric] + const date = formatDate(entry['created_at']) + if (!formatted[date]) { + formatted[date] = entry[metric]! + } else { + formatted[date] += entry[metric]! } } } const topFiveAlertEntries = Object.entries(totalTopAlerts) - .sort(({ 1: a }, { 1: b }) => b - a) + .sort(([_keya, a], [_keyb, b]) => b - a) .slice(0, 5) - for (const { 0: key, 1: value } of topFiveAlertEntries) { + for (const [key, value] of topFiveAlertEntries) { sortedTopFiveAlerts[key] = value } @@ -305,16 +406,17 @@ function formatData( top_five_alert_types: sortedTopFiveAlerts } } + function formatDate(date: string): string { return `${Months[new Date(date).getMonth()]} ${new Date(date).getDate()}` } function renderLineCharts( grid: contrib.grid, - screen: any, + screen: Widgets.Screen, title: string, - coords: number[], - data: { [key: string]: number } + coords: Array, + data: Record ): void { const line = grid.set(...coords, contrib.line, { style: { line: 'cyan', text: 'cyan', baseline: 'black' }, diff --git a/src/commands/analytics/fetch-org-analytics.ts b/src/commands/analytics/fetch-org-analytics.ts new file mode 100644 index 000000000..bfb286a6d --- /dev/null +++ b/src/commands/analytics/fetch-org-analytics.ts @@ -0,0 +1,33 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleApiCall, handleUnsuccessfulApiResponse } from '../../utils/api' +import { setupSdk } from '../../utils/sdk' + +import type { Spinner } from '@socketsecurity/registry/lib/spinner' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function fetchOrgAnalyticsData( + time: number, + spinner: Spinner, + apiToken: string +): Promise['data'] | undefined> { + const socketSdk = await setupSdk(apiToken) + const result = await handleApiCall( + socketSdk.getOrgAnalytics(time.toString()), + 'fetching analytics data' + ) + + if (result.success === false) { + handleUnsuccessfulApiResponse('getOrgAnalytics', result, spinner) + return undefined + } + + spinner.stop() + + if (!result.data.length) { + logger.log('No analytics data is available for this organization yet.') + return undefined + } + + return result.data +} diff --git a/src/commands/analytics/fetch-repo-analytics.ts b/src/commands/analytics/fetch-repo-analytics.ts new file mode 100644 index 000000000..1914cf7d6 --- /dev/null +++ b/src/commands/analytics/fetch-repo-analytics.ts @@ -0,0 +1,34 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleApiCall, handleUnsuccessfulApiResponse } from '../../utils/api' +import { setupSdk } from '../../utils/sdk' + +import type { Spinner } from '@socketsecurity/registry/lib/spinner' +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +export async function fetchRepoAnalyticsData( + repo: string, + time: number, + spinner: Spinner, + apiToken: string +): Promise['data'] | undefined> { + const socketSdk = await setupSdk(apiToken) + const result = await handleApiCall( + socketSdk.getRepoAnalytics(repo, time.toString()), + 'fetching analytics data' + ) + + if (result.success === false) { + handleUnsuccessfulApiResponse('getRepoAnalytics', result, spinner) + return undefined + } + + spinner.stop() + + if (!result.data.length) { + logger.log('No analytics data is available for this organization yet.') + return undefined + } + + return result.data +} diff --git a/src/utils/markdown.ts b/src/utils/markdown.ts new file mode 100644 index 000000000..4952c12d3 --- /dev/null +++ b/src/utils/markdown.ts @@ -0,0 +1,28 @@ +export function mdTableStringNumber( + title1: string, + title2: string, + obj: Record +): string { + // | Date | Counts | + // | ----------- | ------ | + // | Header | 201464 | + // | Paragraph | 18 | + let mw1 = title1.length + let mw2 = title2.length + for (const [key, value] of Object.entries(obj)) { + mw1 = Math.max(mw1, key.length) + mw2 = Math.max(mw2, String(value ?? '').length) + } + + const lines = [] + lines.push(`| ${title1.padEnd(mw1, ' ')} | ${title2.padEnd(mw2)} |`) + lines.push(`| ${'-'.repeat(mw1)} | ${'-'.repeat(mw2)} |`) + for (const [key, value] of Object.entries(obj)) { + lines.push( + `| ${key.padEnd(mw1, ' ')} | ${String(value ?? '').padStart(mw2, ' ')} |` + ) + } + lines.push(`| ${'-'.repeat(mw1)} | ${'-'.repeat(mw2)} |`) + + return lines.join('\n') +}