From 16eca8fbbe4962f85e640656de1e4fc52951450f Mon Sep 17 00:00:00 2001 From: David Sherret Date: Wed, 21 Jan 2026 16:22:21 -0500 Subject: [PATCH 1/2] Show bar chart with failures per day on insights page --- lib/github-api-client.ts | 4 ++ lib/render.tsx | 71 +++++++++++++++++++++++ routes/insights.test.tsx | 13 ++++- routes/insights.tsx | 118 ++++++++++++++++++++++++++++++++++++--- 4 files changed, 196 insertions(+), 10 deletions(-) diff --git a/lib/github-api-client.ts b/lib/github-api-client.ts index 50bb4ef..b2bf2d5 100644 --- a/lib/github-api-client.ts +++ b/lib/github-api-client.ts @@ -90,12 +90,16 @@ export class RealGitHubApiClient { async listWorkflowRuns( perPage = 30, page = 1, + branch?: string, ): Promise<{ runs: WorkflowRun[]; totalCount: number }> { const url = new URL( `https://api.github.com/repos/${OWNER}/${REPO}/actions/runs`, ); url.searchParams.set("per_page", perPage.toString()); url.searchParams.set("page", page.toString()); + if (branch) { + url.searchParams.set("branch", branch); + } const response = await this.#fileFetcher.get(url, this.#getHeaders()); diff --git a/lib/render.tsx b/lib/render.tsx index a4b2c93..dab7eea 100644 --- a/lib/render.tsx +++ b/lib/render.tsx @@ -16,6 +16,77 @@ export function formatDuration(ms: number): string { return `${minutes}m ${remainingSeconds.toFixed(1)}s`; } +export interface TestTimelineProps { + dateRange: string[]; + dailyCounts: { date: string; count: number }[]; + color: "red" | "yellow"; +} + +export function TestTimeline({ dateRange, dailyCounts, color }: TestTimelineProps) { + if (dateRange.length === 0) { + return null; + } + + // Convert dailyCounts array to a map for quick lookup + const countsMap = new Map(dailyCounts.map((d) => [d.date, d.count])); + + // Find the max count for scaling bar heights + const maxCount = Math.max(1, ...dailyCounts.map((d) => d.count)); + + // Color schemes + const colors = { + red: { fill: "#FEE2E2", stroke: "#EF4444", active: "#EF4444" }, + yellow: { fill: "#FEF3C7", stroke: "#F59E0B", active: "#F59E0B" }, + }; + const colorScheme = colors[color]; + + // Calculate the last occurrence date + const sortedDates = dailyCounts.map((d) => d.date).sort(); + const lastOccurrence = sortedDates[sortedDates.length - 1]; + const lastOccurrenceFormatted = lastOccurrence + ? new Date(lastOccurrence + "T00:00:00").toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }) + : null; + + return ( +
+
+
+ {dateRange.map((date) => { + const count = countsMap.get(date) || 0; + const height = count > 0 ? Math.max(4, (count / maxCount) * 16) : 2; + const isActive = count > 0; + + return ( +
+
+
+ ); + })} +
+ {lastOccurrenceFormatted && ( + + Last: {lastOccurrenceFormatted} + + )} +
+
+ ); +} + export function getStatusBadge(status: string, conclusion: string | null) { if (status !== "completed") { return ( diff --git a/routes/insights.test.tsx b/routes/insights.test.tsx index 871ccd8..cf87643 100644 --- a/routes/insights.test.tsx +++ b/routes/insights.test.tsx @@ -31,9 +31,18 @@ class MockGitHubApiClient listWorkflowRuns( _perPage?: number, - _page?: number, + page?: number, + branch?: string, ) { - return Promise.resolve(this.#runs); + let runs = this.#runs.runs; + if (branch) { + runs = runs.filter((r) => r.head_branch === branch); + } + // simulate pagination - page 2+ returns empty + if (page && page > 1) { + return Promise.resolve({ totalCount: runs.length, runs: [] }); + } + return Promise.resolve({ totalCount: runs.length, runs }); } listJobs(runId: number) { diff --git a/routes/insights.tsx b/routes/insights.tsx index c0098d1..7913452 100644 --- a/routes/insights.tsx +++ b/routes/insights.tsx @@ -5,7 +5,7 @@ import type { } from "@/lib/test-results-downloader.ts"; import type { GitHubApiClient, WorkflowRun } from "@/lib/github-api-client.ts"; import type { Logger } from "@/lib/logger.ts"; -import { formatDuration } from "@/lib/render.tsx"; +import { formatDuration, TestTimeline } from "@/lib/render.tsx"; export const handler = define.handlers({ GET(ctx) { @@ -29,14 +29,17 @@ export class InsightsPageController { } async get() { - // Fetch the last 100 runs (to ensure we get at least 20 completed main branch runs) - const { runs: allRuns } = await this.#githubClient.listWorkflowRuns(100, 1); - - // Filter to only completed CI runs on main branch + // fetch main branch runs directly + const [page1, page2] = await Promise.all([ + this.#githubClient.listWorkflowRuns(100, 1, "main"), + this.#githubClient.listWorkflowRuns(100, 2, "main"), + ]); + const allRuns = [...page1.runs, ...page2.runs]; + + // Filter to only completed CI runs const mainBranchRuns = allRuns .filter( (run: WorkflowRun) => - run.head_branch === "main" && run.status === "completed" && run.name.toLowerCase() === "ci", ) @@ -59,6 +62,12 @@ export class InsightsPageController { } }))).filter((r) => r != null); + // Build a map of runId to date for timeline lookups + const runIdToDate = new Map(); + allResults.forEach(({ runId, run }) => { + runIdToDate.set(runId, run.created_at.split("T")[0]); + }); + // Analyze flaky tests across all runs const flakyTestsMap = new Map< string, @@ -70,6 +79,7 @@ export class InsightsPageController { avgFlakyCount: number; runIds: number[]; jobCounts: Map; + dailyCounts: Map; // date -> flaky count } >(); @@ -81,6 +91,7 @@ export class InsightsPageController { path: string; failureCount: number; runIds: number[]; + dailyCounts: Map; // date -> failure count } >(); @@ -92,6 +103,8 @@ export class InsightsPageController { runId: number, jobName: string, ) { + const runDate = runIdToDate.get(runId)!; + // Track flaky tests if (test.flakyCount && test.flakyCount > 0) { const key = `${test.path}::${test.name}`; @@ -109,6 +122,10 @@ export class InsightsPageController { jobName, (existing.jobCounts.get(jobName) || 0) + test.flakyCount, ); + existing.dailyCounts.set( + runDate, + (existing.dailyCounts.get(runDate) || 0) + test.flakyCount, + ); } else { flakyTestsMap.set(key, { name: test.name, @@ -118,6 +135,7 @@ export class InsightsPageController { avgFlakyCount: test.flakyCount, runIds: [runId], jobCounts: new Map([[jobName, test.flakyCount]]), + dailyCounts: new Map([[runDate, test.flakyCount]]), }); } @@ -138,12 +156,17 @@ export class InsightsPageController { if (!existing.runIds.includes(runId)) { existing.runIds.push(runId); } + existing.dailyCounts.set( + runDate, + (existing.dailyCounts.get(runDate) || 0) + 1, + ); } else { failedTestsMap.set(key, { name: test.name, path: test.path, failureCount: 1, runIds: [runId], + dailyCounts: new Map([[runDate, 1]]), }); } } @@ -177,11 +200,48 @@ export class InsightsPageController { } >(); - allResults.forEach(({ runId, results, jobs }) => { + // Track daily statistics for the chart + const dailyStatsMap = new Map< + string, + { + date: string; + failureCount: number; + flakyCount: number; + runCount: number; + } + >(); + + allResults.forEach(({ runId, run, results, jobs }) => { + // Aggregate daily stats + const dateKey = run.created_at.split("T")[0]; + let dayStats = dailyStatsMap.get(dateKey); + if (!dayStats) { + dayStats = { + date: dateKey, + failureCount: 0, + flakyCount: 0, + runCount: 0, + }; + dailyStatsMap.set(dateKey, dayStats); + } + dayStats.runCount++; + + // Count failures and flakes for this run + const countTestStats = (tests: RecordedTestResult[]) => { + tests.forEach((test) => { + if (test.failed) dayStats!.failureCount++; + if (test.flakyCount && test.flakyCount > 0) { + dayStats!.flakyCount += test.flakyCount; + } + if (test.subTests) countTestStats(test.subTests); + }); + }; + results.forEach((jobResult) => { jobResult.tests.forEach((test) => processTest(test, runId, jobResult.name) ); + countTestStats(jobResult.tests); }); // Process job timing data @@ -259,12 +319,26 @@ export class InsightsPageController { name, count, })), + dailyCounts: Array.from(test.dailyCounts.entries()).map(( + [date, count], + ) => ({ + date, + count, + })), })).sort( (a, b) => b.totalFlakyCounts - a.totalFlakyCounts, ); // Convert to array and sort by failure count - const failedTests = Array.from(failedTestsMap.values()).sort( + const failedTests = Array.from(failedTestsMap.values()).map((test) => ({ + ...test, + dailyCounts: Array.from(test.dailyCounts.entries()).map(( + [date, count], + ) => ({ + date, + count, + })), + })).sort( (a, b) => b.failureCount - a.failureCount, ); @@ -295,6 +369,22 @@ export class InsightsPageController { })) .sort((a, b) => b.avgDuration - a.avgDuration); + // Get the date range from oldest run to today + const allDates = Array.from(runIdToDate.values()).sort(); + const oldestDate = allDates[0]; + const today = new Date().toISOString().split("T")[0]; + + // Generate all dates from oldest to today + const dateRange: string[] = []; + if (oldestDate) { + const current = new Date(oldestDate + "T00:00:00"); + const end = new Date(today + "T00:00:00"); + while (current <= end) { + dateRange.push(current.toISOString().split("T")[0]); + current.setDate(current.getDate() + 1); + } + } + return { data: { flakyTests, @@ -302,6 +392,7 @@ export class InsightsPageController { flakyJobs, jobPerformance, stepPerformance, + dateRange, totalRunsAnalyzed: mainBranchRuns.length, oldestRun: mainBranchRuns[mainBranchRuns.length - 1], newestRun: mainBranchRuns[0], @@ -317,6 +408,7 @@ export default define.page(function InsightsPage({ data }) { flakyJobs, jobPerformance, stepPerformance, + dateRange, totalRunsAnalyzed, oldestRun, newestRun, @@ -394,6 +486,11 @@ export default define.page(function InsightsPage({ data }) {
+
@@ -479,6 +576,11 @@ export default define.page(function InsightsPage({ data }) { ))}
)} +
From 054447a3fb08a21294b2d9bc91b6fdb4b643d218 Mon Sep 17 00:00:00 2001 From: David Sherret Date: Wed, 21 Jan 2026 16:22:56 -0500 Subject: [PATCH 2/2] update --- lib/render.tsx | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/lib/render.tsx b/lib/render.tsx index dab7eea..8fa40f4 100644 --- a/lib/render.tsx +++ b/lib/render.tsx @@ -22,7 +22,9 @@ export interface TestTimelineProps { color: "red" | "yellow"; } -export function TestTimeline({ dateRange, dailyCounts, color }: TestTimelineProps) { +export function TestTimeline( + { dateRange, dailyCounts, color }: TestTimelineProps, +) { if (dateRange.length === 0) { return null; } @@ -45,15 +47,18 @@ export function TestTimeline({ dateRange, dailyCounts, color }: TestTimelineProp const lastOccurrence = sortedDates[sortedDates.length - 1]; const lastOccurrenceFormatted = lastOccurrence ? new Date(lastOccurrence + "T00:00:00").toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }) + month: "short", + day: "numeric", + }) : null; return (
-
+
{dateRange.map((date) => { const count = countsMap.get(date) || 0; const height = count > 0 ? Math.max(4, (count / maxCount) * 16) : 2; @@ -64,7 +69,14 @@ export function TestTimeline({ dateRange, dailyCounts, color }: TestTimelineProp key={date} class="flex-1" style={{ minWidth: "2px", maxWidth: "8px" }} - title={`${new Date(date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" })}: ${count} ${color === "red" ? "failure" : "flake"}${count !== 1 ? "s" : ""}`} + title={`${ + new Date(date + "T00:00:00").toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }) + }: ${count} ${color === "red" ? "failure" : "flake"}${ + count !== 1 ? "s" : "" + }`} >