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..8fa40f4 100644
--- a/lib/render.tsx
+++ b/lib/render.tsx
@@ -16,6 +16,89 @@ 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 }) {
))}
)}
+