Skip to content

Instantly share code, notes, and snippets.

@elroid
Last active July 10, 2023 09:44
Show Gist options
  • Save elroid/c42107f13647b2e319f01ffeffdc48c0 to your computer and use it in GitHub Desktop.
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.
exec {
workingDir rootDir
commandLine "./make_release_notes.sh"
}
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
}
}
git --version
git fetch --tags
java -jar app/src/main/java/buildscript/CommitParser.jar
@elroid
Copy link
Author

elroid commented May 6, 2020

Running it results in something like this:

git version 2.21.0 (Apple Git-122)
Starting release notes generator...
Writing to:crashlytics_release_notes.txt:
Android Release Notes
-----------
(changes since release_1.2)

New Features
-----------
 * 1234 - Added new designs for home screen

Bugs Fixed
-----------
 * 1235 - Fixed horrible crash in splash screen

Known Issues
-----------
* Facebook login not working - bug report has been filed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment