Skip to content

Commit 325217e

Browse files
igordmnkropp
andauthored
Changelog script. Support cherry-pick PRs (#5321)
Fixes https://youtrack.jetbrains.com/issue/CMP-8192/Changelog-script.-Handle-multi-cherry-pick-PRs-into-a-release Now there is a special format for Release Notes, not for the PR. Example: #5312 - There was an old non-structured way determined it by "Cherry-picked from ...", it is removed, as it is less convenient (we still need to define Release Notes) - We can determine cherry-picks automatically by git, but not always (in case of conflicts or additional fixes). Because of this, now it is a requirement either to describe the release notes the usual way or add links to the original PRs. ## Testing ``` kotlin changelog.main.kts v1.7.3..v1.8.0 kotlin changelog.main.kts v1.8.0..v1.8.1+dev2468 ``` Doesn't change old entries, and include new ones ## Release Notes N/A --------- Co-authored-by: Victor Kropp <[email protected]>
1 parent d80fdca commit 325217e

File tree

2 files changed

+86
-57
lines changed

2 files changed

+86
-57
lines changed

tools/changelog/PR_FORMAT.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,22 @@ N/A
4040
```
4141

4242
### Prerelease Fix
43-
Will be includede in alpha/beta/rc changelog, excluded from stable.
43+
Will be included in alpha/beta/rc changelog, excluded from stable.
4444
```
4545
## Release Notes
4646
### Fixes - Multiple Platforms
4747
- _(prerelease fix)_ Fixed CPU overheating on pressing Shift appeared in 1.8.0-alpha02
4848
```
4949

50+
### Cherry-picks to a release branch
51+
The PR can contain only cherry-picks. We can point to them instead of copying the release notes.
52+
```
53+
## Release Notes
54+
https://github.com/JetBrains/compose-multiplatform/pull/5292
55+
https://github.com/JetBrains/compose-multiplatform/pull/5294
56+
https://github.com/JetBrains/compose-multiplatform/pull/5295
57+
```
58+
5059
## Possible Sections
5160
<!--
5261
- Note that this is parsed by [changelog.main.kts]

tools/changelog/changelog.main.kts

Lines changed: 76 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -200,18 +200,18 @@ fun generateChangelog() {
200200
append(
201201
"""
202202
## Dependencies
203-
203+
204204
- Gradle Plugin `org.jetbrains.compose`, version `$versionCompose`. Based on Jetpack Compose libraries:
205205
- [Runtime $versionRedirectingCompose](https://developer.android.com/jetpack/androidx/releases/compose-runtime#$versionRedirectingCompose)
206206
- [UI $versionRedirectingCompose](https://developer.android.com/jetpack/androidx/releases/compose-ui#$versionRedirectingCompose)
207207
- [Foundation $versionRedirectingCompose](https://developer.android.com/jetpack/androidx/releases/compose-foundation#$versionRedirectingCompose)
208208
- [Material $versionRedirectingCompose](https://developer.android.com/jetpack/androidx/releases/compose-material#$versionRedirectingCompose)
209209
- [Material3 $versionRedirectingComposeMaterial3](https://developer.android.com/jetpack/androidx/releases/compose-material3#$versionRedirectingComposeMaterial3)
210-
210+
211211
- Lifecycle libraries `org.jetbrains.androidx.lifecycle:lifecycle-*:$versionLifecycle`. Based on [Jetpack Lifecycle $versionRedirectingLifecycle](https://developer.android.com/jetpack/androidx/releases/lifecycle#$versionRedirectingLifecycle)
212212
- Navigation libraries `org.jetbrains.androidx.navigation:navigation-*:$versionNavigation`. Based on [Jetpack Navigation $versionRedirectingNavigation](https://developer.android.com/jetpack/androidx/releases/navigation#$versionRedirectingNavigation)
213213
- 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)
214-
214+
215215
---
216216
""".trimIndent()
217217
)
@@ -267,7 +267,7 @@ fun checkPr() {
267267
releaseNotes is ReleaseNotes.Specified && releaseNotes.entries.isEmpty() -> {
268268
err.println("""
269269
"## Release Notes" doesn't contain any items, or "### Section - Subsection" isn't specified
270-
270+
271271
See the format in $prFormatLink
272272
""".trimIndent())
273273
exitProcess(1)
@@ -276,18 +276,18 @@ fun checkPr() {
276276
err.println("""
277277
"## Release Notes" contains nonstandard "Section - Subsection" pairs:
278278
${nonstandardSections.joinToString(", ")}
279-
279+
280280
Allowed sections: ${standardSections.joinToString(", ")}
281281
Allowed subsections: ${standardSubsections.joinToString(", ")}
282-
282+
283283
See the full format in $prFormatLink
284284
""".trimIndent())
285285
exitProcess(1)
286286
}
287287
releaseNotes == null -> {
288288
err.println("""
289289
"## Release Notes" section is missing in the PR description
290-
290+
291291
See the format in $prFormatLink
292292
""".trimIndent())
293293
exitProcess(1)
@@ -303,6 +303,11 @@ fun checkPr() {
303303
*/
304304
fun currentChangelogDate() = LocalDate.now().format(DateTimeFormatter.ofPattern("MMMM yyyy", Locale.ENGLISH))
305305

306+
fun GitHubPullEntry.extractReleaseNotes() = extractReleaseNotes(body, number, htmlUrl)
307+
308+
fun GitHubPullEntry.unknownChangelogEntries() =
309+
listOf(ChangelogEntry("- $title", null, null, number, htmlUrl, false))
310+
306311
/**
307312
* Extract by format [PR_FORMAT.md]
308313
*/
@@ -330,6 +335,14 @@ fun extractReleaseNotes(body: String?, prNumber: Int, prLink: String): ReleaseNo
330335
if (relNoteBody == null) return null
331336
if (relNoteBody.trim().lowercase() == "n/a") return ReleaseNotes.NA
332337

338+
// Check if the release notes contain only GitHub PR links
339+
val pullRequests = relNoteBody
340+
.split("\n")
341+
.map { it.trim() }
342+
.mapNotNull(PullRequestLink::parseOrNull)
343+
344+
if (pullRequests.isNotEmpty()) return ReleaseNotes.CherryPicks(pullRequests)
345+
333346
val list = mutableListOf<ChangelogEntry>()
334347
var section: String? = null
335348
var subsection: String? = null
@@ -381,40 +394,44 @@ fun extractReleaseNotes(body: String?, prNumber: Int, prLink: String): ReleaseNo
381394
* JetBrains/compose-multiplatform-core
382395
*/
383396
fun entriesForRepo(repo: String, firstCommit: String, lastCommit: String): List<ChangelogEntry> {
384-
val pulls = (1..5)
397+
val pulls = (1..10)
385398
.flatMap {
386399
requestJson<Array<GitHubPullEntry>>("https://api.github.com/repos/$repo/pulls?state=closed&per_page=100&page=$it").toList()
387400
}
388401

389-
val shaToOriginalPull = pulls.associateBy { it.mergeCommitSha }
402+
val shaToPull = pulls.associateBy { it.mergeCommitSha }
390403
val numberToPull = pulls.associateBy { it.number }
391-
val pullToCherryPickPull = pulls.associateWith {
392-
it.findCherryPickPullNumber()?.let(numberToPull::get)
393-
}
394-
395-
fun pullOf(sha: String): GitHubPullEntry? {
396-
val originalPr = shaToOriginalPull[sha]
397-
return pullToCherryPickPull[originalPr] ?: originalPr
398-
}
399-
400-
fun changelogEntriesFor(pullRequest: GitHubPullEntry) = with(pullRequest) {
401-
extractReleaseNotes(body, number, htmlUrl)?.entries ?:
402-
listOf(ChangelogEntry("- $title", null, null, number, htmlUrl, false))
404+
val pullToReleaseNotes = Cache( GitHubPullEntry::extractReleaseNotes)
405+
406+
// if GitHubPullEntry is a cherry-picks PR (contains a list of links to other PRs), replace it by the original PRs
407+
fun List<GitHubPullEntry>.replaceCherryPicks(): List<GitHubPullEntry> = flatMap { pullRequest ->
408+
val releaseNotes = pullToReleaseNotes[pullRequest]
409+
if (releaseNotes is ReleaseNotes.CherryPicks) {
410+
releaseNotes.pullRequests
411+
.filter { it.repo == repo }
412+
.mapNotNull { numberToPull[it.number] }
413+
} else {
414+
listOf(pullRequest)
415+
}
403416
}
404417

405418
val repoFolder = githubClone(repo)
406419

407-
// Commits that exist in [firstCommit] and not identified as cherry-picks.
408-
// We identify them via reading a PR description "Cherry-picked from ..."
420+
// Commits that exist in [firstCommit] and not identified as cherry-picks by `git log`
421+
// We'll try to exclude them via manual links to cherry-picks
409422
val cherryPickedPrsInFirstCommit = gitLogShas(repoFolder, firstCommit, lastCommit, "--cherry-pick --left-only")
410-
.mapNotNull(::pullOf)
423+
.mapNotNull(shaToPull::get)
424+
.replaceCherryPicks()
411425

412426
return gitLogShas(repoFolder, firstCommit, lastCommit, "--cherry-pick --right-only")
413427
.reversed() // older changes are at the bottom
414-
.mapNotNull(::pullOf)
428+
.mapNotNull(shaToPull::get)
429+
.replaceCherryPicks()
415430
.minus(cherryPickedPrsInFirstCommit)
416431
.distinctBy { it.number }
417-
.flatMap(::changelogEntriesFor)
432+
.flatMap {
433+
pullToReleaseNotes[it]?.entries ?: it.unknownChangelogEntries()
434+
}
418435
}
419436

420437
/**
@@ -443,7 +460,11 @@ fun androidxLibToRedirectionVersion(commit: String): Map<String, String> {
443460
fun androidxLibToVersion(commit: String): Map<String, String> {
444461
val repo = "ssh://[email protected]/ui/compose-teamcity-config.git"
445462
val file = ".teamcity/compose/Library.kt"
446-
val libraryKt = spaceContentOf(repo, file, commit)
463+
val libraryKt = try {
464+
spaceContentOf(repo, file, commit)
465+
} catch (_: Exception) {
466+
""
467+
}
447468

448469
return if (libraryKt.isBlank()) {
449470
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")
@@ -490,18 +511,34 @@ fun githubClone(repo: String): File {
490511
pipeProcess("git clone --bare $url $absolutePath").waitAndCheck()
491512
} else {
492513
println("Fetching $url into ${folder.absolutePath}")
493-
pipeProcess("git -C $absolutePath fetch").waitAndCheck()
514+
pipeProcess("git -C $absolutePath fetch --tags").waitAndCheck()
494515
}
495516
return folder
496517
}
497518

519+
data class PullRequestLink(val repo: String, val number: Int) {
520+
companion object {
521+
fun parseOrNull(link: String): PullRequestLink? {
522+
val (repo, number) = Regex("https://github\\.com/(.+)/pull/(\\d+)/?")
523+
.matchEntire(link)
524+
?.destructured
525+
?: return null
526+
return PullRequestLink(repo, number.toInt())
527+
}
528+
}
529+
}
530+
498531
sealed interface ReleaseNotes {
499532
val entries: List<ChangelogEntry>
500533

501534
object NA: ReleaseNotes {
502535
override val entries: List<ChangelogEntry> get() = emptyList()
503536
}
504537

538+
class CherryPicks(val pullRequests: List<PullRequestLink>): ReleaseNotes {
539+
override val entries: List<ChangelogEntry> get() = emptyList()
540+
}
541+
505542
class Specified(override val entries: List<ChangelogEntry>): ReleaseNotes
506543
}
507544

@@ -530,32 +567,6 @@ data class GitHubPullEntry(
530567
@SerializedName("merge_commit_sha") val mergeCommitSha: String?,
531568
)
532569

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-
559570
//region ========================================== UTILS =========================================
560571
fun pipeProcess(command: String) = ProcessBuilder(command.split(" "))
561572
.redirectOutput(Redirect.PIPE)
@@ -578,7 +589,11 @@ fun Process.waitAndCheck() {
578589
}
579590
}
580591

581-
fun Process.readText(): String = inputStream.bufferedReader().use { it.readText() }
592+
fun Process.readText(): String = inputStream.bufferedReader().use {
593+
it.readText().also {
594+
waitAndCheck()
595+
}
596+
}
582597

583598
inline fun <reified T> requestJson(url: String): T =
584599
Gson().fromJson(requestPlain(url), T::class.java)
@@ -588,7 +603,7 @@ fun requestPlain(url: String): String = exponentialRetry {
588603
val connection = URL(url).openConnection()
589604
connection.setRequestProperty("User-Agent", "Compose-Multiplatform-Script")
590605
if (token != null) {
591-
connection.setRequestProperty("Authorization", if (token.startsWith("github_pat")) token else "Bearer $token")
606+
connection.setRequestProperty("Authorization", "Bearer $token")
592607
}
593608
connection.getInputStream().use {
594609
it.bufferedReader().readText()
@@ -613,4 +628,9 @@ fun <T> exponentialRetry(block: () -> T): T {
613628

614629
fun String.startsWithAny(vararg prefixes: String): Boolean = prefixes.any { startsWith(it) }
615630

631+
class Cache<K, V>(private val create: (K) -> V) {
632+
private val map = mutableMapOf<K,V>()
633+
operator fun get(key: K): V = map.getOrPut(key) { create(key) }
634+
}
635+
616636
//endregion

0 commit comments

Comments
 (0)