Igor Demin
3 weeks ago
committed by
GitHub
3 changed files with 321 additions and 0 deletions
@ -0,0 +1,17 @@
|
||||
## Proposed Changes |
||||
|
||||
[Optional] Fixes $linkToTheIssue |
||||
|
||||
[Optional] Fixes "Description of the issue" |
||||
|
||||
[Optional] RelNote [Section\Subsection] Adds a feature |
||||
|
||||
- |
||||
- |
||||
- |
||||
|
||||
## Testing |
||||
|
||||
Describe how you tested your changes |
||||
|
||||
[Optional] This should be tested by QA |
@ -0,0 +1,301 @@
|
||||
/** |
||||
* Script for creating a changelog. Call |
||||
* ``` |
||||
* kotlin changelog.main.kts v1.6.0-rc02 release/1.6.0 |
||||
* ``` |
||||
* where v1.6.0-rc02 - the first commit |
||||
* where release/1.6.0 - the last commit |
||||
*/ |
||||
|
||||
/** |
||||
* Run from command line: |
||||
* 1. Download https://github.com/JetBrains/kotlin/releases/tag/v1.9.22 and add `bin` to PATH |
||||
* 2. Call `kotlin <fileName>` |
||||
* |
||||
* Run from IntelliJ: |
||||
* 1. Right click on the script |
||||
* 2. More Run/Debug |
||||
* 3. Modify Run Configuration... |
||||
* 4. Clear all "Before launch" tasks (you can edit the system-wide template as well) |
||||
* 5. OK |
||||
*/ |
||||
|
||||
@file:Repository("https://repo1.maven.org/maven2/") |
||||
@file:DependsOn("com.google.code.gson:gson:2.10.1") |
||||
|
||||
import com.google.gson.Gson |
||||
import java.io.File |
||||
import java.io.IOException |
||||
import java.net.URL |
||||
import java.util.concurrent.TimeUnit |
||||
|
||||
val firstCommit = args.getOrNull(0) ?: error("Please call this way: kotlin changelog.main.kts <firstCommit> <lastCommit>") |
||||
val lastCommit = args.getOrNull(1) ?: error("Please call this way: kotlin changelog.main.kts <firstCommit> <lastCommit>") |
||||
val token = args.getOrNull(2) |
||||
|
||||
// TODO automate or pass as arguments |
||||
// Exclude cherry-picked commits to the previous versions |
||||
val excludeFirstCommit = "v1.6.0-rc01" |
||||
val excludeLastCommit = "v1.6.2" |
||||
|
||||
if (token == null) { |
||||
println("To increase the rate limit, specify token (https://github.com/settings/tokens): kotlin changelog.main.kts <firstCommit> <lastCommit> TOKEN") |
||||
} |
||||
|
||||
// commits that don't have a link to a PR (a link should be something like " (#454)") |
||||
val commitToPRLinkMapping = File("commit-to-pr-mapping.txt").readLines().associate { |
||||
val splits = it.split(" ") |
||||
val commit = splits[0] |
||||
val prLink = splits[1] |
||||
commit to prLink |
||||
} |
||||
|
||||
val entries = entriesForRepo("JetBrains/compose-multiplatform-core") + |
||||
entriesForRepo("JetBrains/compose-multiplatform") |
||||
|
||||
fun List<ChangelogEntry>.ofType(type: Type) = |
||||
filter { it.type == type }.sortedByDescending { it.platforms.sumOf { it.sorting } } |
||||
|
||||
val highlighted = entries.ofType(Type.Highlighted) |
||||
val normal = entries.ofType(Type.Normal) |
||||
val prereleaseFixes = entries.ofType(Type.PrereleaseFix) |
||||
val unknown = entries.ofType(Type.Unknown) |
||||
|
||||
println("\n# CHANGELOG") |
||||
|
||||
println( |
||||
buildString { |
||||
append("_Changes since ${commitToVersion(firstCommit)}_\n") |
||||
append("\n") |
||||
|
||||
if (highlighted.isNotEmpty()) |
||||
append(highlighted.joinToString("\n") { it.format() }).append("\n") |
||||
|
||||
if (normal.isNotEmpty()) |
||||
append(normal.joinToString("\n") { it.format() }).append("\n") |
||||
|
||||
if (prereleaseFixes.isNotEmpty()) |
||||
append(prereleaseFixes.joinToString("\n") { it.format() }).append("\n") |
||||
|
||||
if (unknown.isNotEmpty()) { |
||||
append("\nUnknown changes for review:\n") |
||||
append(unknown.joinToString("\n") { it.format() }).append("\n") |
||||
} |
||||
} |
||||
) |
||||
|
||||
/** |
||||
* Transforms v1.6.0-beta01 to 1.6.0-beta01 |
||||
*/ |
||||
fun commitToVersion(commit: String) = |
||||
if (commit.startsWith("v") && commit.contains(".")) { |
||||
commit.removePrefix("v") |
||||
} else { |
||||
commit |
||||
} |
||||
|
||||
fun ChangelogEntry.format() = buildString { |
||||
append("- ") |
||||
// if (type == Type.Highlighted) append("**") |
||||
if (type == Type.PrereleaseFix) append("_(prerelease fix)_ ") |
||||
append("[$title]($link)") |
||||
// if (type == Type.Highlighted) append("**") |
||||
// platforms.forEach { |
||||
// append(" <sub>$it</sub>") |
||||
// } |
||||
} |
||||
|
||||
/** |
||||
* @param repo Example: |
||||
* JetBrains/compose-multiplatform-core |
||||
*/ |
||||
fun entriesForRepo(repo: String): List<ChangelogEntry> { |
||||
val pullNumberToPull = (1..5) |
||||
.flatMap { |
||||
request<Array<GitHubPullEntry>>("https://api.github.com/repos/$repo/pulls?state=closed&per_page=100&page=$it").toList() |
||||
} |
||||
.associateBy { it.number } |
||||
|
||||
fun prForCommit(commit: GitHubCompareResponse.CommitEntry): GitHubPullEntry? { |
||||
val repoNumber = repoNumberForCommit(commit) |
||||
return pullNumberToPull[repoNumber] |
||||
} |
||||
|
||||
fun changelogEntryFor( |
||||
commit: GitHubCompareResponse.CommitEntry, |
||||
pullRequest: GitHubPullEntry? |
||||
): ChangelogEntry { |
||||
return if (pullRequest != null) { |
||||
val prTitle = pullRequest.title |
||||
val prNumber = pullRequest.number |
||||
ChangelogEntry( |
||||
prTitle, |
||||
"https://github.com/$repo/pull/$prNumber", |
||||
typeOf(pullRequest), |
||||
platformsOf(pullRequest) |
||||
) |
||||
} else { |
||||
val commitSha = commit.sha |
||||
val commitTitle = commit.commit.message.substringBefore("\n") |
||||
ChangelogEntry(commitTitle, "https://github.com/$repo/commit/$commitSha", Type.Unknown, emptyList()) |
||||
} |
||||
} |
||||
|
||||
val commits = fetchPagedUntilEmpty { page -> |
||||
request<GitHubCompareResponse>("https://api.github.com/repos/$repo/compare/$firstCommit...$lastCommit?per_page=1000&page=$page") |
||||
.commits |
||||
}.toSet() |
||||
|
||||
fun GitHubCompareResponse.CommitEntry.commitId() = repoNumberForCommit(this)?.toString() ?: sha |
||||
|
||||
val excludedCommitIds = fetchPagedUntilEmpty { page -> |
||||
request<GitHubCompareResponse>("https://api.github.com/repos/$repo/compare/$excludeFirstCommit...$excludeLastCommit?per_page=1000&page=$page") |
||||
.commits |
||||
.map { it.commitId() } |
||||
}.toSet() |
||||
|
||||
// Exclude cherry-picks with the same message (they have a different SHA, we can compare by it) |
||||
val nonCherrypickedCommits = commits.filter { it.commitId() !in excludedCommitIds } |
||||
|
||||
return nonCherrypickedCommits |
||||
.map { changelogEntryFor(it, prForCommit(it)) } |
||||
} |
||||
|
||||
/** |
||||
* Extract the PR number from the commit. |
||||
*/ |
||||
fun repoNumberForCommit(commit: GitHubCompareResponse.CommitEntry): Int? { |
||||
val commitTitle = commit.commit.message.substringBefore("\n") |
||||
val prLink = commitToPRLinkMapping[commit.sha] |
||||
return when { |
||||
prLink != null -> prLink.substringAfterLast("/").toIntOrNull() |
||||
// check title similar to `Fix import android flavors with compose resources (#4319)` |
||||
commitTitle.contains(" (#") -> commitTitle.substringAfter(" (#").substringBefore(")").toIntOrNull() |
||||
else -> null |
||||
} |
||||
} |
||||
|
||||
fun typeOf(pullRequest: GitHubPullEntry): Type { |
||||
val labels = pullRequest.labels.mapTo(mutableSetOf()) { it.name.lowercase() } |
||||
return when { |
||||
// labels.contains("changelog: highlight") -> Type.Highlighted |
||||
// labels.contains("changelog: normal") -> Type.Normal |
||||
// labels.contains("changelog: prerelease fix") -> Type.PrereleaseFix |
||||
else -> Type.Normal |
||||
} |
||||
} |
||||
|
||||
fun platformsOf(pullRequest: GitHubPullEntry) = pullRequest.labels.mapNotNull { |
||||
when (it.name.lowercase()) { |
||||
"ios" -> Platform.IOS |
||||
"android" -> Platform.Android |
||||
"desktop" -> Platform.Desktop |
||||
"web" -> Platform.Web |
||||
"common" -> Platform.Common |
||||
else -> null |
||||
} |
||||
} |
||||
|
||||
data class ChangelogEntry( |
||||
val title: String, |
||||
val link: String, |
||||
val type: Type, |
||||
val platforms: List<Platform>, |
||||
) |
||||
|
||||
enum class Type { Highlighted, Normal, PrereleaseFix, Unknown } |
||||
|
||||
enum class Platform(val title: String, val sorting: Int) { |
||||
Common("Common", 0x11111), |
||||
IOS("iOS", 0x01000), |
||||
Android("Android", 0x00100), |
||||
Desktop("Desktop", 0x00010), |
||||
Web("Web", 0x00001); |
||||
|
||||
override fun toString() = title |
||||
} |
||||
|
||||
// example https://api.github.com/repos/JetBrains/compose-multiplatform-core/compare/v1.6.0-rc02...release/1.6.0 |
||||
data class GitHubCompareResponse(val commits: List<CommitEntry>) { |
||||
data class CommitEntry(val sha: String, val commit: Commit) |
||||
data class Commit(val message: String) |
||||
} |
||||
|
||||
// example https://api.github.com/repos/JetBrains/compose-multiplatform-core/pulls?state=closed |
||||
data class GitHubPullEntry(val number: Int, val title: String, val body: String, val labels: List<Label>) { |
||||
class Label(val name: String) |
||||
} |
||||
|
||||
//region ========================================== UTILS ========================================= |
||||
|
||||
// from https://stackoverflow.com/a/41495542 |
||||
fun String.runCommand(workingDir: File = File(".")) { |
||||
ProcessBuilder(*split(" ").toTypedArray()) |
||||
.directory(workingDir) |
||||
.redirectOutput(ProcessBuilder.Redirect.INHERIT) |
||||
.redirectError(ProcessBuilder.Redirect.INHERIT) |
||||
.start() |
||||
.waitFor(5, TimeUnit.MINUTES) |
||||
} |
||||
|
||||
fun String.execCommand(workingDir: File = File(".")): String? { |
||||
try { |
||||
val parts = this.split("\\s".toRegex()) |
||||
val proc = ProcessBuilder(*parts.toTypedArray()) |
||||
.directory(workingDir) |
||||
.redirectOutput(ProcessBuilder.Redirect.PIPE) |
||||
.redirectError(ProcessBuilder.Redirect.PIPE) |
||||
.start() |
||||
|
||||
proc.waitFor(60, TimeUnit.MINUTES) |
||||
return proc.inputStream.bufferedReader().readText() |
||||
} catch (e: IOException) { |
||||
e.printStackTrace() |
||||
return null |
||||
} |
||||
} |
||||
|
||||
inline fun <reified T> request( |
||||
url: String |
||||
): T = exponentialRetry { |
||||
println("Request $url") |
||||
val connection = URL(url).openConnection() |
||||
connection.setRequestProperty("User-Agent", "Compose-Multiplatform-Script") |
||||
if (token != null) { |
||||
connection.setRequestProperty("Authorization", "Bearer $token") |
||||
} |
||||
connection.getInputStream().use { |
||||
Gson().fromJson( |
||||
it.bufferedReader(), |
||||
T::class.java |
||||
) |
||||
} |
||||
} |
||||
|
||||
fun <T> exponentialRetry(block: () -> T): T { |
||||
val exception = IOException() |
||||
val retriesMinutes = listOf(1, 5, 15, 30, 60) |
||||
for (retriesMinute in retriesMinutes) { |
||||
try { |
||||
return block() |
||||
} catch (e: IOException) { |
||||
e.printStackTrace() |
||||
exception.addSuppressed(e) |
||||
println("Retry in $retriesMinute minutes") |
||||
Thread.sleep(retriesMinute.toLong() * 60 * 1000) |
||||
} |
||||
} |
||||
throw exception |
||||
} |
||||
|
||||
inline fun <T> fetchPagedUntilEmpty(fetch: (page: Int) -> List<T>): MutableList<T> { |
||||
val all = mutableListOf<T>() |
||||
var page = 1 |
||||
do { |
||||
val result = fetch(page++) |
||||
all.addAll(result) |
||||
} while (result.isNotEmpty()) |
||||
return all |
||||
} |
||||
|
||||
//endregion |
@ -0,0 +1,3 @@
|
||||
4e948e4bc3a9bd0ee364a6f8ddf5d982394e53c8 https://github.com/JetBrains/compose-multiplatform/pull/4333 |
||||
1603734af1def23861391630ffcbd058350e7652 https://github.com/JetBrains/compose-multiplatform-core/pull/1113 |
||||
f683673b7ed1bd9c8ec26d5f421b207af9a7561f https://github.com/JetBrains/compose-multiplatform/pull/4300 |
Loading…
Reference in new issue