Skip to content

Commit 11c7bdc

Browse files
authored
Merge fe91d08 into 4832b5d
2 parents 4832b5d + fe91d08 commit 11c7bdc

File tree

6 files changed

+405
-0
lines changed

6 files changed

+405
-0
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: Generate Test Report
2+
3+
on:
4+
schedule:
5+
- cron: 51 8 * * * # Runs automatically once a day
6+
workflow_dispatch: # Allow triggering the workflow manually
7+
8+
permissions:
9+
contents: read
10+
issues: write
11+
12+
jobs:
13+
report:
14+
name: "Generate Test Report"
15+
runs-on: ubuntu-latest
16+
17+
steps:
18+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
19+
with:
20+
submodules: true
21+
22+
- name: Set up JDK 17
23+
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0
24+
with:
25+
java-version: 17
26+
distribution: temurin
27+
cache: gradle
28+
29+
- name: Generate Test Report
30+
env:
31+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
32+
run: ./gradlew generateTestReport
33+
34+
- name: Update tracking issue
35+
run: gh issue edit 7421 --body-file test-report.md

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ plugins {
2323
alias(libs.plugins.errorprone)
2424
alias(libs.plugins.crashlytics) apply false
2525
id("PublishingPlugin")
26+
id("test-report")
2627
id("firebase-ci")
2728
id("smoke-tests")
2829
alias(libs.plugins.google.services)

plugins/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ gradlePlugin {
114114
id = "copy-google-services"
115115
implementationClass = "com.google.firebase.gradle.plugins.CopyGoogleServicesPlugin"
116116
}
117+
register("testReportPlugin") {
118+
id = "test-report"
119+
implementationClass = "com.google.firebase.gradle.plugins.report.UnitTestReportPlugin"
120+
}
117121
}
118122
}
119123

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

0 commit comments

Comments
 (0)