Last active
July 10, 2023 09:44
-
-
Save elroid/c42107f13647b2e319f01ffeffdc48c0 to your computer and use it in GitHub Desktop.
A Kotlin script for generating release notes. Put directives in your commit messages, starting the line with specific flags (like #feature or @Fixes) and a human-readable description. Call the script as part of your build process, which gets the tags from git and prints a text file of release notes for your app distribution system.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
exec { | |
workingDir rootDir | |
commandLine "./make_release_notes.sh" | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package buildscript | |
import java.io.BufferedWriter | |
import java.io.File | |
import java.io.IOException | |
import java.util.concurrent.TimeUnit | |
const val DEBUG = false | |
val PREFIXES = listOf("#", "%", "@") | |
val FEATURE_FLAG = listOf("feature", "feat") | |
val FIX_FLAG = listOf("fixed", "fixes", "fix") | |
val KNOWN_ISSUE_FLAG = listOf("known") | |
val DELETE_FLAG = listOf("delete", "deletes", "del", "cancel", "cancels", "remove", "removes") | |
val releaseNotesFile = File("release_notes.txt") | |
/** | |
* Usage: | |
* Each flag must contain a prefix (either #, %, or @) and one of four flag types: | |
* - Features are denoted by feature or feat | |
* - Bug fixes are denoted by fixed, fixes or fix | |
* - Known issues are denoted by known | |
* - And if there are existing directives that are no longer relevant they can be | |
* cancelled with the flags delete, deletes, del, cancel, cancels, remove, or removes | |
* | |
* Rules: | |
* 1. Only strings between one of the flags and a newline character will be included. | |
* 2. Only one directive per line will be recognised. | |
* | |
* Conventions: | |
* Issue number (Jira/DevOps/etc) should precede the description e.g.: | |
* #feat ABC-123 - This is a description of the feature | |
* | |
* Tip: if you need to add to release notes without changing the code, you can use something like this: | |
* For one feature: | |
* `git commit --allow-empty -m "#feat some feature"` | |
* For multiple features you can use the interactive editor: | |
* `git commit --allow-empty` | |
* | |
* Compiler installed with: | |
* curl -s https://get.sdkman.io | bash | |
* (Via: https://kotlinlang.org/docs/tutorials/command-line.html ) | |
* | |
* Compiled with: | |
* kotlinc app/src/main/java/buildscript/CommitParser.kt -include-runtime -d app/src/main/java/buildscript/CommitParser.jar | |
* | |
* Run with: | |
* java -jar app/src/main/java/buildscript/CommitParser.jar | |
* or | |
* ./make_release_notes.sh | |
*/ | |
fun main() { | |
try { | |
info("Starting release notes generator...") | |
//we need the branch to work out whether to look for release or qa... | |
val currentBranch = "git rev-parse --abbrev-ref HEAD".runCommand()?.trim() | |
val tag = if (currentBranch == "master") "[Rr]elease" else "[Qq]a" | |
debug("Searching for tag:$tag") | |
//get the most recent tag | |
val recentTag = "git describe".runCommand()?.trim() | |
debug("recent tag:$recentTag") | |
if(recentTag == null) { | |
warn("No recent tag found") | |
return | |
} | |
//if the current commit is tagged, exclude and look for the previous one | |
val lastTag = if(recentTag.contains("-")){ | |
//this commit is not tagged (e.g. "qa_1.2.3-1-a47e5ddcb") - use recent as last | |
val dashIndex = recentTag.indexOf("-") | |
recentTag.substring(0, dashIndex) | |
} | |
else{ | |
//this commit is tagged (e.g. "qa_1.2.3") - use previous (by adding ^) | |
"git describe --tags --abbrev=0 --match $tag* $recentTag^".runCommand()?.trim() | |
} | |
debug("Using lastTag:$lastTag") | |
if (lastTag?.isNotEmpty() == true) { | |
debug("Got last tag: [$lastTag]") | |
val allHistory = "git log --pretty=\"%s%n%b\" --no-merges $lastTag..HEAD" | |
val allCommitsText = allHistory.runCommand() | |
val features = mutableListOf<String>() | |
val fixes = mutableListOf<String>() | |
val knownIssues = mutableListOf<String>() | |
val lines = allCommitsText?.lines() | |
val toDelete = mutableListOf<String>() | |
val featureFlags = makeFlags(FEATURE_FLAG) | |
val fixedFlags = makeFlags(FIX_FLAG) | |
val knownFlags = makeFlags(KNOWN_ISSUE_FLAG) | |
val delFlags = makeFlags(DELETE_FLAG) | |
lines?.reversed()?.forEach { elem -> | |
when { | |
!elem.isWorthPrinting() -> debug("Doing nothing - not worth printing: $elem") | |
toDelete.addMatch(elem, delFlags) -> debug("Added to deleted: $elem") | |
features.addMatch(elem, featureFlags) -> debug("Added to features: $elem") | |
fixes.addMatch(elem, fixedFlags) -> debug("Added to fixes: $elem") | |
knownIssues.addMatch(elem, knownFlags) -> debug("Added to knownIssues: $elem") | |
} | |
} | |
debug("Features:$features") | |
debug("Fixes:$fixes") | |
debug("KnownIssues:$knownIssues") | |
//remove any deleted lines | |
toDelete.forEach { | |
if (features.remove(it) || fixes.remove(it) || knownIssues.remove(it)) | |
info("Removed: $it") | |
else | |
warn("Couldn't find a match for deleted item: $it") | |
} | |
val notes = mutableListOf("Android Release Notes") | |
notes.addDiv() | |
notes.add("(changes since ${lastTag})") | |
notes.add("") | |
buildSection("New Features", features, notes) | |
buildSection("Bugs Fixed", fixes, notes) | |
buildSection("Known Issues", knownIssues, notes) | |
writeToFile(notes) | |
info("Done!") | |
} else { | |
warn("NO TAG FOUND - Giving up.") | |
} | |
} catch (e: Exception) { | |
warn("Error: $e", e) | |
} | |
} | |
fun makeFlags(flags: List<String>): List<String> { | |
val allFlags = mutableListOf<String>() | |
flags.forEach { flag -> | |
PREFIXES.forEach { prefix -> | |
allFlags.add("$prefix$flag ") | |
} | |
} | |
return allFlags | |
} | |
fun MutableList<String>.addMatch(elem: String, flags: List<String>): Boolean { | |
//debug("") | |
debug("Start looking at $elem") | |
for (flag in flags) { | |
if (elem.contains(flag)) { | |
addUnique(elem.strip(flag)) | |
debug("Found comment with flag $flag: $elem *********************************************************") | |
return true | |
} | |
} | |
return false | |
} | |
fun buildSection(title: String, elems: List<String>, notes: MutableList<String>) { | |
if (elems.isNotEmpty()) { | |
notes.add(title) | |
notes.addDiv() | |
elems.sortedBy { it }.forEach { | |
notes.addBullet(it) | |
} | |
notes.add("") | |
} | |
} | |
fun MutableList<String>.addUnique(str: String): Boolean { | |
return if (!contains(str) && str.isWorthPrinting()) { | |
debug("Adding element: $str") | |
add(str) | |
true | |
} else false | |
} | |
fun MutableList<String>.addDiv() { | |
add("-----------") | |
} | |
fun MutableList<String>.addBullet(newLine: String) { | |
add(" * $newLine") | |
} | |
fun String.isWorthPrinting(): Boolean { | |
val toCheck = replace("\"", "") | |
return toCheck.trim().isNotEmpty() | |
} | |
fun String.strip(prefix: String): String { | |
val start = indexOf(prefix) | |
val end = start + prefix.length | |
return substring(end).trim() | |
} | |
fun writeToFile(notes: MutableList<String>) { | |
info("Writing to:$releaseNotesFile:") | |
releaseNotesFile.bufferedWriter().use { out -> | |
notes.forEach { | |
info(it) | |
out.writeLn(it) | |
} | |
} | |
} | |
fun BufferedWriter.writeLn(line: String) { | |
this.write(line) | |
this.newLine() | |
} | |
fun debug(msg: String) { | |
if (DEBUG) println(msg) | |
} | |
fun info(msg: String) { | |
println(msg) | |
} | |
fun warn(msg: String, e: Throwable? = null) { | |
println("WARN: $msg") | |
e?.apply { | |
stackTrace.forEachIndexed { i, elem -> | |
println("[$i]:$elem") | |
} | |
} | |
} | |
fun String.runCommand( | |
workingDir: File = File("."), | |
timeoutAmount: Long = 10, | |
timeoutUnit: TimeUnit = TimeUnit.SECONDS | |
): String? { | |
debug("Calling:$this") | |
var error = "None" | |
return try { | |
val arr = this.split("\\s".toRegex()).toTypedArray() | |
val process = ProcessBuilder(*arr).directory(workingDir) | |
.redirectOutput(ProcessBuilder.Redirect.PIPE) | |
.redirectError(ProcessBuilder.Redirect.PIPE) | |
.start().apply { | |
waitFor(timeoutAmount, timeoutUnit) | |
} | |
val result = process.inputStream.bufferedReader().readText() | |
error = process.errorStream.bufferedReader().readText() | |
if (error.isNotBlank()) { | |
warn("Error calling $this :$error") | |
} | |
result | |
} catch (e: IOException) { | |
warn("Error calling $this : $error") | |
e.printStackTrace() | |
null | |
} | |
} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
git --version | |
git fetch --tags | |
java -jar app/src/main/java/buildscript/CommitParser.jar |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Running it results in something like this: