From 0e5abc8a91e38036ef3b30469bac0688247290a2 Mon Sep 17 00:00:00 2001 From: Emily Ploszaj Date: Wed, 24 Sep 2025 14:51:20 -0500 Subject: [PATCH 1/5] Add unit test report generator --- .../gradle/plugins/report/ReportCommit.java | 3 + .../gradle/plugins/report/TestReport.java | 15 + .../gradle/plugins/report/UnitTestReport.java | 296 ++++++++++++++++++ 3 files changed, 314 insertions(+) create mode 100644 plugins/src/main/java/com/google/firebase/gradle/plugins/report/ReportCommit.java create mode 100644 plugins/src/main/java/com/google/firebase/gradle/plugins/report/TestReport.java create mode 100644 plugins/src/main/java/com/google/firebase/gradle/plugins/report/UnitTestReport.java diff --git a/plugins/src/main/java/com/google/firebase/gradle/plugins/report/ReportCommit.java b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/ReportCommit.java new file mode 100644 index 00000000000..1ebbfd0b8d8 --- /dev/null +++ b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/ReportCommit.java @@ -0,0 +1,3 @@ +package com.google.firebase.gradle.plugins.report; + +public record ReportCommit(String sha, int pr) {} diff --git a/plugins/src/main/java/com/google/firebase/gradle/plugins/report/TestReport.java b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/TestReport.java new file mode 100644 index 00000000000..f8a058a4e79 --- /dev/null +++ b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/TestReport.java @@ -0,0 +1,15 @@ +package com.google.firebase.gradle.plugins.report; + +public record TestReport(String name, Type type, Status status, String commit, String url) { + + public enum Type { + UNIT_TEST, + INSTRUMENTATION_TEST + } + + public enum Status { + SUCCESS, + FAILURE, + OTHER + } +} diff --git a/plugins/src/main/java/com/google/firebase/gradle/plugins/report/UnitTestReport.java b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/UnitTestReport.java new file mode 100644 index 00000000000..a88cade689f --- /dev/null +++ b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/UnitTestReport.java @@ -0,0 +1,296 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.firebase.gradle.plugins.report; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.io.FileWriter; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.gradle.internal.Pair; +import org.gradle.internal.impldep.org.eclipse.jgit.annotations.NonNull; + +@SuppressWarnings("NewApi") +public class UnitTestReport { + private static final Pattern PR_NUMBER_MATCHER = Pattern.compile(".*\\(#([0-9]+)\\)"); + private static final String URL_PREFIX = + "https://api.github.com/repos/firebase/firebase-android-sdk/"; + private static final Gson GSON = new GsonBuilder().create(); + private final HttpClient client; + private final String apiToken; + + public UnitTestReport(@NonNull String apiToken) { + this.client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); + this.apiToken = apiToken; + } + + public void createReport(int commitCount) { + JsonArray response = request("commits", JsonArray.class); + List commits = + response.getAsJsonArray().asList().stream() + .limit(commitCount) + .map( + (el) -> { + JsonObject obj = el.getAsJsonObject(); + int pr = -1; + Matcher matcher = + PR_NUMBER_MATCHER.matcher( + obj.getAsJsonObject("commit").get("message").getAsString()); + if (matcher.find()) { + pr = Integer.parseInt(matcher.group(1)); + } + return new ReportCommit(obj.get("sha").getAsString(), pr); + }) + .toList(); + outputReport(commits); + } + + public void outputReport(@NonNull List commits) { + List reports = new ArrayList<>(); + for (ReportCommit commit : commits) { + reports.addAll(parseTestReports(commit.sha())); + } + StringBuilder output = new StringBuilder(); + output.append("### Unit Tests\n\n"); + output.append( + generateTable( + commits, reports.stream().filter(r -> r.type() == TestReport.Type.UNIT_TEST).toList())); + output.append("\n"); + output.append("### Instrumentation Tests\n\n"); + output.append( + generateTable( + commits, + reports.stream() + .filter(r -> r.type() == TestReport.Type.INSTRUMENTATION_TEST) + .toList())); + output.append("\n"); + + try { + FileWriter writer = new FileWriter("test-report.md"); + writer.append(output.toString()); + writer.close(); + } catch (Exception e) { + throw new RuntimeException("Error writing report file", e); + } + } + + public @NonNull String generateTable( + @NonNull List reportCommits, @NonNull List reports) { + Map commitLookup = + reportCommits.stream().collect(Collectors.toMap(ReportCommit::sha, c -> c)); + List commits = reports.stream().map(TestReport::commit).distinct().toList(); + List sdks = reports.stream().map(TestReport::name).distinct().sorted().toList(); + Map, TestReport> lookup = new HashMap<>(); + for (TestReport report : reports) { + lookup.put(Pair.of(report.name(), report.commit()), report); + } + Map successPercentage = new HashMap<>(); + int passingSdks = 0; + // Get success percentage + for (String sdk : sdks) { + int sdkTestCount = 0; + int sdkTestSuccess = 0; + for (String commit : commits) { + if (lookup.containsKey(Pair.of(sdk, commit))) { + TestReport report = lookup.get(Pair.of(sdk, commit)); + if (report.status() != TestReport.Status.OTHER) { + sdkTestCount++; + if (report.status() == TestReport.Status.SUCCESS) { + sdkTestSuccess++; + } + } + } + } + if (sdkTestSuccess == sdkTestCount) { + passingSdks++; + } + successPercentage.put(sdk, sdkTestSuccess * 100 / sdkTestCount); + } + sdks = + sdks.stream() + .filter(s -> successPercentage.get(s) != 100) + .sorted(Comparator.comparing(successPercentage::get)) + .toList(); + if (sdks.isEmpty()) { + return "*All tests passing*\n"; + } + StringBuilder output = new StringBuilder("| |"); + for (String commit : commits) { + ReportCommit rc = commitLookup.get(commit); + output.append(" "); + if (rc != null && rc.pr() != -1) { + output + .append("[#") + .append(rc.pr()) + .append("](https://github.com/firebase/firebase-android-sdk/pull/") + .append(rc.pr()) + .append(")"); + } else { + output.append(commit); + } + output.append(" |"); + } + output.append(" Success Rate |\n|"); + output.append(" :--- |"); + output.append(" :---: |".repeat(commits.size())); + output.append(" :--- |"); + for (String sdk : sdks) { + output.append("\n| ").append(sdk).append(" |"); + for (String commit : commits) { + if (lookup.containsKey(Pair.of(sdk, commit))) { + TestReport report = lookup.get(Pair.of(sdk, commit)); + String icon = + switch (report.status()) { + case SUCCESS -> "✅"; + case FAILURE -> "⛔"; + case OTHER -> "➖"; + }; + String link = " [%s](%s)".formatted(icon, report.url()); + output.append(link); + } + output.append(" |"); + } + output.append(" "); + int successChance = successPercentage.get(sdk); + if (successChance == 100) { + output.append("✅ 100%"); + } else { + output.append("⛔ ").append(successChance).append("%"); + } + output.append(" |"); + } + output.append("\n"); + if (passingSdks > 0) { + output.append("\n*+").append(passingSdks).append(" passing SDKs*\n"); + } + return output.toString(); + } + + public @NonNull List parseTestReports(@NonNull String commit) { + JsonObject runs = request("actions/runs?head_sha=" + commit); + for (JsonElement el : runs.getAsJsonArray("workflow_runs")) { + JsonObject run = el.getAsJsonObject(); + String name = run.get("name").getAsString(); + if (Objects.equals(name, "CI Tests")) { + return parseCITests(run.get("id").getAsString(), commit); + } + } + return List.of(); + } + + public @NonNull List parseCITests(@NonNull String id, @NonNull String commit) { + List reports = new ArrayList<>(); + JsonObject jobs = request("actions/runs/" + id + "/jobs"); + for (JsonElement el : jobs.getAsJsonArray("jobs")) { + JsonObject job = el.getAsJsonObject(); + String jid = job.get("name").getAsString(); + if (jid.startsWith("Unit Tests (:")) { + reports.add(parseJob(TestReport.Type.UNIT_TEST, job, commit)); + } else if (jid.startsWith("Instrumentation Tests (:")) { + reports.add(parseJob(TestReport.Type.INSTRUMENTATION_TEST, job, commit)); + } + } + return reports; + } + + public @NonNull TestReport parseJob( + @NonNull TestReport.Type type, @NonNull JsonObject job, @NonNull String commit) { + String name = job.get("name").getAsString().split("\\(:")[1]; + name = name.substring(0, name.length() - 1); // Remove trailing ")" + TestReport.Status status = TestReport.Status.OTHER; + if (Objects.equals(job.get("status").getAsString(), "completed")) { + if (Objects.equals(job.get("conclusion").getAsString(), "success")) { + status = TestReport.Status.SUCCESS; + } else { + status = TestReport.Status.FAILURE; + } + } + String url = job.get("html_url").getAsString(); + return new TestReport(name, type, status, commit, url); + } + + private JsonObject request(String path) { + return request(path, JsonObject.class); + } + + private T request(String path, Class clazz) { + return request(URI.create(URL_PREFIX + path), clazz); + } + + /** + * Abstracts away paginated calling. + * Naively joins pages together by merging root level arrays. + */ + private T request(URI uri, Class clazz) { + HttpRequest request = + HttpRequest.newBuilder() + .GET() + .uri(uri) + .header("Authorization", "Bearer " + apiToken) + .build(); + try { + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + String body = response.body(); + if (response.statusCode() >= 300) { + System.err.println(response); + System.err.println(body); + } + T json = GSON.fromJson(body, clazz); + if (json instanceof JsonObject obj) { + // Retrieve and merge objects from other pages, if present + response + .headers() + .firstValue("Link") + .ifPresent( + link -> { + List parts = Arrays.stream(link.split(",")).toList(); + for (String part : parts) { + if (part.endsWith("rel=\"next\"")) { + // ; rel="next" -> foo + String url = part.split(">;")[0].split("<")[1]; + JsonObject p = request(URI.create(url), JsonObject.class); + for (String key : obj.keySet()) { + if (obj.get(key).isJsonArray() && p.has(key) && p.get(key).isJsonArray()) { + obj.getAsJsonArray(key).addAll(p.getAsJsonArray(key)); + } + } + break; + } + } + }); + } + return json; + } catch (IOException | InterruptedException e) { + throw new RuntimeException(e); + } + } +} From 624dcfea47772dac3b9fdd9c5f56fc9fc20c1eb5 Mon Sep 17 00:00:00 2001 From: Emily Ploszaj Date: Wed, 24 Sep 2025 15:08:30 -0500 Subject: [PATCH 2/5] Copyright headers --- .../gradle/plugins/report/ReportCommit.java | 14 ++++++++++++++ .../firebase/gradle/plugins/report/TestReport.java | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/plugins/src/main/java/com/google/firebase/gradle/plugins/report/ReportCommit.java b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/ReportCommit.java index 1ebbfd0b8d8..b6d9b59369e 100644 --- a/plugins/src/main/java/com/google/firebase/gradle/plugins/report/ReportCommit.java +++ b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/ReportCommit.java @@ -1,3 +1,17 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package com.google.firebase.gradle.plugins.report; public record ReportCommit(String sha, int pr) {} diff --git a/plugins/src/main/java/com/google/firebase/gradle/plugins/report/TestReport.java b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/TestReport.java index f8a058a4e79..38e8de8e1c3 100644 --- a/plugins/src/main/java/com/google/firebase/gradle/plugins/report/TestReport.java +++ b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/TestReport.java @@ -1,3 +1,17 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package com.google.firebase.gradle.plugins.report; public record TestReport(String name, Type type, Status status, String commit, String url) { From a5919ea702f340b80c0c3e7bfc099a81af913cee Mon Sep 17 00:00:00 2001 From: Emily Ploszaj Date: Mon, 29 Sep 2025 15:26:00 -0500 Subject: [PATCH 3/5] Convert to kotlin --- .../{ReportCommit.java => ReportCommit.kt} | 5 +- .../report/{TestReport.java => TestReport.kt} | 21 +- .../gradle/plugins/report/UnitTestReport.java | 296 ----------------- .../gradle/plugins/report/UnitTestReport.kt | 310 ++++++++++++++++++ 4 files changed, 325 insertions(+), 307 deletions(-) rename plugins/src/main/java/com/google/firebase/gradle/plugins/report/{ReportCommit.java => ReportCommit.kt} (83%) rename plugins/src/main/java/com/google/firebase/gradle/plugins/report/{TestReport.java => TestReport.kt} (70%) delete mode 100644 plugins/src/main/java/com/google/firebase/gradle/plugins/report/UnitTestReport.java create mode 100644 plugins/src/main/java/com/google/firebase/gradle/plugins/report/UnitTestReport.kt diff --git a/plugins/src/main/java/com/google/firebase/gradle/plugins/report/ReportCommit.java b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/ReportCommit.kt similarity index 83% rename from plugins/src/main/java/com/google/firebase/gradle/plugins/report/ReportCommit.java rename to plugins/src/main/java/com/google/firebase/gradle/plugins/report/ReportCommit.kt index b6d9b59369e..9d9650c6afe 100644 --- a/plugins/src/main/java/com/google/firebase/gradle/plugins/report/ReportCommit.java +++ b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/ReportCommit.kt @@ -11,7 +11,6 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +package com.google.firebase.gradle.plugins.report -package com.google.firebase.gradle.plugins.report; - -public record ReportCommit(String sha, int pr) {} +@JvmRecord data class ReportCommit(val sha: String, val pr: Int) diff --git a/plugins/src/main/java/com/google/firebase/gradle/plugins/report/TestReport.java b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/TestReport.kt similarity index 70% rename from plugins/src/main/java/com/google/firebase/gradle/plugins/report/TestReport.java rename to plugins/src/main/java/com/google/firebase/gradle/plugins/report/TestReport.kt index 38e8de8e1c3..79e6ed50814 100644 --- a/plugins/src/main/java/com/google/firebase/gradle/plugins/report/TestReport.java +++ b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/TestReport.kt @@ -11,19 +11,24 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +package com.google.firebase.gradle.plugins.report -package com.google.firebase.gradle.plugins.report; - -public record TestReport(String name, Type type, Status status, String commit, String url) { - - public enum Type { +@JvmRecord +data class TestReport( + val name: String, + val type: Type, + val status: Status, + val commit: String, + val url: String, +) { + enum class Type { UNIT_TEST, - INSTRUMENTATION_TEST + INSTRUMENTATION_TEST, } - public enum Status { + enum class Status { SUCCESS, FAILURE, - OTHER + OTHER, } } diff --git a/plugins/src/main/java/com/google/firebase/gradle/plugins/report/UnitTestReport.java b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/UnitTestReport.java deleted file mode 100644 index a88cade689f..00000000000 --- a/plugins/src/main/java/com/google/firebase/gradle/plugins/report/UnitTestReport.java +++ /dev/null @@ -1,296 +0,0 @@ -// Copyright 2025 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package com.google.firebase.gradle.plugins.report; - -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import java.io.FileWriter; -import java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; -import org.gradle.internal.Pair; -import org.gradle.internal.impldep.org.eclipse.jgit.annotations.NonNull; - -@SuppressWarnings("NewApi") -public class UnitTestReport { - private static final Pattern PR_NUMBER_MATCHER = Pattern.compile(".*\\(#([0-9]+)\\)"); - private static final String URL_PREFIX = - "https://api.github.com/repos/firebase/firebase-android-sdk/"; - private static final Gson GSON = new GsonBuilder().create(); - private final HttpClient client; - private final String apiToken; - - public UnitTestReport(@NonNull String apiToken) { - this.client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); - this.apiToken = apiToken; - } - - public void createReport(int commitCount) { - JsonArray response = request("commits", JsonArray.class); - List commits = - response.getAsJsonArray().asList().stream() - .limit(commitCount) - .map( - (el) -> { - JsonObject obj = el.getAsJsonObject(); - int pr = -1; - Matcher matcher = - PR_NUMBER_MATCHER.matcher( - obj.getAsJsonObject("commit").get("message").getAsString()); - if (matcher.find()) { - pr = Integer.parseInt(matcher.group(1)); - } - return new ReportCommit(obj.get("sha").getAsString(), pr); - }) - .toList(); - outputReport(commits); - } - - public void outputReport(@NonNull List commits) { - List reports = new ArrayList<>(); - for (ReportCommit commit : commits) { - reports.addAll(parseTestReports(commit.sha())); - } - StringBuilder output = new StringBuilder(); - output.append("### Unit Tests\n\n"); - output.append( - generateTable( - commits, reports.stream().filter(r -> r.type() == TestReport.Type.UNIT_TEST).toList())); - output.append("\n"); - output.append("### Instrumentation Tests\n\n"); - output.append( - generateTable( - commits, - reports.stream() - .filter(r -> r.type() == TestReport.Type.INSTRUMENTATION_TEST) - .toList())); - output.append("\n"); - - try { - FileWriter writer = new FileWriter("test-report.md"); - writer.append(output.toString()); - writer.close(); - } catch (Exception e) { - throw new RuntimeException("Error writing report file", e); - } - } - - public @NonNull String generateTable( - @NonNull List reportCommits, @NonNull List reports) { - Map commitLookup = - reportCommits.stream().collect(Collectors.toMap(ReportCommit::sha, c -> c)); - List commits = reports.stream().map(TestReport::commit).distinct().toList(); - List sdks = reports.stream().map(TestReport::name).distinct().sorted().toList(); - Map, TestReport> lookup = new HashMap<>(); - for (TestReport report : reports) { - lookup.put(Pair.of(report.name(), report.commit()), report); - } - Map successPercentage = new HashMap<>(); - int passingSdks = 0; - // Get success percentage - for (String sdk : sdks) { - int sdkTestCount = 0; - int sdkTestSuccess = 0; - for (String commit : commits) { - if (lookup.containsKey(Pair.of(sdk, commit))) { - TestReport report = lookup.get(Pair.of(sdk, commit)); - if (report.status() != TestReport.Status.OTHER) { - sdkTestCount++; - if (report.status() == TestReport.Status.SUCCESS) { - sdkTestSuccess++; - } - } - } - } - if (sdkTestSuccess == sdkTestCount) { - passingSdks++; - } - successPercentage.put(sdk, sdkTestSuccess * 100 / sdkTestCount); - } - sdks = - sdks.stream() - .filter(s -> successPercentage.get(s) != 100) - .sorted(Comparator.comparing(successPercentage::get)) - .toList(); - if (sdks.isEmpty()) { - return "*All tests passing*\n"; - } - StringBuilder output = new StringBuilder("| |"); - for (String commit : commits) { - ReportCommit rc = commitLookup.get(commit); - output.append(" "); - if (rc != null && rc.pr() != -1) { - output - .append("[#") - .append(rc.pr()) - .append("](https://github.com/firebase/firebase-android-sdk/pull/") - .append(rc.pr()) - .append(")"); - } else { - output.append(commit); - } - output.append(" |"); - } - output.append(" Success Rate |\n|"); - output.append(" :--- |"); - output.append(" :---: |".repeat(commits.size())); - output.append(" :--- |"); - for (String sdk : sdks) { - output.append("\n| ").append(sdk).append(" |"); - for (String commit : commits) { - if (lookup.containsKey(Pair.of(sdk, commit))) { - TestReport report = lookup.get(Pair.of(sdk, commit)); - String icon = - switch (report.status()) { - case SUCCESS -> "✅"; - case FAILURE -> "⛔"; - case OTHER -> "➖"; - }; - String link = " [%s](%s)".formatted(icon, report.url()); - output.append(link); - } - output.append(" |"); - } - output.append(" "); - int successChance = successPercentage.get(sdk); - if (successChance == 100) { - output.append("✅ 100%"); - } else { - output.append("⛔ ").append(successChance).append("%"); - } - output.append(" |"); - } - output.append("\n"); - if (passingSdks > 0) { - output.append("\n*+").append(passingSdks).append(" passing SDKs*\n"); - } - return output.toString(); - } - - public @NonNull List parseTestReports(@NonNull String commit) { - JsonObject runs = request("actions/runs?head_sha=" + commit); - for (JsonElement el : runs.getAsJsonArray("workflow_runs")) { - JsonObject run = el.getAsJsonObject(); - String name = run.get("name").getAsString(); - if (Objects.equals(name, "CI Tests")) { - return parseCITests(run.get("id").getAsString(), commit); - } - } - return List.of(); - } - - public @NonNull List parseCITests(@NonNull String id, @NonNull String commit) { - List reports = new ArrayList<>(); - JsonObject jobs = request("actions/runs/" + id + "/jobs"); - for (JsonElement el : jobs.getAsJsonArray("jobs")) { - JsonObject job = el.getAsJsonObject(); - String jid = job.get("name").getAsString(); - if (jid.startsWith("Unit Tests (:")) { - reports.add(parseJob(TestReport.Type.UNIT_TEST, job, commit)); - } else if (jid.startsWith("Instrumentation Tests (:")) { - reports.add(parseJob(TestReport.Type.INSTRUMENTATION_TEST, job, commit)); - } - } - return reports; - } - - public @NonNull TestReport parseJob( - @NonNull TestReport.Type type, @NonNull JsonObject job, @NonNull String commit) { - String name = job.get("name").getAsString().split("\\(:")[1]; - name = name.substring(0, name.length() - 1); // Remove trailing ")" - TestReport.Status status = TestReport.Status.OTHER; - if (Objects.equals(job.get("status").getAsString(), "completed")) { - if (Objects.equals(job.get("conclusion").getAsString(), "success")) { - status = TestReport.Status.SUCCESS; - } else { - status = TestReport.Status.FAILURE; - } - } - String url = job.get("html_url").getAsString(); - return new TestReport(name, type, status, commit, url); - } - - private JsonObject request(String path) { - return request(path, JsonObject.class); - } - - private T request(String path, Class clazz) { - return request(URI.create(URL_PREFIX + path), clazz); - } - - /** - * Abstracts away paginated calling. - * Naively joins pages together by merging root level arrays. - */ - private T request(URI uri, Class clazz) { - HttpRequest request = - HttpRequest.newBuilder() - .GET() - .uri(uri) - .header("Authorization", "Bearer " + apiToken) - .build(); - try { - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - String body = response.body(); - if (response.statusCode() >= 300) { - System.err.println(response); - System.err.println(body); - } - T json = GSON.fromJson(body, clazz); - if (json instanceof JsonObject obj) { - // Retrieve and merge objects from other pages, if present - response - .headers() - .firstValue("Link") - .ifPresent( - link -> { - List parts = Arrays.stream(link.split(",")).toList(); - for (String part : parts) { - if (part.endsWith("rel=\"next\"")) { - // ; rel="next" -> foo - String url = part.split(">;")[0].split("<")[1]; - JsonObject p = request(URI.create(url), JsonObject.class); - for (String key : obj.keySet()) { - if (obj.get(key).isJsonArray() && p.has(key) && p.get(key).isJsonArray()) { - obj.getAsJsonArray(key).addAll(p.getAsJsonArray(key)); - } - } - break; - } - } - }); - } - return json; - } catch (IOException | InterruptedException e) { - throw new RuntimeException(e); - } - } -} diff --git a/plugins/src/main/java/com/google/firebase/gradle/plugins/report/UnitTestReport.kt b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/UnitTestReport.kt new file mode 100644 index 00000000000..742e81ddc73 --- /dev/null +++ b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/UnitTestReport.kt @@ -0,0 +1,310 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package com.google.firebase.gradle.plugins.report + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import java.io.FileWriter +import java.io.IOException +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration +import java.util.Arrays +import java.util.function.Function +import java.util.regex.Matcher +import java.util.regex.Pattern +import java.util.stream.Collectors +import org.gradle.internal.Pair + +@SuppressWarnings("NewApi") +class UnitTestReport(private val apiToken: String) { + private val client: HttpClient + + init { + this.client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build() + } + + fun createReport(commitCount: Int) { + val response = request("commits", JsonArray::class.java) + val commits = + response + .getAsJsonArray() + .asList() + .stream() + .limit(commitCount.toLong()) + .map { el: JsonElement -> + val obj = el.getAsJsonObject() + var pr = -1 + val matcher: Matcher = + PR_NUMBER_MATCHER.matcher(obj.getAsJsonObject("commit").get("message").asString) + if (matcher.find()) { + pr = matcher.group(1).toInt() + } + ReportCommit(obj.get("sha").asString, pr) + } + .toList() + outputReport(commits) + } + + private fun outputReport(commits: List) { + val reports: MutableList = ArrayList() + for (commit in commits) { + reports.addAll(parseTestReports(commit.sha)) + } + val output = StringBuilder() + output.append("### Unit Tests\n\n") + output.append( + generateTable( + commits, + reports.stream().filter { r: TestReport -> r.type == TestReport.Type.UNIT_TEST }.toList(), + ) + ) + output.append("\n") + output.append("### Instrumentation Tests\n\n") + output.append( + generateTable( + commits, + reports + .stream() + .filter { r: TestReport -> r.type == TestReport.Type.INSTRUMENTATION_TEST } + .toList(), + ) + ) + output.append("\n") + + try { + val writer = FileWriter("test-report.md") + writer.append(output.toString()) + writer.close() + } catch (e: Exception) { + throw RuntimeException("Error writing report file", e) + } + } + + private fun generateTable(reportCommits: List, reports: List): String { + val commitLookup = + reportCommits + .stream() + .collect(Collectors.toMap(ReportCommit::sha, Function { c: ReportCommit? -> c })) + val commits = reports.stream().map(TestReport::commit).distinct().toList() + var sdks = reports.stream().map(TestReport::name).distinct().sorted().toList() + val lookup: MutableMap, TestReport> = HashMap() + for (report in reports) { + lookup.put(Pair.of(report.name, report.commit), report) + } + val successPercentage: MutableMap = HashMap() + var passingSdks = 0 + // Get success percentage + for (sdk in sdks) { + var sdkTestCount = 0 + var sdkTestSuccess = 0 + for (commit in commits) { + if (lookup.containsKey(Pair.of(sdk, commit))) { + val report: TestReport = lookup.get(Pair.of(sdk, commit))!! + if (report.status != TestReport.Status.OTHER) { + sdkTestCount++ + if (report.status == TestReport.Status.SUCCESS) { + sdkTestSuccess++ + } + } + } + } + if (sdkTestSuccess == sdkTestCount) { + passingSdks++ + } + successPercentage.put(sdk, sdkTestSuccess * 100 / sdkTestCount) + } + sdks = + sdks + .stream() + .filter { s: String? -> successPercentage[s] != 100 } + .sorted(Comparator.comparing { o: String -> successPercentage[o]!! }) + .toList() + if (sdks.isEmpty()) { + return "*All tests passing*\n" + } + val output = StringBuilder("| |") + for (commit in commits) { + val rc = commitLookup.get(commit) + output.append(" ") + if (rc != null && rc.pr != -1) { + output + .append("[#") + .append(rc.pr) + .append("](https://github.com/firebase/firebase-android-sdk/pull/") + .append(rc.pr) + .append(")") + } else { + output.append(commit) + } + output.append(" |") + } + output.append(" Success Rate |\n|") + output.append(" :--- |") + output.append(" :---: |".repeat(commits.size)) + output.append(" :--- |") + for (sdk in sdks) { + output.append("\n| ").append(sdk).append(" |") + for (commit in commits) { + if (lookup.containsKey(Pair.of(sdk, commit))) { + val report: TestReport = lookup[Pair.of(sdk, commit)]!! + val icon = + when (report.status) { + TestReport.Status.SUCCESS -> "✅" + TestReport.Status.FAILURE -> "⛔" + TestReport.Status.OTHER -> "➖" + } + val link: String = " [%s](%s)".format(icon, report.url) + output.append(link) + } + output.append(" |") + } + output.append(" ") + val successChance: Int = successPercentage.get(sdk)!! + if (successChance == 100) { + output.append("✅ 100%") + } else { + output.append("⛔ ").append(successChance).append("%") + } + output.append(" |") + } + output.append("\n") + if (passingSdks > 0) { + output.append("\n*+").append(passingSdks).append(" passing SDKs*\n") + } + return output.toString() + } + + private fun parseTestReports(commit: String): List { + val runs = request("actions/runs?head_sha=" + commit) + for (el in runs.getAsJsonArray("workflow_runs")) { + val run = el.getAsJsonObject() + val name = run.get("name").getAsString() + if (name == "CI Tests") { + return parseCITests(run.get("id").getAsString(), commit) + } + } + return listOf() + } + + private fun parseCITests(id: String, commit: String): List { + val reports: MutableList = ArrayList() + val jobs = request("actions/runs/" + id + "/jobs") + for (el in jobs.getAsJsonArray("jobs")) { + val job = el.getAsJsonObject() + val jid = job.get("name").getAsString() + if (jid.startsWith("Unit Tests (:")) { + reports.add(parseJob(TestReport.Type.UNIT_TEST, job, commit)) + } else if (jid.startsWith("Instrumentation Tests (:")) { + reports.add(parseJob(TestReport.Type.INSTRUMENTATION_TEST, job, commit)) + } + } + return reports + } + + private fun parseJob(type: TestReport.Type, job: JsonObject, commit: String): TestReport { + var name = + job + .get("name") + .getAsString() + .split("\\(:".toRegex()) + .dropLastWhile { it.isEmpty() } + .toTypedArray()[1] + name = name.substring(0, name.length - 1) // Remove trailing ")" + var status = TestReport.Status.OTHER + if (job.get("status").asString == "completed") { + if (job.get("conclusion").asString == "success") { + status = TestReport.Status.SUCCESS + } else { + status = TestReport.Status.FAILURE + } + } + val url = job.get("html_url").getAsString() + return TestReport(name, type, status, commit, url) + } + + private fun request(path: String): JsonObject { + return request(path, JsonObject::class.java) + } + + private fun request(path: String, clazz: Class): T { + return request(URI.create(URL_PREFIX + path), clazz) + } + + /** + * Abstracts away paginated calling. Naively joins pages together by merging root level arrays. + */ + private fun request(uri: URI, clazz: Class): T { + val request = + HttpRequest.newBuilder() + .GET() + .uri(uri) + .header("Authorization", "Bearer $apiToken") + .header("X-GitHub-Api-Version", "2022-11-28") + .build() + try { + val response = client.send(request, HttpResponse.BodyHandlers.ofString()) + val body = response.body() + if (response.statusCode() >= 300) { + System.err.println(response) + System.err.println(body) + } + val json: T = GSON.fromJson(body, clazz) + if (json is JsonObject) { + // Retrieve and merge objects from other pages, if present + response.headers().firstValue("Link").ifPresent { link: String -> + val parts = + Arrays.stream(link.split(",".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()) + .toList() + for (part in parts) { + if (part.endsWith("rel=\"next\"")) { + // ; rel="next" -> foo + val url = + part + .split(">;".toRegex()) + .dropLastWhile { it.isEmpty() } + .toTypedArray()[0] + .split("<".toRegex()) + .dropLastWhile { it.isEmpty() } + .toTypedArray()[1] + val p = request(URI.create(url), JsonObject::class.java) + for (key in json.keySet()) { + if (json.get(key).isJsonArray && p.has(key) && p.get(key).isJsonArray) { + json.getAsJsonArray(key).addAll(p.getAsJsonArray(key)) + } + } + break + } + } + } + } + return json + } catch (e: IOException) { + throw RuntimeException(e) + } catch (e: InterruptedException) { + throw RuntimeException(e) + } + } + + companion object { + private val PR_NUMBER_MATCHER: Pattern = Pattern.compile(".*\\(#([0-9]+)\\)") + private const val URL_PREFIX = "https://api.github.com/repos/firebase/firebase-android-sdk/" + private val GSON: Gson = GsonBuilder().create() + } +} From e157a9fcc61ee4f246796c2171cafbf2dbef215c Mon Sep 17 00:00:00 2001 From: Emily Ploszaj Date: Tue, 30 Sep 2025 16:55:08 -0500 Subject: [PATCH 4/5] Clean up --- .../gradle/plugins/report/ReportCommit.kt | 30 ++++---- .../gradle/plugins/report/TestReport.kt | 29 +++---- .../gradle/plugins/report/UnitTestReport.kt | 76 +++++++------------ 3 files changed, 58 insertions(+), 77 deletions(-) diff --git a/plugins/src/main/java/com/google/firebase/gradle/plugins/report/ReportCommit.kt b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/ReportCommit.kt index 9d9650c6afe..a2620cac385 100644 --- a/plugins/src/main/java/com/google/firebase/gradle/plugins/report/ReportCommit.kt +++ b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/ReportCommit.kt @@ -1,16 +1,18 @@ -// Copyright 2025 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package com.google.firebase.gradle.plugins.report -@JvmRecord data class ReportCommit(val sha: String, val pr: Int) +data class ReportCommit(val sha: String, val pr: Int) diff --git a/plugins/src/main/java/com/google/firebase/gradle/plugins/report/TestReport.kt b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/TestReport.kt index 79e6ed50814..69f5a3f38e0 100644 --- a/plugins/src/main/java/com/google/firebase/gradle/plugins/report/TestReport.kt +++ b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/TestReport.kt @@ -1,19 +1,20 @@ -// Copyright 2025 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package com.google.firebase.gradle.plugins.report -@JvmRecord data class TestReport( val name: String, val type: Type, diff --git a/plugins/src/main/java/com/google/firebase/gradle/plugins/report/UnitTestReport.kt b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/UnitTestReport.kt index 742e81ddc73..bdebda83068 100644 --- a/plugins/src/main/java/com/google/firebase/gradle/plugins/report/UnitTestReport.kt +++ b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/UnitTestReport.kt @@ -1,16 +1,18 @@ -// Copyright 2025 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package com.google.firebase.gradle.plugins.report import com.google.gson.Gson @@ -25,23 +27,17 @@ import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse import java.time.Duration -import java.util.Arrays -import java.util.function.Function import java.util.regex.Matcher import java.util.regex.Pattern -import java.util.stream.Collectors import org.gradle.internal.Pair @SuppressWarnings("NewApi") class UnitTestReport(private val apiToken: String) { - private val client: HttpClient - - init { - this.client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build() - } + private val client: HttpClient = + HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build() fun createReport(commitCount: Int) { - val response = request("commits", JsonArray::class.java) + val response = request("commits?per_page=$commitCount", JsonArray::class.java) val commits = response .getAsJsonArray() @@ -72,7 +68,7 @@ class UnitTestReport(private val apiToken: String) { output.append( generateTable( commits, - reports.stream().filter { r: TestReport -> r.type == TestReport.Type.UNIT_TEST }.toList(), + reports.filter { r: TestReport -> r.type == TestReport.Type.UNIT_TEST }, ) ) output.append("\n") @@ -80,10 +76,7 @@ class UnitTestReport(private val apiToken: String) { output.append( generateTable( commits, - reports - .stream() - .filter { r: TestReport -> r.type == TestReport.Type.INSTRUMENTATION_TEST } - .toList(), + reports.filter { r: TestReport -> r.type == TestReport.Type.INSTRUMENTATION_TEST }, ) ) output.append("\n") @@ -98,16 +91,10 @@ class UnitTestReport(private val apiToken: String) { } private fun generateTable(reportCommits: List, reports: List): String { - val commitLookup = - reportCommits - .stream() - .collect(Collectors.toMap(ReportCommit::sha, Function { c: ReportCommit? -> c })) - val commits = reports.stream().map(TestReport::commit).distinct().toList() - var sdks = reports.stream().map(TestReport::name).distinct().sorted().toList() - val lookup: MutableMap, TestReport> = HashMap() - for (report in reports) { - lookup.put(Pair.of(report.name, report.commit), report) - } + val commitLookup = reportCommits.associateBy(ReportCommit::sha) + val commits = reports.map(TestReport::commit).distinct() + var sdks = reports.map(TestReport::name).distinct().sorted() + val lookup = reports.associateBy({ report -> Pair.of(report.name, report.commit) }) val successPercentage: MutableMap = HashMap() var passingSdks = 0 // Get success percentage @@ -132,10 +119,8 @@ class UnitTestReport(private val apiToken: String) { } sdks = sdks - .stream() .filter { s: String? -> successPercentage[s] != 100 } - .sorted(Comparator.comparing { o: String -> successPercentage[o]!! }) - .toList() + .sortedBy { o: String -> successPercentage[o]!! } if (sdks.isEmpty()) { return "*All tests passing*\n" } @@ -144,12 +129,7 @@ class UnitTestReport(private val apiToken: String) { val rc = commitLookup.get(commit) output.append(" ") if (rc != null && rc.pr != -1) { - output - .append("[#") - .append(rc.pr) - .append("](https://github.com/firebase/firebase-android-sdk/pull/") - .append(rc.pr) - .append(")") + output.append("[#${rc.pr}](https://github.com/firebase/firebase-android-sdk/pull/${rc.pr})") } else { output.append(commit) } @@ -269,9 +249,7 @@ class UnitTestReport(private val apiToken: String) { if (json is JsonObject) { // Retrieve and merge objects from other pages, if present response.headers().firstValue("Link").ifPresent { link: String -> - val parts = - Arrays.stream(link.split(",".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()) - .toList() + val parts = link.split(",".toRegex()).dropLastWhile { it.isEmpty() } for (part in parts) { if (part.endsWith("rel=\"next\"")) { // ; rel="next" -> foo From b272f9aebc0658394d11c97ed68f06904f5d2bb3 Mon Sep 17 00:00:00 2001 From: Emily Ploszaj Date: Wed, 1 Oct 2025 16:26:30 -0500 Subject: [PATCH 5/5] Use kotlin serialization --- .../gradle/plugins/report/UnitTestReport.kt | 73 ++++++++++--------- 1 file changed, 40 insertions(+), 33 deletions(-) diff --git a/plugins/src/main/java/com/google/firebase/gradle/plugins/report/UnitTestReport.kt b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/UnitTestReport.kt index bdebda83068..820734f5f6c 100644 --- a/plugins/src/main/java/com/google/firebase/gradle/plugins/report/UnitTestReport.kt +++ b/plugins/src/main/java/com/google/firebase/gradle/plugins/report/UnitTestReport.kt @@ -15,11 +15,10 @@ */ package com.google.firebase.gradle.plugins.report -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import com.google.gson.JsonArray -import com.google.gson.JsonElement -import com.google.gson.JsonObject +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject import java.io.FileWriter import java.io.IOException import java.net.URI @@ -30,6 +29,8 @@ import java.time.Duration import java.util.regex.Matcher import java.util.regex.Pattern import org.gradle.internal.Pair +import java.util.stream.Stream +import kotlin.streams.toList @SuppressWarnings("NewApi") class UnitTestReport(private val apiToken: String) { @@ -40,19 +41,17 @@ class UnitTestReport(private val apiToken: String) { val response = request("commits?per_page=$commitCount", JsonArray::class.java) val commits = response - .getAsJsonArray() - .asList() .stream() .limit(commitCount.toLong()) .map { el: JsonElement -> - val obj = el.getAsJsonObject() + val obj = el as JsonObject var pr = -1 val matcher: Matcher = - PR_NUMBER_MATCHER.matcher(obj.getAsJsonObject("commit").get("message").asString) + PR_NUMBER_MATCHER.matcher((obj["commit"] as JsonObject)["message"].toString()) if (matcher.find()) { pr = matcher.group(1).toInt() } - ReportCommit(obj.get("sha").asString, pr) + ReportCommit(obj["sha"].toString(), pr) } .toList() outputReport(commits) @@ -173,11 +172,11 @@ class UnitTestReport(private val apiToken: String) { private fun parseTestReports(commit: String): List { val runs = request("actions/runs?head_sha=" + commit) - for (el in runs.getAsJsonArray("workflow_runs")) { - val run = el.getAsJsonObject() - val name = run.get("name").getAsString() + for (el in runs["workflow_runs"] as JsonArray) { + val run = el as JsonObject + val name = run["name"].toString() if (name == "CI Tests") { - return parseCITests(run.get("id").getAsString(), commit) + return parseCITests(run["id"].toString(), commit) } } return listOf() @@ -186,9 +185,9 @@ class UnitTestReport(private val apiToken: String) { private fun parseCITests(id: String, commit: String): List { val reports: MutableList = ArrayList() val jobs = request("actions/runs/" + id + "/jobs") - for (el in jobs.getAsJsonArray("jobs")) { - val job = el.getAsJsonObject() - val jid = job.get("name").getAsString() + for (el in jobs["jobs"] as JsonArray) { + val job = el as JsonObject + val jid = job["name"].toString() if (jid.startsWith("Unit Tests (:")) { reports.add(parseJob(TestReport.Type.UNIT_TEST, job, commit)) } else if (jid.startsWith("Instrumentation Tests (:")) { @@ -200,22 +199,21 @@ class UnitTestReport(private val apiToken: String) { private fun parseJob(type: TestReport.Type, job: JsonObject, commit: String): TestReport { var name = - job - .get("name") - .getAsString() + job["name"] + .toString() .split("\\(:".toRegex()) .dropLastWhile { it.isEmpty() } .toTypedArray()[1] name = name.substring(0, name.length - 1) // Remove trailing ")" var status = TestReport.Status.OTHER - if (job.get("status").asString == "completed") { - if (job.get("conclusion").asString == "success") { + if (job["status"].toString() == "completed") { + if (job["conclusion"].toString() == "success") { status = TestReport.Status.SUCCESS } else { status = TestReport.Status.FAILURE } } - val url = job.get("html_url").getAsString() + val url = job["html_url"].toString() return TestReport(name, type, status, commit, url) } @@ -245,10 +243,14 @@ class UnitTestReport(private val apiToken: String) { System.err.println(response) System.err.println(body) } - val json: T = GSON.fromJson(body, clazz) + val json = when (clazz) { + JsonObject::class.java -> Json.decodeFromString(body) + JsonArray::class.java -> Json.decodeFromString(body) + else -> throw IllegalArgumentException() + } if (json is JsonObject) { // Retrieve and merge objects from other pages, if present - response.headers().firstValue("Link").ifPresent { link: String -> + return response.headers().firstValue("Link").map { link: String -> val parts = link.split(",".toRegex()).dropLastWhile { it.isEmpty() } for (part in parts) { if (part.endsWith("rel=\"next\"")) { @@ -262,17 +264,20 @@ class UnitTestReport(private val apiToken: String) { .dropLastWhile { it.isEmpty() } .toTypedArray()[1] val p = request(URI.create(url), JsonObject::class.java) - for (key in json.keySet()) { - if (json.get(key).isJsonArray && p.has(key) && p.get(key).isJsonArray) { - json.getAsJsonArray(key).addAll(p.getAsJsonArray(key)) + return@map JsonObject(json.keys.associateWith { + key: String -> + + if (json[key] is JsonArray && p.containsKey(key) && p[key] is JsonArray) { + JsonArray(Stream.concat((json[key] as JsonArray).stream(), (p[key] as JsonArray).stream()).toList()) } - } - break + json[key]!! + }) } } - } + return@map json + }.orElse(json) as T } - return json + return json as T } catch (e: IOException) { throw RuntimeException(e) } catch (e: InterruptedException) { @@ -281,8 +286,10 @@ class UnitTestReport(private val apiToken: String) { } companion object { + /* + * Matches commit names for their PR number generated by GitHub, eg, `foo bar (#1234)`. + */ private val PR_NUMBER_MATCHER: Pattern = Pattern.compile(".*\\(#([0-9]+)\\)") private const val URL_PREFIX = "https://api.github.com/repos/firebase/firebase-android-sdk/" - private val GSON: Gson = GsonBuilder().create() } }