@@ -159,18 +159,21 @@ fun generateChangelog() {
159
159
previousVersion = previousVersionInChangelog
160
160
}
161
161
162
- fun getChangelog (firstCommit : String , lastCommit : String , firstVersion : String ): String {
162
+ fun getChangelog (firstCommit : String , lastCommit : String , firstVersion : String , lastVersion : String ): String {
163
+ val isPrerelease = lastVersion.contains(" -" )
164
+
163
165
val entries = entriesForRepo(" JetBrains/compose-multiplatform-core" , firstCommit, lastCommit) +
164
166
entriesForRepo(" JetBrains/compose-multiplatform" , firstCommit, lastCommit)
165
167
166
168
return buildString {
167
- appendLine(" # $versionName (${currentChangelogDate()} )" )
169
+ appendLine(" # $lastVersion (${currentChangelogDate()} )" )
168
170
169
171
appendLine()
170
172
appendLine(" _Changes since ${firstVersion} _" )
171
173
appendLine()
172
174
173
175
entries
176
+ .filter { isPrerelease || ! it.isPrerelease }
174
177
.sortedBy { it.sectionOrder() }
175
178
.groupBy { it.sectionName() }
176
179
.forEach { (section, sectionEntries) ->
@@ -184,7 +187,11 @@ fun generateChangelog() {
184
187
appendLine(" ### $subsection " )
185
188
appendLine()
186
189
subsectionEntries.forEach {
187
- appendLine(it.run { " $message [#$prNumber ]($link )" })
190
+ if (it.link != null ) {
191
+ appendLine(it.run { " $message [#$prNumber ]($link )" })
192
+ } else {
193
+ appendLine(it.message)
194
+ }
188
195
}
189
196
appendLine()
190
197
}
@@ -232,7 +239,7 @@ fun generateChangelog() {
232
239
println ()
233
240
println (" Generating changelog between $previousVersion and $versionName " )
234
241
235
- val newChangelog = getChangelog(previousVersionCommit, versionCommit, previousVersion)
242
+ val newChangelog = getChangelog(previousVersionCommit, versionCommit, previousVersion, versionName )
236
243
237
244
changelogFile.writeText(
238
245
newChangelog + previousChangelog
@@ -327,7 +334,7 @@ fun extractReleaseNotes(body: String?, prNumber: Int, prLink: String): ReleaseNo
327
334
var section: String? = null
328
335
var subsection: String? = null
329
336
var isFirstLine = true
330
- var shouldPadLines = false
337
+ var isPrerelease = false
331
338
332
339
for (line in relNoteBody.split(" \n " )) {
333
340
// parse "## Section - Subsection"
@@ -336,27 +343,30 @@ fun extractReleaseNotes(body: String?, prNumber: Int, prLink: String): ReleaseNo
336
343
section = s.substringBefore(" -" , " " ).trim().normalizeSectionName().ifEmpty { null }
337
344
subsection = s.substringAfter(" -" , " " ).trim().normalizeSubsectionName().ifEmpty { null }
338
345
isFirstLine = true
339
- shouldPadLines = false
346
+ isPrerelease = false
340
347
} else if (section != null && line.isNotBlank()) {
341
348
var lineFixed = line
342
349
343
350
if (isFirstLine && ! lineFixed.startsWith(" -" )) {
344
351
lineFixed = " - $lineFixed "
345
- shouldPadLines = true
346
352
}
347
- if (! isFirstLine && shouldPadLines ) {
353
+ if (! isFirstLine && ! lineFixed.startsWithAny( " " , " - " ) ) {
348
354
lineFixed = " $lineFixed "
349
355
}
350
356
lineFixed = lineFixed.trimEnd().removeSuffix(" ." )
351
357
352
358
val isTopLevel = lineFixed.startsWith(" -" )
359
+ if (isTopLevel) {
360
+ isPrerelease = lineFixed.contains(" (prerelease fix)" )
361
+ }
353
362
list.add(
354
363
ChangelogEntry (
355
364
lineFixed,
356
365
section,
357
366
subsection,
358
367
prNumber,
359
- prLink.takeIf { isTopLevel }
368
+ prLink.takeIf { isTopLevel },
369
+ isPrerelease
360
370
)
361
371
)
362
372
isFirstLine = false
@@ -376,46 +386,35 @@ fun entriesForRepo(repo: String, firstCommit: String, lastCommit: String): List<
376
386
requestJson<Array <GitHubPullEntry >>(" https://api.github.com/repos/$repo /pulls?state=closed&per_page=100&page=$it " ).toList()
377
387
}
378
388
379
- val shaToPull = pulls.associateBy { it.mergeCommitSha }
380
-
381
- fun changelogEntriesFor (
382
- pullRequest : GitHubPullEntry ?
383
- ): List <ChangelogEntry > {
384
- return if (pullRequest != null ) {
385
- with (pullRequest) {
386
- extractReleaseNotes(body, number, htmlUrl)?.entries ? :
387
- listOf (ChangelogEntry (" - $title " , null , null , number, htmlUrl))
388
- }
389
- } else {
390
- listOf ()
391
- }
389
+ val shaToOriginalPull = pulls.associateBy { it.mergeCommitSha }
390
+ val numberToPull = pulls.associateBy { it.number }
391
+ val pullToCherryPickPull = pulls.associateWith {
392
+ it.findCherryPickPullNumber()?.let (numberToPull::get)
392
393
}
393
394
394
- class CommitsResult (val commits : List <GitHubCompareResponse .CommitEntry >, val mergeBaseSha : String )
395
+ fun pullOf (sha : String ): GitHubPullEntry ? {
396
+ val originalPr = shaToOriginalPull[sha]
397
+ return pullToCherryPickPull[originalPr] ? : originalPr
398
+ }
395
399
396
- fun fetchCommits (firsCommitSha : String , lastCommitSha : String ): CommitsResult {
397
- lateinit var mergeBaseCommit: String
398
- val commits = fetchPagedUntilEmpty { page ->
399
- val result = requestJson<GitHubCompareResponse >(" https://api.github.com/repos/$repo /compare/$firsCommitSha ...$lastCommitSha ?per_page=1000&page=$page " )
400
- mergeBaseCommit = result.mergeBaseCommit.sha
401
- result.commits
402
- }
403
- return CommitsResult (commits, mergeBaseCommit)
400
+ fun changelogEntriesFor (pullRequest : GitHubPullEntry ) = with (pullRequest) {
401
+ extractReleaseNotes(body, number, htmlUrl)?.entries ? :
402
+ listOf (ChangelogEntry (" - $title " , null , null , number, htmlUrl, false ))
404
403
}
405
404
406
- val main = fetchCommits(firstCommit, lastCommit)
407
- val previous = fetchCommits(main.mergeBaseSha, firstCommit)
408
- val pullRequests = main.commits.mapNotNull { shaToPull[it.sha] }.toSet()
409
- val previousVersionPullRequests = previous.commits.mapNotNull { shaToPull[it.sha] }.toSet()
410
- return (pullRequests - previousVersionPullRequests).flatMap { changelogEntriesFor(it) }
411
- }
405
+ val repoFolder = githubClone(repo)
412
406
413
- /* *
414
- * @param repo Example:
415
- * JetBrains/compose-multiplatform-core
416
- */
417
- fun pullRequest (repo : String , prNumber : String ): GitHubPullEntry {
418
- return requestJson<GitHubPullEntry >(" https://api.github.com/repos/$repo /pulls/$prNumber " )
407
+ // Commits that exist in [firstCommit] and not identified as cherry-picks.
408
+ // We identify them via reading a PR description "Cherry-picked from ..."
409
+ val cherryPickedPrsInFirstCommit = gitLogShas(repoFolder, firstCommit, lastCommit, " --cherry-pick --left-only" )
410
+ .mapNotNull(::pullOf)
411
+
412
+ return gitLogShas(repoFolder, firstCommit, lastCommit, " --cherry-pick --right-only" )
413
+ .reversed() // older changes are at the bottom
414
+ .mapNotNull(::pullOf)
415
+ .minus(cherryPickedPrsInFirstCommit)
416
+ .distinctBy { it.number }
417
+ .flatMap(::changelogEntriesFor)
419
418
}
420
419
421
420
/* *
@@ -447,7 +446,7 @@ fun androidxLibToVersion(commit: String): Map<String, String> {
447
446
val libraryKt = spaceContentOf(repo, file, commit)
448
447
449
448
return if (libraryKt.isBlank()) {
450
- println (" Can't clone $repo to know library versions. Please register your ssh key in https://jetbrains.team/m/me/authentication?tab=GitKeys" )
449
+ println (" Can't find library versions in $repo for $commit . Either the format is changed, or you need to register your ssh key in https://jetbrains.team/m/me/authentication?tab=GitKeys" )
451
450
emptyMap()
452
451
} else {
453
452
val regex = Regex (" Library\\ .(.*)\\ s*->\\ s*\" (.*)\" " )
@@ -468,6 +467,34 @@ fun spaceContentOf(repoUrl: String, path: String, tagName: String): String {
468
467
.readText()
469
468
}
470
469
470
+ /* *
471
+ * Return a list of shas between [firstCommit] and [lastCommit] in [folder]
472
+ */
473
+ fun gitLogShas (folder : File , firstCommit : String , lastCommit : String , additionalArgs : String ): List <String > {
474
+ val absolutePath = folder.absolutePath
475
+ val commits = pipeProcess(" git -C $absolutePath log --oneline --format=%H $additionalArgs $firstCommit ...$lastCommit " ).
476
+ readText()
477
+ return commits.split(" \n " )
478
+ }
479
+
480
+ /* *
481
+ * Clone or fetch GitHub repo into [result] folder
482
+ */
483
+ fun githubClone (repo : String ): File {
484
+ val url = " https://github.com/$repo "
485
+ val folder = File (" build/github/$repo " )
486
+ val absolutePath = folder.absolutePath
487
+ if (! folder.exists()) {
488
+ folder.mkdirs()
489
+ println (" Cloning $url into ${folder.absolutePath} " )
490
+ pipeProcess(" git clone --bare $url $absolutePath " ).waitAndCheck()
491
+ } else {
492
+ println (" Fetching $url into ${folder.absolutePath} " )
493
+ pipeProcess(" git -C $absolutePath fetch" ).waitAndCheck()
494
+ }
495
+ return folder
496
+ }
497
+
471
498
sealed interface ReleaseNotes {
472
499
val entries: List <ChangelogEntry >
473
500
@@ -484,6 +511,7 @@ data class ChangelogEntry(
484
511
val subsection : String? ,
485
512
val prNumber : Int ,
486
513
val link : String? ,
514
+ val isPrerelease : Boolean ,
487
515
)
488
516
489
517
fun ChangelogEntry.sectionOrder (): Int = section?.let (standardSections::indexOf) ? : standardSections.size
@@ -493,15 +521,6 @@ fun ChangelogEntry.subsectionName(): String = subsection ?: "Unknown"
493
521
fun String.normalizeSectionName () = standardSections.find { it.lowercase() == this .lowercase() } ? : this
494
522
fun String.normalizeSubsectionName () = standardSubsections.find { it.lowercase() == this .lowercase() } ? : this
495
523
496
- // example https://api.github.com/repos/JetBrains/compose-multiplatform-core/compare/v1.6.0-rc02...release/1.6.0
497
- data class GitHubCompareResponse (
498
- val commits : List <CommitEntry >,
499
- @SerializedName(" merge_base_commit" ) val mergeBaseCommit : CommitEntry
500
- ) {
501
- data class CommitEntry (val sha : String , val commit : Commit )
502
- data class Commit (val message : String )
503
- }
504
-
505
524
// example https://api.github.com/repos/JetBrains/compose-multiplatform-core/pulls?state=closed
506
525
data class GitHubPullEntry (
507
526
@SerializedName(" html_url" ) val htmlUrl : String ,
@@ -511,6 +530,32 @@ data class GitHubPullEntry(
511
530
@SerializedName(" merge_commit_sha" ) val mergeCommitSha : String? ,
512
531
)
513
532
533
+ /* *
534
+ * Find a link to the original Pull request:
535
+ * - to show the original PR instead of cherry-pick to users
536
+ * (and the cherry-pick PR can be found in the comments, GitHub mentions all the links)
537
+ * - to distinguish in case the diff is changed
538
+ *
539
+ * The link should be in format "Cherry-picked from <link>"
540
+ */
541
+ fun GitHubPullEntry.findCherryPickPullNumber (): Int? = body
542
+ ?.lowercase()
543
+ ?.split(" \n " )
544
+ ?.map { it.trim() }
545
+ ?.find { it.startsWithAny(" cherry-pick" , " cherrypick" , " cherry pick" ) }
546
+ ?.let {
547
+ val numberFromLink = it
548
+ .substringAfter(" http" , " " )
549
+ .substringAfter(" /pull/" , " " )
550
+ .substringBefore(" " )
551
+ .toIntOrNull()
552
+ val numberFromId = it
553
+ .substringAfter(" #" , " " )
554
+ .substringBefore(" " )
555
+ .toIntOrNull()
556
+ numberFromLink ? : numberFromId
557
+ }
558
+
514
559
// region ========================================== UTILS =========================================
515
560
fun pipeProcess (command : String ) = ProcessBuilder (command.split(" " ))
516
561
.redirectOutput(Redirect .PIPE )
@@ -525,6 +570,14 @@ fun Process.pipeTo(command: String): Process = pipeProcess(command).also {
525
570
}
526
571
}
527
572
573
+ fun Process.waitAndCheck () {
574
+ val exitCode = waitFor()
575
+ if (exitCode != 0 ) {
576
+ val message = errorStream.bufferedReader().use { it.readText() }
577
+ error(" Command failed with exit code $exitCode :\n $message " )
578
+ }
579
+ }
580
+
528
581
fun Process.readText (): String = inputStream.bufferedReader().use { it.readText() }
529
582
530
583
inline fun <reified T > requestJson (url : String ): T =
@@ -535,7 +588,7 @@ fun requestPlain(url: String): String = exponentialRetry {
535
588
val connection = URL (url).openConnection()
536
589
connection.setRequestProperty(" User-Agent" , " Compose-Multiplatform-Script" )
537
590
if (token != null ) {
538
- connection.setRequestProperty(" Authorization" , " Bearer $token " )
591
+ connection.setRequestProperty(" Authorization" , if (token.startsWith( " github_pat " )) token else " Bearer $token " )
539
592
}
540
593
connection.getInputStream().use {
541
594
it.bufferedReader().readText()
@@ -558,14 +611,6 @@ fun <T> exponentialRetry(block: () -> T): T {
558
611
throw exception
559
612
}
560
613
561
- inline fun <T > fetchPagedUntilEmpty (fetch : (page: Int ) -> List <T >): MutableList <T > {
562
- val all = mutableListOf<T >()
563
- var page = 1
564
- do {
565
- val result = fetch(page++ )
566
- all.addAll(result)
567
- } while (result.isNotEmpty())
568
- return all
569
- }
614
+ fun String.startsWithAny (vararg prefixes : String ): Boolean = prefixes.any { startsWith(it) }
570
615
571
616
// endregion
0 commit comments