Skip to content

Commit 16eca8f

Browse files
committed
Show bar chart with failures per day on insights page
1 parent 026ddb7 commit 16eca8f

File tree

4 files changed

+196
-10
lines changed

4 files changed

+196
-10
lines changed

lib/github-api-client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,16 @@ export class RealGitHubApiClient {
9090
async listWorkflowRuns(
9191
perPage = 30,
9292
page = 1,
93+
branch?: string,
9394
): Promise<{ runs: WorkflowRun[]; totalCount: number }> {
9495
const url = new URL(
9596
`https://api.github.com/repos/${OWNER}/${REPO}/actions/runs`,
9697
);
9798
url.searchParams.set("per_page", perPage.toString());
9899
url.searchParams.set("page", page.toString());
100+
if (branch) {
101+
url.searchParams.set("branch", branch);
102+
}
99103

100104
const response = await this.#fileFetcher.get(url, this.#getHeaders());
101105

lib/render.tsx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,77 @@ export function formatDuration(ms: number): string {
1616
return `${minutes}m ${remainingSeconds.toFixed(1)}s`;
1717
}
1818

19+
export interface TestTimelineProps {
20+
dateRange: string[];
21+
dailyCounts: { date: string; count: number }[];
22+
color: "red" | "yellow";
23+
}
24+
25+
export function TestTimeline({ dateRange, dailyCounts, color }: TestTimelineProps) {
26+
if (dateRange.length === 0) {
27+
return null;
28+
}
29+
30+
// Convert dailyCounts array to a map for quick lookup
31+
const countsMap = new Map(dailyCounts.map((d) => [d.date, d.count]));
32+
33+
// Find the max count for scaling bar heights
34+
const maxCount = Math.max(1, ...dailyCounts.map((d) => d.count));
35+
36+
// Color schemes
37+
const colors = {
38+
red: { fill: "#FEE2E2", stroke: "#EF4444", active: "#EF4444" },
39+
yellow: { fill: "#FEF3C7", stroke: "#F59E0B", active: "#F59E0B" },
40+
};
41+
const colorScheme = colors[color];
42+
43+
// Calculate the last occurrence date
44+
const sortedDates = dailyCounts.map((d) => d.date).sort();
45+
const lastOccurrence = sortedDates[sortedDates.length - 1];
46+
const lastOccurrenceFormatted = lastOccurrence
47+
? new Date(lastOccurrence + "T00:00:00").toLocaleDateString("en-US", {
48+
month: "short",
49+
day: "numeric",
50+
})
51+
: null;
52+
53+
return (
54+
<div class="mt-2">
55+
<div class="flex items-center gap-2">
56+
<div class="flex items-end gap-px flex-1" style={{ minWidth: 0, height: "16px" }}>
57+
{dateRange.map((date) => {
58+
const count = countsMap.get(date) || 0;
59+
const height = count > 0 ? Math.max(4, (count / maxCount) * 16) : 2;
60+
const isActive = count > 0;
61+
62+
return (
63+
<div
64+
key={date}
65+
class="flex-1"
66+
style={{ minWidth: "2px", maxWidth: "8px" }}
67+
title={`${new Date(date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" })}: ${count} ${color === "red" ? "failure" : "flake"}${count !== 1 ? "s" : ""}`}
68+
>
69+
<div
70+
style={{
71+
height: `${height}px`,
72+
backgroundColor: isActive ? colorScheme.active : "#E5E7EB",
73+
borderRadius: "1px",
74+
}}
75+
/>
76+
</div>
77+
);
78+
})}
79+
</div>
80+
{lastOccurrenceFormatted && (
81+
<span class="text-xs text-gray-500 whitespace-nowrap">
82+
Last: {lastOccurrenceFormatted}
83+
</span>
84+
)}
85+
</div>
86+
</div>
87+
);
88+
}
89+
1990
export function getStatusBadge(status: string, conclusion: string | null) {
2091
if (status !== "completed") {
2192
return (

routes/insights.test.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,18 @@ class MockGitHubApiClient
3131

3232
listWorkflowRuns(
3333
_perPage?: number,
34-
_page?: number,
34+
page?: number,
35+
branch?: string,
3536
) {
36-
return Promise.resolve(this.#runs);
37+
let runs = this.#runs.runs;
38+
if (branch) {
39+
runs = runs.filter((r) => r.head_branch === branch);
40+
}
41+
// simulate pagination - page 2+ returns empty
42+
if (page && page > 1) {
43+
return Promise.resolve({ totalCount: runs.length, runs: [] });
44+
}
45+
return Promise.resolve({ totalCount: runs.length, runs });
3746
}
3847

3948
listJobs(runId: number) {

routes/insights.tsx

Lines changed: 110 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type {
55
} from "@/lib/test-results-downloader.ts";
66
import type { GitHubApiClient, WorkflowRun } from "@/lib/github-api-client.ts";
77
import type { Logger } from "@/lib/logger.ts";
8-
import { formatDuration } from "@/lib/render.tsx";
8+
import { formatDuration, TestTimeline } from "@/lib/render.tsx";
99

1010
export const handler = define.handlers({
1111
GET(ctx) {
@@ -29,14 +29,17 @@ export class InsightsPageController {
2929
}
3030

3131
async get() {
32-
// Fetch the last 100 runs (to ensure we get at least 20 completed main branch runs)
33-
const { runs: allRuns } = await this.#githubClient.listWorkflowRuns(100, 1);
34-
35-
// Filter to only completed CI runs on main branch
32+
// fetch main branch runs directly
33+
const [page1, page2] = await Promise.all([
34+
this.#githubClient.listWorkflowRuns(100, 1, "main"),
35+
this.#githubClient.listWorkflowRuns(100, 2, "main"),
36+
]);
37+
const allRuns = [...page1.runs, ...page2.runs];
38+
39+
// Filter to only completed CI runs
3640
const mainBranchRuns = allRuns
3741
.filter(
3842
(run: WorkflowRun) =>
39-
run.head_branch === "main" &&
4043
run.status === "completed" &&
4144
run.name.toLowerCase() === "ci",
4245
)
@@ -59,6 +62,12 @@ export class InsightsPageController {
5962
}
6063
}))).filter((r) => r != null);
6164

65+
// Build a map of runId to date for timeline lookups
66+
const runIdToDate = new Map<number, string>();
67+
allResults.forEach(({ runId, run }) => {
68+
runIdToDate.set(runId, run.created_at.split("T")[0]);
69+
});
70+
6271
// Analyze flaky tests across all runs
6372
const flakyTestsMap = new Map<
6473
string,
@@ -70,6 +79,7 @@ export class InsightsPageController {
7079
avgFlakyCount: number;
7180
runIds: number[];
7281
jobCounts: Map<string, number>;
82+
dailyCounts: Map<string, number>; // date -> flaky count
7383
}
7484
>();
7585

@@ -81,6 +91,7 @@ export class InsightsPageController {
8191
path: string;
8292
failureCount: number;
8393
runIds: number[];
94+
dailyCounts: Map<string, number>; // date -> failure count
8495
}
8596
>();
8697

@@ -92,6 +103,8 @@ export class InsightsPageController {
92103
runId: number,
93104
jobName: string,
94105
) {
106+
const runDate = runIdToDate.get(runId)!;
107+
95108
// Track flaky tests
96109
if (test.flakyCount && test.flakyCount > 0) {
97110
const key = `${test.path}::${test.name}`;
@@ -109,6 +122,10 @@ export class InsightsPageController {
109122
jobName,
110123
(existing.jobCounts.get(jobName) || 0) + test.flakyCount,
111124
);
125+
existing.dailyCounts.set(
126+
runDate,
127+
(existing.dailyCounts.get(runDate) || 0) + test.flakyCount,
128+
);
112129
} else {
113130
flakyTestsMap.set(key, {
114131
name: test.name,
@@ -118,6 +135,7 @@ export class InsightsPageController {
118135
avgFlakyCount: test.flakyCount,
119136
runIds: [runId],
120137
jobCounts: new Map([[jobName, test.flakyCount]]),
138+
dailyCounts: new Map([[runDate, test.flakyCount]]),
121139
});
122140
}
123141

@@ -138,12 +156,17 @@ export class InsightsPageController {
138156
if (!existing.runIds.includes(runId)) {
139157
existing.runIds.push(runId);
140158
}
159+
existing.dailyCounts.set(
160+
runDate,
161+
(existing.dailyCounts.get(runDate) || 0) + 1,
162+
);
141163
} else {
142164
failedTestsMap.set(key, {
143165
name: test.name,
144166
path: test.path,
145167
failureCount: 1,
146168
runIds: [runId],
169+
dailyCounts: new Map([[runDate, 1]]),
147170
});
148171
}
149172
}
@@ -177,11 +200,48 @@ export class InsightsPageController {
177200
}
178201
>();
179202

180-
allResults.forEach(({ runId, results, jobs }) => {
203+
// Track daily statistics for the chart
204+
const dailyStatsMap = new Map<
205+
string,
206+
{
207+
date: string;
208+
failureCount: number;
209+
flakyCount: number;
210+
runCount: number;
211+
}
212+
>();
213+
214+
allResults.forEach(({ runId, run, results, jobs }) => {
215+
// Aggregate daily stats
216+
const dateKey = run.created_at.split("T")[0];
217+
let dayStats = dailyStatsMap.get(dateKey);
218+
if (!dayStats) {
219+
dayStats = {
220+
date: dateKey,
221+
failureCount: 0,
222+
flakyCount: 0,
223+
runCount: 0,
224+
};
225+
dailyStatsMap.set(dateKey, dayStats);
226+
}
227+
dayStats.runCount++;
228+
229+
// Count failures and flakes for this run
230+
const countTestStats = (tests: RecordedTestResult[]) => {
231+
tests.forEach((test) => {
232+
if (test.failed) dayStats!.failureCount++;
233+
if (test.flakyCount && test.flakyCount > 0) {
234+
dayStats!.flakyCount += test.flakyCount;
235+
}
236+
if (test.subTests) countTestStats(test.subTests);
237+
});
238+
};
239+
181240
results.forEach((jobResult) => {
182241
jobResult.tests.forEach((test) =>
183242
processTest(test, runId, jobResult.name)
184243
);
244+
countTestStats(jobResult.tests);
185245
});
186246

187247
// Process job timing data
@@ -259,12 +319,26 @@ export class InsightsPageController {
259319
name,
260320
count,
261321
})),
322+
dailyCounts: Array.from(test.dailyCounts.entries()).map((
323+
[date, count],
324+
) => ({
325+
date,
326+
count,
327+
})),
262328
})).sort(
263329
(a, b) => b.totalFlakyCounts - a.totalFlakyCounts,
264330
);
265331

266332
// Convert to array and sort by failure count
267-
const failedTests = Array.from(failedTestsMap.values()).sort(
333+
const failedTests = Array.from(failedTestsMap.values()).map((test) => ({
334+
...test,
335+
dailyCounts: Array.from(test.dailyCounts.entries()).map((
336+
[date, count],
337+
) => ({
338+
date,
339+
count,
340+
})),
341+
})).sort(
268342
(a, b) => b.failureCount - a.failureCount,
269343
);
270344

@@ -295,13 +369,30 @@ export class InsightsPageController {
295369
}))
296370
.sort((a, b) => b.avgDuration - a.avgDuration);
297371

372+
// Get the date range from oldest run to today
373+
const allDates = Array.from(runIdToDate.values()).sort();
374+
const oldestDate = allDates[0];
375+
const today = new Date().toISOString().split("T")[0];
376+
377+
// Generate all dates from oldest to today
378+
const dateRange: string[] = [];
379+
if (oldestDate) {
380+
const current = new Date(oldestDate + "T00:00:00");
381+
const end = new Date(today + "T00:00:00");
382+
while (current <= end) {
383+
dateRange.push(current.toISOString().split("T")[0]);
384+
current.setDate(current.getDate() + 1);
385+
}
386+
}
387+
298388
return {
299389
data: {
300390
flakyTests,
301391
failedTests,
302392
flakyJobs,
303393
jobPerformance,
304394
stepPerformance,
395+
dateRange,
305396
totalRunsAnalyzed: mainBranchRuns.length,
306397
oldestRun: mainBranchRuns[mainBranchRuns.length - 1],
307398
newestRun: mainBranchRuns[0],
@@ -317,6 +408,7 @@ export default define.page<typeof handler>(function InsightsPage({ data }) {
317408
flakyJobs,
318409
jobPerformance,
319410
stepPerformance,
411+
dateRange,
320412
totalRunsAnalyzed,
321413
oldestRun,
322414
newestRun,
@@ -394,6 +486,11 @@ export default define.page<typeof handler>(function InsightsPage({ data }) {
394486
</span>
395487
</span>
396488
</div>
489+
<TestTimeline
490+
dateRange={dateRange}
491+
dailyCounts={test.dailyCounts}
492+
color="red"
493+
/>
397494
</div>
398495
<div class="flex-shrink-0">
399496
<div class="bg-red-100 text-red-800 px-3 py-2 rounded text-center">
@@ -479,6 +576,11 @@ export default define.page<typeof handler>(function InsightsPage({ data }) {
479576
))}
480577
</div>
481578
)}
579+
<TestTimeline
580+
dateRange={dateRange}
581+
dailyCounts={test.dailyCounts}
582+
color="yellow"
583+
/>
482584
</div>
483585
<div class="flex-shrink-0">
484586
<div class="bg-yellow-100 text-yellow-800 px-3 py-2 rounded text-center">

0 commit comments

Comments
 (0)