feat(conventional): parse body and footers according to the rules

Previous assumption about multiple labeled body blocks and footers is
not correct. There is only one body text block with multi-line support.
A footer always starts with a token with a separator.
- A body ends when a footer is found or text ends.
- A footer ends when another footer is found or text ends.
This commit is contained in:
maulik13
2021-02-22 11:45:32 +01:00
committed by Felix Wiedmann
parent dc4d1c581a
commit a20992af14
5 changed files with 263 additions and 103 deletions

View File

@@ -11,6 +11,11 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
const breakingChangeKeywords = "BREAKING CHANGE"
const defaultBreakingChangePrefix = breakingChangeKeywords + ":"
const footerTokenRegex = "^(?P<token>[^\\s][\\w\\- ]+[^\\s])<SEP>.*"
var defaultTokenSeparators = [2]string{ ": ", " #"}
// Analyzer struct // Analyzer struct
type Analyzer struct { type Analyzer struct {
analyzeCommits analyzeCommits analyzeCommits analyzeCommits
@@ -100,23 +105,6 @@ func getMessageParts(msg string) (header string, bodyBlocks []string){
return return
} }
func parseMessageBlock(msg string, prefixes []string) shared.MessageBlock {
for _, prefix := range prefixes {
if !strings.HasPrefix(msg, prefix + ":") {
continue
}
content := strings.Replace(msg, prefix+":", "", 1)
return shared.MessageBlock{
Label: prefix,
Content: strings.TrimSpace(content),
}
}
return shared.MessageBlock{
Label: "",
Content: msg,
}
}
// //
// getRegexMatchedMap will match a regex with named groups and map the matching // getRegexMatchedMap will match a regex with named groups and map the matching
// results to corresponding group names // results to corresponding group names
@@ -133,3 +121,44 @@ func getRegexMatchedMap(regEx, url string) (paramsMap map[string]string) {
} }
return paramsMap return paramsMap
} }
//
// getMessageBlocksFromTexts converts strings to an array of MessageBlock
//
func getMessageBlocksFromTexts(txtArray, separators []string) []shared.MessageBlock {
blocks := make([]shared.MessageBlock, len(txtArray))
for i, line := range txtArray{
blocks[i] = parseMessageBlock(line, separators)
}
return blocks
}
//
// parseMessageBlock parses a text in to MessageBlock
//
func parseMessageBlock(msg string, separators []string) shared.MessageBlock {
msgBlock := shared.MessageBlock{
Label: "",
Content: msg,
}
if token, sep := findFooterToken(msg, separators); len(token) > 0{
msgBlock.Label = token
content := strings.Replace(msg, token + sep, "", 1)
msgBlock.Content = strings.TrimSpace(content)
}
return msgBlock
}
//
// findFooterToken checks if given text has a token with one of the separators and returns a token
//
func findFooterToken(text string, separators []string) (token string, sep string) {
for _, sep := range separators {
regex := strings.Replace(footerTokenRegex, "<SEP>", sep, 1)
matches := getRegexMatchedMap(regex, text)
if token, ok := matches["token"]; ok {
return token, sep
}
}
return "", ""
}

View File

