diff --git a/frontends/nextjs/src/lib/github/workflows/analysis/runs/analyze-workflow-runs.test.ts b/frontends/nextjs/src/lib/github/workflows/analysis/runs/analyze-workflow-runs.test.ts index ce3d5d2a1..ce1897add 100644 --- a/frontends/nextjs/src/lib/github/workflows/analysis/runs/analyze-workflow-runs.test.ts +++ b/frontends/nextjs/src/lib/github/workflows/analysis/runs/analyze-workflow-runs.test.ts @@ -1,5 +1,52 @@ import { describe, it, expect } from 'vitest' -import { summarizeWorkflowRuns } from './analyze-workflow-runs' +import { + analyzeWorkflowRuns, + parseWorkflowRuns, + summarizeWorkflowRuns, +} from './analyze-workflow-runs' + +describe('parseWorkflowRuns', () => { + it('normalizes unknown entries and ignores items without numeric IDs', () => { + const runs = [ + { + id: 1, + name: 'Build', + status: 'completed', + conclusion: 'success', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:10:00Z', + head_branch: 'main', + event: 'push', + }, + { id: 'not-a-number' }, + { + id: 2, + name: '', + status: '', + conclusion: 'failure', + created_at: '', + updated_at: '', + head_branch: '', + event: '', + }, + ] + + const parsed = parseWorkflowRuns(runs) + + expect(parsed).toHaveLength(2) + expect(parsed[0].name).toBe('Build') + expect(parsed[1]).toEqual({ + id: 2, + name: 'Unknown workflow', + status: 'unknown', + conclusion: 'failure', + created_at: '', + updated_at: '', + head_branch: 'unknown', + event: 'unknown', + }) + }) +}) describe('summarizeWorkflowRuns', () => { it('summarizes totals, success rate, and failure hotspots', () => { @@ -60,3 +107,24 @@ describe('summarizeWorkflowRuns', () => { expect(summary.mostRecent).toBeNull() }) }) + +describe('analyzeWorkflowRuns', () => { + it('returns parsed summary and formatted output', () => { + const result = analyzeWorkflowRuns([ + { + id: 7, + name: 'Deploy', + status: 'completed', + conclusion: 'success', + created_at: '2024-02-01T00:00:00Z', + updated_at: '2024-02-01T00:05:00Z', + head_branch: 'main', + event: 'workflow_dispatch', + }, + ]) + + expect(result.summary.total).toBe(1) + expect(result.formatted).toContain('Workflow Run Analysis') + expect(result.formatted).toContain('Deploy') + }) +}) diff --git a/frontends/nextjs/src/lib/github/workflows/analysis/runs/analyze-workflow-runs.ts b/frontends/nextjs/src/lib/github/workflows/analysis/runs/analyze-workflow-runs.ts index 0c9de1453..2f0049237 100644 --- a/frontends/nextjs/src/lib/github/workflows/analysis/runs/analyze-workflow-runs.ts +++ b/frontends/nextjs/src/lib/github/workflows/analysis/runs/analyze-workflow-runs.ts @@ -1,164 +1,18 @@ -export type WorkflowRunLike = { - id: number - name: string - status: string - conclusion: string | null - created_at: string - updated_at: string - head_branch: string - event: string -} +import { parseWorkflowRuns, WorkflowRunLike } from './parser' +import { formatWorkflowRunAnalysis, summarizeWorkflowRuns, WorkflowRunSummary } from './stats' -export type WorkflowRunSummary = { - total: number - completed: number - successful: number - failed: number - cancelled: number - inProgress: number - successRate: number - mostRecent: WorkflowRunLike | null - recentRuns: WorkflowRunLike[] - topFailingWorkflows: Array<{ name: string; failures: number }> - failingBranches: Array<{ branch: string; failures: number }> - failingEvents: Array<{ event: string; failures: number }> -} +export type { WorkflowRunLike, WorkflowRunSummary } +export { parseWorkflowRuns, summarizeWorkflowRuns, formatWorkflowRunAnalysis } -const DEFAULT_RECENT_COUNT = 5 -const DEFAULT_TOP_COUNT = 3 - -function toTopCounts( - values: string[], - topCount: number -): Array<{ key: string; count: number }> { - const counts = new Map() - values.forEach((value) => { - counts.set(value, (counts.get(value) || 0) + 1) - }) - - return Array.from(counts.entries()) - .map(([key, count]) => ({ key, count })) - .sort((a, b) => b.count - a.count || a.key.localeCompare(b.key)) - .slice(0, topCount) -} - -export function summarizeWorkflowRuns( - runs: WorkflowRunLike[], +export function analyzeWorkflowRuns( + runs: unknown[], options?: { recentCount?: number; topCount?: number } -): WorkflowRunSummary { - const recentCount = options?.recentCount ?? DEFAULT_RECENT_COUNT - const topCount = options?.topCount ?? DEFAULT_TOP_COUNT - const total = runs.length - - const completedRuns = runs.filter((run) => run.status === 'completed') - const successful = completedRuns.filter((run) => run.conclusion === 'success').length - const failed = completedRuns.filter((run) => run.conclusion === 'failure').length - const cancelled = completedRuns.filter((run) => run.conclusion === 'cancelled').length - const inProgress = total - completedRuns.length - const successRate = completedRuns.length - ? Math.round((successful / completedRuns.length) * 100) - : 0 - - const sortedByUpdated = [...runs].sort( - (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() - ) - const mostRecent = sortedByUpdated[0] ?? null - const recentRuns = sortedByUpdated.slice(0, recentCount) - - const failureRuns = completedRuns.filter((run) => run.conclusion === 'failure') - const topFailingWorkflows = toTopCounts( - failureRuns.map((run) => run.name), - topCount - ).map((entry) => ({ name: entry.key, failures: entry.count })) - - const failingBranches = toTopCounts( - failureRuns.map((run) => run.head_branch), - topCount - ).map((entry) => ({ branch: entry.key, failures: entry.count })) - - const failingEvents = toTopCounts( - failureRuns.map((run) => run.event), - topCount - ).map((entry) => ({ event: entry.key, failures: entry.count })) +) { + const parsedRuns = parseWorkflowRuns(runs) + const summary = summarizeWorkflowRuns(parsedRuns, options) return { - total, - completed: completedRuns.length, - successful, - failed, - cancelled, - inProgress, - successRate, - mostRecent, - recentRuns, - topFailingWorkflows, - failingBranches, - failingEvents, + summary, + formatted: formatWorkflowRunAnalysis(summary), } } - -export function formatWorkflowRunAnalysis(summary: WorkflowRunSummary) { - const lines: string[] = [] - - lines.push('Workflow Run Analysis') - lines.push('---------------------') - lines.push(`Total runs: ${summary.total}`) - lines.push( - `Completed: ${summary.completed} (success: ${summary.successful}, failed: ${summary.failed}, cancelled: ${summary.cancelled})` - ) - lines.push(`In progress: ${summary.inProgress}`) - lines.push(`Success rate: ${summary.successRate}%`) - - if (summary.mostRecent) { - lines.push('') - lines.push('Most recent run:') - lines.push( - `- ${summary.mostRecent.name} | ${summary.mostRecent.status}${ - summary.mostRecent.conclusion ? `/${summary.mostRecent.conclusion}` : '' - } | ${summary.mostRecent.head_branch} | ${summary.mostRecent.updated_at}` - ) - } - - if (summary.recentRuns.length > 0) { - lines.push('') - lines.push('Recent runs:') - summary.recentRuns.forEach((run) => { - lines.push( - `- ${run.name} | ${run.status}${ - run.conclusion ? `/${run.conclusion}` : '' - } | ${run.head_branch} | ${run.updated_at}` - ) - }) - } - - if (summary.topFailingWorkflows.length > 0) { - lines.push('') - lines.push('Top failing workflows:') - summary.topFailingWorkflows.forEach((entry) => { - lines.push(`- ${entry.name}: ${entry.failures}`) - }) - } - - if (summary.failingBranches.length > 0) { - lines.push('') - lines.push('Failing branches:') - summary.failingBranches.forEach((entry) => { - lines.push(`- ${entry.branch}: ${entry.failures}`) - }) - } - - if (summary.failingEvents.length > 0) { - lines.push('') - lines.push('Failing events:') - summary.failingEvents.forEach((entry) => { - lines.push(`- ${entry.event}: ${entry.failures}`) - }) - } - - if (summary.total === 0) { - lines.push('') - lines.push('No workflow runs available to analyze.') - } - - return lines.join('\n') -} diff --git a/frontends/nextjs/src/lib/github/workflows/analysis/runs/parser.ts b/frontends/nextjs/src/lib/github/workflows/analysis/runs/parser.ts new file mode 100644 index 000000000..25570391d --- /dev/null +++ b/frontends/nextjs/src/lib/github/workflows/analysis/runs/parser.ts @@ -0,0 +1,50 @@ +export type WorkflowRunLike = { + id: number + name: string + status: string + conclusion: string | null + created_at: string + updated_at: string + head_branch: string + event: string +} + +const FALLBACK_NAME = 'Unknown workflow' +const FALLBACK_STATUS = 'unknown' +const FALLBACK_BRANCH = 'unknown' +const FALLBACK_EVENT = 'unknown' + +function toStringOrFallback(value: unknown, fallback: string) { + return typeof value === 'string' && value.trim() ? value : fallback +} + +export function parseWorkflowRuns(runs: unknown[]): WorkflowRunLike[] { + if (!Array.isArray(runs)) { + return [] + } + + return runs + .map((run) => { + const candidate = run as Partial & { id?: unknown } + const id = Number(candidate.id) + + if (!Number.isFinite(id)) { + return null + } + + return { + id, + name: toStringOrFallback(candidate.name, FALLBACK_NAME), + status: toStringOrFallback(candidate.status, FALLBACK_STATUS), + conclusion: + candidate.conclusion === null || typeof candidate.conclusion === 'string' + ? candidate.conclusion + : null, + created_at: toStringOrFallback(candidate.created_at, ''), + updated_at: toStringOrFallback(candidate.updated_at, ''), + head_branch: toStringOrFallback(candidate.head_branch, FALLBACK_BRANCH), + event: toStringOrFallback(candidate.event, FALLBACK_EVENT), + } + }) + .filter((run): run is WorkflowRunLike => Boolean(run)) +} diff --git a/frontends/nextjs/src/lib/github/workflows/analysis/runs/stats.ts b/frontends/nextjs/src/lib/github/workflows/analysis/runs/stats.ts new file mode 100644 index 000000000..138771e60 --- /dev/null +++ b/frontends/nextjs/src/lib/github/workflows/analysis/runs/stats.ts @@ -0,0 +1,153 @@ +import { WorkflowRunLike } from './parser' + +export type WorkflowRunSummary = { + total: number + completed: number + successful: number + failed: number + cancelled: number + inProgress: number + successRate: number + mostRecent: WorkflowRunLike | null + recentRuns: WorkflowRunLike[] + topFailingWorkflows: Array<{ name: string; failures: number }> + failingBranches: Array<{ branch: string; failures: number }> + failingEvents: Array<{ event: string; failures: number }> +} + +const DEFAULT_RECENT_COUNT = 5 +const DEFAULT_TOP_COUNT = 3 + +function toTopCounts( + values: string[], + topCount: number +): Array<{ key: string; count: number }> { + const counts = new Map() + values.forEach((value) => { + counts.set(value, (counts.get(value) || 0) + 1) + }) + + return Array.from(counts.entries()) + .map(([key, count]) => ({ key, count })) + .sort((a, b) => b.count - a.count || a.key.localeCompare(b.key)) + .slice(0, topCount) +} + +export function summarizeWorkflowRuns( + runs: WorkflowRunLike[], + options?: { recentCount?: number; topCount?: number } +): WorkflowRunSummary { + const recentCount = options?.recentCount ?? DEFAULT_RECENT_COUNT + const topCount = options?.topCount ?? DEFAULT_TOP_COUNT + const total = runs.length + + const completedRuns = runs.filter((run) => run.status === 'completed') + const successful = completedRuns.filter((run) => run.conclusion === 'success').length + const failed = completedRuns.filter((run) => run.conclusion === 'failure').length + const cancelled = completedRuns.filter((run) => run.conclusion === 'cancelled').length + const inProgress = total - completedRuns.length + const successRate = completedRuns.length + ? Math.round((successful / completedRuns.length) * 100) + : 0 + + const sortedByUpdated = [...runs].sort( + (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() + ) + const mostRecent = sortedByUpdated[0] ?? null + const recentRuns = sortedByUpdated.slice(0, recentCount) + + const failureRuns = completedRuns.filter((run) => run.conclusion === 'failure') + const topFailingWorkflows = toTopCounts( + failureRuns.map((run) => run.name), + topCount + ).map((entry) => ({ name: entry.key, failures: entry.count })) + + const failingBranches = toTopCounts( + failureRuns.map((run) => run.head_branch), + topCount + ).map((entry) => ({ branch: entry.key, failures: entry.count })) + + const failingEvents = toTopCounts( + failureRuns.map((run) => run.event), + topCount + ).map((entry) => ({ event: entry.key, failures: entry.count })) + + return { + total, + completed: completedRuns.length, + successful, + failed, + cancelled, + inProgress, + successRate, + mostRecent, + recentRuns, + topFailingWorkflows, + failingBranches, + failingEvents, + } +} + +export function formatWorkflowRunAnalysis(summary: WorkflowRunSummary) { + const lines: string[] = [] + + lines.push('Workflow Run Analysis') + lines.push('---------------------') + lines.push(`Total runs: ${summary.total}`) + lines.push( + `Completed: ${summary.completed} (success: ${summary.successful}, failed: ${summary.failed}, cancelled: ${summary.cancelled})` + ) + lines.push(`In progress: ${summary.inProgress}`) + lines.push(`Success rate: ${summary.successRate}%`) + + if (summary.mostRecent) { + lines.push('') + lines.push('Most recent run:') + lines.push( + `- ${summary.mostRecent.name} | ${summary.mostRecent.status}${ + summary.mostRecent.conclusion ? `/${summary.mostRecent.conclusion}` : '' + } | ${summary.mostRecent.head_branch} | ${summary.mostRecent.updated_at}` + ) + } + + if (summary.recentRuns.length > 0) { + lines.push('') + lines.push('Recent runs:') + summary.recentRuns.forEach((run) => { + lines.push( + `- ${run.name} | ${run.status}${run.conclusion ? `/${run.conclusion}` : ''} | ${run.head_branch} | ${run.updated_at}` + ) + }) + } + + if (summary.topFailingWorkflows.length > 0) { + lines.push('') + lines.push('Top failing workflows:') + summary.topFailingWorkflows.forEach((entry) => { + lines.push(`- ${entry.name}: ${entry.failures}`) + }) + } + + if (summary.failingBranches.length > 0) { + lines.push('') + lines.push('Failing branches:') + summary.failingBranches.forEach((entry) => { + lines.push(`- ${entry.branch}: ${entry.failures}`) + }) + } + + if (summary.failingEvents.length > 0) { + lines.push('') + lines.push('Failing events:') + summary.failingEvents.forEach((entry) => { + lines.push(`- ${entry.event}: ${entry.failures}`) + }) + } + + if (summary.total === 0) { + lines.push('') + lines.push('No workflow runs available to analyze.') + } + + return lines.join('\n') +}