You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
524 lines
20 KiB
524 lines
20 KiB
/** |
|
* Script for creating a changelog. Call: |
|
* ``` |
|
* kotlin changelog.main.kts release/1.6.0 1.6.0-rc02 |
|
* ``` |
|
* where: |
|
* release/1.6.0 - the commit of the version |
|
* 1.6.0-rc02 - the name of the version |
|
* |
|
* It modifies CHANGELOG.md and adds new changes between the last version in CHANGELOG.md and the specified version. |
|
* |
|
* Changelog entries are generated from reading Release Notes in GitHub PR's. |
|
* |
|
* ## How to run Kotlin scripts |
|
* Option 1 - via Command line |
|
* 1. Download https://github.com/JetBrains/kotlin/releases/tag/v1.9.22 and add `bin` to PATH |
|
* |
|
* Option 2 - via IntelliJ: |
|
* 1. Right click on the script |
|
* 2. More Run/Debug |
|
* 3. Modify Run Configuration... |
|
* 4. Add Program arguments |
|
* 5. Clear all "Before launch" tasks (you can edit the system-wide template as well) |
|
* 6. OK |
|
*/ |
|
|
|
@file:Repository("https://repo1.maven.org/maven2/") |
|
@file:DependsOn("com.google.code.gson:gson:2.10.1") |
|
|
|
import com.google.gson.Gson |
|
import java.io.IOException |
|
import java.lang.ProcessBuilder.Redirect |
|
import java.net.URL |
|
import java.net.URLEncoder |
|
import java.nio.charset.StandardCharsets.UTF_8 |
|
import java.time.LocalDate |
|
import java.time.format.DateTimeFormatter |
|
import java.util.* |
|
import kotlin.text.substringAfterLast |
|
|
|
//region ========================================== CONSTANTS ========================================= |
|
|
|
// Sections from the template https://github.com/JetBrains/compose-multiplatform/blob/master/.github/PULL_REQUEST_TEMPLATE.md?plain=1 |
|
// Changelog should contain only these categories |
|
|
|
val standardSections = listOf( |
|
"Highlights", |
|
"Known Issues", |
|
"Breaking Changes", |
|
"Features", |
|
"Fixes", |
|
) |
|
|
|
val standardSubsections = listOf( |
|
"Multiple Platforms", |
|
"iOS", |
|
"Desktop", |
|
"Web", |
|
"Android", |
|
"Resources", |
|
"Gradle Plugin", |
|
"Lifecycle", |
|
"Navigation", |
|
) |
|
|
|
val changelogFile = __FILE__.resolve("../../CHANGELOG.md").canonicalFile |
|
|
|
//endregion |
|
|
|
val argsKeyless = args |
|
.filter { !it.contains("=") } |
|
|
|
val argsKeyToValue = args |
|
.filter { it.contains("=") } |
|
.associate { it.substringBefore("=") to it.substringAfter("=") } |
|
|
|
val versionCommit = argsKeyless.getOrNull(0) ?: "HEAD" |
|
val token = argsKeyToValue["token"] |
|
|
|
println("Note. The script supports optional arguments: kotlin changelog.main.kts [versionCommit] [versionName] [token=githubToken]") |
|
if (token == null) { |
|
println("To increase the rate limit, specify token (https://github.com/settings/tokens)") |
|
} |
|
println() |
|
|
|
val androidxLibToVersion = androidxLibToVersion(versionCommit) |
|
val androidxLibToRedirectingVersion = androidxLibToRedirectingVersion(versionCommit) |
|
|
|
fun formatAndroidxLibVersion(libName: String) = |
|
androidxLibToVersion[libName] ?: "PLACEHOLDER".also { |
|
println("Can't find $libName version. Using PLACEHOLDER") |
|
} |
|
|
|
fun formatAndroidxLibRedirectingVersion(libName: String) = |
|
androidxLibToRedirectingVersion[libName] ?: "PLACEHOLDER".also { |
|
println("Can't find $libName redirecting version. Using PLACEHOLDER") |
|
} |
|
|
|
val versionCompose = formatAndroidxLibVersion("COMPOSE") |
|
val versionComposeMaterial3Adaptive = formatAndroidxLibVersion("COMPOSE_MATERIAL3_ADAPTIVE") |
|
val versionLifecycle = formatAndroidxLibVersion("LIFECYCLE") |
|
val versionNavigation = formatAndroidxLibVersion("NAVIGATION") |
|
|
|
val versionRedirectingCompose = formatAndroidxLibRedirectingVersion("compose") |
|
val versionRedirectingComposeFoundation = formatAndroidxLibRedirectingVersion("compose.foundation") |
|
val versionRedirectingComposeMaterial = formatAndroidxLibRedirectingVersion("compose.material") |
|
val versionRedirectingComposeMaterial3 = formatAndroidxLibRedirectingVersion("compose.material3") |
|
val versionRedirectingComposeMaterial3Adaptive = formatAndroidxLibRedirectingVersion("compose.material3.adaptive") |
|
val versionRedirectingLifecycle = formatAndroidxLibRedirectingVersion("lifecycle") |
|
val versionRedirectingNavigation = formatAndroidxLibRedirectingVersion("navigation") |
|
|
|
val versionName = versionCompose |
|
|
|
val currentChangelog = changelogFile.readText() |
|
val previousChangelog = |
|
if (currentChangelog.startsWith("# $versionName ")) { |
|
val nextChangelogIndex = currentChangelog.indexOf("\n# ") |
|
currentChangelog.substring(nextChangelogIndex).removePrefix("\n") |
|
} else { |
|
currentChangelog |
|
} |
|
|
|
val previousVersion = previousChangelog.substringAfter("# ").substringBefore(" (") |
|
|
|
println() |
|
println("Generating changelog between $previousVersion and $versionName") |
|
|
|
val newChangelog = getChangelog("v$previousVersion", versionCommit, previousVersion) |
|
|
|
changelogFile.writeText( |
|
newChangelog + previousChangelog |
|
) |
|
|
|
println() |
|
println("CHANGELOG.md changed") |
|
|
|
|
|
fun getChangelog(firstCommit: String, lastCommit: String, firstVersion: String): String { |
|
val entries = entriesForRepo("JetBrains/compose-multiplatform-core", firstCommit, lastCommit) + |
|
entriesForRepo("JetBrains/compose-multiplatform", firstCommit, lastCommit) |
|
|
|
return buildString { |
|
appendLine("# $versionName (${currentChangelogDate()})") |
|
|
|
appendLine() |
|
appendLine("_Changes since ${firstVersion}_") |
|
appendLine() |
|
|
|
entries |
|
.sortedBy { it.sectionOrder() } |
|
.groupBy { it.sectionName() } |
|
.forEach { (section, sectionEntries) -> |
|
appendLine("## $section") |
|
appendLine() |
|
|
|
sectionEntries |
|
.sortedBy { it.subsectionOrder() } |
|
.groupBy { it.subsectionName() } |
|
.forEach { (subsection, subsectionEntries) -> |
|
appendLine("### $subsection") |
|
appendLine() |
|
subsectionEntries.forEach { |
|
appendLine(it.format()) |
|
} |
|
appendLine() |
|
} |
|
} |
|
|
|
append( |
|
""" |
|
## Dependencies |
|
|
|
- Gradle Plugin `org.jetbrains.compose`, version `$versionCompose`. Based on Jetpack Compose libraries: |
|
- [Runtime $versionRedirectingCompose](https://developer.android.com/jetpack/androidx/releases/compose-runtime#$versionRedirectingCompose) |
|
- [UI $versionRedirectingCompose](https://developer.android.com/jetpack/androidx/releases/compose-ui#$versionRedirectingCompose) |
|
- [Foundation $versionRedirectingComposeFoundation](https://developer.android.com/jetpack/androidx/releases/compose-foundation#$versionRedirectingComposeFoundation) |
|
- [Material $versionRedirectingComposeMaterial](https://developer.android.com/jetpack/androidx/releases/compose-material#$versionRedirectingComposeMaterial) |
|
- [Material3 $versionRedirectingComposeMaterial3](https://developer.android.com/jetpack/androidx/releases/compose-material3#$versionRedirectingComposeMaterial3) |
|
|
|
- Lifecycle libraries `org.jetbrains.androidx.lifecycle:lifecycle-*:$versionLifecycle`. Based on [Jetpack Lifecycle $versionRedirectingLifecycle](https://developer.android.com/jetpack/androidx/releases/lifecycle#$versionRedirectingLifecycle) |
|
- Navigation libraries `org.jetbrains.androidx.navigation:navigation-*:$versionNavigation`. Based on [Jetpack Navigation $versionRedirectingNavigation](https://developer.android.com/jetpack/androidx/releases/navigation#$versionRedirectingNavigation) |
|
- Material3 Adaptive libraries `org.jetbrains.compose.material3.adaptive:adaptive*:$versionComposeMaterial3Adaptive`. Based on [Jetpack Material3 Adaptive $versionRedirectingComposeMaterial3Adaptive](https://developer.android.com/jetpack/androidx/releases/compose-material3-adaptive#$versionRedirectingComposeMaterial3Adaptive) |
|
|
|
--- |
|
""".trimIndent() |
|
) |
|
|
|
appendLine() |
|
appendLine() |
|
|
|
val nonstandardSectionEntries = entries |
|
.filter { |
|
it.section != null && it.subsection != null |
|
&& it.section !in standardSections && it.subsection !in standardSubsections |
|
} |
|
|
|
if (nonstandardSectionEntries.isNotEmpty()) { |
|
println() |
|
println("WARNING! Changelog contains nonstandard sections. Please change them to the standard ones, or enhance the list in the PR template.") |
|
|
|
for (entry in nonstandardSectionEntries) { |
|
println("${entry.section} - ${entry.subsection} in ${entry.link}") |
|
} |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* September 2024 |
|
*/ |
|
fun currentChangelogDate() = LocalDate.now().format(DateTimeFormatter.ofPattern("MMMM yyyy", Locale.ENGLISH)) |
|
|
|
/** |
|
* Formats: |
|
* - A new approach to implementation of `platformLayers`. Now extra layers (such as Dialogs and Popups) drawing is merged into a single screen size canvas. |
|
* |
|
* to: |
|
* - [A new approach to implementation of `platformLayers`](link). Now extra layers (such as Dialogs and Popups) drawing is merged into a single screen size canvas. |
|
*/ |
|
fun ChangelogEntry.format(): String { |
|
return try { |
|
tryFormat() |
|
} catch (e: Exception) { |
|
throw RuntimeException("Formatting error of ChangelogEntry. Message:\n$message", e) |
|
} |
|
} |
|
|
|
fun ChangelogEntry.tryFormat(): String { |
|
return if (link != null) { |
|
val prefixRegex = "^[-\\s]*" // "- " |
|
val tagRegex1 = "\\(.*\\)\\s*" // "(something) " |
|
val tagRegex2 = "\\[.*\\]\\s*" // "[something] " |
|
val tagRegex3 = "_.*_\\s*" // "_something_ " |
|
val linkStartIndex = maxOf( |
|
message.endIndexOfFirstGroup(Regex("($prefixRegex).*"))?.plus(1) ?: 0, |
|
message.endIndexOfFirstGroup(Regex("($prefixRegex$tagRegex1).*"))?.plus(1) ?: 0, |
|
message.endIndexOfFirstGroup(Regex("($prefixRegex$tagRegex2).*"))?.plus(1) ?: 0, |
|
message.endIndexOfFirstGroup(Regex("($prefixRegex$tagRegex3).*"))?.plus(1) ?: 0, |
|
) |
|
val linkLastIndex = message.indexOfAny(listOf(". ", " ("), linkStartIndex).ifNegative { message.length } |
|
|
|
val beforeLink = message.substring(0, linkStartIndex) |
|
val inLink = message.substring(linkStartIndex, linkLastIndex).removeLinks() |
|
val afterLink = message.substring(linkLastIndex, message.length) |
|
|
|
"$beforeLink[$inLink]($link)$afterLink" |
|
} else { |
|
message |
|
} |
|
} |
|
|
|
fun Int.ifNegative(value: () -> Int): Int = if (this < 0) value() else this |
|
|
|
fun String.endIndexOfFirstGroup(regex: Regex): Int? = |
|
regex.find(this)?.groups?.toList()?.getOrNull(1)?.range?.endInclusive |
|
|
|
/** |
|
* Converts: |
|
* Message (title)[some link], message |
|
* |
|
* to: |
|
* Message title, message |
|
*/ |
|
fun String.removeLinks(): String = replace(Regex("\\[([^)]*)\\]\\([^\\]]*\\)"), "$1") |
|
|
|
/** |
|
* Extract by format https://github.com/JetBrains/compose-multiplatform/blob/master/.github/PULL_REQUEST_TEMPLATE.md?plain=1 |
|
*/ |
|
fun GitHubPullEntry.extractReleaseNotes(link: String): List<ChangelogEntry> { |
|
// extract body inside "## Release Notes" |
|
val relNoteBody = run { |
|
val after = body?.substringAfter("## Release Notes", "")?.ifBlank { null } |
|
?: body?.substringAfter("## Release notes", "")?.ifBlank { null } ?: body?.substringAfter( |
|
"## RelNote", |
|
"" |
|
)?.ifBlank { null } |
|
|
|
val before = after?.substringBefore("\n## ", "")?.ifBlank { null } ?: after?.substringBefore("\n# ", "") |
|
?.ifBlank { null } ?: after |
|
|
|
before?.trim() |
|
} |
|
|
|
if (relNoteBody?.trim()?.lowercase() == "n/a") return emptyList() |
|
|
|
val list = mutableListOf<ChangelogEntry>() |
|
var section: String? = null |
|
var subsection: String? = null |
|
var isFirstLine = true |
|
var shouldPadLines = false |
|
|
|
for (line in relNoteBody.orEmpty().split("\n")) { |
|
// parse "### Section - Subsection" |
|
if (line.startsWith("### ")) { |
|
val s = line.removePrefix("### ") |
|
section = s.substringBefore("-", "").trim().normalizeSectionName().ifEmpty { null } |
|
subsection = s.substringAfter("-", "").trim().normalizeSubsectionName().ifEmpty { null } |
|
isFirstLine = true |
|
shouldPadLines = false |
|
} else if (section != null && line.isNotBlank()) { |
|
var lineFixed = line |
|
|
|
if (isFirstLine && !lineFixed.startsWith("-")) { |
|
lineFixed = "- $lineFixed" |
|
shouldPadLines = true |
|
} |
|
if (!isFirstLine && shouldPadLines) { |
|
lineFixed = " $lineFixed" |
|
} |
|
lineFixed = lineFixed.trimEnd().removeSuffix(".") |
|
|
|
val isTopLevel = lineFixed.startsWith("-") |
|
list.add( |
|
ChangelogEntry( |
|
lineFixed, |
|
section, |
|
subsection, |
|
link.takeIf { isTopLevel } |
|
) |
|
) |
|
isFirstLine = false |
|
} |
|
} |
|
|
|
return list |
|
} |
|
|
|
/** |
|
* @param repo Example: |
|
* JetBrains/compose-multiplatform-core |
|
*/ |
|
fun entriesForRepo(repo: String, firstCommit: String, lastCommit: String): List<ChangelogEntry> { |
|
val pulls = (1..5) |
|
.flatMap { |
|
requestJson<Array<GitHubPullEntry>>("https://api.github.com/repos/$repo/pulls?state=closed&per_page=100&page=$it").toList() |
|
} |
|
|
|
val pullNumberToPull = pulls.associateBy { it.number } |
|
val pullTitleToPull = pulls.associateBy { it.title } |
|
|
|
fun prForCommit(commit: GitHubCompareResponse.CommitEntry): GitHubPullEntry? { |
|
val (repoTitle, repoNumber) = repoTitleAndNumberForCommit(commit) |
|
return repoNumber?.let(pullNumberToPull::get) ?: pullTitleToPull[repoTitle] |
|
} |
|
|
|
fun changelogEntriesFor( |
|
pullRequest: GitHubPullEntry? |
|
): List<ChangelogEntry> { |
|
return if (pullRequest != null) { |
|
val prTitle = pullRequest.title |
|
val prNumber = pullRequest.number |
|
val prLink = "https://github.com/$repo/pull/$prNumber" |
|
val prList = pullRequest.extractReleaseNotes(prLink) |
|
val changelogMessage = "- $prTitle" |
|
prList.ifEmpty { |
|
listOf(ChangelogEntry(changelogMessage, null, null, prLink)) |
|
} |
|
} else { |
|
listOf() |
|
} |
|
} |
|
|
|
class CommitsResult(val commits: List<GitHubCompareResponse.CommitEntry>, val mergeBaseSha: String) |
|
|
|
fun fetchCommits(firsCommitSha: String, lastCommitSha: String): CommitsResult { |
|
lateinit var mergeBaseCommit: String |
|
val commits = fetchPagedUntilEmpty { page -> |
|
val result = requestJson<GitHubCompareResponse>("https://api.github.com/repos/$repo/compare/$firsCommitSha...$lastCommitSha?per_page=1000&page=$page") |
|
mergeBaseCommit = result.merge_base_commit.sha |
|
result.commits |
|
} |
|
return CommitsResult(commits, mergeBaseCommit) |
|
} |
|
|
|
val main = fetchCommits(firstCommit, lastCommit) |
|
val previous = fetchCommits(main.mergeBaseSha, firstCommit) |
|
val pullRequests = main.commits.mapNotNull { prForCommit(it) }.toSet() |
|
val previousVersionPullRequests = previous.commits.mapNotNull { prForCommit(it) }.toSet() |
|
return (pullRequests - previousVersionPullRequests).flatMap { changelogEntriesFor(it) } |
|
} |
|
|
|
/** |
|
* Extract the PR number from the commit. |
|
*/ |
|
fun repoTitleAndNumberForCommit(commit: GitHubCompareResponse.CommitEntry): Pair<String, Int?> { |
|
val commitTitle = commit.commit.message.substringBefore("\n") |
|
// check title similar to `Fix import android flavors with compose resources (#4319)` |
|
val title = commitTitle.substringBeforeLast(" (#") |
|
val number = commitTitle.substringAfterLast(" (#").substringBefore(")").toIntOrNull() |
|
return title to number |
|
} |
|
|
|
/** |
|
* Extract redirecting versions from core repo, file gradle.properties |
|
* |
|
* Example |
|
* https://raw.githubusercontent.com/JetBrains/compose-multiplatform-core/v1.8.0%2Bdev1966/gradle.properties |
|
* artifactRedirecting.androidx.graphics.version=1.0.1 |
|
*/ |
|
fun androidxLibToRedirectingVersion(commit: String): Map<String, String> { |
|
val gradleProperties = githubContentOf("JetBrains/compose-multiplatform-core", "gradle.properties", commit) |
|
val regex = Regex("artifactRedirecting\\.androidx\\.(.*)\\.version=(.*)") |
|
return regex.findAll(gradleProperties).associate { result -> |
|
result.groupValues[1].trim() to result.groupValues[2].trim() |
|
} |
|
} |
|
|
|
/** |
|
* Extract versions from CI config, file .teamcity/compose/Library.kt |
|
* |
|
* Example |
|
* https://jetbrains.team/p/ui/repositories/compose-teamcity-config/files/8f8408ccd05a9188895969b1fa0243050716baad/.teamcity/compose/Library.kt?tab=source&line=37&lines-count=1 |
|
* Library.CORE_BUNDLE -> "1.1.0-alpha01" |
|
*/ |
|
fun androidxLibToVersion(commit: String): Map<String, String> { |
|
val repo = "ssh://git@git.jetbrains.team/ui/compose-teamcity-config.git" |
|
val file = ".teamcity/compose/Library.kt" |
|
val libraryKt = spaceContentOf(repo, file, commit) |
|
|
|
return if (libraryKt.isBlank()) { |
|
println("Can't clone $repo to know library versions. Please register your ssh key in https://jetbrains.team/m/me/authentication?tab=GitKeys") |
|
emptyMap() |
|
} else { |
|
val regex = Regex("Library\\.(.*)\\s*->\\s*\"(.*)\"") |
|
return regex.findAll(libraryKt).associate { result -> |
|
result.groupValues[1].trim() to result.groupValues[2].trim() |
|
} |
|
} |
|
} |
|
|
|
fun githubContentOf(repo: String, path: String, commit: String): String { |
|
val commitEncoded = URLEncoder.encode(commit, UTF_8) |
|
return requestPlain("https://raw.githubusercontent.com/$repo/$commitEncoded/$path") |
|
} |
|
|
|
fun spaceContentOf(repoUrl: String, path: String, tagName: String): String { |
|
return pipeProcess("git archive --remote=$repoUrl $tagName $path") |
|
.pipeTo("tar -xO $path") |
|
.readText() |
|
} |
|
|
|
data class ChangelogEntry( |
|
val message: String, |
|
val section: String?, |
|
val subsection: String?, |
|
val link: String?, |
|
) |
|
|
|
fun ChangelogEntry.sectionOrder(): Int = section?.let(standardSections::indexOf) ?: standardSections.size |
|
fun ChangelogEntry.subsectionOrder(): Int = subsection?.let(standardSubsections::indexOf) ?: standardSubsections.size |
|
fun ChangelogEntry.sectionName(): String = section ?: "Unknown" |
|
fun ChangelogEntry.subsectionName(): String = subsection ?: "Unknown" |
|
fun String.normalizeSectionName() = standardSections.find { it.lowercase() == this.lowercase() } ?: this |
|
fun String.normalizeSubsectionName() = standardSubsections.find { it.lowercase() == this.lowercase() } ?: this |
|
|
|
// example https://api.github.com/repos/JetBrains/compose-multiplatform-core/compare/v1.6.0-rc02...release/1.6.0 |
|
data class GitHubCompareResponse(val commits: List<CommitEntry>, val merge_base_commit: CommitEntry) { |
|
data class CommitEntry(val sha: String, val commit: Commit) |
|
data class Commit(val message: String) |
|
} |
|
|
|
// example https://api.github.com/repos/JetBrains/compose-multiplatform-core/pulls?state=closed |
|
data class GitHubPullEntry(val number: Int, val title: String, val body: String?, val labels: List<Label>) { |
|
class Label(val name: String) |
|
} |
|
|
|
//region ========================================== UTILS ========================================= |
|
fun pipeProcess(command: String) = ProcessBuilder(command.split(" ")) |
|
.redirectOutput(Redirect.PIPE) |
|
.redirectError(Redirect.PIPE) |
|
.start()!! |
|
|
|
fun Process.pipeTo(command: String): Process = pipeProcess(command).also { |
|
inputStream.use { input -> |
|
it.outputStream.use { out -> |
|
input.copyTo(out) |
|
} |
|
} |
|
} |
|
|
|
fun Process.readText(): String = inputStream.bufferedReader().use { it.readText() } |
|
|
|
inline fun <reified T> requestJson(url: String): T = |
|
Gson().fromJson(requestPlain(url), T::class.java) |
|
|
|
fun requestPlain(url: String): String = exponentialRetry { |
|
println("Request $url") |
|
val connection = URL(url).openConnection() |
|
connection.setRequestProperty("User-Agent", "Compose-Multiplatform-Script") |
|
if (token != null) { |
|
connection.setRequestProperty("Authorization", "Bearer $token") |
|
} |
|
connection.getInputStream().use { |
|
it.bufferedReader().readText() |
|
} |
|
} |
|
|
|
fun <T> exponentialRetry(block: () -> T): T { |
|
val exception = IOException() |
|
val retriesMinutes = listOf(1, 5, 15, 30, 60) |
|
for (retriesMinute in retriesMinutes) { |
|
try { |
|
return block() |
|
} catch (e: IOException) { |
|
e.printStackTrace() |
|
exception.addSuppressed(e) |
|
println("Retry in $retriesMinute minutes") |
|
Thread.sleep(retriesMinute.toLong() * 60 * 1000) |
|
} |
|
} |
|
throw exception |
|
} |
|
|
|
inline fun <T> fetchPagedUntilEmpty(fetch: (page: Int) -> List<T>): MutableList<T> { |
|
val all = mutableListOf<T>() |
|
var page = 1 |
|
do { |
|
val result = fetch(page++) |
|
all.addAll(result) |
|
} while (result.isNotEmpty()) |
|
return all |
|
} |
|
|
|
//endregion |