28
28
@file:DependsOn(" com.google.code.gson:gson:2.10.1" )
29
29
30
30
import com.google.gson.Gson
31
- import java.io.File
32
31
import java.io.IOException
32
+ import java.lang.ProcessBuilder.Redirect
33
33
import java.net.URL
34
+ import java.net.URLEncoder
35
+ import java.nio.charset.StandardCharsets.UTF_8
34
36
import java.time.LocalDate
35
37
import java.time.format.DateTimeFormatter
36
38
import java.util.*
37
- import java.util.concurrent.TimeUnit
39
+ import kotlin.text.substringAfterLast
38
40
39
41
// region ========================================== CONSTANTS =========================================
40
42
@@ -73,7 +75,6 @@ val argsKeyToValue = args
73
75
.associate { it.substringBefore(" =" ) to it.substringAfter(" =" ) }
74
76
75
77
val versionCommit = argsKeyless.getOrNull(0 ) ? : " HEAD"
76
- val versionName = argsKeyless.getOrNull(1 ) ? : versionCommit
77
78
val token = argsKeyToValue[" token" ]
78
79
79
80
println (" Note. The script supports optional arguments: kotlin changelog.main.kts [versionCommit] [versionName] [token=githubToken]" )
@@ -82,6 +83,34 @@ if (token == null) {
82
83
}
83
84
println ()
84
85
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
+
85
114
val currentChangelog = changelogFile.readText()
86
115
val previousChangelog =
87
116
if (currentChangelog.startsWith(" # $versionName " )) {
@@ -93,9 +122,10 @@ val previousChangelog =
93
122
94
123
val previousVersion = previousChangelog.substringAfter(" # " ).substringBefore(" (" )
95
124
125
+ println ()
96
126
println (" Generating changelog between $previousVersion and $versionName " )
97
127
98
- val newChangelog = getChangelog(" v$previousVersion " , versionCommit, previousVersion, versionName )
128
+ val newChangelog = getChangelog(" v$previousVersion " , versionCommit, previousVersion)
99
129
100
130
changelogFile.writeText(
101
131
newChangelog + previousChangelog
@@ -105,12 +135,12 @@ println()
105
135
println (" CHANGELOG.md changed" )
106
136
107
137
108
- fun getChangelog (firstCommit : String , lastCommit : String , firstVersion : String , lastVersion : String ): String {
138
+ fun getChangelog (firstCommit : String , lastCommit : String , firstVersion : String ): String {
109
139
val entries = entriesForRepo(" JetBrains/compose-multiplatform-core" , firstCommit, lastCommit) +
110
140
entriesForRepo(" JetBrains/compose-multiplatform" , firstCommit, lastCommit)
111
141
112
142
return buildString {
113
- appendLine(" # $lastVersion (${currentChangelogDate()} )" )
143
+ appendLine(" # $versionName (${currentChangelogDate()} )" )
114
144
115
145
appendLine()
116
146
appendLine(" _Changes since ${firstVersion} _" )
@@ -140,16 +170,16 @@ fun getChangelog(firstCommit: String, lastCommit: String, firstVersion: String,
140
170
"""
141
171
## Dependencies
142
172
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 )
153
183
154
184
---
155
185
""" .trimIndent()
@@ -188,13 +218,26 @@ fun currentChangelogDate() = LocalDate.now().format(DateTimeFormatter.ofPattern(
188
218
* - [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.
189
219
*/
190
220
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 {
191
229
return if (link != null ) {
230
+ val prefixRegex = " ^[-\\ s]*" // "- "
231
+ val tagRegex1 = " \\ (.*\\ )\\ s*" // "(something) "
232
+ val tagRegex2 = " \\ [.*\\ ]\\ s*" // "[something] "
233
+ val tagRegex3 = " _.*_\\ s*" // "_something_ "
192
234
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 ,
196
239
)
197
- val linkLastIndex = message.indexOfAny(listOf (" . " , " (" )).ifNegative { message.length }
240
+ val linkLastIndex = message.indexOfAny(listOf (" . " , " (" ), linkStartIndex ).ifNegative { message.length }
198
241
199
242
val beforeLink = message.substring(0 , linkStartIndex)
200
243
val inLink = message.substring(linkStartIndex, linkLastIndex).removeLinks()
@@ -208,13 +251,8 @@ fun ChangelogEntry.format(): String {
208
251
209
252
fun Int.ifNegative (value : () -> Int ): Int = if (this < 0 ) value() else this
210
253
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
218
256
219
257
/* *
220
258
* Converts:
@@ -243,27 +281,44 @@ fun GitHubPullEntry.extractReleaseNotes(link: String): List<ChangelogEntry> {
243
281
before?.trim()
244
282
}
245
283
284
+ if (relNoteBody?.trim()?.lowercase() == " n/a" ) return emptyList()
285
+
246
286
val list = mutableListOf<ChangelogEntry >()
247
287
var section: String? = null
248
288
var subsection: String? = null
289
+ var isFirstLine = true
290
+ var shouldPadLines = false
249
291
250
292
for (line in relNoteBody.orEmpty().split(" \n " )) {
251
293
// parse "### Section - Subsection"
252
294
if (line.startsWith(" ### " )) {
253
295
val s = line.removePrefix(" ### " )
254
296
section = s.substringBefore(" -" , " " ).trim().normalizeSectionName().ifEmpty { null }
255
297
subsection = s.substringAfter(" -" , " " ).trim().normalizeSubsectionName().ifEmpty { null }
298
+ isFirstLine = true
299
+ shouldPadLines = false
256
300
} 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(" -" )
259
313
list.add(
260
314
ChangelogEntry (
261
- trimmedLine ,
315
+ lineFixed ,
262
316
section,
263
317
subsection,
264
318
link.takeIf { isTopLevel }
265
319
)
266
320
)
321
+ isFirstLine = false
267
322
}
268
323
}
269
324
@@ -277,7 +332,7 @@ fun GitHubPullEntry.extractReleaseNotes(link: String): List<ChangelogEntry> {
277
332
fun entriesForRepo (repo : String , firstCommit : String , lastCommit : String ): List <ChangelogEntry > {
278
333
val pulls = (1 .. 5 )
279
334
.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()
281
336
}
282
337
283
338
val pullNumberToPull = pulls.associateBy { it.number }
@@ -310,8 +365,7 @@ fun entriesForRepo(repo: String, firstCommit: String, lastCommit: String): List<
310
365
fun fetchCommits (firsCommitSha : String , lastCommitSha : String ): CommitsResult {
311
366
lateinit var mergeBaseCommit: String
312
367
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 " )
315
369
mergeBaseCommit = result.merge_base_commit.sha
316
370
result.commits
317
371
}
@@ -336,6 +390,55 @@ fun repoTitleAndNumberForCommit(commit: GitHubCompareResponse.CommitEntry): Pair
336
390
return title to number
337
391
}
338
392
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
+
339
442
data class ChangelogEntry (
340
443
val message : String ,
341
444
val section : String? ,
@@ -362,48 +465,33 @@ data class GitHubPullEntry(val number: Int, val title: String, val body: String?
362
465
}
363
466
364
467
// 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
+ }
390
478
}
391
479
}
392
480
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 {
396
487
println (" Request $url " )
397
488
val connection = URL (url).openConnection()
398
489
connection.setRequestProperty(" User-Agent" , " Compose-Multiplatform-Script" )
399
490
if (token != null ) {
400
491
connection.setRequestProperty(" Authorization" , " Bearer $token " )
401
492
}
402
493
connection.getInputStream().use {
403
- Gson ().fromJson(
404
- it.bufferedReader(),
405
- T ::class .java
406
- )
494
+ it.bufferedReader().readText()
407
495
}
408
496
}
409
497
0 commit comments