Files
openide/platform/jewel/scripts/annotate-api-dump-changes.main.kts
Sebastiano Poggi 4bf638d106 [JEWEL-977] Validate IJP API dumps are up-to-date on CI
This change adds a script to validate that the IJP API dumps are up-to-date,
and runs it in the GitHub Actions CI. It also rewrites the
validate-api-dump-changes.main.kts script to use clikt as well.

closes https://github.com/JetBrains/intellij-community/pull/3245

(cherry picked from commit c11e3fecf8d04d2abc9dbd027bcc7cbcce8cda22)


(cherry picked from commit 2af726c6cd283a4b46dcc35bea04c8a53c780141)

IJ-MR-179919

GitOrigin-RevId: 7f80e6e540ee0769d5c9228de2c0ce8047d0b90c
2025-10-31 12:48:53 +00:00

242 lines
9.5 KiB
Kotlin
Executable File

#!/usr/bin/env kotlin
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
@file:Import("utils.main.kts")
@file:DependsOn("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1")
@file:DependsOn("com.github.ajalt.clikt:clikt-jvm:5.0.3")
@file:Suppress("RAW_RUN_BLOCKING")
import com.github.ajalt.clikt.command.SuspendingCliktCommand
import com.github.ajalt.clikt.command.main
import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import java.io.File
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
private class AnnotateApiDumpChangesCommand : SuspendingCliktCommand(name = "annotate") {
private val verbose: Boolean by option("--verbose", "-v", help = "Enable verbose output.").flag(default = false)
override fun help(context: Context): String = "Annotates API dump files for breaking changes against a base commit."
override suspend fun run() {
print("⏳ Locating Jewel root...")
val jewelRoot = findJewelRoot() ?: exitWithError("Could not find the Jewel root directory.")
println(" DONE: ${jewelRoot.canonicalPath}")
val baseCommit = determineBaseCommit(jewelRoot)
println("Checking against base commit: $baseCommit")
println("\nValidating stable API dumps...")
val stableViolations = validateDumps(experimental = false, baseCommit, jewelRoot) { it.name == "api-dump.txt" }
println("\nValidating experimental API dumps...")
val experimentalViolations =
validateDumps(experimental = true, baseCommit, jewelRoot) { it.name == "api-dump-experimental.txt" }
println("\nWriting summary...")
writeSummary(stableViolations, experimentalViolations)
println("\nDone processing API dumps")
if (stableViolations) {
exitWithError("Stable API breakages found.")
} else {
printlnSuccess("✅ No stable API breakages found.")
}
}
private suspend fun determineBaseCommit(jewelRoot: File): String =
if (checkPrNumber()) {
requireGhTool()
runCommand("gh pr view ${getPrNumber()} --json baseRefOid -q .baseRefOid", jewelRoot).getOrThrow().trim()
} else {
printlnWarn("GitHub PR number not found, falling back to checking against HEAD~1 instead")
runCommand("git rev-parse HEAD~1", jewelRoot).getOrThrow().trim()
}
private data class ValidationResult(val file: File, val log: CharSequence, val foundBreakages: Boolean)
private suspend fun validateDumps(
experimental: Boolean,
baseCommit: String,
jewelRoot: File,
dumpsFilter: (File) -> Boolean,
): Boolean {
val samplesDir: String = File(jewelRoot, "samples").absolutePath
val apiDumpFiles =
jewelRoot.walkTopDown().filter { dumpsFilter(it) && !it.absolutePath.startsWith(samplesDir) }.toList()
println()
println("Detected API dumps:\n${apiDumpFiles.joinToString("\n") { " * ${it.toRelativeString(jewelRoot)}" }}")
val results = coroutineScope {
apiDumpFiles
.map { file ->
async {
var foundBreakages = false
val log = buildString {
appendLine("\n Checking ${file.toRelativeString(jewelRoot)}")
val isModifiedResult =
runCommand(
"git diff --quiet $baseCommit -- ${file.absolutePath}",
jewelRoot,
exitOnError = false,
)
if (verbose) {
appendLine(" First diff result:\n${isModifiedResult.output}")
}
if (isModifiedResult.isFailure) {
appendLine(" Detected changes, investigating...")
val command = "git --no-pager diff --unified=0 $baseCommit -- ${file.absolutePath}"
if (verbose) {
appendLine(" Running: $command...")
}
val diffResult = runCommand(command, jewelRoot)
if (verbose) {
appendLine(" Second diff result:\n${diffResult.output}")
}
foundBreakages = processDiff(diffResult.output, file, experimental, this, jewelRoot)
}
}
ValidationResult(file, log, foundBreakages)
}
}
.awaitAll()
}
results.forEach { result -> print(result.log) }
return results.any { it.foundBreakages }
}
private val chunkHeaderRegex = "^@@ \\-([0-9]+)(?:,[0-9]+)? \\+([0-9]+)".toRegex()
private fun processDiff(
diff: String,
file: File,
experimental: Boolean,
log: StringBuilder,
jewelRoot: File,
): Boolean {
var foundBreakages = false
var oldLineNum = 0
var newLineNum = 0
var lastLineWasRemoval = false
diff.lines().forEach { line ->
when {
line.startsWith("@@") -> {
val match = chunkHeaderRegex.find(line)
if (match != null) {
oldLineNum = match.groupValues[1].toInt()
newLineNum = match.groupValues[2].toInt()
}
lastLineWasRemoval = false
}
line.startsWith("-") && !line.startsWith("---") -> {
reportBreakage(
file,
line,
log,
oldLineNum,
newLineNum,
experimental,
annotate = !lastLineWasRemoval,
jewelRoot,
)
foundBreakages = true
oldLineNum++
lastLineWasRemoval = true
}
line.startsWith("+") && !line.startsWith("+++") -> {
newLineNum++
lastLineWasRemoval = false
}
line.startsWith(" ") -> {
oldLineNum++
newLineNum++
lastLineWasRemoval = false
}
}
}
return foundBreakages
}
private fun reportBreakage(
file: File,
line: String,
log: StringBuilder,
oldLineNum: Int,
newLineNum: Int,
experimental: Boolean,
annotate: Boolean,
jewelRoot: File,
) {
val lineContent =
line
.substring(1) // Skip the first character (either + or -)
.replace("%", "%25") // Escape the % character
.replace("\r", "%0D") // Escape the \r character
.replace("\n", "%0A") // Escape the \n character
val type = if (experimental) "experimental" else "stable"
val message = "⚠️ Breaking $type API change:\n line $oldLineNum removed: $lineContent"
log.appendLine(" " + if (experimental) message.asWarning() else message.asError())
if (!annotate) return
// Use the magic log format that GitHub workflows accept to annotate code
// See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands
val severity = if (experimental) "warning" else "error"
log.append("::$severity ")
val repoRoot = jewelRoot.parentFile.parentFile
log.append("file=${file.toRelativeString(repoRoot)},")
log.append("line=$newLineNum,")
val title = if (experimental) "Breaking experimental API change" else "Breaking API change"
log.appendLine("title=$title::This looks like a breaking API change, make sure it's intended.")
}
private fun writeSummary(stableViolations: Boolean, experimentalViolations: Boolean) {
val summary = buildString {
appendLine("## Binary check result")
if (!stableViolations && !experimentalViolations) {
appendLine("✅ No API breakages found.")
} else {
if (stableViolations) {
appendLine("❌ Stable API breakages found.")
}
if (experimentalViolations) {
appendLine("⚠️ Experimental API breakages found.")
}
}
}
println(summary.prependIndent())
// Write the summary to the magical GITHUB_STEP_SUMMARY file
// See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands
val summaryFile = System.getenv("GITHUB_STEP_SUMMARY")?.takeIf { !it.isBlank() }?.let { File(it) }
if (summaryFile != null) {
summaryFile.writeText(summary)
println("Summary written to ${summaryFile.absolutePath}")
} else {
printlnWarn("GITHUB_STEP_SUMMARY environment variable not set")
}
}
}
runBlocking { AnnotateApiDumpChangesCommand().main(args) }