Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/github-api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
83 changes: 83 additions & 0 deletions lib/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div class="mt-2">
<div class="flex items-center gap-2">
<div
class="flex items-end gap-px flex-1"
style={{ minWidth: 0, height: "16px" }}
>
{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 (
<div
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" : ""
}`}
>
<div
style={{
height: `${height}px`,
backgroundColor: isActive ? colorScheme.active : "#E5E7EB",
borderRadius: "1px",
}}
/>
</div>
);
})}
</div>
{lastOccurrenceFormatted && (
<span class="text-xs text-gray-500 whitespace-nowrap">
Last: {lastOccurrenceFormatted}
</span>
)}
</div>
</div>
);
}

export function getStatusBadge(status: string, conclusion: string | null) {
if (status !== "completed") {
return (
Expand Down
13 changes: 11 additions & 2 deletions routes/insights.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
118 changes: 110 additions & 8 deletions routes/insights.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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",
)
Expand All @@ -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<number, string>();
allResults.forEach(({ runId, run }) => {
runIdToDate.set(runId, run.created_at.split("T")[0]);
});

// Analyze flaky tests across all runs
const flakyTestsMap = new Map<
string,
Expand All @@ -70,6 +79,7 @@ export class InsightsPageController {
avgFlakyCount: number;
runIds: number[];
jobCounts: Map<string, number>;
dailyCounts: Map<string, number>; // date -> flaky count
}
>();

Expand All @@ -81,6 +91,7 @@ export class InsightsPageController {
path: string;
failureCount: number;
runIds: number[];
dailyCounts: Map<string, number>; // date -> failure count
}
>();

Expand All @@ -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}`;
Expand All @@ -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,
Expand All @@ -118,6 +135,7 @@ export class InsightsPageController {
avgFlakyCount: test.flakyCount,
runIds: [runId],
jobCounts: new Map([[jobName, test.flakyCount]]),
dailyCounts: new Map([[runDate, test.flakyCount]]),
});
}

Expand All @@ -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]]),
});
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
);

Expand Down Expand Up @@ -295,13 +369,30 @@ 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,
failedTests,
flakyJobs,
jobPerformance,
stepPerformance,
dateRange,
totalRunsAnalyzed: mainBranchRuns.length,
oldestRun: mainBranchRuns[mainBranchRuns.length - 1],
newestRun: mainBranchRuns[0],
Expand All @@ -317,6 +408,7 @@ export default define.page<typeof handler>(function InsightsPage({ data }) {
flakyJobs,
jobPerformance,
stepPerformance,
dateRange,
totalRunsAnalyzed,
oldestRun,
newestRun,
Expand Down Expand Up @@ -394,6 +486,11 @@ export default define.page<typeof handler>(function InsightsPage({ data }) {
</span>
</span>
</div>
<TestTimeline
dateRange={dateRange}
dailyCounts={test.dailyCounts}
color="red"
/>
</div>
<div class="flex-shrink-0">
<div class="bg-red-100 text-red-800 px-3 py-2 rounded text-center">
Expand Down Expand Up @@ -479,6 +576,11 @@ export default define.page<typeof handler>(function InsightsPage({ data }) {
))}
</div>
)}
<TestTimeline
dateRange={dateRange}
dailyCounts={test.dailyCounts}
color="yellow"
/>
</div>
<div class="flex-shrink-0">
<div class="bg-yellow-100 text-yellow-800 px-3 py-2 rounded text-center">
Expand Down