mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 22:04:56 +00:00
refactor: split workflow run analysis helpers
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
153
frontends/nextjs/src/lib/github/workflows/analysis/runs/stats.ts
Normal file
153
frontends/nextjs/src/lib/github/workflows/analysis/runs/stats.ts
Normal 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')
|
||||
}
|
||||
Reference in New Issue
Block a user