Skip to content

Commit e9597e7

Browse files
authored
Changelog script. Read dependencies, support N/A (#5181)
Fixes https://youtrack.jetbrains.com/issue/CMP-7203/Changelog-script.-Generate-Dependencies-section Fixes https://youtrack.jetbrains.com/issue/CMP-7137/Changelog-script-small-fixes - Read dependencies from git repos - Support N/A - Supports any prefix. For example `- (experimental) Change` - Try to fix the line start Can be read commit by commit.
1 parent 3961563 commit e9597e7

File tree

2 files changed

+158
-68
lines changed

2 files changed

+158
-68
lines changed

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,18 @@ This should be tested by QA
1515

1616
## Release Notes
1717
<!--
18-
Optional, if omitted - won't be included in the changelog
18+
If we definitely shouldn't add Release Notes, add only N/A.
1919
20-
Sections:
20+
Or enumerate sections, subsections and all changes.
21+
22+
Possible sections:
2123
- Highlights
2224
- Known Issues
2325
- Breaking Changes
2426
- Features
2527
- Fixes
2628
27-
Subsections:
29+
Possible subsections:
2830
- Multiple Platforms
2931
- iOS
3032
- Desktop

tools/changelog.main.kts

Lines changed: 153 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,15 @@
2828
@file:DependsOn("com.google.code.gson:gson:2.10.1")
2929

3030
import com.google.gson.Gson
31-
import java.io.File
3231
import java.io.IOException
32+
import java.lang.ProcessBuilder.Redirect
3333
import java.net.URL
34+
import java.net.URLEncoder
35+
import java.nio.charset.StandardCharsets.UTF_8
3436
import java.time.LocalDate
3537
import java.time.format.DateTimeFormatter
3638
import java.util.*
37-
import java.util.concurrent.TimeUnit
39+
import kotlin.text.substringAfterLast
3840

3941
//region ========================================== CONSTANTS =========================================
4042

@@ -73,7 +75,6 @@ val argsKeyToValue = args
7375
.associate { it.substringBefore("=") to it.substringAfter("=") }
7476

7577
val versionCommit = argsKeyless.getOrNull(0) ?: "HEAD"
76-
val versionName = argsKeyless.getOrNull(1) ?: versionCommit
7778
val token = argsKeyToValue["token"]
7879

7980
println("Note. The script supports optional arguments: kotlin changelog.main.kts [versionCommit] [versionName] [token=githubToken]")
@@ -82,6 +83,34 @@ if (token == null) {
8283
}
8384
println()
8485

86+
val androidxLibToVersion = androidxLibToVersion(versionCommit)
87+
val androidxLibToRedirectingVersion = androidxLibToRedirectingVersion(versionCommit)
88+
89+
fun formatAndroidxLibVersion(libName: String) =
90+
androidxLibToVersion[libName] ?: "PLACEHOLDER".also {
91+
println("Can't find $libName version. Using PLACEHOLDER")
92+
}
93+
94+
fun formatAndroidxLibRedirectingVersion(libName: String) =
95+
androidxLibToRedirectingVersion[libName] ?: "PLACEHOLDER".also {
96+
println("Can't find $libName redirecting version. Using PLACEHOLDER")
97+
}
98+
99+
val versionCompose = formatAndroidxLibVersion("COMPOSE")
100+
val versionComposeMaterial3Adaptive = formatAndroidxLibVersion("COMPOSE_MATERIAL3_ADAPTIVE")
101+
val versionLifecycle = formatAndroidxLibVersion("LIFECYCLE")
102+
val versionNavigation = formatAndroidxLibVersion("NAVIGATION")
103+
104+
val versionRedirectingCompose = formatAndroidxLibRedirectingVersion("compose")
105+
val versionRedirectingComposeFoundation = formatAndroidxLibRedirectingVersion("compose.foundation")
106+
val versionRedirectingComposeMaterial = formatAndroidxLibRedirectingVersion("compose.material")
107+
val versionRedirectingComposeMaterial3 = formatAndroidxLibRedirectingVersion("compose.material3")
108+
val versionRedirectingComposeMaterial3Adaptive = formatAndroidxLibRedirectingVersion("compose.material3.adaptive")
109+
val versionRedirectingLifecycle = formatAndroidxLibRedirectingVersion("lifecycle")
110+
val versionRedirectingNavigation = formatAndroidxLibRedirectingVersion("navigation")
111+
112+
val versionName = versionCompose
113+
85114
val currentChangelog = changelogFile.readText()
86115
val previousChangelog =
87116
if (currentChangelog.startsWith("# $versionName ")) {
@@ -93,9 +122,10 @@ val previousChangelog =
93122

94123
val previousVersion = previousChangelog.substringAfter("# ").substringBefore(" (")
95124

125+
println()
96126
println("Generating changelog between $previousVersion and $versionName")
97127

98-
val newChangelog = getChangelog("v$previousVersion", versionCommit, previousVersion, versionName)
128+
val newChangelog = getChangelog("v$previousVersion", versionCommit, previousVersion)
99129

100130
changelogFile.writeText(
101131
newChangelog + previousChangelog
@@ -105,12 +135,12 @@ println()
105135
println("CHANGELOG.md changed")
106136

107137

108-
fun getChangelog(firstCommit: String, lastCommit: String, firstVersion: String, lastVersion: String): String {
138+
fun getChangelog(firstCommit: String, lastCommit: String, firstVersion: String): String {
109139
val entries = entriesForRepo("JetBrains/compose-multiplatform-core", firstCommit, lastCommit) +
110140
entriesForRepo("JetBrains/compose-multiplatform", firstCommit, lastCommit)
111141

112142
return buildString {
113-
appendLine("# $lastVersion (${currentChangelogDate()})")
143+
appendLine("# $versionName (${currentChangelogDate()})")
114144

115145
appendLine()
116146
appendLine("_Changes since ${firstVersion}_")
@@ -140,16 +170,16 @@ fun getChangelog(firstCommit: String, lastCommit: String, firstVersion: String,
140170
"""
141171
## Dependencies
142172
143-
- Gradle Plugin `org.jetbrains.compose`, version `$lastVersion`. Based on Jetpack Compose libraries:
144-
- [Runtime REDIRECT_PLACEHOLDER](https://developer.android.com/jetpack/androidx/releases/compose-runtime#REDIRECT_PLACEHOLDER)
145-
- [UI REDIRECT_PLACEHOLDER](https://developer.android.com/jetpack/androidx/releases/compose-ui#REDIRECT_PLACEHOLDER)
146-
- [Foundation REDIRECT_PLACEHOLDER](https://developer.android.com/jetpack/androidx/releases/compose-foundation#REDIRECT_PLACEHOLDER)
147-
- [Material REDIRECT_PLACEHOLDER](https://developer.android.com/jetpack/androidx/releases/compose-material#REDIRECT_PLACEHOLDER)
148-
- [Material3 REDIRECT_PLACEHOLDER](https://developer.android.com/jetpack/androidx/releases/compose-material3#REDIRECT_PLACEHOLDER)
149-
150-
- 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)
151-
- 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)
152-
- 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)
173+
- Gradle Plugin `org.jetbrains.compose`, version `$versionCompose`. Based on Jetpack Compose libraries:
174+
- [Runtime $versionRedirectingCompose](https://developer.android.com/jetpack/androidx/releases/compose-runtime#$versionRedirectingCompose)
175+
- [UI $versionRedirectingCompose](https://developer.android.com/jetpack/androidx/releases/compose-ui#$versionRedirectingCompose)
176+
- [Foundation $versionRedirectingComposeFoundation](https://developer.android.com/jetpack/androidx/releases/compose-foundation#$versionRedirectingComposeFoundation)
177+
- [Material $versionRedirectingComposeMaterial](https://developer.android.com/jetpack/androidx/releases/compose-material#$versionRedirectingComposeMaterial)
178+
- [Material3 $versionRedirectingComposeMaterial3](https://developer.android.com/jetpack/androidx/releases/compose-material3#$versionRedirectingComposeMaterial3)
179+
180+
- Lifecycle libraries `org.jetbrains.androidx.lifecycle:lifecycle-*:$versionLifecycle`. Based on [Jetpack Lifecycle $versionRedirectingLifecycle](https://developer.android.com/jetpack/androidx/releases/lifecycle#$versionRedirectingLifecycle)
181+
- Navigation libraries `org.jetbrains.androidx.navigation:navigation-*:$versionNavigation`. Based on [Jetpack Navigation $versionRedirectingNavigation](https://developer.android.com/jetpack/androidx/releases/navigation#$versionRedirectingNavigation)
182+
- 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)
153183
154184
---
155185
""".trimIndent()
@@ -188,13 +218,26 @@ fun currentChangelogDate() = LocalDate.now().format(DateTimeFormatter.ofPattern(
188218
* - [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.
189219
*/
190220
fun ChangelogEntry.format(): String {
221+
return try {
222+
tryFormat()
223+
} catch (e: Exception) {
224+
throw RuntimeException("Formatting error of ChangelogEntry. Message:\n$message", e)
225+
}
226+
}
227+
228+
fun ChangelogEntry.tryFormat(): String {
191229
return if (link != null) {
230+
val prefixRegex = "^[-\\s]*" // "- "
231+
val tagRegex1 = "\\(.*\\)\\s*" // "(something) "
232+
val tagRegex2 = "\\[.*\\]\\s*" // "[something] "
233+
val tagRegex3 = "_.*_\\s*" // "_something_ "
192234
val linkStartIndex = maxOf(
193-
message.indexOfFirst { !it.isWhitespace() && it != '-' }.ifNegative { 0 },
194-
message.endIndexOf("_(prerelease fix)_ ").ifNegative { 0 },
195-
message.endIndexOf("(prerelease fix) ").ifNegative { 0 },
235+
message.endIndexOfFirstGroup(Regex("($prefixRegex).*"))?.plus(1) ?: 0,
236+
message.endIndexOfFirstGroup(Regex("($prefixRegex$tagRegex1).*"))?.plus(1) ?: 0,
237+
message.endIndexOfFirstGroup(Regex("($prefixRegex$tagRegex2).*"))?.plus(1) ?: 0,
238+
message.endIndexOfFirstGroup(Regex("($prefixRegex$tagRegex3).*"))?.plus(1) ?: 0,
196239
)
197-
val linkLastIndex = message.indexOfAny(listOf(". ", " (")).ifNegative { message.length }
240+
val linkLastIndex = message.indexOfAny(listOf(". ", " ("), linkStartIndex).ifNegative { message.length }
198241

199242
val beforeLink = message.substring(0, linkStartIndex)
200243
val inLink = message.substring(linkStartIndex, linkLastIndex).removeLinks()
@@ -208,13 +251,8 @@ fun ChangelogEntry.format(): String {
208251

209252
fun Int.ifNegative(value: () -> Int): Int = if (this < 0) value() else this
210253

211-
fun String.endIndexOf(value: String): Int = indexOf(value).let {
212-
if (it >= 0) {
213-
it + value.length
214-
} else {
215-
it
216-
}
217-
}
254+
fun String.endIndexOfFirstGroup(regex: Regex): Int? =
255+
regex.find(this)?.groups?.toList()?.getOrNull(1)?.range?.endInclusive
218256

219257
/**
220258
* Converts:
@@ -243,27 +281,44 @@ fun GitHubPullEntry.extractReleaseNotes(link: String): List<ChangelogEntry> {
243281
before?.trim()
244282
}
245283

284+
if (relNoteBody?.trim()?.lowercase() == "n/a") return emptyList()
285+
246286
val list = mutableListOf<ChangelogEntry>()
247287
var section: String? = null
248288
var subsection: String? = null
289+
var isFirstLine = true
290+
var shouldPadLines = false
249291

250292
for (line in relNoteBody.orEmpty().split("\n")) {
251293
// parse "### Section - Subsection"
252294
if (line.startsWith("### ")) {
253295
val s = line.removePrefix("### ")
254296
section = s.substringBefore("-", "").trim().normalizeSectionName().ifEmpty { null }
255297
subsection = s.substringAfter("-", "").trim().normalizeSubsectionName().ifEmpty { null }
298+
isFirstLine = true
299+
shouldPadLines = false
256300
} else if (section != null && line.isNotBlank()) {
257-
val isTopLevel = line.startsWith("-")
258-
val trimmedLine = line.trimEnd().removeSuffix(".")
301+
var lineFixed = line
302+
303+
if (isFirstLine && !lineFixed.startsWith("-")) {
304+
lineFixed = "- $lineFixed"
305+
shouldPadLines = true
306+
}
307+
if (!isFirstLine && shouldPadLines) {
308+
lineFixed = " $lineFixed"
309+
}
310+
lineFixed = lineFixed.trimEnd().removeSuffix(".")
311+
312+
val isTopLevel = lineFixed.startsWith("-")
259313
list.add(
260314
ChangelogEntry(
261-
trimmedLine,
315+
lineFixed,
262316
section,
263317
subsection,
264318
link.takeIf { isTopLevel }
265319
)
266320
)
321+
isFirstLine = false
267322
}
268323
}
269324

@@ -277,7 +332,7 @@ fun GitHubPullEntry.extractReleaseNotes(link: String): List<ChangelogEntry> {
277332
fun entriesForRepo(repo: String, firstCommit: String, lastCommit: String): List<ChangelogEntry> {
278333
val pulls = (1..5)
279334
.flatMap {
280-
request<Array<GitHubPullEntry>>("https://api.github.com/repos/$repo/pulls?state=closed&per_page=100&page=$it").toList()
335+
requestJson<Array<GitHubPullEntry>>("https://api.github.com/repos/$repo/pulls?state=closed&per_page=100&page=$it").toList()
281336
}
282337

283338
val pullNumberToPull = pulls.associateBy { it.number }
@@ -310,8 +365,7 @@ fun entriesForRepo(repo: String, firstCommit: String, lastCommit: String): List<
310365
fun fetchCommits(firsCommitSha: String, lastCommitSha: String): CommitsResult {
311366
lateinit var mergeBaseCommit: String
312367
val commits = fetchPagedUntilEmpty { page ->
313-
val result =
314-
request<GitHubCompareResponse>("https://api.github.com/repos/$repo/compare/$firsCommitSha...$lastCommitSha?per_page=1000&page=$page")
368+
val result = requestJson<GitHubCompareResponse>("https://api.github.com/repos/$repo/compare/$firsCommitSha...$lastCommitSha?per_page=1000&page=$page")
315369
mergeBaseCommit = result.merge_base_commit.sha
316370
result.commits
317371
}
@@ -336,6 +390,55 @@ fun repoTitleAndNumberForCommit(commit: GitHubCompareResponse.CommitEntry): Pair
336390
return title to number
337391
}
338392

393+
/**
394+
* Extract redirecting versions from core repo, file gradle.properties
395+
*
396+
* Example
397+
* https://raw.githubusercontent.com/JetBrains/compose-multiplatform-core/v1.8.0%2Bdev1966/gradle.properties
398+
* artifactRedirecting.androidx.graphics.version=1.0.1
399+
*/
400+
fun androidxLibToRedirectingVersion(commit: String): Map<String, String> {
401+
val gradleProperties = githubContentOf("JetBrains/compose-multiplatform-core", "gradle.properties", commit)
402+
val regex = Regex("artifactRedirecting\\.androidx\\.(.*)\\.version=(.*)")
403+
return regex.findAll(gradleProperties).associate { result ->
404+
result.groupValues[1].trim() to result.groupValues[2].trim()
405+
}
406+
}
407+
408+
/**
409+
* Extract versions from CI config, file .teamcity/compose/Library.kt
410+
*
411+
* Example
412+
* https://jetbrains.team/p/ui/repositories/compose-teamcity-config/files/8f8408ccd05a9188895969b1fa0243050716baad/.teamcity/compose/Library.kt?tab=source&line=37&lines-count=1
413+
* Library.CORE_BUNDLE -> "1.1.0-alpha01"
414+
*/
415+
fun androidxLibToVersion(commit: String): Map<String, String> {
416+
val repo = "ssh://git@git.jetbrains.team/ui/compose-teamcity-config.git"
417+
val file = ".teamcity/compose/Library.kt"
418+
val libraryKt = spaceContentOf(repo, file, commit)
419+
420+
return if (libraryKt.isBlank()) {
421+
println("Can't clone $repo to know library versions. Please register your ssh key in https://jetbrains.team/m/me/authentication?tab=GitKeys")
422+
emptyMap()
423+
} else {
424+
val regex = Regex("Library\\.(.*)\\s*->\\s*\"(.*)\"")
425+
return regex.findAll(libraryKt).associate { result ->
426+
result.groupValues[1].trim() to result.groupValues[2].trim()
427+
}
428+
}
429+
}
430+
431+
fun githubContentOf(repo: String, path: String, commit: String): String {
432+
val commitEncoded = URLEncoder.encode(commit, UTF_8)
433+
return requestPlain("https://raw.githubusercontent.com/$repo/$commitEncoded/$path")
434+
}
435+
436+
fun spaceContentOf(repoUrl: String, path: String, tagName: String): String {
437+
return pipeProcess("git archive --remote=$repoUrl $tagName $path")
438+
.pipeTo("tar -xO $path")
439+
.readText()
440+
}
441+
339442
data class ChangelogEntry(
340443
val message: String,
341444
val section: String?,
@@ -362,48 +465,33 @@ data class GitHubPullEntry(val number: Int, val title: String, val body: String?
362465
}
363466

364467
//region ========================================== UTILS =========================================
365-
366-
// from https://stackoverflow.com/a/41495542
367-
fun String.runCommand(workingDir: File = File(".")) {
368-
ProcessBuilder(*split(" ").toTypedArray())
369-
.directory(workingDir)
370-
.redirectOutput(ProcessBuilder.Redirect.INHERIT)
371-
.redirectError(ProcessBuilder.Redirect.INHERIT)
372-
.start()
373-
.waitFor(5, TimeUnit.MINUTES)
374-
}
375-
376-
fun String.execCommand(workingDir: File = File(".")): String? {
377-
try {
378-
val parts = this.split("\\s".toRegex())
379-
val proc = ProcessBuilder(*parts.toTypedArray())
380-
.directory(workingDir)
381-
.redirectOutput(ProcessBuilder.Redirect.PIPE)
382-
.redirectError(ProcessBuilder.Redirect.PIPE)
383-
.start()
384-
385-
proc.waitFor(60, TimeUnit.MINUTES)
386-
return proc.inputStream.bufferedReader().readText()
387-
} catch (e: IOException) {
388-
e.printStackTrace()
389-
return null
468+
fun pipeProcess(command: String) = ProcessBuilder(command.split(" "))
469+
.redirectOutput(Redirect.PIPE)
470+
.redirectError(Redirect.PIPE)
471+
.start()!!
472+
473+
fun Process.pipeTo(command: String): Process = pipeProcess(command).also {
474+
inputStream.use { input ->
475+
it.outputStream.use { out ->
476+
input.copyTo(out)
477+
}
390478
}
391479
}
392480

393-
inline fun <reified T> request(
394-
url: String
395-
): T = exponentialRetry {
481+
fun Process.readText(): String = inputStream.bufferedReader().use { it.readText() }
482+
483+
inline fun <reified T> requestJson(url: String): T =
484+
Gson().fromJson(requestPlain(url), T::class.java)
485+
486+
fun requestPlain(url: String): String = exponentialRetry {
396487
println("Request $url")
397488
val connection = URL(url).openConnection()
398489
connection.setRequestProperty("User-Agent", "Compose-Multiplatform-Script")
399490
if (token != null) {
400491
connection.setRequestProperty("Authorization", "Bearer $token")
401492
}
402493
connection.getInputStream().use {
403-
Gson().fromJson(
404-
it.bufferedReader(),
405-
T::class.java
406-
)
494+
it.bufferedReader().readText()
407495
}
408496
}
409497

0 commit comments

Comments
 (0)