Skip to content

Commit 026ddb7

Browse files
committed
Add steps and job info
1 parent 6eb5be9 commit 026ddb7

File tree

6 files changed

+939
-21
lines changed

6 files changed

+939
-21
lines changed

app.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,10 @@ export interface AppState {
2828

2929
export type AppStore = ReturnType<typeof createRequestStore>;
3030

31-
const configProvider = new ConfigProvider();
3231
// services that live for the duration of the application
3332
const appStore = defineStore()
34-
.add("loggerFactory", () => {
35-
return new LoggerFactory();
36-
})
33+
.add("config", () => new ConfigProvider())
34+
.add("loggerFactory", () => new LoggerFactory())
3735
.add("artifactParser", (): ArtifactParser => {
3836
return new ZipArtifactParser();
3937
})
@@ -46,7 +44,7 @@ const appStore = defineStore()
4644
.add("githubClient", (store): GitHubApiClient => {
4745
return new RealGitHubApiClient(
4846
store.get("fileFetcher"),
49-
configProvider.githubToken,
47+
store.get("config").githubToken,
5048
);
5149
})
5250
.add("testResultsDownloader", (store): TestResultsDownloader => {

lib/github-api-client.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,31 @@ interface ArtifactsListResponse {
4040
artifacts: Artifact[];
4141
}
4242

43+
export interface WorkflowStep {
44+
name: string;
45+
status: string;
46+
conclusion: string | null;
47+
number: number;
48+
started_at: string | null;
49+
completed_at: string | null;
50+
}
51+
52+
export interface WorkflowJob {
53+
id: number;
54+
run_id: number;
55+
name: string;
56+
status: string;
57+
conclusion: string | null;
58+
started_at: string;
59+
completed_at: string | null;
60+
steps?: WorkflowStep[];
61+
}
62+
63+
interface JobsListResponse {
64+
total_count: number;
65+
jobs: WorkflowJob[];
66+
}
67+
4368
export type GitHubApiClient = ExtractInterface<RealGitHubApiClient>;
4469

4570
export class RealGitHubApiClient {
@@ -136,4 +161,20 @@ export class RealGitHubApiClient {
136161

137162
return await response.blob();
138163
}
164+
165+
async listJobs(runId: number): Promise<WorkflowJob[]> {
166+
const url =
167+
`https://api.github.com/repos/${OWNER}/${REPO}/actions/runs/${runId}/jobs`;
168+
169+
const response = await this.#fileFetcher.get(url, this.#getHeaders());
170+
171+
if (!response.ok) {
172+
throw new Error(
173+
`Failed to list jobs: ${response.status} ${response.statusText}`,
174+
);
175+
}
176+
177+
const data: JobsListResponse = await response.json();
178+
return data.jobs;
179+
}
139180
}

routes/insights.test.tsx

Lines changed: 230 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { assertEquals } from "@std/assert";
22
import { InsightsPageController } from "./insights.tsx";
3-
import type { GitHubApiClient, WorkflowRun } from "@/lib/github-api-client.ts";
3+
import type {
4+
GitHubApiClient,
5+
WorkflowJob,
6+
WorkflowRun,
7+
} from "@/lib/github-api-client.ts";
48
import type {
59
JobTestResults,
610
TestResultsDownloader,
@@ -12,19 +16,29 @@ interface RunsWithCount {
1216
runs: WorkflowRun[];
1317
}
1418

15-
class MockGitHubApiClient implements Pick<GitHubApiClient, "listWorkflowRuns"> {
19+
class MockGitHubApiClient
20+
implements Pick<GitHubApiClient, "listWorkflowRuns" | "listJobs"> {
1621
#runs: RunsWithCount = { totalCount: 0, runs: [] };
22+
#jobs: Map<number, WorkflowJob[]> = new Map();
1723

1824
mockRuns(runs: RunsWithCount) {
1925
this.#runs = runs;
2026
}
2127

28+
mockJobs(runId: number, jobs: WorkflowJob[]) {
29+
this.#jobs.set(runId, jobs);
30+
}
31+
2232
listWorkflowRuns(
2333
_perPage?: number,
2434
_page?: number,
2535
) {
2636
return Promise.resolve(this.#runs);
2737
}
38+
39+
listJobs(runId: number) {
40+
return Promise.resolve(this.#jobs.get(runId) || []);
41+
}
2842
}
2943

3044
class MockTestResultsDownloader implements TestResultsDownloader {
@@ -77,6 +91,8 @@ Deno.test("filters main branch completed CI runs", async () => {
7791
];
7892

7993
mockGithub.mockRuns({ totalCount: 5, runs });
94+
mockGithub.mockJobs(1, []);
95+
mockGithub.mockJobs(5, []);
8096
mockDownloader.mockResults(1, []);
8197
mockDownloader.mockResults(5, []);
8298

@@ -106,6 +122,7 @@ Deno.test("limits to 20 main branch runs", async () => {
106122

107123
// Mock results for first 20 runs only
108124
for (let i = 1; i <= 20; i++) {
125+
mockGithub.mockJobs(i, []);
109126
mockDownloader.mockResults(i, []);
110127
}
111128

@@ -129,6 +146,8 @@ Deno.test("tracks flaky tests", async () => {
129146
];
130147

131148
mockGithub.mockRuns({ totalCount: 2, runs });
149+
mockGithub.mockJobs(1, []);
150+
mockGithub.mockJobs(2, []);
132151

133152
mockDownloader.mockResults(1, [
134153
{
@@ -534,6 +553,8 @@ Deno.test("tracks flaky job counts", async () => {
534553
];
535554

536555
mockGithub.mockRuns({ totalCount: 2, runs });
556+
mockGithub.mockJobs(1, []);
557+
mockGithub.mockJobs(2, []);
537558

538559
mockDownloader.mockResults(1, [
539560
{
@@ -624,3 +645,210 @@ Deno.test("tracks flaky job counts", async () => {
624645
assertEquals(result.data.flakyJobs[2].name, "windows-x64");
625646
assertEquals(result.data.flakyJobs[2].count, 3);
626647
});
648+
649+
Deno.test("tracks job performance metrics", async () => {
650+
const mockGithub = new MockGitHubApiClient();
651+
const mockDownloader = new MockTestResultsDownloader();
652+
653+
const runs = [
654+
createMockRun(1, "CI", "completed", "main"),
655+
createMockRun(2, "CI", "completed", "main"),
656+
];
657+
658+
mockGithub.mockRuns({ totalCount: 2, runs });
659+
660+
// Mock jobs with timing data for run 1
661+
mockGithub.mockJobs(1, [
662+
{
663+
id: 101,
664+
run_id: 1,
665+
name: "test-linux",
666+
status: "completed",
667+
conclusion: "success",
668+
started_at: "2024-01-01T10:00:00Z",
669+
completed_at: "2024-01-01T10:05:00Z", // 5 minutes = 300 seconds
670+
},
671+
{
672+
id: 102,
673+
run_id: 1,
674+
name: "test-windows",
675+
status: "completed",
676+
conclusion: "success",
677+
started_at: "2024-01-01T10:00:00Z",
678+
completed_at: "2024-01-01T10:10:00Z", // 10 minutes = 600 seconds
679+
},
680+
]);
681+
682+
// Mock jobs with timing data for run 2
683+
mockGithub.mockJobs(2, [
684+
{
685+
id: 201,
686+
run_id: 2,
687+
name: "test-linux",
688+
status: "completed",
689+
conclusion: "success",
690+
started_at: "2024-01-02T10:00:00Z",
691+
completed_at: "2024-01-02T10:07:00Z", // 7 minutes = 420 seconds
692+
},
693+
{
694+
id: 202,
695+
run_id: 2,
696+
name: "test-windows",
697+
status: "completed",
698+
conclusion: "success",
699+
started_at: "2024-01-02T10:00:00Z",
700+
completed_at: "2024-01-02T10:08:00Z", // 8 minutes = 480 seconds
701+
},
702+
]);
703+
704+
mockDownloader.mockResults(1, []);
705+
mockDownloader.mockResults(2, []);
706+
707+
const controller = new InsightsPageController(
708+
new NullLogger(),
709+
mockGithub,
710+
mockDownloader,
711+
);
712+
const result = await controller.get();
713+
714+
// Verify jobPerformance is returned and sorted by average duration descending
715+
assertEquals(result.data.jobPerformance.length, 2);
716+
717+
// test-windows: avg=(600+480)/2=540s, min=480s, max=600s
718+
assertEquals(result.data.jobPerformance[0].name, "test-windows");
719+
assertEquals(result.data.jobPerformance[0].avgDuration, 540);
720+
assertEquals(result.data.jobPerformance[0].minDuration, 480);
721+
assertEquals(result.data.jobPerformance[0].maxDuration, 600);
722+
assertEquals(result.data.jobPerformance[0].count, 2);
723+
724+
// test-linux: avg=(300+420)/2=360s, min=300s, max=420s
725+
assertEquals(result.data.jobPerformance[1].name, "test-linux");
726+
assertEquals(result.data.jobPerformance[1].avgDuration, 360);
727+
assertEquals(result.data.jobPerformance[1].minDuration, 300);
728+
assertEquals(result.data.jobPerformance[1].maxDuration, 420);
729+
assertEquals(result.data.jobPerformance[1].count, 2);
730+
});
731+
732+
Deno.test("tracks step performance metrics", async () => {
733+
const mockGithub = new MockGitHubApiClient();
734+
const mockDownloader = new MockTestResultsDownloader();
735+
736+
const runs = [
737+
createMockRun(1, "CI", "completed", "main"),
738+
createMockRun(2, "CI", "completed", "main"),
739+
];
740+
741+
mockGithub.mockRuns({ totalCount: 2, runs });
742+
743+
// Mock jobs with step timing data for run 1
744+
mockGithub.mockJobs(1, [
745+
{
746+
id: 101,
747+
run_id: 1,
748+
name: "test-job",
749+
status: "completed",
750+
conclusion: "success",
751+
started_at: "2024-01-01T10:00:00Z",
752+
completed_at: "2024-01-01T10:10:00Z",
753+
steps: [
754+
{
755+
name: "Checkout code",
756+
status: "completed",
757+
conclusion: "success",
758+
number: 1,
759+
started_at: "2024-01-01T10:00:00Z",
760+
completed_at: "2024-01-01T10:00:30Z", // 30 seconds
761+
},
762+
{
763+
name: "Run tests",
764+
status: "completed",
765+
conclusion: "success",
766+
number: 2,
767+
started_at: "2024-01-01T10:00:30Z",
768+
completed_at: "2024-01-01T10:05:30Z", // 5 minutes = 300 seconds
769+
},
770+
{
771+
name: "Upload results",
772+
status: "completed",
773+
conclusion: "success",
774+
number: 3,
775+
started_at: "2024-01-01T10:05:30Z",
776+
completed_at: "2024-01-01T10:06:00Z", // 30 seconds
777+
},
778+
],
779+
},
780+
]);
781+
782+
// Mock jobs with step timing data for run 2
783+
mockGithub.mockJobs(2, [
784+
{
785+
id: 201,
786+
run_id: 2,
787+
name: "test-job",
788+
status: "completed",
789+
conclusion: "success",
790+
started_at: "2024-01-02T10:00:00Z",
791+
completed_at: "2024-01-02T10:08:00Z",
792+
steps: [
793+
{
794+
name: "Checkout code",
795+
status: "completed",
796+
conclusion: "success",
797+
number: 1,
798+
started_at: "2024-01-02T10:00:00Z",
799+
completed_at: "2024-01-02T10:00:20Z", // 20 seconds
800+
},
801+
{
802+
name: "Run tests",
803+
status: "completed",
804+
conclusion: "success",
805+
number: 2,
806+
started_at: "2024-01-02T10:00:20Z",
807+
completed_at: "2024-01-02T10:04:20Z", // 4 minutes = 240 seconds
808+
},
809+
{
810+
name: "Upload results",
811+
status: "completed",
812+
conclusion: "success",
813+
number: 3,
814+
started_at: "2024-01-02T10:04:20Z",
815+
completed_at: "2024-01-02T10:05:00Z", // 40 seconds
816+
},
817+
],
818+
},
819+
]);
820+
821+
mockDownloader.mockResults(1, []);
822+
mockDownloader.mockResults(2, []);
823+
824+
const controller = new InsightsPageController(
825+
new NullLogger(),
826+
mockGithub,
827+
mockDownloader,
828+
);
829+
const result = await controller.get();
830+
831+
// Verify stepPerformance is returned and sorted by average duration descending
832+
assertEquals(result.data.stepPerformance.length, 3);
833+
834+
// Run tests: avg=(300+240)/2=270s, min=240s, max=300s
835+
assertEquals(result.data.stepPerformance[0].name, "Run tests");
836+
assertEquals(result.data.stepPerformance[0].avgDuration, 270);
837+
assertEquals(result.data.stepPerformance[0].minDuration, 240);
838+
assertEquals(result.data.stepPerformance[0].maxDuration, 300);
839+
assertEquals(result.data.stepPerformance[0].count, 2);
840+
841+
// Upload results: avg=(30+40)/2=35s, min=30s, max=40s
842+
assertEquals(result.data.stepPerformance[1].name, "Upload results");
843+
assertEquals(result.data.stepPerformance[1].avgDuration, 35);
844+
assertEquals(result.data.stepPerformance[1].minDuration, 30);
845+
assertEquals(result.data.stepPerformance[1].maxDuration, 40);
846+
assertEquals(result.data.stepPerformance[1].count, 2);
847+
848+
// Checkout code: avg=(30+20)/2=25s, min=20s, max=30s
849+
assertEquals(result.data.stepPerformance[2].name, "Checkout code");
850+
assertEquals(result.data.stepPerformance[2].avgDuration, 25);
851+
assertEquals(result.data.stepPerformance[2].minDuration, 20);
852+
assertEquals(result.data.stepPerformance[2].maxDuration, 30);
853+
assertEquals(result.data.stepPerformance[2].count, 2);
854+
});

0 commit comments

Comments
 (0)