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"`
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"`
BreakingChangePrefixes []string `envconfig:"BRAKING_CHANGE_PREFIXES" default:"BREAKING CHANGE:,BREAKING CHANGES:"`
IssueIDPrefixes []string `envconfig:"ISSUEID_PREFIXES" default:"jira:,JIRA:,Jira:"`
BreakingChangePrefixes []string `envconfig:"BRAKING_CHANGE_PREFIXES" default:"BREAKING CHANGE,BREAKING CHANGES"`
IssueIDPrefixes []string `envconfig:"ISSUEID_PREFIXES" default:"jira,JIRA,Jira"`
TagPattern string `envconfig:"TAG_PATTERN" default:"%d.%d.%d"`
ReleaseNotesTags map[string]string `envconfig:"RELEASE_NOTES_TAGS" default:"fix:Bug Fixes,feat:Features"`
ValidateMessageSkipBranches []string `envconfig:"VALIDATE_MESSAGE_SKIP_BRANCHES" default:"master,develop"`

View file

@ -16,7 +16,18 @@ func main() {
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)
releasenotesProcessor := sv.NewReleaseNoteProcessor(cfg.ReleaseNotesTags)
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}}
`
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 .}}

View file

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

View file

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

View file

@ -26,16 +26,17 @@ func (p ReleaseNoteProcessorImpl) Create(version *semver.Version, date time.Time
sections := make(map[string]ReleaseNoteSection)
var breakingChanges []string
for _, commit := range commits {
if name, exists := p.tags[commit.Type]; exists {
section, sexists := sections[commit.Type]
if name, exists := p.tags[commit.Message.Type]; exists {
section, sexists := sections[commit.Message.Type]
if !sexists {
section = ReleaseNoteSection{Name: name}
}
section.Items = append(section.Items, commit)
sections[commit.Type] = section
sections[commit.Message.Type] = section
}
if value, exists := commit.Metadata[BreakingChangesKey]; exists {
breakingChanges = append(breakingChanges, value)
if commit.Message.BreakingMessage() != "" {
// 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",
version: semver.MustParse("1.0.0"),
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"}),
},
}

View file

@ -69,16 +69,16 @@ func (p SemVerCommitsProcessorImpl) NextVersion(version semver.Version, commits
}
func (p SemVerCommitsProcessorImpl) versionTypeToUpdate(commit GitCommitLog) versionType {
if _, exists := commit.Metadata[BreakingChangesKey]; exists {
if commit.Message.IsBreakingChange {
return major
}
if _, exists := p.MajorVersionTypes[commit.Type]; exists {
if _, exists := p.MajorVersionTypes[commit.Message.Type]; exists {
return major
}
if _, exists := p.MinorVersionTypes[commit.Type]; exists {
if _, exists := p.MinorVersionTypes[commit.Message.Type]; exists {
return minor
}
if _, exists := p.PatchVersionTypes[commit.Type]; exists {
if _, exists := p.PatchVersionTypes[commit.Message.Type]; exists {
return patch
}
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")},
{"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")},
{"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 {
t.Run(tt.name, func(t *testing.T) {