Skip to content

Commit 800fefe

Browse files
igordmnkropp
andauthored
Changelog fixes for 1.8 (#5309)
- identify cherry-picks - via "git log --cherry-pick" (we have to clone repos for that) - in case it doesn't work, additionally identified via "cherry-picked from" in PR descriptions - fix multiline changes formatting - exclude prerelease fixes ## Release Notes N/A --------- Co-authored-by: Victor Kropp <[email protected]>
1 parent 15944ad commit 800fefe

File tree

1 file changed

+108
-63
lines changed

1 file changed

+108
-63
lines changed

tools/changelog/changelog.main.kts

Lines changed: 108 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -159,18 +159,21 @@ fun generateChangelog() {
159159
previousVersion = previousVersionInChangelog
160160
}
161161

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+
163165
val entries = entriesForRepo("JetBrains/compose-multiplatform-core", firstCommit, lastCommit) +
164166
entriesForRepo("JetBrains/compose-multiplatform", firstCommit, lastCommit)
165167

166168
return buildString {
167-
appendLine("# $versionName (${currentChangelogDate()})")
169+
appendLine("# $lastVersion (${currentChangelogDate()})")
168170

169171
appendLine()
170172
appendLine("_Changes since ${firstVersion}_")
171173
appendLine()
172174

173175
entries
176+
.filter { isPrerelease || !it.isPrerelease }
174177
.sortedBy { it.sectionOrder() }
175178
.groupBy { it.sectionName() }
176179
.forEach { (section, sectionEntries) ->
@@ -184,7 +187,11 @@ fun generateChangelog() {
184187
appendLine("### $subsection")
185188
appendLine()
186189
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+
}
188195
}
189196
appendLine()
190197
}
@@ -232,7 +239,7 @@ fun generateChangelog() {
232239
println()
233240
println("Generating changelog between $previousVersion and $versionName")
234241

235-
val newChangelog = getChangelog(previousVersionCommit, versionCommit, previousVersion)
242+
val newChangelog = getChangelog(previousVersionCommit, versionCommit, previousVersion, versionName)
236243

237244
changelogFile.writeText(
238245
newChangelog + previousChangelog
@@ -327,7 +334,7 @@ fun extractReleaseNotes(body: String?, prNumber: Int, prLink: String): ReleaseNo
327334
var section: String? = null
328335
var subsection: String? = null
329336
var isFirstLine = true
330-
var shouldPadLines = false
337+
var isPrerelease = false
331338

332339
for (line in relNoteBody.split("\n")) {
333340
// parse "## Section - Subsection"
@@ -336,27 +343,30 @@ fun extractReleaseNotes(body: String?, prNumber: Int, prLink: String): ReleaseNo
336343
section = s.substringBefore("-", "").trim().normalizeSectionName().ifEmpty { null }
337344
subsection = s.substringAfter("-", "").trim().normalizeSubsectionName().ifEmpty { null }
338345
isFirstLine = true
339-
shouldPadLines = false
346+
isPrerelease = false
340347
} else if (section != null && line.isNotBlank()) {
341348
var lineFixed = line
342349

343350
if (isFirstLine && !lineFixed.startsWith("-")) {
344351
lineFixed = "- $lineFixed"
345-
shouldPadLines = true
346352
}
347-
if (!isFirstLine && shouldPadLines) {
353+
if (!isFirstLine && !lineFixed.startsWithAny(" ", "-")) {
348354
lineFixed = " $lineFixed"
349355
}
350356
lineFixed = lineFixed.trimEnd().removeSuffix(".")
351357

352358
val isTopLevel = lineFixed.startsWith("-")
359+
if (isTopLevel) {
360+
isPrerelease = lineFixed.contains("(prerelease fix)")
361+
}
353362
list.add(
354363
ChangelogEntry(
355364
lineFixed,
356365
section,
357366
subsection,
358367
prNumber,
359-
prLink.takeIf { isTopLevel }
368+
prLink.takeIf { isTopLevel },
369+
isPrerelease
360370
)
361371
)
362372
isFirstLine = false
@@ -376,46 +386,35 @@ fun entriesForRepo(repo: String, firstCommit: String, lastCommit: String): List<
376386
requestJson<Array<GitHubPullEntry>>("https://api.github.com/repos/$repo/pulls?state=closed&per_page=100&page=$it").toList()
377387
}
378388

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)
392393
}
393394

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+
}
395399

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))
404403
}
405404

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)
412406

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)
419418
}
420419

421420
/**
@@ -447,7 +446,7 @@ fun androidxLibToVersion(commit: String): Map<String, String> {
447446
val libraryKt = spaceContentOf(repo, file, commit)
448447

449448
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")
451450
emptyMap()
452451
} else {
453452
val regex = Regex("Library\\.(.*)\\s*->\\s*\"(.*)\"")
@@ -468,6 +467,34 @@ fun spaceContentOf(repoUrl: String, path: String, tagName: String): String {
468467
.readText()
469468
}
470469

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+
471498
sealed interface ReleaseNotes {
472499
val entries: List<ChangelogEntry>
473500

@@ -484,6 +511,7 @@ data class ChangelogEntry(
484511
val subsection: String?,
485512
val prNumber: Int,
486513
val link: String?,
514+
val isPrerelease: Boolean,
487515
)
488516

489517
fun ChangelogEntry.sectionOrder(): Int = section?.let(standardSections::indexOf) ?: standardSections.size
@@ -493,15 +521,6 @@ fun ChangelogEntry.subsectionName(): String = subsection ?: "Unknown"
493521
fun String.normalizeSectionName() = standardSections.find { it.lowercase() == this.lowercase() } ?: this
494522
fun String.normalizeSubsectionName() = standardSubsections.find { it.lowercase() == this.lowercase() } ?: this
495523

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-
505524
// example https://api.github.com/repos/JetBrains/compose-multiplatform-core/pulls?state=closed
506525
data class GitHubPullEntry(
507526
@SerializedName("html_url") val htmlUrl: String,
@@ -511,6 +530,32 @@ data class GitHubPullEntry(
511530
@SerializedName("merge_commit_sha") val mergeCommitSha: String?,
512531
)
513532

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+
514559
//region ========================================== UTILS =========================================
515560
fun pipeProcess(command: String) = ProcessBuilder(command.split(" "))
516561
.redirectOutput(Redirect.PIPE)
@@ -525,6 +570,14 @@ fun Process.pipeTo(command: String): Process = pipeProcess(command).also {
525570
}
526571
}
527572

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+
528581
fun Process.readText(): String = inputStream.bufferedReader().use { it.readText() }
529582

530583
inline fun <reified T> requestJson(url: String): T =
@@ -535,7 +588,7 @@ fun requestPlain(url: String): String = exponentialRetry {
535588
val connection = URL(url).openConnection()
536589
connection.setRequestProperty("User-Agent", "Compose-Multiplatform-Script")
537590
if (token != null) {
538-
connection.setRequestProperty("Authorization", "Bearer $token")
591+
connection.setRequestProperty("Authorization", if (token.startsWith("github_pat")) token else "Bearer $token")
539592
}
540593
connection.getInputStream().use {
541594
it.bufferedReader().readText()
@@ -558,14 +611,6 @@ fun <T> exponentialRetry(block: () -> T): T {
558611
throw exception
559612
}
560613

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) }
570615

571616
//endregion

0 commit comments

Comments
 (0)