Skip to content

Commit 0e5abc8

Browse files
committed
Add unit test report generator
1 parent b0b3a8b commit 0e5abc8

File tree

3 files changed

+314
-0
lines changed

3 files changed

+314
-0
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package com.google.firebase.gradle.plugins.report;
2+
3+
public record ReportCommit(String sha, int pr) {}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.google.firebase.gradle.plugins.report;
2+
3+
public record TestReport(String name, Type type, Status status, String commit, String url) {
4+
5+
public enum Type {
6+
UNIT_TEST,
7+
INSTRUMENTATION_TEST
8+
}
9+
10+
public enum Status {
11+
SUCCESS,
12+
FAILURE,
13+
OTHER
14+
}
15+
}
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package com.google.firebase.gradle.plugins.report;
16+
17+
import com.google.gson.Gson;
18+
import com.google.gson.GsonBuilder;
19+
import com.google.gson.JsonArray;
20+
import com.google.gson.JsonElement;
21+
import com.google.gson.JsonObject;
22+
import java.io.FileWriter;
23+
import java.io.IOException;
24+
import java.net.URI;
25+
import java.net.http.HttpClient;
26+
import java.net.http.HttpRequest;
27+
import java.net.http.HttpResponse;
28+
import java.time.Duration;
29+
import java.util.ArrayList;
30+
import java.util.Arrays;
31+
import java.util.Comparator;
32+
import java.util.HashMap;
33+
import java.util.List;
34+
import java.util.Map;
35+
import java.util.Objects;
36+
import java.util.regex.Matcher;
37+
import java.util.regex.Pattern;
38+
import java.util.stream.Collectors;
39+
import org.gradle.internal.Pair;
40+
import org.gradle.internal.impldep.org.eclipse.jgit.annotations.NonNull;
41+
42+
@SuppressWarnings("NewApi")
43+
public class UnitTestReport {
44+
private static final Pattern PR_NUMBER_MATCHER = Pattern.compile(".*\\(#([0-9]+)\\)");
45+
private static final String URL_PREFIX =
46+
"https://api.github.com/repos/firebase/firebase-android-sdk/";
47+
private static final Gson GSON = new GsonBuilder().create();
48+
private final HttpClient client;
49+
private final String apiToken;
50+
51+
public UnitTestReport(@NonNull String apiToken) {
52+
this.client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
53+
this.apiToken = apiToken;
54+
}
55+
56+
public void createReport(int commitCount) {
57+
JsonArray response = request("commits", JsonArray.class);
58+
List<ReportCommit> commits =
59+
response.getAsJsonArray().asList().stream()
60+
.limit(commitCount)
61+
.map(
62+
(el) -> {
63+
JsonObject obj = el.getAsJsonObject();
64+
int pr = -1;
65+
Matcher matcher =
66+
PR_NUMBER_MATCHER.matcher(
67+
obj.getAsJsonObject("commit").get("message").getAsString());
68+
if (matcher.find()) {
69+
pr = Integer.parseInt(matcher.group(1));
70+
}
71+
return new ReportCommit(obj.get("sha").getAsString(), pr);
72+
})
73+
.toList();
74+
outputReport(commits);
75+
}
76+
77+
public void outputReport(@NonNull List<ReportCommit> commits) {
78+
List<TestReport> reports = new ArrayList<>();
79+
for (ReportCommit commit : commits) {
80+
reports.addAll(parseTestReports(commit.sha()));
81+
}
82+
StringBuilder output = new StringBuilder();
83+
output.append("### Unit Tests\n\n");
84+
output.append(
85+
generateTable(
86+
commits, reports.stream().filter(r -> r.type() == TestReport.Type.UNIT_TEST).toList()));
87+
output.append("\n");
88+
output.append("### Instrumentation Tests\n\n");
89+
output.append(
90+
generateTable(
91+
commits,
92+
reports.stream()
93+
.filter(r -> r.type() == TestReport.Type.INSTRUMENTATION_TEST)
94+
.toList()));
95+
output.append("\n");
96+
97+
try {
98+
FileWriter writer = new FileWriter("test-report.md");
99+
writer.append(output.toString());
100+
writer.close();
101+
} catch (Exception e) {
102+
throw new RuntimeException("Error writing report file", e);
103+
}
104+
}
105+
106+
public @NonNull String generateTable(
107+
@NonNull List<ReportCommit> reportCommits, @NonNull List<TestReport> reports) {
108+
Map<String, ReportCommit> commitLookup =
109+
reportCommits.stream().collect(Collectors.toMap(ReportCommit::sha, c -> c));
110+
List<String> commits = reports.stream().map(TestReport::commit).distinct().toList();
111+
List<String> sdks = reports.stream().map(TestReport::name).distinct().sorted().toList();
112+
Map<Pair<String, String>, TestReport> lookup = new HashMap<>();
113+
for (TestReport report : reports) {
114+
lookup.put(Pair.of(report.name(), report.commit()), report);
115+
}
116+
Map<String, Integer> successPercentage = new HashMap<>();
117+
int passingSdks = 0;
118+
// Get success percentage
119+
for (String sdk : sdks) {
120+
int sdkTestCount = 0;
121+
int sdkTestSuccess = 0;
122+
for (String commit : commits) {
123+
if (lookup.containsKey(Pair.of(sdk, commit))) {
124+
TestReport report = lookup.get(Pair.of(sdk, commit));
125+
if (report.status() != TestReport.Status.OTHER) {
126+
sdkTestCount++;
127+
if (report.status() == TestReport.Status.SUCCESS) {
128+
sdkTestSuccess++;
129+
}
130+
}
131+
}
132+
}
133+
if (sdkTestSuccess == sdkTestCount) {
134+
passingSdks++;
135+
}
136+
successPercentage.put(sdk, sdkTestSuccess * 100 / sdkTestCount);
137+
}
138+
sdks =
139+
sdks.stream()
140+
.filter(s -> successPercentage.get(s) != 100)
141+
.sorted(Comparator.comparing(successPercentage::get))
142+
.toList();
143+
if (sdks.isEmpty()) {
144+
return "*All tests passing*\n";
145+
}
146+
StringBuilder output = new StringBuilder("| |");
147+
for (String commit : commits) {
148+
ReportCommit rc = commitLookup.get(commit);
149+
output.append(" ");
150+
if (rc != null && rc.pr() != -1) {
151+
output
152+
.append("[#")
153+
.append(rc.pr())
154+
.append("](https://github.com/firebase/firebase-android-sdk/pull/")
155+
.append(rc.pr())
156+
.append(")");
157+
} else {
158+
output.append(commit);
159+
}
160+
output.append(" |");
161+
}
162+
output.append(" Success Rate |\n|");
163+
output.append(" :--- |");
164+
output.append(" :---: |".repeat(commits.size()));
165+
output.append(" :--- |");
166+
for (String sdk : sdks) {
167+
output.append("\n| ").append(sdk).append(" |");
168+
for (String commit : commits) {
169+
if (lookup.containsKey(Pair.of(sdk, commit))) {
170+
TestReport report = lookup.get(Pair.of(sdk, commit));
171+
String icon =
172+
switch (report.status()) {
173+
case SUCCESS -> "✅";
174+
case FAILURE -> "⛔";
175+
case OTHER -> "➖";
176+
};
177+
String link = " [%s](%s)".formatted(icon, report.url());
178+
output.append(link);
179+
}
180+
output.append(" |");
181+
}
182+
output.append(" ");
183+
int successChance = successPercentage.get(sdk);
184+
if (successChance == 100) {
185+
output.append("✅ 100%");
186+
} else {
187+
output.append("⛔ ").append(successChance).append("%");
188+
}
189+
output.append(" |");
190+
}
191+
output.append("\n");
192+
if (passingSdks > 0) {
193+
output.append("\n*+").append(passingSdks).append(" passing SDKs*\n");
194+
}
195+
return output.toString();
196+
}
197+
198+
public @NonNull List<TestReport> parseTestReports(@NonNull String commit) {
199+
JsonObject runs = request("actions/runs?head_sha=" + commit);
200+
for (JsonElement el : runs.getAsJsonArray("workflow_runs")) {
201+
JsonObject run = el.getAsJsonObject();
202+
String name = run.get("name").getAsString();
203+
if (Objects.equals(name, "CI Tests")) {
204+
return parseCITests(run.get("id").getAsString(), commit);
205+
}
206+
}
207+
return List.of();
208+
}
209+
210+
public @NonNull List<TestReport> parseCITests(@NonNull String id, @NonNull String commit) {
211+
List<TestReport> reports = new ArrayList<>();
212+
JsonObject jobs = request("actions/runs/" + id + "/jobs");
213+
for (JsonElement el : jobs.getAsJsonArray("jobs")) {
214+
JsonObject job = el.getAsJsonObject();
215+
String jid = job.get("name").getAsString();
216+
if (jid.startsWith("Unit Tests (:")) {
217+
reports.add(parseJob(TestReport.Type.UNIT_TEST, job, commit));
218+
} else if (jid.startsWith("Instrumentation Tests (:")) {
219+
reports.add(parseJob(TestReport.Type.INSTRUMENTATION_TEST, job, commit));
220+
}
221+
}
222+
return reports;
223+
}
224+
225+
public @NonNull TestReport parseJob(
226+
@NonNull TestReport.Type type, @NonNull JsonObject job, @NonNull String commit) {
227+
String name = job.get("name").getAsString().split("\\(:")[1];
228+
name = name.substring(0, name.length() - 1); // Remove trailing ")"
229+
TestReport.Status status = TestReport.Status.OTHER;
230+
if (Objects.equals(job.get("status").getAsString(), "completed")) {
231+
if (Objects.equals(job.get("conclusion").getAsString(), "success")) {
232+
status = TestReport.Status.SUCCESS;
233+
} else {
234+
status = TestReport.Status.FAILURE;
235+
}
236+
}
237+
String url = job.get("html_url").getAsString();
238+
return new TestReport(name, type, status, commit, url);
239+
}
240+
241+
private JsonObject request(String path) {
242+
return request(path, JsonObject.class);
243+
}
244+
245+
private <T> T request(String path, Class<T> clazz) {
246+
return request(URI.create(URL_PREFIX + path), clazz);
247+
}
248+
249+
/**
250+
* Abstracts away paginated calling.
251+
* Naively joins pages together by merging root level arrays.
252+
*/
253+
private <T> T request(URI uri, Class<T> clazz) {
254+
HttpRequest request =
255+
HttpRequest.newBuilder()
256+
.GET()
257+
.uri(uri)
258+
.header("Authorization", "Bearer " + apiToken)
259+
.build();
260+
try {
261+
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
262+
String body = response.body();
263+
if (response.statusCode() >= 300) {
264+
System.err.println(response);
265+
System.err.println(body);
266+
}
267+
T json = GSON.fromJson(body, clazz);
268+
if (json instanceof JsonObject obj) {
269+
// Retrieve and merge objects from other pages, if present
270+
response
271+
.headers()
272+
.firstValue("Link")
273+
.ifPresent(
274+
link -> {
275+
List<String> parts = Arrays.stream(link.split(",")).toList();
276+
for (String part : parts) {
277+
if (part.endsWith("rel=\"next\"")) {
278+
// <foo>; rel="next" -> foo
279+
String url = part.split(">;")[0].split("<")[1];
280+
JsonObject p = request(URI.create(url), JsonObject.class);
281+
for (String key : obj.keySet()) {
282+
if (obj.get(key).isJsonArray() && p.has(key) && p.get(key).isJsonArray()) {
283+
obj.getAsJsonArray(key).addAll(p.getAsJsonArray(key));
284+
}
285+
}
286+
break;
287+
}
288+
}
289+
});
290+
}
291+
return json;
292+
} catch (IOException | InterruptedException e) {
293+
throw new RuntimeException(e);
294+
}
295+
}
296+
}

0 commit comments

Comments
 (0)