diff --git a/tools/changelog.main.kts b/tools/changelog.main.kts new file mode 100644 index 00000000000..2762a096d0c --- /dev/null +++ b/tools/changelog.main.kts @@ -0,0 +1,431 @@ +/** + * Script for creating a changelog. Call + * ``` + * kotlin changelog.main.kts v1.6.0-rc02 release/1.6.0 + * ``` + * where v1.6.0-rc02 - the first commit + * where release/1.6.0 - the last commit + */ + +/** + * Run from command line: + * 1. Download https://github.com/JetBrains/kotlin/releases/tag/v1.9.22 and add `bin` to PATH + * 2. Call `kotlin ` + * + * Run from IntelliJ: + * 1. Right click on the script + * 2. More Run/Debug + * 3. Modify Run Configuration... + * 4. Clear all "Before launch" tasks (you can edit the system-wide template as well) + * 5. 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.File +import java.io.IOException +import java.net.URL +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.* +import java.util.concurrent.TimeUnit + +//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") + +//endregion + +val versionCommit = args.find { !it.contains("=") } ?: "HEAD" +val token = args.find { it.startsWith("token=") }?.removePrefix("token=") + +println("Note. The script supports optional arguments: kotlin changelog.main.kts [versionCommit] [token=githubToken]") +if (token == null) { + println("To increase the rate limit, specify token (https://github.com/settings/tokens)") +} +println() + +val currentChangelog = changelogFile.readText() +val currentVersion = commitToVersion(versionCommit) +val previousChangelog = + if (currentChangelog.startsWith("# $currentVersion ")) { + val nextChangelogIndex = currentChangelog.indexOf("\n# ") + currentChangelog.substring(nextChangelogIndex).removePrefix("\n") + } else { + currentChangelog + } + +val previousVersion = previousChangelog.substringAfter("# ").substringBefore(" (") + +println("Generating changelog between $previousVersion and $currentVersion") + +val newChangelog = getChangelog("v$previousVersion", versionCommit) + +changelogFile.writeText( + newChangelog + previousChangelog +) + +println() +println("CHANGELOG.md changed") + + +fun getChangelog(firstCommit: String, lastCommit: String): String { + val entries = entriesForRepo("JetBrains/compose-multiplatform-core", firstCommit, lastCommit) + + entriesForRepo("JetBrains/compose-multiplatform", firstCommit, lastCommit) + + return buildString { + appendLine("# ${commitToVersion(lastCommit)} (${currentChangelogDate()})") + + appendLine() + appendLine("_Changes since ${commitToVersion(firstCommit)}_") + 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 `${commitToVersion(lastCommit)}`. Based on Jetpack Compose libraries: + - [Runtime REDIRECT_PLACEHOLDER](https://developer.android.com/jetpack/androidx/releases/compose-runtime#REDIRECT_PLACEHOLDER) + - [UI REDIRECT_PLACEHOLDER](https://developer.android.com/jetpack/androidx/releases/compose-ui#REDIRECT_PLACEHOLDER) + - [Foundation REDIRECT_PLACEHOLDER](https://developer.android.com/jetpack/androidx/releases/compose-foundation#REDIRECT_PLACEHOLDER) + - [Material REDIRECT_PLACEHOLDER](https://developer.android.com/jetpack/androidx/releases/compose-material#REDIRECT_PLACEHOLDER) + - [Material3 REDIRECT_PLACEHOLDER](https://developer.android.com/jetpack/androidx/releases/compose-material3#REDIRECT_PLACEHOLDER) + + - Lifecycle libraries `org.jetbrains.androidx.lifecycle:lifecycle-*:RELEASE_PLACEHOLDER`. Based on [Jetpack Lifecycle REDIRECT_PLACEHOLDER](https://developer.android.com/jetpack/androidx/releases/lifecycle#REDIRECT_PLACEHOLDER) + - Navigation libraries `org.jetbrains.androidx.navigation:navigation-*:RELEASE_PLACEHOLDER`. Based on [Jetpack Navigation REDIRECT_PLACEHOLDER](https://developer.android.com/jetpack/androidx/releases/navigation#REDIRECT_PLACEHOLDER) + - Material3 Adaptive libraries `org.jetbrains.compose.material3.adaptive:adaptive*:RELEASE_PLACEHOLDER`. Based on [Jetpack Material3 Adaptive REDIRECT_PLACEHOLDER](https://developer.android.com/jetpack/androidx/releases/compose-material3-adaptive#REDIRECT_PLACEHOLDER) + + ___ + """.trimIndent() + ) + + appendLine() + appendLine() + + val nonstandardSections = entries.mapNotNull { it.section }.toSet() - standardSections + val nonstandardSubsections = entries.mapNotNull { it.subsection }.toSet() - standardSubsections + + if (nonstandardSections.isNotEmpty()) { + println() + println("WARNING! Changelog contains nonstandard sections. Please change them to the standard ones, or enhance the list in the PR template. List:\n${nonstandardSections.joinToString("\n")}") + } + + if (nonstandardSubsections.isNotEmpty()) { + println() + println("WARNING! Changelog contains nonstandard subsections. Please change them to the standard ones, or enhance the list in the PR template. List:\n${nonstandardSubsections.joinToString("\n")}") + } + } +} + +/** + * Transforms v1.6.0-beta01 to 1.6.0-beta01 + */ +fun commitToVersion(commit: String) = + if (commit.startsWith("v") && commit.contains(".")) { + commit.removePrefix("v") + } else { + commit.removePrefix("release/") + } + +/** + * 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 if (link != null) { + val linkStartIndex = maxOf( + message.indexOfFirst { !it.isWhitespace() && it != '-' }.ifNegative { 0 }, + message.endIndexOf("_(prerelease fix)_ ").ifNegative { 0 }, + message.endIndexOf("(prerelease fix) ").ifNegative { 0 }, + ) + val linkLastIndex = message.indexOfAny(listOf(". ", " (")).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.endIndexOf(value: String): Int = indexOf(value).let { + if (it >= 0) { + it + value.length + } else { + it + } +} + +/** + * 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 { + // 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() + } + + val list = mutableListOf() + var section: String? = null + var subsection: String? = null + + for (line in relNoteBody.orEmpty().split("\n")) { + // parse "### Section - Subsection" + if (line.startsWith("### ")) { + val s = line.removePrefix("### ") + section = s.substringBefore("-", "").trim().ifEmpty { null } + subsection = s.substringAfter("-", "").trim().ifEmpty { null } + } else if (section != null && line.isNotBlank()) { + val isTopLevel = line.startsWith("-") + val trimmedLine = line.trimEnd().removeSuffix(".") + list.add( + ChangelogEntry( + trimmedLine, + section, + subsection, + link.takeIf { isTopLevel } + ) + ) + } + } + + return list +} + +/** + * @param repo Example: + * JetBrains/compose-multiplatform-core + */ +fun entriesForRepo(repo: String, firstCommit: String, lastCommit: String): List { + val pulls = (1..5) + .flatMap { + request>("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 { + 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, val mergeBaseSha: String) + + fun fetchCommits(firsCommitSha: String, lastCommitSha: String): CommitsResult { + lateinit var mergeBaseCommit: String + val commits = fetchPagedUntilEmpty { page -> + val result = + request("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 { + 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 +} + +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 = section?.let(standardSubsections::indexOf) ?: standardSubsections.size +fun ChangelogEntry.sectionName(): String = section ?: "Unknown" +fun ChangelogEntry.subsectionName(): String = subsection ?: "Unknown" + +// 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, 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