From cdd531a166093722f8abc0e41f820730de5de771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joel=20Mong=C3=A5rd?= Date: Sat, 23 Sep 2023 23:25:36 +0200 Subject: [PATCH] feat: Change log DSL --- .../plugin/changelog/ChangeLogFormatter.kt | 59 ++++- .../plugin/changelog/ChangeLogSettings.kt | 9 +- .../changelog/ChangeLogFormatterTest.kt | 23 +- .../git/semver/plugin/changelog/TestApi.kt | 248 ++++++++++++++++++ 4 files changed, 311 insertions(+), 28 deletions(-) create mode 100644 src/test/kotlin/git/semver/plugin/changelog/TestApi.kt diff --git a/src/main/kotlin/git/semver/plugin/changelog/ChangeLogFormatter.kt b/src/main/kotlin/git/semver/plugin/changelog/ChangeLogFormatter.kt index 68a3dd8..e755315 100644 --- a/src/main/kotlin/git/semver/plugin/changelog/ChangeLogFormatter.kt +++ b/src/main/kotlin/git/semver/plugin/changelog/ChangeLogFormatter.kt @@ -13,13 +13,18 @@ class ChangeLogFormatter(private val settings: SemverSettings, private val forma private val changeLogRegex = format.changeLogPattern.toRegex(SemverSettings.REGEX_OPTIONS) internal fun formatLog(changeLog: List): String { + val infos = commitInfos(changeLog) + return formatLog(infos) + } + + private fun formatLog(infos: List): String { val groupedByHeading = TreeMap>() - changeLog.sortedBy { it.text }.groupBy { it.text }.forEach { addCommit(it, groupedByHeading) } + + infos.sortedBy { it.text }.forEach { addCommit(it, groupedByHeading) } val builder = StringBuilder() addStaticText(builder, format.header) - groupedByHeading.forEach { (heading, items) -> addStaticText(builder, format.groupStart) builder.appendLine().appendLine(heading) @@ -37,46 +42,65 @@ class ChangeLogFormatter(private val settings: SemverSettings, private val forma return builder.trim().toString() } + fun commitInfos(changeLog: List): List { + return changeLog.groupBy { it.text }.map { commitInfo(it.key, it.value) } + } + + private fun commitInfo(commitText: String, commits: List): CommitInfo { + val isBreaking = settings.majorRegex.containsMatchIn(commitText) + val match = changeLogRegex.find(commitText) + return if (match != null) { + CommitInfo( + commits, + commitText, + isBreaking, + match.groupValue(TYPE), + match.groupValue(SCOPE), + match.groupValue(MESSAGE) + ) + } else { + CommitInfo(commits, commitText, isBreaking) + } + } + private fun addCommit( - commit: Map.Entry>, + commit: CommitInfo, resultMap: MutableMap> ) { fun addChangeLogText(category: String?, message: String, scope: String? = null) { if (!category.isNullOrEmpty()) { - resultMap.computeIfAbsent(category) { mutableSetOf() }.add(formatLine(scope, message, commit.value).trim()) + resultMap.computeIfAbsent(category) { mutableSetOf() }.add(formatLine(scope, message, commit).trim()) } } - val text = commit.key - if (format.breakingChangeHeader != null && settings.majorRegex.containsMatchIn(text)) { + val text = commit.text + if (format.breakingChangeHeader.isNotEmpty() && commit.isBreaking) { addChangeLogText(format.breakingChangeHeader, text) return } - val match = changeLogRegex.find(text) - if (match == null) { + if (commit.type == null) { addChangeLogText(format.missingTypeHeader, text) return } - val scope = match.groupValue(SCOPE) + val scope = commit.scope val scopeHeading = scope?.let { format.headerTexts[it] } if (scopeHeading != null) { addChangeLogText(scopeHeading, text) return } - val typeHeading = format.headerTexts[match.groupValue(TYPE)] + val typeHeading = format.headerTexts[commit.type] if (typeHeading != null) { - val message = match.groupValue(MESSAGE)!! - addChangeLogText(typeHeading, message, scope) + addChangeLogText(typeHeading, commit.message!!, scope) return } addChangeLogText(format.otherChangeHeader, text) } - private fun formatLine(scope: String?, message: String, commits: List) = commits + private fun formatLine(scope: String?, message: String, commitInfo: CommitInfo) = commitInfo.commits .map { it.sha.take(format.changeShaLength) } .filter { it.isNotEmpty() } .joinToString(" ", "", " ") + @@ -87,4 +111,13 @@ class ChangeLogFormatter(private val settings: SemverSettings, private val forma text?.let(builder::appendLine) private fun MatchResult.groupValue(groupId: String) = this.groups[groupId]?.value + + class CommitInfo( + val commits: List, + val text: String, + val isBreaking: Boolean, + val type: String? = null, + val scope: String? = null, + val message: String? = null + ) } \ No newline at end of file diff --git a/src/main/kotlin/git/semver/plugin/changelog/ChangeLogSettings.kt b/src/main/kotlin/git/semver/plugin/changelog/ChangeLogSettings.kt index ee59d34..bce9de6 100644 --- a/src/main/kotlin/git/semver/plugin/changelog/ChangeLogSettings.kt +++ b/src/main/kotlin/git/semver/plugin/changelog/ChangeLogSettings.kt @@ -2,9 +2,10 @@ package git.semver.plugin.changelog data class ChangeLogSettings( var header: String? = null, - var breakingChangeHeader: String? = null, - var otherChangeHeader: String? = null, - var missingTypeHeader: String? = null, + var breakingChangeHeader: String = "", + var otherChangeHeader: String = "", + var missingTypeHeader: String = "", + var missingScopeHeader: String = "", var headerTexts: MutableMap = mutableMapOf(), var changePrefix: String = "", var changePostfix: String = "", @@ -36,7 +37,7 @@ data class ChangeLogSettings( "refactor" to "### Refactorings \uD83D\uDE9C", "release" to "" ), - "- ") + changePrefix = "- ") val simpleChangeLog get() = ChangeLogSettings( breakingChangeHeader = "## Breaking Changes", diff --git a/src/test/kotlin/git/semver/plugin/changelog/ChangeLogFormatterTest.kt b/src/test/kotlin/git/semver/plugin/changelog/ChangeLogFormatterTest.kt index 47213b9..a966c6a 100644 --- a/src/test/kotlin/git/semver/plugin/changelog/ChangeLogFormatterTest.kt +++ b/src/test/kotlin/git/semver/plugin/changelog/ChangeLogFormatterTest.kt @@ -10,7 +10,8 @@ class ChangeLogFormatterTest { fun format_log_empty() { val settings = SemverSettings() - val actual = ChangeLogFormatter(settings, ChangeLogSettings.defaultChangeLog).formatLog(listOf()) + val format = ChangeLogSettings.defaultChangeLog + val actual = ChangeLogFormatter(settings, format).formatLog(listOf()) assertThat(actual).startsWith("## What's Changed") } @@ -29,14 +30,14 @@ class ChangeLogFormatterTest { .startsWith("## What's Changed") .containsOnlyOnce("Bugfix 1") .contains("### Breaking Changes") - .contains("- 0050000 fix(#5)!: A breaking change") + .contains("- 0050000 fix(changelog)!: A breaking change") .contains("### Bug Fixes") .contains("- 0060000 build(deps): A build change") .contains("- 0090000 A CI change") .contains("### Tests") .contains("- 0080000 Added some tests") .contains("### New Features") - .contains("- 0040000 #2: A feature") + .contains("- 0040000 semver: A feature") .contains("- 0100000 xyz: Some other change") .contains("- 0110000 An uncategorized change") .doesNotContain("1.2.3") @@ -54,12 +55,12 @@ class ChangeLogFormatterTest { assertThat(actual) .containsOnlyOnce("Bugfix 1") .contains("## Breaking Changes") - .contains("- fix(#5)!: A breaking change") + .contains("- fix(changelog)!: A breaking change") .contains("## Bug Fixes") .doesNotContain("- build(deps): A build change") .doesNotContain("- A CI change") .contains("## New Features") - .contains("- #2: A feature") + .contains("- semver: A feature") .doesNotContain("- xyz: Some other change") .doesNotContain("- An uncategorized change") .doesNotContain("1.2.3") @@ -82,9 +83,9 @@ class ChangeLogFormatterTest { .startsWith("## What's Changed") .containsOnlyOnce("Bugfix 1") .contains("### Breaking Changes") - .contains("- fix(#5)!: A breaking change") + .contains("- fix(changelog)!: A breaking change") .doesNotContain("1.2.3") - .contains(" more text") + .contains(" Body text") println(actual) } @@ -100,11 +101,11 @@ class ChangeLogFormatterTest { private fun createChangeLog(): List { val changeLog = mapOf( - "0010000000" to "fix(#1): Bugfix 1", - "0020000000" to "fix(#1): Bugfix 1", + "0010000000" to "fix(changelog): Bugfix 1 #1", + "0020000000" to "fix(changelog): Bugfix 1 #1", "0030000000" to "fix(deps): Bugfix broken deps", - "0040000000" to "feat(#2): A feature", - "0050000000" to "fix(#5)!: A breaking change\nmore text", + "0040000000" to "feat(semver): A feature", + "0050000000" to "fix(changelog)!: A breaking change\n\nBody text", "0060000000" to "build(deps): A build change", "0070000000" to "release: 1.2.3-alpha", "0080000000" to "test: Added some tests", diff --git a/src/test/kotlin/git/semver/plugin/changelog/TestApi.kt b/src/test/kotlin/git/semver/plugin/changelog/TestApi.kt new file mode 100644 index 0000000..b274e6c --- /dev/null +++ b/src/test/kotlin/git/semver/plugin/changelog/TestApi.kt @@ -0,0 +1,248 @@ +import git.semver.plugin.changelog.ChangeLogFormatter +import git.semver.plugin.changelog.ChangeLogFormatter.CommitInfo +import git.semver.plugin.changelog.ChangeLogSettings +import git.semver.plugin.scm.Commit +import git.semver.plugin.semver.SemverSettings +import java.util.TreeMap + +class ChangeLogContext { + private val flaggedCommits = mutableSetOf() + + fun flagCommit(commit: Commit) { + flaggedCommits.add(commit) + } + + fun isCommitFlagged(commit: Commit): Boolean { + return commit in flaggedCommits + } +} + +open class TextBuilder { + val out = StringBuilder() + + fun build(): String { + return out.toString() + } + + fun heading1(text: String?) { + out.append("# ").appendLine(text) + } + + fun heading2(text: String?) { + out.append("## ").appendLine(text) + } + + fun heading3(text: String?) { + out.append("### ").appendLine(text) + } + + fun heading4(text: String?) { + out.append("#### ").appendLine(text) + } + + fun append(t: String?): TextBuilder { + out.append(t) + return this + } + + fun appendLine(t: String? = ""): TextBuilder { + out.appendLine(t) + return this + } +} + +open class MarkdownDocumentBuilder( + private val commitInfos: List, + private val context: ChangeLogContext +) : TextBuilder() { + fun breakingChanges(block: MarkdownDocumentBuilder.() -> Unit) { + filter(block) { it.isBreaking } + } + + fun withScopes(vararg scopes: String, block: MarkdownDocumentBuilder.() -> Unit) { + filter(block) { it.scope in scopes } + } + + fun withoutScope(block: MarkdownDocumentBuilder.() -> Unit) { + filter(block) { it.scope == null } + } + + fun withTypes(vararg types: String, block: MarkdownDocumentBuilder.() -> Unit) { + filter(block) { it.type in types } + } + + fun withoutType(block: MarkdownDocumentBuilder.() -> Unit) { + filter(block) { it.type == null } + } + + fun with(filter: (CommitInfo) -> Boolean, block: MarkdownDocumentBuilder.() -> Unit) { + filter(block, filter) + } + + private fun filter(block: MarkdownDocumentBuilder.() -> Unit, filter: (CommitInfo) -> Boolean) { + val filteredCommits = commitInfos().filter(filter) + if (filteredCommits.isNotEmpty()) { + val builder = MarkdownDocumentBuilder(filteredCommits, context) + builder.block() + out.appendLine(builder.build()) + } + } + + fun groupByScope(block: GroupBuilder.() -> Unit) { + groupBy({ it.scope ?: "" }, block) + } + + fun groupByType(block: GroupBuilder.() -> Unit) { + groupBy({ it.type ?: "" }, block) + } + + fun groupBy(group: (CommitInfo) -> String, block: GroupBuilder.() -> Unit) { + apply(commitInfos().groupBy(group), block) + } + + fun groupBySorted(group: (CommitInfo) -> String, block: GroupBuilder.() -> Unit) { + apply(commitInfos().groupByTo(TreeMap(), group), block) + } + + private fun apply( + groupedByScope: Map>, + block: GroupBuilder.() -> Unit + ) { + for ((key, scopeCommits) in groupedByScope.filterKeys { it.isNotEmpty() }) { + val builder = GroupBuilder(scopeCommits, context, key) + block(builder) + out.appendLine(builder.build()) + } + } + + fun changes(block: ChangeFormatter.() -> String) { + for (c in commitInfos()) { + context.flagCommit(c.commits[0]) + val builder = ChangeFormatter(c) + + out.appendLine(builder.block()) + } + } + + private fun commitInfos() = commitInfos.filter { !context.isCommitFlagged(it.commits[0]) } +} + +class GroupBuilder( + commitInfos: List, + flagManager: ChangeLogContext, + val group: String +) : MarkdownDocumentBuilder(commitInfos, flagManager) + +class ChangeFormatter( + private val commitInfo: CommitInfo +) { + + fun messageHeader() = (commitInfo.message ?: commitInfo.text).lineSequence().first() + + fun messageBody() = (commitInfo.message ?: commitInfo.text).lineSequence().drop(1).dropWhile { it.isEmpty() } + + fun text() = commitInfo.text + + + fun scope(format: String = "%s: ") = commitInfo.scope?.let { format.format(it) }.orEmpty() + + fun type(format: String = "%s") = commitInfo.type?.let { format.format(it) }.orEmpty() + + fun hash(format: String = "%s ", len: Int = 40) = + commitInfo.commits.joinToString("") { format.format(it.sha.take(len)) } +} + +fun markdownDocument(commitInfos: List, block: MarkdownDocumentBuilder.() -> Unit): String { + val documentBuilder = MarkdownDocumentBuilder(commitInfos, ChangeLogContext()) + documentBuilder.block() + return documentBuilder.build() +} + +fun main() { + + val settings = SemverSettings() + + val format = ChangeLogSettings.defaultChangeLog + val commitInfos = ChangeLogFormatter(settings, format).commitInfos(createChangeLog()) + + val markdown = markdownDocument(commitInfos) { + appendLine(format.header).appendLine() + + breakingChanges { + heading3("Breaking Changes") +// groupByType { +// heading3(group) +// groupByScope { +// heading4(group) + changes { + "- ${hash()}${type()}${scope("(%s)")}: ${messageHeader()}" + } +// } +// } + } + +// withScope("deps") { +// heading2("Dependency Updates") +//// groupByType { +//// heading3(group) +// changes(changeFormatter) +//// } +// } + + groupBySorted({ format.headerTexts[it.scope] ?: format.headerTexts[it.type].orEmpty() }) { + + appendLine(group) +// groupByScope { +// heading3(group) +// changes(changeFormatter) +// } +// withoutScope { +// changes(changeFormatter) +// } + + + changes { + "- ${hash()}${scope()}${messageHeader()}" + } + } + + + groupBySorted({ format.headerTexts[it.type] ?: format.otherChangeHeader }) { + + appendLine(group) +// groupByScope { +// heading3(group) +// changes(changeFormatter) +// } +// withoutScope { +// changes(changeFormatter) +// } + + + changes { + "- ${hash()}${scope()}${text()}" + } + } + + appendLine("End of Document") + } + + println(markdown) +} + +private fun createChangeLog(): List { + val changeLog = mapOf( + "0010000000" to "fix(changelog): Bugfix 1 #1", + "0020000000" to "fix(changelog): Bugfix 1 #1", + "0030000000" to "fix(deps): Bugfix broken deps", + "0040000000" to "feat(semver): A feature", + "0050000000" to "fix(changelog)!: A breaking change\n\nBody text", + "0060000000" to "build(deps): A build change", + "0070000000" to "release: 1.2.3-alpha", + "0080000000" to "test: Added some tests", + "0090000000" to "ci: A CI change", + "0100000000" to "xyz: Some other change", + "0110000000" to "An uncategorized change" + ) + return changeLog.map { Commit(it.value, it.key, emptySequence()) } +} \ No newline at end of file