refactor: split workflow run analysis helpers

This commit is contained in:
2025-12-27 23:35:08 +00:00
parent 99d4411a41
commit b1d81875fc
4 changed files with 283 additions and 158 deletions

View File

@@ -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')
})
})

View File

@@ -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<string, number>()
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')
}

View File

@@ -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<WorkflowRunLike> & { 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))
}

View File

@@ -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<string, number>()
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')
}