@@ -2,6 +2,7 @@
package analyzer package analyzer
import ( import (
"bufio"
"github.com/Nightapes/go-semantic-release/pkg/config" "github.com/Nightapes/go-semantic-release/pkg/config"
"strings" "strings"
@@ -19,8 +20,7 @@ type conventional struct {
// CONVENTIONAL identifier // CONVENTIONAL identifier
const CONVENTIONAL = "conventional" const CONVENTIONAL = "conventional"
const breakingChangeKeywords = "BREAKING CHANGE" var conventionalFooterTokenSep = defaultTokenSeparators
const breakingChangePrefix = breakingChangeKeywords + ":"
func newConventional(config config.AnalyzerConfig) *conventional { func newConventional(config config.AnalyzerConfig) *conventional {
return &conventional{ return &conventional{
@@ -91,9 +91,15 @@ func (a *conventional) getRules() []Rule {
} }
func (a *conventional) analyze(commit shared.Commit, rule Rule) *shared.AnalyzedCommit { func (a *conventional) analyze(commit shared.Commit, rule Rule) *shared.AnalyzedCommit {
prefixes := append(a.config.BlockPrefixes, breakingChangeKeywords) tokenSep := append(a.config.TokenSeparators, conventionalFooterTokenSep[:]...)
firstSplit := strings.SplitN(commit.Message, "\n", 2)
header := firstSplit[0]
body := ""
if len(firstSplit) > 1 {
body = firstSplit[1]
}
header, txtBlocks := getMessageParts(commit.Message)
matches := getRegexMatchedMap(a.regex, header) matches := getRegexMatchedMap(a.regex, header)
if len(matches) == 0 || matches["type"] != rule.Tag{ if len(matches) == 0 || matches["type"] != rule.Tag{
@@ -101,24 +107,7 @@ func (a *conventional) analyze(commit shared.Commit, rule Rule) *shared.Analyzed
return nil return nil
} }
msgBlockMap := make(map[string][]shared.MessageBlock) msgBlockMap := getConventionalMessageBlockMap(body, tokenSep)
footer := ""
if len(txtBlocks) > 0 {
bodyCount := len(txtBlocks)-1
if len(txtBlocks) == 1 {
bodyCount = 1
}
bodyTxtBlocks := txtBlocks[0:bodyCount]
if len(txtBlocks) > 1{
footer = txtBlocks[len(txtBlocks)-1]
}
msgBlockMap["body"] = getMessageBlocks(bodyTxtBlocks, prefixes)
if len(footer) > 0{
footerLines := strings.Split(footer, "\n")
msgBlockMap["footer"] = getMessageBlocks(footerLines, prefixes)
}
}
analyzed := &shared.AnalyzedCommit{ analyzed := &shared.AnalyzedCommit{
Commit: commit, Commit: commit,
@@ -129,15 +118,10 @@ func (a *conventional) analyze(commit shared.Commit, rule Rule) *shared.Analyzed
MessageBlocks: msgBlockMap, MessageBlocks: msgBlockMap,
} }
isBreaking := matches["breaking"] == "!" || strings.Contains(commit.Message, breakingChangePrefix) isBreaking := matches["breaking"] == "!" || strings.Contains(commit.Message, defaultBreakingChangePrefix)
analyzed.IsBreaking = isBreaking analyzed.IsBreaking = isBreaking
oldMsgSplit := strings.SplitN(commit.Message, "\n", 2) oldFormatMessage := strings.TrimSpace(matches["subject"] + "\n" + body)
originalBodyBlock := ""
if len(oldMsgSplit) > 1 {
originalBodyBlock = oldMsgSplit[1]
}
oldFormatMessage := strings.TrimSpace(matches["subject"] + "\n" + originalBodyBlock)
if !isBreaking { if !isBreaking {
analyzed.ParsedMessage = strings.Trim(oldFormatMessage, " ") analyzed.ParsedMessage = strings.Trim(oldFormatMessage, " ")
a.log.Tracef("%s: found %s", commit.Message, rule.Tag) a.log.Tracef("%s: found %s", commit.Message, rule.Tag)
@@ -145,7 +129,7 @@ func (a *conventional) analyze(commit shared.Commit, rule Rule) *shared.Analyzed
} }
a.log.Infof(" %s, BREAKING CHANGE found", commit.Message) a.log.Infof(" %s, BREAKING CHANGE found", commit.Message)
breakingChange := strings.SplitN(oldFormatMessage, breakingChangePrefix, 2) breakingChange := strings.SplitN(oldFormatMessage, defaultBreakingChangePrefix, 2)
if len(breakingChange) > 1 { if len(breakingChange) > 1 {
analyzed.ParsedMessage = strings.TrimSpace(breakingChange[0]) analyzed.ParsedMessage = strings.TrimSpace(breakingChange[0])
@@ -157,12 +141,50 @@ func (a *conventional) analyze(commit shared.Commit, rule Rule) *shared.Analyzed
return analyzed return analyzed
} }
func getMessageBlocks(txtArray, prefixes []string) []shared.MessageBlock { func getConventionalMessageBlockMap(txtBlock string, tokenSep []string) map[string][]shared.MessageBlock{
blocks := make([]shared.MessageBlock, len(txtArray)) msgBlockMap := make(map[string][]shared.MessageBlock)
for i, line := range txtArray{ footers := make([]string, 0)
blocks[i] = parseMessageBlock(line, prefixes) body := ""
footerBlock := ""
line := ""
footerFound := false
// Look through each line
scanner := bufio.NewScanner(strings.NewReader(txtBlock))
for scanner.Scan() {
line = scanner.Text()
if token, _ := findFooterToken(line, tokenSep); len(token) > 0 {
// if footer was already found from before
if len(footerBlock) > 0{
footers = append(footers, strings.TrimSpace(footerBlock))
} }
return blocks footerFound = true
footerBlock = ""
}
//'\n' is removed when reading from scanner
if !footerFound {
body += line + "\n"
}else{
footerBlock += line + "\n"
}
}
if len(footerBlock) > 0 {
footers = append(footers, strings.TrimSpace(footerBlock))
}
body = strings.TrimSpace(body)
if len(body) > 0{
msgBlockMap["body"] = []shared.MessageBlock {{
Label: "",
Content: body,
} }
}
footerBlocks := getMessageBlocksFromTexts(footers, tokenSep)
if len(footerBlocks) > 0 {
msgBlockMap["footer"] = footerBlocks
}
return msgBlockMap
} }

View File

@@ -154,7 +154,7 @@ func TestConventional(t *testing.T) {
IsBreaking: true, IsBreaking: true,
Subject: "my first break", Subject: "my first break",
MessageBlocks: map[string][]shared.MessageBlock{ MessageBlocks: map[string][]shared.MessageBlock{
"body" : { shared.MessageBlock{ "footer" : { shared.MessageBlock{
Label: "BREAKING CHANGE", Label: "BREAKING CHANGE",
Content: "change api to v2", Content: "change api to v2",
}, },
@@ -176,7 +176,7 @@ func TestConventional(t *testing.T) {
IsBreaking: true, IsBreaking: true,
Subject: "my first break", Subject: "my first break",
MessageBlocks: map[string][]shared.MessageBlock{ MessageBlocks: map[string][]shared.MessageBlock{
"body" : {shared.MessageBlock{ "footer" : {shared.MessageBlock{
Label: "BREAKING CHANGE", Label: "BREAKING CHANGE",
Content: "hey from the change", Content: "hey from the change",
}, },
@@ -328,10 +328,77 @@ func TestConventional(t *testing.T) {
}, },
}, },
}, },
}
conventional, err := analyzer.New("conventional", config.AnalyzerConfig{}, config.ChangelogConfig{})
assert.NoError(t, err)
for _, test := range testConfigs {
analyzedCommits := conventional.Analyze(test.commits)
assert.Equalf(t, test.wantAnalyzedCommits["major"], analyzedCommits["major"], "Testcase %s should have major commits", test.testCase)
assert.Equalf(t, test.wantAnalyzedCommits["minor"], analyzedCommits["minor"], "Testcase %s should have minor commits", test.testCase)
assert.Equalf(t, test.wantAnalyzedCommits["patch"], analyzedCommits["patch"], "Testcase %s should have patch commits", test.testCase)
assert.Equalf(t, test.wantAnalyzedCommits["none"], analyzedCommits["none"], "Testcase %s should have none commits", test.testCase)
}
}
func TestConventional_BodyAndFooters(t *testing.T) {
t.Parallel()
testConfigs := []struct {
testCase string
commits []shared.Commit
expectedAnalyzedCommits map[shared.Release][]shared.AnalyzedCommit
}{
{
testCase: "Only body, no footer",
commits: []shared.Commit{
{
Message: "fix: squash bug for logging\n\nNow the logs will not print lines twice. The following changed:\n\n-Buffer -Stdout",
Author: "me",
Hash: "12345667",
},
},
expectedAnalyzedCommits: map[shared.Release][]shared.AnalyzedCommit{
"patch": {
{
Commit: shared.Commit{
Message: "fix: squash bug for logging\n\nNow the logs will not print lines twice. The following changed:\n\n-Buffer -Stdout",
Author: "me",
Hash: "12345667",
},
Scope: "",
ParsedMessage: "squash bug for logging\n\nNow the logs will not print lines twice. The following changed:\n\n-Buffer -Stdout",
Tag: "fix",
TagString: "Bug fixes",
Print: true,
Subject: "squash bug for logging",
MessageBlocks: map[string][]shared.MessageBlock{
"body": {
shared.MessageBlock{
Label: "",
Content: "Now the logs will not print lines twice. The following changed:\n\n-Buffer -Stdout",
},
},
},
},
},
"major": {},
"minor": {},
"none": {},
},
},
{
testCase: "Only footers, no body",
commits: []shared.Commit{
{
Message: "fix: squash bug for logging\n\nNote: now the logs will not print lines twice.\n\nIssue: #123\nSeverity: medium",
Author: "me",
Hash: "12345667",
},
},
expectedAnalyzedCommits: map[shared.Release][]shared.AnalyzedCommit{
"patch": {
{ {
testCase: "fix issue with footers",
wantAnalyzedCommits: map[shared.Release][]shared.AnalyzedCommit{
"patch": {{
Commit: shared.Commit{ Commit: shared.Commit{
Message: "fix: squash bug for logging\n\nNote: now the logs will not print lines twice.\n\nIssue: #123\nSeverity: medium", Message: "fix: squash bug for logging\n\nNote: now the logs will not print lines twice.\n\nIssue: #123\nSeverity: medium",
Author: "me", Author: "me",
@@ -344,12 +411,12 @@ func TestConventional(t *testing.T) {
Print: true, Print: true,
Subject: "squash bug for logging", Subject: "squash bug for logging",
MessageBlocks: map[string][]shared.MessageBlock{ MessageBlocks: map[string][]shared.MessageBlock{
"body": { shared.MessageBlock{ "footer": {
shared.MessageBlock{
Label: "Note", Label: "Note",
Content: "now the logs will not print lines twice.", Content: "now the logs will not print lines twice.",
}, },
}, shared.MessageBlock{
"footer": { shared.MessageBlock{
Label: "Issue", Label: "Issue",
Content: "#123", Content: "#123",
}, },
@@ -359,29 +426,71 @@ func TestConventional(t *testing.T) {
}, },
}, },
}, },
}}, },
"minor": {}, },
"major": {}, "major": {},
"minor": {},
"none": {}, "none": {},
}, },
},
{
testCase: "Body and footers",
commits: []shared.Commit{ commits: []shared.Commit{
{ {
Message: "fix: squash bug for logging\n\nNote: now the logs will not print lines twice.\n\nIssue: #123\nSeverity: medium", Message: "fix: squash bug for logging\n\nNow the logs will not print lines twice. The following changed:\n\n-Buffer -Stdout\n\nIssue: #123\nSeverity: medium",
Author: "me", Author: "me",
Hash: "12345667", Hash: "12345667",
}, },
}, },
expectedAnalyzedCommits: map[shared.Release][]shared.AnalyzedCommit{
"patch": {
{
Commit: shared.Commit{
Message: "fix: squash bug for logging\n\nNow the logs will not print lines twice. The following changed:\n\n-Buffer -Stdout\n\nIssue: #123\nSeverity: medium",
Author: "me",
Hash: "12345667",
}, },
Scope: "",
ParsedMessage: "squash bug for logging\n\nNow the logs will not print lines twice. The following changed:\n\n-Buffer -Stdout\n\nIssue: #123\nSeverity: medium",
Tag: "fix",
TagString: "Bug fixes",
Print: true,
Subject: "squash bug for logging",
MessageBlocks: map[string][]shared.MessageBlock{
"body": {
shared.MessageBlock{
Label: "",
Content: "Now the logs will not print lines twice. The following changed:\n\n-Buffer -Stdout",
},
},
"footer": {
shared.MessageBlock{
Label: "Issue",
Content: "#123",
},
shared.MessageBlock{
Label: "Severity",
Content: "medium",
},
},
},
},
},
"major": {},
"minor": {},
"none": {},
},
},
} }
conventional, err := analyzer.New("conventional", config.AnalyzerConfig{BlockPrefixes: []string{"Note", "Issue", "Severity"}}, config.ChangelogConfig{}) conventional, err := analyzer.New("conventional", config.AnalyzerConfig{}, config.ChangelogConfig{})
assert.NoError(t, err) assert.NoError(t, err)
for _, test := range testConfigs { for _, test := range testConfigs {
analyzedCommits := conventional.Analyze(test.commits) analyzedCommits := conventional.Analyze(test.commits)
assert.Equalf(t, test.wantAnalyzedCommits["major"], analyzedCommits["major"], "Testcase %s should have major commits", test.testCase) assert.Equalf(t, test.expectedAnalyzedCommits["major"], analyzedCommits["major"], "Testcase %s should have major commits", test.testCase)
assert.Equalf(t, test.wantAnalyzedCommits["minor"], analyzedCommits["minor"], "Testcase %s should have minor commits", test.testCase) assert.Equalf(t, test.expectedAnalyzedCommits["minor"], analyzedCommits["minor"], "Testcase %s should have minor commits", test.testCase)
assert.Equalf(t, test.wantAnalyzedCommits["patch"], analyzedCommits["patch"], "Testcase %s should have patch commits", test.testCase) assert.Equalf(t, test.expectedAnalyzedCommits["patch"], analyzedCommits["patch"], "Testcase %s should have patch commits", test.testCase)
assert.Equalf(t, test.wantAnalyzedCommits["none"], analyzedCommits["none"], "Testcase %s should have none commits", test.testCase) assert.Equalf(t, test.expectedAnalyzedCommits["none"], analyzedCommits["none"], "Testcase %s should have none commits", test.testCase)
} }
} }

View File

@@ -15,7 +15,7 @@ const (
// AnalyzerConfig struct // AnalyzerConfig struct
type AnalyzerConfig struct { type AnalyzerConfig struct {
BlockPrefixes []string `yaml:"blockPrefixes"` TokenSeparators []string `yaml:"tokenSeparators"`
} }
// ChangelogConfig struct // ChangelogConfig struct

View File

@@ -100,7 +100,7 @@ github:
Compress: false}}, Compress: false}},
ReleaseTitle: "go-semantic-release release", ReleaseTitle: "go-semantic-release release",
IsPreRelease: false, IsPreRelease: false,
Analyzer: config.AnalyzerConfig{BlockPrefixes: []string{}}, Analyzer: config.AnalyzerConfig{TokenSeparators: []string{}},
}, result) }, result)
} }