feat(analyzer): update AnalyzedCommit to add flexibility in parsing a message

This provides flexibility of parsing and rendering structured messages
with more detail in the changelog and helps extract metadata from the 
message. The new structure can be used to split a message in multiple 
blocks (e.g. footer)
This commit is contained in:
maulik13
2021-02-16 15:25:34 +01:00
committed by Felix Wiedmann
parent 81bdb68ee4
commit dc4d1c581a
13 changed files with 284 additions and 41 deletions

View File

@@ -3,6 +3,8 @@ package analyzer
import (
"fmt"
"regexp"
"strings"
"github.com/Nightapes/go-semantic-release/internal/shared"
"github.com/Nightapes/go-semantic-release/pkg/config"
@@ -11,8 +13,9 @@ import (
// Analyzer struct
type Analyzer struct {
analyzeCommits analyzeCommits
Config config.ChangelogConfig
analyzeCommits analyzeCommits
ChangelogConfig config.ChangelogConfig
AnalyzerConfig config.AnalyzerConfig
}
// Rule for commits
@@ -24,14 +27,15 @@ type Rule struct {
}
type analyzeCommits interface {
analyze(commit shared.Commit, tag Rule) (*shared.AnalyzedCommit, bool)
analyze(commit shared.Commit, tag Rule) *shared.AnalyzedCommit
getRules() []Rule
}
// New Analyzer struct for given commit format
func New(format string, config config.ChangelogConfig) (*Analyzer, error) {
func New(format string, analyzerConfig config.AnalyzerConfig, chglogConfig config.ChangelogConfig) (*Analyzer, error) {
analyzer := &Analyzer{
Config: config,
AnalyzerConfig: analyzerConfig,
ChangelogConfig: chglogConfig,
}
switch format {
@@ -39,7 +43,7 @@ func New(format string, config config.ChangelogConfig) (*Analyzer, error) {
analyzer.analyzeCommits = newAngular()
log.Debugf("Commit format set to %s", ANGULAR)
case CONVENTIONAL:
analyzer.analyzeCommits = newConventional()
analyzer.analyzeCommits = newConventional(analyzerConfig)
log.Debugf("Commit format set to %s", CONVENTIONAL)
default:
return nil, fmt.Errorf("invalid commit format: %s", format)
@@ -62,14 +66,14 @@ func (a *Analyzer) Analyze(commits []shared.Commit) map[shared.Release][]shared.
for _, commit := range commits {
for _, rule := range a.analyzeCommits.getRules() {
analyzedCommit, hasBreakingChange := a.analyzeCommits.analyze(commit, rule)
analyzedCommit := a.analyzeCommits.analyze(commit, rule)
if analyzedCommit == nil {
continue
}
if a.Config.PrintAll || rule.Changelog {
if a.ChangelogConfig.PrintAll || rule.Changelog {
analyzedCommit.Print = true
}
if hasBreakingChange {
if analyzedCommit.IsBreaking {
analyzedCommits["major"] = append(analyzedCommits["major"], *analyzedCommit)
break
}
@@ -80,3 +84,52 @@ func (a *Analyzer) Analyze(commits []shared.Commit) map[shared.Release][]shared.
log.Debugf("Analyzed commits: major=%d minor=%d patch=%d none=%d", len(analyzedCommits["major"]), len(analyzedCommits["minor"]), len(analyzedCommits["patch"]), len(analyzedCommits["none"]))
return analyzedCommits
}
func getMessageParts(msg string) (header string, bodyBlocks []string){
firstSplit := strings.SplitN(msg, "\n", 2)
header = firstSplit[0]
bodyBlocks = make([]string, 0)
if len(firstSplit) < 2 {
return
}
// Trim and then split by a blank line
remaining := strings.Trim(firstSplit[1], "\n")
bodyBlocks = strings.Split(remaining, "\n\n")
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
// results to corresponding group names
//
func getRegexMatchedMap(regEx, url string) (paramsMap map[string]string) {
var compRegEx = regexp.MustCompile(regEx)
match := compRegEx.FindStringSubmatch(url)
paramsMap = make(map[string]string)
for i, name := range compRegEx.SubexpNames() {
if i > 0 && i <= len(match) {
paramsMap[name] = match[i]
}
}
return paramsMap
}

View File

@@ -10,7 +10,7 @@ import (
func TestAnalyzer(t *testing.T) {
_, err := analyzer.New("unknown", config.ChangelogConfig{})
_, err := analyzer.New("unknown", config.AnalyzerConfig{}, config.ChangelogConfig{})
assert.Error(t, err)
}

View File

@@ -86,12 +86,12 @@ func (a *angular) getRules() []Rule {
return a.rules
}
func (a *angular) analyze(commit shared.Commit, rule Rule) (*shared.AnalyzedCommit, bool) {
func (a *angular) analyze(commit shared.Commit, rule Rule) *shared.AnalyzedCommit {
re := regexp.MustCompile(strings.Replace(a.regex, "TAG", rule.Tag, -1))
matches := re.FindStringSubmatch(commit.Message)
if matches == nil {
a.log.Tracef("%s does not match %s, skip", commit.Message, rule.Tag)
return nil, false
return nil
}
analyzed := &shared.AnalyzedCommit{
@@ -105,18 +105,21 @@ func (a *angular) analyze(commit shared.Commit, rule Rule) (*shared.AnalyzedComm
if !strings.Contains(message, "BREAKING CHANGE:") {
analyzed.ParsedMessage = strings.Trim(message, " ")
a.log.Tracef("%s: found %s", commit.Message, rule.Tag)
return analyzed, false
return analyzed
}
a.log.Tracef(" %s, BREAKING CHANGE found", commit.Message)
breakingChange := strings.SplitN(message, "BREAKING CHANGE:", 2)
analyzed.IsBreaking = true
if len(breakingChange) > 1 {
analyzed.ParsedMessage = strings.TrimSpace(breakingChange[0])
analyzed.ParsedBreakingChangeMessage = strings.TrimSpace(breakingChange[1])
return analyzed, true
return analyzed
}
analyzed.ParsedBreakingChangeMessage = breakingChange[0]
return analyzed, true
return analyzed
}

View File

@@ -75,6 +75,7 @@ func TestAngular(t *testing.T) {
TagString: "Features",
Print: true,
ParsedBreakingChangeMessage: "change api to v2",
IsBreaking: true,
},
},
"patch": {},
@@ -135,6 +136,7 @@ func TestAngular(t *testing.T) {
TagString: "Features",
Print: true,
ParsedBreakingChangeMessage: "change api to v2",
IsBreaking: true,
},
},
"patch": {},
@@ -212,7 +214,7 @@ func TestAngular(t *testing.T) {
},
}
angular, err := analyzer.New("angular", config.ChangelogConfig{})
angular, err := analyzer.New("angular", config.AnalyzerConfig{}, config.ChangelogConfig{})
assert.NoError(t, err)
for _, test := range testConfigs {

View File

@@ -2,7 +2,7 @@
package analyzer
import (
"regexp"
"github.com/Nightapes/go-semantic-release/pkg/config"
"strings"
log "github.com/sirupsen/logrus"
@@ -14,14 +14,18 @@ type conventional struct {
rules []Rule
regex string
log *log.Entry
config config.AnalyzerConfig
}
// CONVENTIONAL identifier
const CONVENTIONAL = "conventional"
const breakingChangeKeywords = "BREAKING CHANGE"
const breakingChangePrefix = breakingChangeKeywords + ":"
func newConventional() *conventional {
func newConventional(config config.AnalyzerConfig) *conventional {
return &conventional{
regex: `^(TAG)(?:\((.*)\))?(\!)?: (?s)(.*)`,
config: config,
regex: `^(?P<type>\w*)(?:\((?P<scope>.*)\))?(?P<breaking>\!)?: (?P<subject>.*)`,
log: log.WithField("analyzer", CONVENTIONAL),
rules: []Rule{
{
@@ -86,37 +90,79 @@ func (a *conventional) getRules() []Rule {
return a.rules
}
func (a *conventional) analyze(commit shared.Commit, rule Rule) (*shared.AnalyzedCommit, bool) {
re := regexp.MustCompile(strings.Replace(a.regex, "TAG", rule.Tag, -1))
matches := re.FindStringSubmatch(commit.Message)
if matches == nil {
func (a *conventional) analyze(commit shared.Commit, rule Rule) *shared.AnalyzedCommit {
prefixes := append(a.config.BlockPrefixes, breakingChangeKeywords)
header, txtBlocks := getMessageParts(commit.Message)
matches := getRegexMatchedMap(a.regex, header)
if len(matches) == 0 || matches["type"] != rule.Tag{
a.log.Tracef("%s does not match %s, skip", commit.Message, rule.Tag)
return nil, false
return nil
}
msgBlockMap := make(map[string][]shared.MessageBlock)
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{
Commit: commit,
Tag: rule.Tag,
TagString: rule.TagString,
Scope: shared.Scope(matches[2]),
Commit: commit,
Tag: rule.Tag,
TagString: rule.TagString,
Scope: shared.Scope(matches["scope"]),
Subject: strings.TrimSpace(matches["subject"]),
MessageBlocks: msgBlockMap,
}
message := strings.Join(matches[4:], "")
if matches[3] == "" && !strings.Contains(message, "BREAKING CHANGE:") {
analyzed.ParsedMessage = strings.Trim(message, " ")
isBreaking := matches["breaking"] == "!" || strings.Contains(commit.Message, breakingChangePrefix)
analyzed.IsBreaking = isBreaking
oldMsgSplit := strings.SplitN(commit.Message, "\n", 2)
originalBodyBlock := ""
if len(oldMsgSplit) > 1 {
originalBodyBlock = oldMsgSplit[1]
}
oldFormatMessage := strings.TrimSpace(matches["subject"] + "\n" + originalBodyBlock)
if !isBreaking {
analyzed.ParsedMessage = strings.Trim(oldFormatMessage, " ")
a.log.Tracef("%s: found %s", commit.Message, rule.Tag)
return analyzed, false
return analyzed
}
a.log.Infof(" %s, BREAKING CHANGE found", commit.Message)
breakingChange := strings.SplitN(message, "BREAKING CHANGE:", 2)
breakingChange := strings.SplitN(oldFormatMessage, breakingChangePrefix, 2)
if len(breakingChange) > 1 {
analyzed.ParsedMessage = strings.TrimSpace(breakingChange[0])
analyzed.ParsedBreakingChangeMessage = strings.TrimSpace(breakingChange[1])
return analyzed, true
} else {
analyzed.ParsedBreakingChangeMessage = breakingChange[0]
}
analyzed.ParsedBreakingChangeMessage = breakingChange[0]
return analyzed, true
return analyzed
}
func getMessageBlocks(txtArray, prefixes []string) []shared.MessageBlock {
blocks := make([]shared.MessageBlock, len(txtArray))
for i, line := range txtArray{
blocks[i] = parseMessageBlock(line, prefixes)
}
return blocks
}

View File

@@ -30,6 +30,8 @@ func TestConventional(t *testing.T) {
ParsedMessage: "my first commit",
Tag: "feat",
TagString: "Features",
Subject: "my first commit",
MessageBlocks: map[string][]shared.MessageBlock{},
Print: true,
},
{
@@ -42,6 +44,8 @@ func TestConventional(t *testing.T) {
ParsedMessage: "no scope",
Tag: "feat",
TagString: "Features",
Subject: "no scope",
MessageBlocks: map[string][]shared.MessageBlock{},
Print: true,
},
},
@@ -77,6 +81,8 @@ func TestConventional(t *testing.T) {
Tag: "feat",
TagString: "Features",
Print: true,
Subject: "my first commit",
MessageBlocks: map[string][]shared.MessageBlock{},
},
},
"major": {
@@ -92,6 +98,9 @@ func TestConventional(t *testing.T) {
TagString: "Features",
Print: true,
ParsedBreakingChangeMessage: "my first break",
IsBreaking: true,
Subject: "my first break",
MessageBlocks: map[string][]shared.MessageBlock{},
},
},
"patch": {},
@@ -125,6 +134,8 @@ func TestConventional(t *testing.T) {
Tag: "feat",
TagString: "Features",
Print: true,
Subject: "my first commit",
MessageBlocks: map[string][]shared.MessageBlock{},
},
},
"major": {
@@ -140,6 +151,15 @@ func TestConventional(t *testing.T) {
TagString: "Features",
Print: true,
ParsedBreakingChangeMessage: "change api to v2",
IsBreaking: true,
Subject: "my first break",
MessageBlocks: map[string][]shared.MessageBlock{
"body" : { shared.MessageBlock{
Label: "BREAKING CHANGE",
Content: "change api to v2",
},
},
},
},
{
Commit: shared.Commit{
@@ -153,6 +173,15 @@ func TestConventional(t *testing.T) {
TagString: "Features",
Print: true,
ParsedBreakingChangeMessage: "hey from the change",
IsBreaking: true,
Subject: "my first break",
MessageBlocks: map[string][]shared.MessageBlock{
"body" : {shared.MessageBlock{
Label: "BREAKING CHANGE",
Content: "hey from the change",
},
},
},
},
},
"patch": {},
@@ -212,6 +241,8 @@ func TestConventional(t *testing.T) {
Tag: "feat",
TagString: "Features",
Print: true,
Subject: "my first commit",
MessageBlocks: map[string][]shared.MessageBlock{},
},
},
"none": {
@@ -227,6 +258,8 @@ func TestConventional(t *testing.T) {
TagString: "Changes to CI/CD",
Print: false,
ParsedBreakingChangeMessage: "",
Subject: "my first build",
MessageBlocks: map[string][]shared.MessageBlock{},
},
},
"patch": {},
@@ -262,6 +295,8 @@ func TestConventional(t *testing.T) {
TagString: "Changes to CI/CD",
Print: false,
ParsedBreakingChangeMessage: "",
Subject: "my first build",
MessageBlocks: map[string][]shared.MessageBlock{},
},
},
"patch": {{
@@ -275,6 +310,8 @@ func TestConventional(t *testing.T) {
Tag: "fix",
TagString: "Bug fixes",
Print: true,
Subject: "my first commit",
MessageBlocks: map[string][]shared.MessageBlock{},
}},
"major": {},
},
@@ -291,9 +328,53 @@ func TestConventional(t *testing.T) {
},
},
},
{
testCase: "fix issue with footers",
wantAnalyzedCommits: map[shared.Release][]shared.AnalyzedCommit{
"patch": {{
Commit: 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",
},
Scope: "",
ParsedMessage: "squash bug for logging\n\nNote: now the logs will not print lines twice.\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: "Note",
Content: "now the logs will not print lines twice.",
},
},
"footer": { shared.MessageBlock{
Label: "Issue",
Content: "#123",
},
shared.MessageBlock{
Label: "Severity",
Content: "medium",
},
},
},
}},
"minor": {},
"major": {},
"none": {},
},
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",
},
},
},
}
conventional, err := analyzer.New("conventional", config.ChangelogConfig{})
conventional, err := analyzer.New("conventional", config.AnalyzerConfig{BlockPrefixes: []string{"Note", "Issue", "Severity"}}, config.ChangelogConfig{})
assert.NoError(t, err)
for _, test := range testConfigs {

View File

@@ -69,6 +69,8 @@ func TestWriteAndReadCache(t *testing.T) {
Tag: "feat",
TagString: "Features",
Print: true,
Subject: "add gitlab as release option",
MessageBlocks: map[string][]shared.MessageBlock{},
},
},
},

View File

@@ -110,7 +110,7 @@ func (c *Changelog) GenerateChanglog(templateConfig shared.ChangelogTemplateConf
for _, commits := range analyzedCommits {
for _, commit := range commits {
if commit.Print {
if commit.ParsedBreakingChangeMessage != "" {
if commit.IsBreaking {
commitsBreakingChange = append(commitsBreakingChange, commit)
continue
}
@@ -142,6 +142,7 @@ func (c *Changelog) GenerateChanglog(templateConfig shared.ChangelogTemplateConf
HasDockerLatest: c.config.Changelog.Docker.Latest,
DockerRepository: c.config.Changelog.Docker.Repository,
}
template := defaultCommitListSubTemplate + defaultChangelog
if c.config.Changelog.TemplatePath != "" {
content, err := ioutil.ReadFile(c.config.Changelog.TemplatePath)

View File

@@ -41,6 +41,8 @@ func TestChangelog(t *testing.T) {
Tag: "feat",
TagString: "Features",
Print: true,
Subject: "my first commit",
MessageBlocks: map[string][]shared.MessageBlock{},
},
},
},
@@ -64,6 +66,8 @@ func TestChangelog(t *testing.T) {
Tag: "feat",
TagString: "Features",
Print: true,
Subject: "my first commit",
MessageBlocks: map[string][]shared.MessageBlock{},
},
},
},
@@ -88,6 +92,8 @@ func TestChangelog(t *testing.T) {
Tag: "feat",
TagString: "Features",
Print: true,
Subject: "my first commit",
MessageBlocks: map[string][]shared.MessageBlock{},
},
{
Commit: shared.Commit{
@@ -101,6 +107,15 @@ func TestChangelog(t *testing.T) {
TagString: "Features",
Print: true,
ParsedBreakingChangeMessage: "change api to v2",
IsBreaking: true,
Subject: "my first break",
MessageBlocks: map[string][]shared.MessageBlock{
"body" : { shared.MessageBlock{
Label: "BREAKING CHANGE",
Content: "change api to v2",
},
},
},
},
},
},
@@ -126,6 +141,15 @@ func TestChangelog(t *testing.T) {
TagString: "Features",
Print: true,
ParsedBreakingChangeMessage: "hey from the change",
IsBreaking: true,
Subject: "my first break",
MessageBlocks: map[string][]shared.MessageBlock{
"body" : { shared.MessageBlock{
Label: "BREAKING CHANGE",
Content: "hey from the change",
},
},
},
},
{
Commit: shared.Commit{
@@ -138,6 +162,8 @@ func TestChangelog(t *testing.T) {
Tag: "feat",
TagString: "Features",
Print: true,
Subject: "my first commit",
MessageBlocks: map[string][]shared.MessageBlock{},
},
{
Commit: shared.Commit{
@@ -150,10 +176,12 @@ func TestChangelog(t *testing.T) {
Tag: "feat",
TagString: "Features",
Print: true,
Subject: "my second commit",
MessageBlocks: map[string][]shared.MessageBlock{},
},
{
Commit: shared.Commit{
Message: "feat: my new commit \n\nmy first break: BREAKING CHANGE: change api to v2",
Message: "feat: my new commit \n\nBREAKING CHANGE: change api to v2",
Author: "me",
Hash: "12345668",
},
@@ -163,6 +191,14 @@ func TestChangelog(t *testing.T) {
TagString: "Features",
Print: true,
ParsedBreakingChangeMessage: "change api to v2",
IsBreaking: true,
Subject: "my new commit",
MessageBlocks: map[string][]shared.MessageBlock{
"body": { shared.MessageBlock{
Label: "BREAKING CHANGE",
Content: "change api to v2",
}},
},
},
{
Commit: shared.Commit{
@@ -176,6 +212,9 @@ func TestChangelog(t *testing.T) {
TagString: "Features",
Print: true,
ParsedBreakingChangeMessage: "my next commit",
IsBreaking: true,
Subject: "my next commit",
MessageBlocks: map[string][]shared.MessageBlock{},
},
},
},

View File

@@ -37,13 +37,22 @@ type ChangelogTemplateConfig struct {
type AnalyzedCommit struct {
Commit Commit `yaml:"commit"`
ParsedMessage string `yaml:"parsedMessage"`
Scope Scope `yaml:"scope"`
ParsedBreakingChangeMessage string `yaml:"parsedBreakingChangeMessage"`
Tag string `yaml:"tag"`
TagString string `yaml:"tagString"`
Scope Scope `yaml:"scope"`
Subject string `yaml:"subject"`
MessageBlocks map[string][]MessageBlock `yaml:"messageBlocks"`
IsBreaking bool `yaml:"isBreaking"`
Print bool `yaml:"print"`
}
// MessageBlock represents a block in the body section of a commit message
type MessageBlock struct {
Label string `yaml:"label""`
Content string `yaml:"content"`
}
//Scope of the commit, like feat, fix,..
type Scope string