1
0
Fork 0

feat: support conventional commits exclamation mark on breaking changes

BREAKING CHANGE: changes commit-log command json, rename subject to description, move all commit message attributes to 'message'
This commit is contained in:
Beatriz Vieira 2021-02-13 23:35:31 -03:00
parent 0df2c6facc
commit 8cf6f1eb56
11 changed files with 233 additions and 76 deletions

View file

@ -12,8 +12,8 @@ type Config struct {
MinorVersionTypes []string `envconfig:"MINOR_VERSION_TYPES" default:"feat"` MinorVersionTypes []string `envconfig:"MINOR_VERSION_TYPES" default:"feat"`
PatchVersionTypes []string `envconfig:"PATCH_VERSION_TYPES" default:"build,ci,chore,docs,fix,perf,refactor,style,test"` PatchVersionTypes []string `envconfig:"PATCH_VERSION_TYPES" default:"build,ci,chore,docs,fix,perf,refactor,style,test"`
IncludeUnknownTypeAsPatch bool `envconfig:"INCLUDE_UNKNOWN_TYPE_AS_PATCH" default:"true"` IncludeUnknownTypeAsPatch bool `envconfig:"INCLUDE_UNKNOWN_TYPE_AS_PATCH" default:"true"`
BreakingChangePrefixes []string `envconfig:"BRAKING_CHANGE_PREFIXES" default:"BREAKING CHANGE:,BREAKING CHANGES:"` BreakingChangePrefixes []string `envconfig:"BRAKING_CHANGE_PREFIXES" default:"BREAKING CHANGE,BREAKING CHANGES"`
IssueIDPrefixes []string `envconfig:"ISSUEID_PREFIXES" default:"jira:,JIRA:,Jira:"` IssueIDPrefixes []string `envconfig:"ISSUEID_PREFIXES" default:"jira,JIRA,Jira"`
TagPattern string `envconfig:"TAG_PATTERN" default:"%d.%d.%d"` TagPattern string `envconfig:"TAG_PATTERN" default:"%d.%d.%d"`
ReleaseNotesTags map[string]string `envconfig:"RELEASE_NOTES_TAGS" default:"fix:Bug Fixes,feat:Features"` ReleaseNotesTags map[string]string `envconfig:"RELEASE_NOTES_TAGS" default:"fix:Bug Fixes,feat:Features"`
ValidateMessageSkipBranches []string `envconfig:"VALIDATE_MESSAGE_SKIP_BRANCHES" default:"master,develop"` ValidateMessageSkipBranches []string `envconfig:"VALIDATE_MESSAGE_SKIP_BRANCHES" default:"master,develop"`

View file

@ -16,7 +16,18 @@ func main() {
cfg := loadConfig() cfg := loadConfig()
git := sv.NewGit(cfg.BreakingChangePrefixes, cfg.IssueIDPrefixes, cfg.TagPattern) // TODO: config using yaml
commitMessageCfg := sv.CommitMessageConfig{
Types: cfg.CommitMessageTypes,
Scope: sv.ScopeConfig{},
Footer: map[string]sv.FooterMetadataConfig{
"issue": {Key: cfg.IssueIDPrefixes[0], KeySynonyms: cfg.IssueIDPrefixes[1:], Regex: cfg.IssueRegex},
"breaking-change": {Key: cfg.BreakingChangePrefixes[0], KeySynonyms: cfg.BreakingChangePrefixes[1:]},
},
}
////
git := sv.NewGit(sv.NewCommitMessageParser(commitMessageCfg), cfg.TagPattern)
semverProcessor := sv.NewSemVerCommitsProcessor(cfg.IncludeUnknownTypeAsPatch, cfg.MajorVersionTypes, cfg.MinorVersionTypes, cfg.PatchVersionTypes) semverProcessor := sv.NewSemVerCommitsProcessor(cfg.IncludeUnknownTypeAsPatch, cfg.MajorVersionTypes, cfg.MinorVersionTypes, cfg.PatchVersionTypes)
releasenotesProcessor := sv.NewReleaseNoteProcessor(cfg.ReleaseNotesTags) releasenotesProcessor := sv.NewReleaseNoteProcessor(cfg.ReleaseNotesTags)
outputFormatter := sv.NewOutputFormatter() outputFormatter := sv.NewOutputFormatter()

121
sv/conventional_commit.go Normal file
View file

@ -0,0 +1,121 @@
package sv
import (
"regexp"
"strings"
)
const (
breakingKey = "breaking-change"
// IssueIDKey key to issue id metadata
issueKey = "issue"
)
// CommitMessageConfig config a commit message
type CommitMessageConfig struct {
Types []string
Scope ScopeConfig
Footer map[string]FooterMetadataConfig
}
// ScopeConfig config scope preferences
type ScopeConfig struct {
Mandatory bool
Values []string
}
// FooterMetadataConfig config footer metadata
type FooterMetadataConfig struct {
Key string
KeySynonyms []string
Regex string
UseHash bool
}
// CommitMessage is a message using conventional commits.
type CommitMessage struct {
Type string `json:"type,omitempty"`
Scope string `json:"scope,omitempty"`
Description string `json:"description,omitempty"`
Body string `json:"body,omitempty"`
IsBreakingChange bool `json:"isBreakingChange,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// Issue return issue from metadata.
func (m CommitMessage) Issue() string {
return m.Metadata[issueKey]
}
// BreakingMessage return breaking change message from metadata.
func (m CommitMessage) BreakingMessage() string {
return m.Metadata[breakingKey]
}
// CommitMessageParser parse commit messages.
type CommitMessageParser interface {
Parse(subject, body string) CommitMessage
}
// CommitMessageParserImpl commit message parser implementation
type CommitMessageParserImpl struct {
cfg CommitMessageConfig
}
// NewCommitMessageParser CommitMessageParserImpl constructor
func NewCommitMessageParser(cfg CommitMessageConfig) CommitMessageParser {
return &CommitMessageParserImpl{cfg: cfg}
}
// Parse parse a commit message
func (p CommitMessageParserImpl) Parse(subject, body string) CommitMessage {
commitType, scope, description, hasBreakingChange := parseSubjectMessage(subject)
metadata := make(map[string]string)
for key, mdCfg := range p.cfg.Footer {
prefixes := append([]string{mdCfg.Key}, mdCfg.KeySynonyms...)
for _, prefix := range prefixes {
if tagValue := extractFooterMetadata(prefix, body, mdCfg.UseHash); tagValue != "" {
metadata[key] = tagValue
break
}
}
}
if _, exists := metadata[breakingKey]; exists {
hasBreakingChange = true
}
return CommitMessage{
Type: commitType,
Scope: scope,
Description: description,
Body: body,
IsBreakingChange: hasBreakingChange,
Metadata: metadata,
}
}
func parseSubjectMessage(message string) (string, string, string, bool) {
regex := regexp.MustCompile("([a-z]+)(\\((.*)\\))?(!)?: (.*)")
result := regex.FindStringSubmatch(message)
if len(result) != 6 {
return "", "", message, false
}
return result[1], result[3], strings.TrimSpace(result[5]), result[4] == "!"
}
func extractFooterMetadata(key, text string, useHash bool) string {
var regex *regexp.Regexp
if useHash {
regex = regexp.MustCompile(key + " (#.*)")
} else {
regex = regexp.MustCompile(key + ": (.*)")
}
result := regex.FindStringSubmatch(text)
if len(result) < 2 {
return ""
}
return result[1]
}

View file

@ -0,0 +1,60 @@
package sv
import (
"reflect"
"testing"
)
var cfg = CommitMessageConfig{
Types: []string{"feat", "fix"},
Scope: ScopeConfig{},
Footer: map[string]FooterMetadataConfig{
"issue": {Key: "jira", KeySynonyms: []string{"Jira"}, Regex: "[A-Z]+-[0-9]+"},
"breaking-change": {Key: "BREAKING CHANGE", KeySynonyms: []string{"BREAKING CHANGES"}},
"refs": {Key: "Refs", UseHash: true},
},
}
var completeBody = `some descriptions
jira: JIRA-123
BREAKING CHANGE: this change breaks everything`
var issueOnlyBody = `some descriptions
jira: JIRA-456`
var issueSynonymsBody = `some descriptions
Jira: JIRA-789`
var hashMetadataBody = `some descriptions
Jira: JIRA-999
Refs #123`
func TestCommitMessageParserImpl_Parse(t *testing.T) {
tests := []struct {
name string
subject string
body string
want CommitMessage
}{
{"simple message", "feat: something awesome", "", CommitMessage{Type: "feat", Scope: "", Description: "something awesome", Body: "", IsBreakingChange: false, Metadata: map[string]string{}}},
{"message with scope", "feat(scope): something awesome", "", CommitMessage{Type: "feat", Scope: "scope", Description: "something awesome", Body: "", IsBreakingChange: false, Metadata: map[string]string{}}},
{"unmapped type", "unkn: something unknown", "", CommitMessage{Type: "unkn", Scope: "", Description: "something unknown", Body: "", IsBreakingChange: false, Metadata: map[string]string{}}},
{"jira and breaking change metadata", "feat: something new", completeBody, CommitMessage{Type: "feat", Scope: "", Description: "something new", Body: completeBody, IsBreakingChange: true, Metadata: map[string]string{issueKey: "JIRA-123", breakingKey: "this change breaks everything"}}},
{"jira only metadata", "feat: something new", issueOnlyBody, CommitMessage{Type: "feat", Scope: "", Description: "something new", Body: issueOnlyBody, IsBreakingChange: false, Metadata: map[string]string{issueKey: "JIRA-456"}}},
{"jira synonyms metadata", "feat: something new", issueSynonymsBody, CommitMessage{Type: "feat", Scope: "", Description: "something new", Body: issueSynonymsBody, IsBreakingChange: false, Metadata: map[string]string{issueKey: "JIRA-789"}}},
{"breaking change with exclamation mark", "feat!: something new", "", CommitMessage{Type: "feat", Scope: "", Description: "something new", Body: "", IsBreakingChange: true, Metadata: map[string]string{}}},
{"hash metadata", "feat: something new", hashMetadataBody, CommitMessage{Type: "feat", Scope: "", Description: "something new", Body: hashMetadataBody, IsBreakingChange: false, Metadata: map[string]string{issueKey: "JIRA-999", "refs": "#123"}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := NewCommitMessageParser(cfg)
if got := p.Parse(tt.subject, tt.body); !reflect.DeepEqual(got, tt.want) {
t.Errorf("CommitMessageParserImpl.Parse() = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -22,7 +22,7 @@ const (
{{- end}} {{- end}}
` `
rnSectionItem = "- {{if .Scope}}**{{.Scope}}:** {{end}}{{.Subject}} ({{.Hash}}){{if .Metadata.issueid}} ({{.Metadata.issueid}}){{end}}" rnSectionItem = "- {{if .Message.Scope}}**{{.Message.Scope}}:** {{end}}{{.Message.Description}} ({{.Hash}}){{if .Message.Metadata.issue}} ({{.Message.Metadata.issue}}){{end}}"
rnSection = `{{- if .}} rnSection = `{{- if .}}

View file

@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"regexp"
"strings" "strings"
"time" "time"
@ -16,11 +15,6 @@ import (
const ( const (
logSeparator = "##" logSeparator = "##"
endLine = "~~" endLine = "~~"
// BreakingChangesKey key to breaking change metadata
BreakingChangesKey = "breakingchange"
// IssueIDKey key to issue id metadata
IssueIDKey = "issueid"
) )
// Git commands // Git commands
@ -37,11 +31,7 @@ type Git interface {
type GitCommitLog struct { type GitCommitLog struct {
Date string `json:"date,omitempty"` Date string `json:"date,omitempty"`
Hash string `json:"hash,omitempty"` Hash string `json:"hash,omitempty"`
Type string `json:"type,omitempty"` Message CommitMessage `json:"message,omitempty"`
Scope string `json:"scope,omitempty"`
Subject string `json:"subject,omitempty"`
Body string `json:"body,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
} }
// GitTag git tag info // GitTag git tag info
@ -74,14 +64,14 @@ func NewLogRange(t LogRangeType, start, end string) LogRange {
// GitImpl git command implementation // GitImpl git command implementation
type GitImpl struct { type GitImpl struct {
messageMetadata map[string][]string messageParser CommitMessageParser
tagPattern string tagPattern string
} }
// NewGit constructor // NewGit constructor
func NewGit(breakinChangePrefixes, issueIDPrefixes []string, tagPattern string) *GitImpl { func NewGit(messageParser CommitMessageParser, tagPattern string) *GitImpl {
return &GitImpl{ return &GitImpl{
messageMetadata: map[string][]string{BreakingChangesKey: breakinChangePrefixes, IssueIDKey: issueIDPrefixes}, messageParser: messageParser,
tagPattern: tagPattern, tagPattern: tagPattern,
} }
} }
@ -119,7 +109,7 @@ func (g GitImpl) Log(lr LogRange) ([]GitCommitLog, error) {
if err != nil { if err != nil {
return nil, combinedOutputErr(err, out) return nil, combinedOutputErr(err, out)
} }
return parseLogOutput(g.messageMetadata, string(out)), nil return parseLogOutput(g.messageParser, string(out)), nil
} }
// Commit runs git commit // Commit runs git commit
@ -177,61 +167,28 @@ func parseTagsOutput(input string) ([]GitTag, error) {
return result, nil return result, nil
} }
func parseLogOutput(messageMetadata map[string][]string, log string) []GitCommitLog { func parseLogOutput(messageParser CommitMessageParser, log string) []GitCommitLog {
scanner := bufio.NewScanner(strings.NewReader(log)) scanner := bufio.NewScanner(strings.NewReader(log))
scanner.Split(splitAt([]byte(endLine))) scanner.Split(splitAt([]byte(endLine)))
var logs []GitCommitLog var logs []GitCommitLog
for scanner.Scan() { for scanner.Scan() {
if text := strings.TrimSpace(strings.Trim(scanner.Text(), "\"")); text != "" { if text := strings.TrimSpace(strings.Trim(scanner.Text(), "\"")); text != "" {
logs = append(logs, parseCommitLog(messageMetadata, text)) logs = append(logs, parseCommitLog(messageParser, text))
} }
} }
return logs return logs
} }
func parseCommitLog(messageMetadata map[string][]string, commit string) GitCommitLog { func parseCommitLog(messageParser CommitMessageParser, commit string) GitCommitLog {
content := strings.Split(strings.Trim(commit, "\""), logSeparator) content := strings.Split(strings.Trim(commit, "\""), logSeparator)
commitType, scope, subject := parseCommitLogMessage(content[2])
metadata := make(map[string]string)
for key, prefixes := range messageMetadata {
for _, prefix := range prefixes {
if tagValue := extractTag(prefix, content[3]); tagValue != "" {
metadata[key] = tagValue
break
}
}
}
return GitCommitLog{ return GitCommitLog{
Date: content[0], Date: content[0],
Hash: content[1], Hash: content[1],
Type: commitType, Message: messageParser.Parse(content[2], content[3]),
Scope: scope,
Subject: subject,
Body: content[3],
Metadata: metadata,
} }
} }
func parseCommitLogMessage(message string) (string, string, string) {
regex := regexp.MustCompile("([a-z]+)(\\((.*)\\))?: (.*)")
result := regex.FindStringSubmatch(message)
if len(result) != 5 {
return "", "", message
}
return result[1], result[3], strings.TrimSpace(result[4])
}
func extractTag(tag, text string) string {
regex := regexp.MustCompile(tag + " (.*)")
result := regex.FindStringSubmatch(text)
if len(result) < 2 {
return ""
}
return result[1]
}
func splitAt(b []byte) func(data []byte, atEOF bool) (advance int, token []byte, err error) { func splitAt(b []byte) func(data []byte, atEOF bool) (advance int, token []byte, err error) {
return func(data []byte, atEOF bool) (advance int, token []byte, err error) { return func(data []byte, atEOF bool) (advance int, token []byte, err error) {
dataLen := len(data) dataLen := len(data)

View file

@ -12,10 +12,17 @@ func version(v string) semver.Version {
} }
func commitlog(t string, metadata map[string]string) GitCommitLog { func commitlog(t string, metadata map[string]string) GitCommitLog {
breaking := false
if _, found := metadata[breakingKey]; found {
breaking = true
}
return GitCommitLog{ return GitCommitLog{
Message: CommitMessage{
Type: t, Type: t,
Subject: "subject text", Description: "subject text",
IsBreakingChange: breaking,
Metadata: metadata, Metadata: metadata,
},
} }
} }

View file

@ -26,16 +26,17 @@ func (p ReleaseNoteProcessorImpl) Create(version *semver.Version, date time.Time
sections := make(map[string]ReleaseNoteSection) sections := make(map[string]ReleaseNoteSection)
var breakingChanges []string var breakingChanges []string
for _, commit := range commits { for _, commit := range commits {
if name, exists := p.tags[commit.Type]; exists { if name, exists := p.tags[commit.Message.Type]; exists {
section, sexists := sections[commit.Type] section, sexists := sections[commit.Message.Type]
if !sexists { if !sexists {
section = ReleaseNoteSection{Name: name} section = ReleaseNoteSection{Name: name}
} }
section.Items = append(section.Items, commit) section.Items = append(section.Items, commit)
sections[commit.Type] = section sections[commit.Message.Type] = section
} }
if value, exists := commit.Metadata[BreakingChangesKey]; exists { if commit.Message.BreakingMessage() != "" {
breakingChanges = append(breakingChanges, value) // TODO: if no message found, should use description instead?
breakingChanges = append(breakingChanges, commit.Message.BreakingMessage())
} }
} }

View file

@ -36,7 +36,7 @@ func TestReleaseNoteProcessorImpl_Create(t *testing.T) {
name: "breaking changes tag", name: "breaking changes tag",
version: semver.MustParse("1.0.0"), version: semver.MustParse("1.0.0"),
date: date, date: date,
commits: []GitCommitLog{commitlog("t1", map[string]string{}), commitlog("unmapped", map[string]string{"breakingchange": "breaks"})}, commits: []GitCommitLog{commitlog("t1", map[string]string{}), commitlog("unmapped", map[string]string{"breaking-change": "breaks"})},
want: releaseNote(semver.MustParse("1.0.0"), date, map[string]ReleaseNoteSection{"t1": newReleaseNoteSection("Tag 1", []GitCommitLog{commitlog("t1", map[string]string{})})}, []string{"breaks"}), want: releaseNote(semver.MustParse("1.0.0"), date, map[string]ReleaseNoteSection{"t1": newReleaseNoteSection("Tag 1", []GitCommitLog{commitlog("t1", map[string]string{})})}, []string{"breaks"}),
}, },
} }

View file

@ -69,16 +69,16 @@ func (p SemVerCommitsProcessorImpl) NextVersion(version semver.Version, commits
} }
func (p SemVerCommitsProcessorImpl) versionTypeToUpdate(commit GitCommitLog) versionType { func (p SemVerCommitsProcessorImpl) versionTypeToUpdate(commit GitCommitLog) versionType {
if _, exists := commit.Metadata[BreakingChangesKey]; exists { if commit.Message.IsBreakingChange {
return major return major
} }
if _, exists := p.MajorVersionTypes[commit.Type]; exists { if _, exists := p.MajorVersionTypes[commit.Message.Type]; exists {
return major return major
} }
if _, exists := p.MinorVersionTypes[commit.Type]; exists { if _, exists := p.MinorVersionTypes[commit.Message.Type]; exists {
return minor return minor
} }
if _, exists := p.PatchVersionTypes[commit.Type]; exists { if _, exists := p.PatchVersionTypes[commit.Message.Type]; exists {
return patch return patch
} }
if p.IncludeUnknownTypeAsPatch { if p.IncludeUnknownTypeAsPatch {

View file

@ -21,7 +21,7 @@ func TestSemVerCommitsProcessorImpl_NextVersion(t *testing.T) {
{"patch update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{})}, version("0.0.1")}, {"patch update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{})}, version("0.0.1")},
{"minor update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{}), commitlog("minor", map[string]string{})}, version("0.1.0")}, {"minor update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{}), commitlog("minor", map[string]string{})}, version("0.1.0")},
{"major update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{}), commitlog("major", map[string]string{})}, version("1.0.0")}, {"major update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{}), commitlog("major", map[string]string{})}, version("1.0.0")},
{"breaking change update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{}), commitlog("patch", map[string]string{"breakingchange": "break"})}, version("1.0.0")}, {"breaking change update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{}), commitlog("patch", map[string]string{"breaking-change": "break"})}, version("1.0.0")},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {