diff --git a/cmd/git-sv/config.go b/cmd/git-sv/config.go index 51421bb..3f8d454 100644 --- a/cmd/git-sv/config.go +++ b/cmd/git-sv/config.go @@ -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"` diff --git a/cmd/git-sv/main.go b/cmd/git-sv/main.go index 43894ee..b1ad572 100644 --- a/cmd/git-sv/main.go +++ b/cmd/git-sv/main.go @@ -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() diff --git a/sv/conventional_commit.go b/sv/conventional_commit.go new file mode 100644 index 0000000..43439a1 --- /dev/null +++ b/sv/conventional_commit.go @@ -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] +} diff --git a/sv/conventional_commit_test.go b/sv/conventional_commit_test.go new file mode 100644 index 0000000..ac11881 --- /dev/null +++ b/sv/conventional_commit_test.go @@ -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) + } + }) + } +} diff --git a/sv/formatter.go b/sv/formatter.go index c0d7e05..4ce87ec 100644 --- a/sv/formatter.go +++ b/sv/formatter.go @@ -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 .}} diff --git a/sv/git.go b/sv/git.go index 0fceca2..41794ea 100644 --- a/sv/git.go +++ b/sv/git.go @@ -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) diff --git a/sv/helpers_test.go b/sv/helpers_test.go index f2a3b61..1e2735a 100644 --- a/sv/helpers_test.go +++ b/sv/helpers_test.go @@ -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, + }, } } diff --git a/sv/releasenotes.go b/sv/releasenotes.go index f75194e..1ea13d9 100644 --- a/sv/releasenotes.go +++ b/sv/releasenotes.go @@ -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()) } } diff --git a/sv/releasenotes_test.go b/sv/releasenotes_test.go index f47fff7..6aba9a8 100644 --- a/sv/releasenotes_test.go +++ b/sv/releasenotes_test.go @@ -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"}), }, } diff --git a/sv/semver.go b/sv/semver.go index faa840c..132b5b3 100644 --- a/sv/semver.go +++ b/sv/semver.go @@ -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 { diff --git a/sv/semver_test.go b/sv/semver_test.go index 1f18479..f3ea960 100644 --- a/sv/semver_test.go +++ b/sv/semver_test.go @@ -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) {