mirror of
https://github.com/thegeeklab/git-sv.git
synced 2024-11-24 21:20:40 +00:00
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:
parent
0df2c6facc
commit
8cf6f1eb56
@ -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"`
|
||||
|
@ -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
121
sv/conventional_commit.go
Normal 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]
|
||||
}
|
60
sv/conventional_commit_test.go
Normal file
60
sv/conventional_commit_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -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 .}}
|
||||
|
||||
|
61
sv/git.go
61
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
|
||||
@ -37,11 +31,7 @@ type Git interface {
|
||||
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"`
|
||||
Message CommitMessage `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// GitTag git tag info
|
||||
@ -74,14 +64,14 @@ func NewLogRange(t LogRangeType, start, end string) LogRange {
|
||||
|
||||
// GitImpl git command implementation
|
||||
type GitImpl struct {
|
||||
messageMetadata map[string][]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},
|
||||
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,
|
||||
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)
|
||||
|
@ -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{
|
||||
Message: CommitMessage{
|
||||
Type: t,
|
||||
Subject: "subject text",
|
||||
Description: "subject text",
|
||||
IsBreakingChange: breaking,
|
||||
Metadata: metadata,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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"}),
|
||||
},
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user