package org.example import com.google.gson.GsonBuilder import com.google.gson.JsonArray import java.net.URL import java.time.Instant import java.util.regex.Matcher import java.util.regex.Pattern import kotlin.system.exitProcess val allowedCategories = listOf("New Features", "Improvements", "Fixes", "Technical Details", "Removed Features") val categoryPattern = "## Changelog (?.*)".toPattern() val changePattern = "\\+ (?.*) - (?.*)".toPattern() val extraInfoPattern = " {4}\\* (?.*)".toPattern() val illegalStartPattern = "^[-=*+ ].*".toPattern() fun getTextFromUrl(urlString: String): List { val url = URL(urlString) val connection = url.openConnection() val inputStream = connection.getInputStream() val text = mutableListOf() inputStream.bufferedReader().useLines { lines -> lines.forEach { text.add(it) } } return text } enum class WhatToDo { NEXT_BETA, OPEN_PRS, ; } fun main() { val firstPr = 1121 val hideWhenError = true val fullVersion = "0.24" val beta = 12 val whatToDo = WhatToDo.NEXT_BETA @Suppress("KotlinConstantConditions") val url = when (whatToDo) { WhatToDo.NEXT_BETA -> "https://api.github.com/repos/hannibal002/SkyHanni/pulls?state=closed&sort=updated&direction=desc&per_page=50" WhatToDo.OPEN_PRS -> "https://api.github.com/repos/hannibal002/SkyHanni/pulls?state=open&sort=updated&direction=desc&per_page=30" } val data = getTextFromUrl(url).joinToString("") val gson = GsonBuilder().create() val fromJson = gson.fromJson(data, JsonArray::class.java) val prs = fromJson.map { gson.fromJson(it, PullRequest::class.java) } readPrs(prs, firstPr, hideWhenError, whatToDo, fullVersion, beta) } fun readPrs( prs: List, firstPr: Int, hideWhenError: Boolean, whatToDo: WhatToDo, fullVersion: String, beta: Int, ) { val categories = mutableListOf() val allChanges = mutableListOf() var errors = 0 var excluded = 0 var done = 0 // TODO find better solution for this sorting logic val filtered = when (whatToDo) { WhatToDo.NEXT_BETA -> prs.filter { it.mergedAt != null } .map { it to it.mergedAt } WhatToDo.OPEN_PRS -> prs .map { it to it.updatedAt } } .map { it.first to Long.MAX_VALUE - Instant.parse(it.second).toEpochMilli() } .sortedBy { it.second } .map { it.first } println("") for (pr in filtered) { val number = pr.number val prLink = pr.htmlUrl val body = pr.body val description = body?.split(System.lineSeparator()) ?: emptyList() if (description.isNotEmpty()) { val last = description.last() if (last == "exclude_from_changelog") { println("") println("Excluded #$number ($prLink)") excluded++ continue } } try { allChanges.addAll(parseChanges(description, prLink, categories)) done++ } catch (t: Throwable) { println("") println("Error in #$number ($prLink)") println(t.message) errors++ } if (whatToDo == WhatToDo.NEXT_BETA) { if (number == firstPr) break } } println("") for (type in OutputType.entries) { print(categories, allChanges, type, fullVersion, beta) } println("") if (excluded > 0) { println("Excluded $excluded PRs.") } if (errors > 0) { println("Found $errors PRs with errors.") } println("Loaded $done PRs correctly.") if (errors > 0) { if (hideWhenError) { exitProcess(-1) } } } enum class OutputType { DISCORD_INTERNAL, GITHUB, DISCORD_PUBLIC, } private fun print( categories: MutableList, allChanges: MutableList, outputType: OutputType, fullVersion: String, beta: Int, ) { val extraInfoPrefix = when (outputType) { OutputType.DISCORD_PUBLIC -> " = " OutputType.GITHUB -> " * " OutputType.DISCORD_INTERNAL -> " - " } val list = createPrint(categories, outputType, allChanges, extraInfoPrefix, fullVersion, beta) val border = "=================================================================================" println("") println("outputType ${outputType.name.lowercase()}:") val totalLength = list.sumOf { it.length } if (outputType != OutputType.GITHUB) { println("$totalLength/2000 characters used") } println(border) for (line in list) { println(line) } println(border) } private fun createPrint( categories: MutableList, outputType: OutputType, allChanges: MutableList, extraInfoPrefix: String, fullVersion: String, beta: Int, ): MutableList { val list = mutableListOf() list.add("## Version $fullVersion Beta $beta") for (category in allowedCategories.map { getCategory(categories, it) }) { if (outputType == OutputType.DISCORD_PUBLIC && category.name == "Technical Details") continue val changes = allChanges.filter { it.category == category } if (changes.isEmpty()) continue list.add("### " + category.name) if (outputType == OutputType.DISCORD_PUBLIC) { list.add("```diff") } for (change in changes) { val pr = when (outputType) { OutputType.DISCORD_PUBLIC -> "" OutputType.GITHUB -> " (${change.prLink})" OutputType.DISCORD_INTERNAL -> " [PR](<${change.prLink}>)" } val changePrefix = getChangePrefix(category.name, outputType) list.add("$changePrefix${change.text} - ${change.author}$pr") for (s in change.extraInfo) { list.add("$extraInfoPrefix$s") } } if (outputType == OutputType.DISCORD_PUBLIC) { list.add("```") } } if (outputType == OutputType.DISCORD_PUBLIC) { val root = "https://github.com/hannibal002/SkyHanni" val releaseLink = "$root/releases/tag/$fullVersion.Beta.$beta>" list.add("For a full changelog, including technical details, see the [GitHub release](<$releaseLink)") val downloadLink = "$root/releases/download/$fullVersion.Beta.$beta/SkyHanni-$fullVersion.Beta.$beta.jar" list.add("Download link: $downloadLink") } return list } fun getChangePrefix(name: String, outputType: OutputType): String = when (outputType) { OutputType.DISCORD_INTERNAL -> "- " OutputType.GITHUB -> "+ " OutputType.DISCORD_PUBLIC -> when (name) { "New Features" -> "+ " "Improvements" -> "+ " "Fixes" -> "~ " "Removed Features" -> "- " else -> error("impossible change prefix") } } inline fun Pattern.matchMatcher(text: String, consumer: Matcher.() -> T) = matcher(text).let { if (it.matches()) consumer(it) else null } @Suppress("IMPLICIT_NOTHING_TYPE_ARGUMENT_IN_RETURN_POSITION") fun parseChanges( description: List, prLink: String, categories: MutableList, ): List { var currentCategory: Category? = null var currentChange: Change? = null val changes = mutableListOf() for (line in description) { if (line == "") { currentChange = null currentCategory = null continue } categoryPattern.matchMatcher(line) { val categoryName = group("category") if (categoryName !in allowedCategories) { error("unknown category: '$categoryName'") } currentCategory = getCategory(categories, categoryName) currentChange = null continue } val category = currentCategory ?: continue changePattern.matchMatcher(line) { val author = group("author") if (author == "your_name_here") { error("no author name") } val text = group("text") if (illegalStartPattern.matcher(text).matches()) { error("illegal start at change: '$text'") } currentChange = Change(text, category, prLink, author).also { changes.add(it) } continue } extraInfoPattern.matchMatcher(line) { val change = currentChange ?: error("Found extra info without change: '$line'") val text = group("text") if (illegalStartPattern.matcher(text).matches()) { error("illegal start at extra info: '$text'") } change.extraInfo.add(text) continue } error("found unexpected line: '$line'") } if (changes.isEmpty()) { error("no changes found") } return changes } fun getCategory(categories: MutableList, newName: String): Category { for (category in categories) { if (category.name == newName) { return category } } val category = Category(newName) categories.add(category) return category } class Category(val name: String) class Change(val text: String, val category: Category, val prLink: String, val author: String) { val extraInfo = mutableListOf() }