├── cmd └── git-sv │ ├── resources │ └── templates │ │ ├── changelog-md.tpl │ │ ├── rn-md-section-breaking-changes.tpl │ │ ├── rn-md-section-commits.tpl │ │ └── releasenotes-md.tpl │ ├── log.go │ ├── resources_test.go │ ├── config_test.go │ ├── prompt.go │ ├── config.go │ ├── main.go │ └── handlers.go ├── .github ├── dependabot.yml └── workflows │ ├── pull-request.yml │ └── ci.yml ├── .gitignore ├── sv ├── formatter_functions.go ├── git_test.go ├── helpers_test.go ├── formatter_functions_test.go ├── formatter.go ├── releasenotes_test.go ├── config.go ├── semver.go ├── semver_test.go ├── formatter_test.go ├── releasenotes.go ├── git.go ├── message.go └── message_test.go ├── .golangci.yml ├── go.mod ├── .sv4git.yml ├── LICENSE ├── Makefile ├── go.sum └── README.md /cmd/git-sv/resources/templates/changelog-md.tpl: -------------------------------------------------------------------------------- 1 | # Changelog 2 | {{- range .}} 3 | 4 | {{template "releasenotes-md.tpl" .}} 5 | --- 6 | {{- end}} -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" -------------------------------------------------------------------------------- /cmd/git-sv/resources/templates/rn-md-section-breaking-changes.tpl: -------------------------------------------------------------------------------- 1 | {{- if ne .Name ""}} 2 | 3 | ### {{.Name}} 4 | {{range $k,$v := .Messages}} 5 | - {{$v}} 6 | {{- end}} 7 | {{- end}} -------------------------------------------------------------------------------- /cmd/git-sv/log.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func warnf(format string, values ...interface{}) { 9 | fmt.Fprintf(os.Stderr, "WARN: "+format+"\n", values...) 10 | } 11 | -------------------------------------------------------------------------------- /cmd/git-sv/resources/templates/rn-md-section-commits.tpl: -------------------------------------------------------------------------------- 1 | {{- if .}}{{- if ne .SectionName ""}} 2 | 3 | ### {{.SectionName}} 4 | {{range $k,$v := .Items}} 5 | - {{if $v.Message.Scope}}**{{$v.Message.Scope}}:** {{end}}{{$v.Message.Description}} ({{$v.Hash}}){{if $v.Message.Metadata.issue}} ({{$v.Message.Metadata.issue}}){{end}} 6 | {{- end}} 7 | {{- end}}{{- end}} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | bin/ 3 | *.exe 4 | *.exe~ 5 | *.dll 6 | *.so 7 | *.dylib 8 | 9 | # Test binary, build with `go test -c` 10 | *.test 11 | 12 | # Output of the go coverage tool, specifically when used with LiteIDE 13 | *.out 14 | 15 | *.sample 16 | todo 17 | 18 | # Additional generated artifacts 19 | artifacts/ 20 | 21 | # Mac metadata 22 | 23 | .DS_Store 24 | -------------------------------------------------------------------------------- /sv/formatter_functions.go: -------------------------------------------------------------------------------- 1 | package sv 2 | 3 | import "time" 4 | 5 | func timeFormat(t time.Time, format string) string { 6 | if t.IsZero() { 7 | return "" 8 | } 9 | return t.Format(format) 10 | } 11 | 12 | func getSection(sections []ReleaseNoteSection, name string) ReleaseNoteSection { 13 | for _, section := range sections { 14 | if section.SectionName() == name { 15 | return section 16 | } 17 | } 18 | return nil 19 | } 20 | -------------------------------------------------------------------------------- /cmd/git-sv/resources/templates/releasenotes-md.tpl: -------------------------------------------------------------------------------- 1 | ## {{if .Release}}{{.Release}}{{end}}{{if and (not .Date.IsZero) .Release}} ({{end}}{{timefmt .Date "2006-01-02"}}{{if and (not .Date.IsZero) .Release}}){{end}} 2 | {{- range $section := .Sections }} 3 | {{- if (eq $section.SectionType "commits") }} 4 | {{- template "rn-md-section-commits.tpl" $section }} 5 | {{- else if (eq $section.SectionType "breaking-changes")}} 6 | {{- template "rn-md-section-breaking-changes.tpl" $section }} 7 | {{- end}} 8 | {{- end}} 9 | -------------------------------------------------------------------------------- /cmd/git-sv/resources_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func Test_checkTemplatesFiles(t *testing.T) { 8 | tests := []string{ 9 | "resources/templates/changelog-md.tpl", 10 | "resources/templates/releasenotes-md.tpl", 11 | } 12 | for _, tt := range tests { 13 | t.Run(tt, func(t *testing.T) { 14 | got, err := defaultTemplatesFS.ReadFile(tt) 15 | if err != nil { 16 | t.Errorf("missing template error = %v", err) 17 | return 18 | } 19 | if len(got) <= 0 { 20 | t.Errorf("empty template") 21 | } 22 | }) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - tagliatelle 4 | 5 | run: 6 | skip-dirs: 7 | - build 8 | - artifacts 9 | 10 | linters-settings: 11 | tagliatelle: 12 | case: 13 | use-field-name: true 14 | rules: 15 | json: camel 16 | yaml: kebab 17 | xml: camel 18 | bson: camel 19 | avro: snake 20 | mapstructure: kebab 21 | 22 | issues: 23 | exclude-rules: 24 | - path: _test\.go 25 | linters: 26 | - gocyclo 27 | - errcheck 28 | - dupl 29 | - gosec 30 | - gochecknoglobals 31 | - testpackage 32 | - path: cmd/git-sv/main.go 33 | linters: 34 | - gochecknoglobals 35 | - funlen 36 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/bvieira/sv4git/v2 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/Masterminds/semver/v3 v3.2.0 7 | github.com/imdario/mergo v0.3.13 8 | github.com/kelseyhightower/envconfig v1.4.0 9 | github.com/manifoldco/promptui v0.9.0 10 | github.com/urfave/cli/v2 v2.24.1 11 | gopkg.in/yaml.v3 v3.0.1 12 | ) 13 | 14 | require ( 15 | github.com/chzyer/readline v1.5.1 // indirect 16 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect 17 | github.com/kr/pretty v0.3.1 // indirect 18 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 19 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect 20 | golang.org/x/sys v0.4.0 // indirect 21 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: pull_request 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | paths-ignore: 7 | - '**/.gitignore' 8 | 9 | jobs: 10 | 11 | lint: 12 | name: Lint 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Check out code 16 | uses: actions/checkout@v2 17 | - name: Run golangci lint 18 | uses: golangci/golangci-lint-action@v2 19 | with: 20 | version: latest 21 | 22 | build: 23 | name: Build 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Check out code 27 | uses: actions/checkout@v2 28 | - name: Set up Go 29 | uses: actions/setup-go@v2 30 | with: 31 | go-version: ^1.19 32 | id: go 33 | - name: Build 34 | run: make build 35 | -------------------------------------------------------------------------------- /.sv4git.yml: -------------------------------------------------------------------------------- 1 | version: "1.1" 2 | 3 | versioning: 4 | update-major: [] 5 | update-minor: [feat] 6 | update-patch: [build, ci, chore, fix, perf, refactor, test] 7 | 8 | tag: 9 | pattern: "v%d.%d.%d" 10 | 11 | release-notes: 12 | sections: 13 | - name: Features 14 | section-type: commits 15 | commit-types: [feat] 16 | - name: Bug Fixes 17 | section-type: commits 18 | commit-types: [fix] 19 | - name: Misc 20 | section-type: commits 21 | commit-types: [build] 22 | - name: Breaking Changes 23 | section-type: breaking-changes 24 | 25 | commit-message: 26 | footer: 27 | issue: 28 | key: issue 29 | add-value-prefix: "#" 30 | issue: 31 | regex: "#?[0-9]+" 32 | -------------------------------------------------------------------------------- /sv/git_test.go: -------------------------------------------------------------------------------- 1 | package sv 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func Test_parseTagsOutput(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | input string 13 | want []GitTag 14 | wantErr bool 15 | }{ 16 | {"with date", "2020-05-01 18:00:00 -0300#1.0.0", []GitTag{{Name: "1.0.0", Date: date("2020-05-01 18:00:00 -0300")}}, false}, 17 | {"without date", "#1.0.0", []GitTag{{Name: "1.0.0", Date: time.Time{}}}, false}, 18 | } 19 | for _, tt := range tests { 20 | t.Run(tt.name, func(t *testing.T) { 21 | got, err := parseTagsOutput(tt.input) 22 | if (err != nil) != tt.wantErr { 23 | t.Errorf("parseTagsOutput() error = %v, wantErr %v", err, tt.wantErr) 24 | return 25 | } 26 | if !reflect.DeepEqual(got, tt.want) { 27 | t.Errorf("parseTagsOutput() = %v, want %v", got, tt.want) 28 | } 29 | }) 30 | } 31 | } 32 | 33 | func date(input string) time.Time { 34 | t, err := time.Parse("2006-01-02 15:04:05 -0700", input) 35 | if err != nil { 36 | panic(err) 37 | } 38 | return t 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Beatriz Vieira 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /sv/helpers_test.go: -------------------------------------------------------------------------------- 1 | package sv 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/Masterminds/semver/v3" 7 | ) 8 | 9 | func version(v string) *semver.Version { 10 | r, _ := semver.NewVersion(v) 11 | return r 12 | } 13 | 14 | func commitlog(ctype string, metadata map[string]string, author string) GitCommitLog { 15 | breaking := false 16 | if _, found := metadata[breakingChangeMetadataKey]; found { 17 | breaking = true 18 | } 19 | return GitCommitLog{ 20 | Message: CommitMessage{ 21 | Type: ctype, 22 | Description: "subject text", 23 | IsBreakingChange: breaking, 24 | Metadata: metadata, 25 | }, 26 | AuthorName: author, 27 | } 28 | } 29 | 30 | func releaseNote(version *semver.Version, tag string, date time.Time, sections []ReleaseNoteSection, authorsNames map[string]struct{}) ReleaseNote { 31 | return ReleaseNote{ 32 | Version: version, 33 | Tag: tag, 34 | Date: date.Truncate(time.Minute), 35 | Sections: sections, 36 | AuthorsNames: authorsNames, 37 | } 38 | } 39 | 40 | func newReleaseNoteCommitsSection(name string, types []string, items []GitCommitLog) ReleaseNoteCommitsSection { 41 | return ReleaseNoteCommitsSection{ 42 | Name: name, 43 | Types: types, 44 | Items: items, 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /sv/formatter_functions_test.go: -------------------------------------------------------------------------------- 1 | package sv 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func Test_timeFormat(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | time time.Time 13 | format string 14 | want string 15 | }{ 16 | {"valid time", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), "2006-01-02", "2022-01-01"}, 17 | {"empty time", time.Time{}, "2006-01-02", ""}, 18 | } 19 | for _, tt := range tests { 20 | t.Run(tt.name, func(t *testing.T) { 21 | if got := timeFormat(tt.time, tt.format); got != tt.want { 22 | t.Errorf("timeFormat() = %v, want %v", got, tt.want) 23 | } 24 | }) 25 | } 26 | } 27 | 28 | func Test_getSection(t *testing.T) { 29 | tests := []struct { 30 | name string 31 | sections []ReleaseNoteSection 32 | sectionName string 33 | want ReleaseNoteSection 34 | }{ 35 | {"existing section", []ReleaseNoteSection{ReleaseNoteCommitsSection{Name: "section 0"}, ReleaseNoteCommitsSection{Name: "section 1"}, ReleaseNoteCommitsSection{Name: "section 2"}}, "section 1", ReleaseNoteCommitsSection{Name: "section 1"}}, 36 | {"nonexisting section", []ReleaseNoteSection{ReleaseNoteCommitsSection{Name: "section 0"}, ReleaseNoteCommitsSection{Name: "section 1"}, ReleaseNoteCommitsSection{Name: "section 2"}}, "section 10", nil}, 37 | } 38 | for _, tt := range tests { 39 | t.Run(tt.name, func(t *testing.T) { 40 | if got := getSection(tt.sections, tt.sectionName); !reflect.DeepEqual(got, tt.want) { 41 | t.Errorf("getSection() = %v, want %v", got, tt.want) 42 | } 43 | }) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /sv/formatter.go: -------------------------------------------------------------------------------- 1 | package sv 2 | 3 | import ( 4 | "bytes" 5 | "io/fs" 6 | "os" 7 | "sort" 8 | "text/template" 9 | "time" 10 | 11 | "github.com/Masterminds/semver/v3" 12 | ) 13 | 14 | type releaseNoteTemplateVariables struct { 15 | Release string 16 | Tag string 17 | Version *semver.Version 18 | Date time.Time 19 | Sections []ReleaseNoteSection 20 | AuthorNames []string 21 | } 22 | 23 | // OutputFormatter output formatter interface. 24 | type OutputFormatter interface { 25 | FormatReleaseNote(releasenote ReleaseNote) (string, error) 26 | FormatChangelog(releasenotes []ReleaseNote) (string, error) 27 | } 28 | 29 | // OutputFormatterImpl formater for release note and changelog. 30 | type OutputFormatterImpl struct { 31 | templates *template.Template 32 | } 33 | 34 | // NewOutputFormatter TemplateProcessor constructor. 35 | func NewOutputFormatter(templatesFS fs.FS) *OutputFormatterImpl { 36 | templateFNs := map[string]interface{}{ 37 | "timefmt": timeFormat, 38 | "getsection": getSection, 39 | "getenv": os.Getenv, 40 | } 41 | tpls := template.Must(template.New("templates").Funcs(templateFNs).ParseFS(templatesFS, "*")) 42 | return &OutputFormatterImpl{templates: tpls} 43 | } 44 | 45 | // FormatReleaseNote format a release note. 46 | func (p OutputFormatterImpl) FormatReleaseNote(releasenote ReleaseNote) (string, error) { 47 | var b bytes.Buffer 48 | if err := p.templates.ExecuteTemplate(&b, "releasenotes-md.tpl", releaseNoteVariables(releasenote)); err != nil { 49 | return "", err 50 | } 51 | return b.String(), nil 52 | } 53 | 54 | // FormatChangelog format a changelog. 55 | func (p OutputFormatterImpl) FormatChangelog(releasenotes []ReleaseNote) (string, error) { 56 | templateVars := make([]releaseNoteTemplateVariables, len(releasenotes)) 57 | for i, v := range releasenotes { 58 | templateVars[i] = releaseNoteVariables(v) 59 | } 60 | 61 | var b bytes.Buffer 62 | if err := p.templates.ExecuteTemplate(&b, "changelog-md.tpl", templateVars); err != nil { 63 | return "", err 64 | } 65 | return b.String(), nil 66 | } 67 | 68 | func releaseNoteVariables(releasenote ReleaseNote) releaseNoteTemplateVariables { 69 | release := releasenote.Tag 70 | if releasenote.Version != nil { 71 | release = "v" + releasenote.Version.String() 72 | } 73 | return releaseNoteTemplateVariables{ 74 | Release: release, 75 | Tag: releasenote.Tag, 76 | Version: releasenote.Version, 77 | Date: releasenote.Date, 78 | Sections: releasenote.Sections, 79 | AuthorNames: toSortedArray(releasenote.AuthorsNames), 80 | } 81 | } 82 | 83 | func toSortedArray(input map[string]struct{}) []string { 84 | result := make([]string, len(input)) 85 | i := 0 86 | for k := range input { 87 | result[i] = k 88 | i++ 89 | } 90 | sort.Strings(result) 91 | return result 92 | } 93 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | paths-ignore: 7 | - "**.md" 8 | - "**/.gitignore" 9 | - ".github/workflows/**" 10 | 11 | jobs: 12 | 13 | lint: 14 | name: Lint 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Check out code 18 | uses: actions/checkout@v3 19 | - name: Run golangci lint 20 | uses: golangci/golangci-lint-action@v3 21 | with: 22 | version: latest 23 | 24 | build: 25 | name: Build 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Check out code 29 | uses: actions/checkout@v3 30 | - name: Set up Go 31 | uses: actions/setup-go@v3 32 | with: 33 | go-version: ^1.19 34 | - name: Build 35 | run: make build 36 | 37 | tag: 38 | name: Tag 39 | runs-on: ubuntu-latest 40 | needs: [lint, build] 41 | steps: 42 | - name: Check out code 43 | uses: actions/checkout@v3 44 | with: 45 | fetch-depth: 0 46 | 47 | - name: Set GitHub Actions as commit author 48 | shell: bash 49 | run: | 50 | git config user.name "github-actions[bot]" 51 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com" 52 | 53 | - name: Setup sv4git 54 | run: | 55 | curl -s https://api.github.com/repos/bvieira/sv4git/releases/latest | jq -r '.assets[] | select(.browser_download_url | contains("linux")) | .browser_download_url' | wget -O /tmp/sv4git.tar.gz -qi - \ 56 | && tar -C /usr/local/bin -xzf /tmp/sv4git.tar.gz 57 | 58 | - name: Create tag 59 | id: create-tag 60 | run: | 61 | git sv tag 62 | VERSION=$(git sv cv) 63 | echo "::set-output name=tag::v$VERSION" 64 | outputs: 65 | tag: ${{ steps.create-tag.outputs.tag }} 66 | 67 | release: 68 | name: Release 69 | runs-on: ubuntu-latest 70 | needs: [tag] 71 | steps: 72 | - name: Check out code 73 | uses: actions/checkout@v3 74 | with: 75 | fetch-depth: 0 76 | 77 | - name: Setup sv4git 78 | run: | 79 | curl -s https://api.github.com/repos/bvieira/sv4git/releases/latest | jq -r '.assets[] | select(.browser_download_url | contains("linux")) | .browser_download_url' | wget -O /tmp/sv4git.tar.gz -qi - \ 80 | && tar -C /usr/local/bin -xzf /tmp/sv4git.tar.gz 81 | 82 | - name: Set up Go 83 | id: go 84 | uses: actions/setup-go@v3 85 | with: 86 | go-version: ^1.19 87 | 88 | - name: Create release notes 89 | run: | 90 | git sv rn -t "${{ needs.tag.outputs.tag }}" > release-notes.md 91 | 92 | - name: Build releases 93 | run: make release-all 94 | 95 | - name: Release 96 | uses: softprops/action-gh-release@v1 97 | with: 98 | body_path: release-notes.md 99 | tag_name: ${{ needs.tag.outputs.tag }} 100 | fail_on_unmatched_files: true 101 | files: | 102 | bin/git-sv_* 103 | -------------------------------------------------------------------------------- /sv/releasenotes_test.go: -------------------------------------------------------------------------------- 1 | package sv 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | "time" 7 | 8 | "github.com/Masterminds/semver/v3" 9 | ) 10 | 11 | func TestReleaseNoteProcessorImpl_Create(t *testing.T) { 12 | date := time.Now() 13 | 14 | tests := []struct { 15 | name string 16 | version *semver.Version 17 | tag string 18 | date time.Time 19 | commits []GitCommitLog 20 | want ReleaseNote 21 | }{ 22 | { 23 | name: "mapped tag", 24 | version: semver.MustParse("1.0.0"), 25 | tag: "v1.0.0", 26 | date: date, 27 | commits: []GitCommitLog{commitlog("t1", map[string]string{}, "a")}, 28 | want: releaseNote(semver.MustParse("1.0.0"), "v1.0.0", date, []ReleaseNoteSection{newReleaseNoteCommitsSection("Tag 1", []string{"t1"}, []GitCommitLog{commitlog("t1", map[string]string{}, "a")})}, map[string]struct{}{"a": {}}), 29 | }, 30 | { 31 | name: "unmapped tag", 32 | version: semver.MustParse("1.0.0"), 33 | tag: "v1.0.0", 34 | date: date, 35 | commits: []GitCommitLog{commitlog("t1", map[string]string{}, "a"), commitlog("unmapped", map[string]string{}, "a")}, 36 | want: releaseNote(semver.MustParse("1.0.0"), "v1.0.0", date, []ReleaseNoteSection{newReleaseNoteCommitsSection("Tag 1", []string{"t1"}, []GitCommitLog{commitlog("t1", map[string]string{}, "a")})}, map[string]struct{}{"a": {}}), 37 | }, 38 | { 39 | name: "breaking changes tag", 40 | version: semver.MustParse("1.0.0"), 41 | tag: "v1.0.0", 42 | date: date, 43 | commits: []GitCommitLog{commitlog("t1", map[string]string{}, "a"), commitlog("unmapped", map[string]string{"breaking-change": "breaks"}, "a")}, 44 | want: releaseNote(semver.MustParse("1.0.0"), "v1.0.0", date, []ReleaseNoteSection{newReleaseNoteCommitsSection("Tag 1", []string{"t1"}, []GitCommitLog{commitlog("t1", map[string]string{}, "a")}), ReleaseNoteBreakingChangeSection{Name: "Breaking Changes", Messages: []string{"breaks"}}}, map[string]struct{}{"a": {}}), 45 | }, 46 | { 47 | name: "multiple authors", 48 | version: semver.MustParse("1.0.0"), 49 | tag: "v1.0.0", 50 | date: date, 51 | commits: []GitCommitLog{commitlog("t1", map[string]string{}, "author3"), commitlog("t1", map[string]string{}, "author2"), commitlog("t1", map[string]string{}, "author1")}, 52 | want: releaseNote(semver.MustParse("1.0.0"), "v1.0.0", date, []ReleaseNoteSection{newReleaseNoteCommitsSection("Tag 1", []string{"t1"}, []GitCommitLog{commitlog("t1", map[string]string{}, "author3"), commitlog("t1", map[string]string{}, "author2"), commitlog("t1", map[string]string{}, "author1")})}, map[string]struct{}{"author1": {}, "author2": {}, "author3": {}}), 53 | }, 54 | } 55 | for _, tt := range tests { 56 | t.Run(tt.name, func(t *testing.T) { 57 | p := NewReleaseNoteProcessor(ReleaseNotesConfig{Sections: []ReleaseNotesSectionConfig{{Name: "Tag 1", SectionType: "commits", CommitTypes: []string{"t1"}}, {Name: "Tag 2", SectionType: "commits", CommitTypes: []string{"t2"}}, {Name: "Breaking Changes", SectionType: "breaking-changes"}}}) 58 | if got := p.Create(tt.version, tt.tag, tt.date, tt.commits); !reflect.DeepEqual(got, tt.want) { 59 | t.Errorf("ReleaseNoteProcessorImpl.Create() = %v, want %v", got, tt.want) 60 | } 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /sv/config.go: -------------------------------------------------------------------------------- 1 | package sv 2 | 3 | // ==== Message ==== 4 | 5 | // CommitMessageConfig config a commit message. 6 | type CommitMessageConfig struct { 7 | Types []string `yaml:"types,flow"` 8 | HeaderSelector string `yaml:"header-selector"` 9 | Scope CommitMessageScopeConfig `yaml:"scope"` 10 | Footer map[string]CommitMessageFooterConfig `yaml:"footer"` 11 | Issue CommitMessageIssueConfig `yaml:"issue"` 12 | } 13 | 14 | // IssueFooterConfig config for issue. 15 | func (c CommitMessageConfig) IssueFooterConfig() CommitMessageFooterConfig { 16 | if v, exists := c.Footer[issueMetadataKey]; exists { 17 | return v 18 | } 19 | return CommitMessageFooterConfig{} 20 | } 21 | 22 | // CommitMessageScopeConfig config scope preferences. 23 | type CommitMessageScopeConfig struct { 24 | Values []string `yaml:"values"` 25 | } 26 | 27 | // CommitMessageFooterConfig config footer metadata. 28 | type CommitMessageFooterConfig struct { 29 | Key string `yaml:"key"` 30 | KeySynonyms []string `yaml:"key-synonyms,flow"` 31 | UseHash bool `yaml:"use-hash"` 32 | AddValuePrefix string `yaml:"add-value-prefix"` 33 | } 34 | 35 | // CommitMessageIssueConfig issue preferences. 36 | type CommitMessageIssueConfig struct { 37 | Regex string `yaml:"regex"` 38 | } 39 | 40 | // ==== Branches ==== 41 | 42 | // BranchesConfig branches preferences. 43 | type BranchesConfig struct { 44 | Prefix string `yaml:"prefix"` 45 | Suffix string `yaml:"suffix"` 46 | DisableIssue bool `yaml:"disable-issue"` 47 | Skip []string `yaml:"skip,flow"` 48 | SkipDetached *bool `yaml:"skip-detached"` 49 | } 50 | 51 | // ==== Versioning ==== 52 | 53 | // VersioningConfig versioning preferences. 54 | type VersioningConfig struct { 55 | UpdateMajor []string `yaml:"update-major,flow"` 56 | UpdateMinor []string `yaml:"update-minor,flow"` 57 | UpdatePatch []string `yaml:"update-patch,flow"` 58 | IgnoreUnknown bool `yaml:"ignore-unknown"` 59 | } 60 | 61 | // ==== Tag ==== 62 | 63 | // TagConfig tag preferences. 64 | type TagConfig struct { 65 | Pattern *string `yaml:"pattern"` 66 | Filter *string `yaml:"filter"` 67 | } 68 | 69 | // ==== Release Notes ==== 70 | 71 | // ReleaseNotesConfig release notes preferences. 72 | type ReleaseNotesConfig struct { 73 | Headers map[string]string `yaml:"headers,omitempty"` 74 | Sections []ReleaseNotesSectionConfig `yaml:"sections"` 75 | } 76 | 77 | func (cfg ReleaseNotesConfig) sectionConfig(sectionType string) *ReleaseNotesSectionConfig { 78 | for _, sectionCfg := range cfg.Sections { 79 | if sectionCfg.SectionType == sectionType { 80 | return §ionCfg 81 | } 82 | } 83 | return nil 84 | } 85 | 86 | // ReleaseNotesSectionConfig preferences for a single section on release notes. 87 | type ReleaseNotesSectionConfig struct { 88 | Name string `yaml:"name"` 89 | SectionType string `yaml:"section-type"` 90 | CommitTypes []string `yaml:"commit-types,flow,omitempty"` 91 | } 92 | 93 | const ( 94 | // ReleaseNotesSectionTypeCommits ReleaseNotesSectionConfig.SectionType value. 95 | ReleaseNotesSectionTypeCommits = "commits" 96 | // ReleaseNotesSectionTypeBreakingChanges ReleaseNotesSectionConfig.SectionType value. 97 | ReleaseNotesSectionTypeBreakingChanges = "breaking-changes" 98 | ) 99 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: usage build lint lint-autofix test test-coverage test-show-coverage run tidy release release-all 2 | 3 | OK_COLOR=\033[32;01m 4 | NO_COLOR=\033[0m 5 | ERROR_COLOR=\033[31;01m 6 | WARN_COLOR=\033[33;01m 7 | 8 | PKGS = $(shell go list ./...) 9 | BIN = git-sv 10 | 11 | ECHOFLAGS ?= 12 | 13 | BUILD_TIME = $(shell date +"%Y%m%d%H%M") 14 | VERSION ?= dev-$(BUILD_TIME) 15 | 16 | BUILDOS ?= linux 17 | BUILDARCH ?= amd64 18 | BUILDENVS ?= CGO_ENABLED=0 GOOS=$(BUILDOS) GOARCH=$(BUILDARCH) 19 | BUILDFLAGS ?= -a -installsuffix cgo --ldflags '-X main.Version=$(VERSION) -extldflags "-lm -lstdc++ -static"' 20 | 21 | COMPRESS_TYPE ?= targz 22 | 23 | usage: Makefile 24 | @echo $(ECHOFLAGS) "to use make call:" 25 | @echo $(ECHOFLAGS) " make " 26 | @echo $(ECHOFLAGS) "" 27 | @echo $(ECHOFLAGS) "list of available actions:" 28 | @sed -n 's/^##//p' $< | column -t -s ':' | sed -e 's/^/ /' 29 | 30 | ## build: build git-sv 31 | build: test 32 | @echo $(ECHOFLAGS) "$(OK_COLOR)==> Building binary ($(BUILDOS)/$(BUILDARCH)/$(BIN))...$(NO_COLOR)" 33 | @$(BUILDENVS) go build -v $(BUILDFLAGS) -o bin/$(BUILDOS)_$(BUILDARCH)/$(BIN) ./cmd/git-sv 34 | 35 | ## lint: run golangci-lint without autofix 36 | lint: 37 | @echo $(ECHOFLAGS) "$(OK_COLOR)==> Running golangci-lint...$(NO_COLOR)" 38 | @golangci-lint run ./... --config .golangci.yml 39 | 40 | ## lint-autofix: run golangci-lint with autofix enabled 41 | lint-autofix: 42 | @echo $(ECHOFLAGS) "$(OK_COLOR)==> Running golangci-lint...$(NO_COLOR)" 43 | @golangci-lint run ./... --config .golangci.yml --fix 44 | 45 | ## test: run unit tests 46 | test: 47 | @echo $(ECHOFLAGS) "$(OK_COLOR)==> Running tests...$(NO_COLOR)" 48 | @go test $(PKGS) 49 | 50 | ## test-coverage: run tests with coverage 51 | test-coverage: 52 | @echo $(ECHOFLAGS) "$(OK_COLOR)==> Running tests with coverage...$(NO_COLOR)" 53 | @go test -race -covermode=atomic -coverprofile coverage.out ./... 54 | 55 | ## test-show-coverage: show coverage 56 | test-show-coverage: test-coverage 57 | @echo $(ECHOFLAGS) "$(OK_COLOR)==> Show test coverage...$(NO_COLOR)" 58 | @go tool cover -html coverage.out 59 | 60 | ## run: run git-sv 61 | run: 62 | @echo $(ECHOFLAGS) "$(OK_COLOR)==> Running bin/$(BUILDOS)_$(BUILDARCH)/$(BIN)...$(NO_COLOR)" 63 | @./bin/$(BUILDOS)_$(BUILDARCH)/$(BIN) $(args) 64 | 65 | ## tidy: execute go mod tidy 66 | tidy: 67 | @echo $(ECHOFLAGS) "$(OK_COLOR)==> runing tidy" 68 | @go mod tidy 69 | 70 | ## release: prepare binary for release 71 | release: 72 | make build 73 | ifeq ($(COMPRESS_TYPE), zip) 74 | @zip -j bin/git-sv_$(VERSION)_$(BUILDOS)_$(BUILDARCH).zip bin/$(BUILDOS)_$(BUILDARCH)/$(BIN) 75 | else 76 | @tar -czf bin/git-sv_$(VERSION)_$(BUILDOS)_$(BUILDARCH).tar.gz -C bin/$(BUILDOS)_$(BUILDARCH)/ $(BIN) 77 | endif 78 | 79 | ## release-all: prepare linux, darwin and windows binary for release (requires sv4git) 80 | release-all: 81 | @rm -rf bin 82 | 83 | VERSION=$(shell git sv nv) BUILDOS=linux BUILDARCH=amd64 make release 84 | VERSION=$(shell git sv nv) BUILDOS=darwin BUILDARCH=amd64 make release 85 | VERSION=$(shell git sv nv) COMPRESS_TYPE=zip BUILDOS=windows BUILDARCH=amd64 make release 86 | 87 | VERSION=$(shell git sv nv) BUILDOS=linux BUILDARCH=arm64 make release 88 | VERSION=$(shell git sv nv) BUILDOS=darwin BUILDARCH=arm64 make release 89 | VERSION=$(shell git sv nv) COMPRESS_TYPE=zip BUILDOS=windows BUILDARCH=arm64 make release 90 | -------------------------------------------------------------------------------- /sv/semver.go: -------------------------------------------------------------------------------- 1 | package sv 2 | 3 | import "github.com/Masterminds/semver/v3" 4 | 5 | type versionType int 6 | 7 | const ( 8 | none versionType = iota 9 | patch 10 | minor 11 | major 12 | ) 13 | 14 | // IsValidVersion return true when a version is valid. 15 | func IsValidVersion(value string) bool { 16 | _, err := semver.NewVersion(value) 17 | return err == nil 18 | } 19 | 20 | // ToVersion parse string to semver.Version. 21 | func ToVersion(value string) (*semver.Version, error) { 22 | version := value 23 | if version == "" { 24 | version = "0.0.0" 25 | } 26 | return semver.NewVersion(version) 27 | } 28 | 29 | // SemVerCommitsProcessor interface. 30 | type SemVerCommitsProcessor interface { 31 | NextVersion(version *semver.Version, commits []GitCommitLog) (*semver.Version, bool) 32 | } 33 | 34 | // SemVerCommitsProcessorImpl process versions using commit log. 35 | type SemVerCommitsProcessorImpl struct { 36 | MajorVersionTypes map[string]struct{} 37 | MinorVersionTypes map[string]struct{} 38 | PatchVersionTypes map[string]struct{} 39 | KnownTypes []string 40 | IncludeUnknownTypeAsPatch bool 41 | } 42 | 43 | // NewSemVerCommitsProcessor SemanticVersionCommitsProcessorImpl constructor. 44 | func NewSemVerCommitsProcessor(vcfg VersioningConfig, mcfg CommitMessageConfig) *SemVerCommitsProcessorImpl { 45 | return &SemVerCommitsProcessorImpl{ 46 | IncludeUnknownTypeAsPatch: !vcfg.IgnoreUnknown, 47 | MajorVersionTypes: toMap(vcfg.UpdateMajor), 48 | MinorVersionTypes: toMap(vcfg.UpdateMinor), 49 | PatchVersionTypes: toMap(vcfg.UpdatePatch), 50 | KnownTypes: mcfg.Types, 51 | } 52 | } 53 | 54 | // NextVersion calculates next version based on commit log. 55 | func (p SemVerCommitsProcessorImpl) NextVersion(version *semver.Version, commits []GitCommitLog) (*semver.Version, bool) { 56 | versionToUpdate := none 57 | for _, commit := range commits { 58 | if v := p.versionTypeToUpdate(commit); v > versionToUpdate { 59 | versionToUpdate = v 60 | } 61 | } 62 | 63 | updated := versionToUpdate != none 64 | if version == nil { 65 | return nil, updated 66 | } 67 | newVersion := updateVersion(*version, versionToUpdate) 68 | return &newVersion, updated 69 | } 70 | 71 | func updateVersion(version semver.Version, versionToUpdate versionType) semver.Version { 72 | switch versionToUpdate { 73 | case major: 74 | return version.IncMajor() 75 | case minor: 76 | return version.IncMinor() 77 | case patch: 78 | return version.IncPatch() 79 | default: 80 | return version 81 | } 82 | } 83 | 84 | func (p SemVerCommitsProcessorImpl) versionTypeToUpdate(commit GitCommitLog) versionType { 85 | if commit.Message.IsBreakingChange { 86 | return major 87 | } 88 | if _, exists := p.MajorVersionTypes[commit.Message.Type]; exists { 89 | return major 90 | } 91 | if _, exists := p.MinorVersionTypes[commit.Message.Type]; exists { 92 | return minor 93 | } 94 | if _, exists := p.PatchVersionTypes[commit.Message.Type]; exists { 95 | return patch 96 | } 97 | if !contains(commit.Message.Type, p.KnownTypes) && p.IncludeUnknownTypeAsPatch { 98 | return patch 99 | } 100 | return none 101 | } 102 | 103 | func toMap(values []string) map[string]struct{} { 104 | result := make(map[string]struct{}) 105 | for _, v := range values { 106 | result[v] = struct{}{} 107 | } 108 | return result 109 | } 110 | -------------------------------------------------------------------------------- /cmd/git-sv/config_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/bvieira/sv4git/v2/sv" 8 | ) 9 | 10 | func Test_merge(t *testing.T) { 11 | boolFalse := false 12 | boolTrue := true 13 | emptyStr := "" 14 | nonEmptyStr := "something" 15 | 16 | tests := []struct { 17 | name string 18 | dst Config 19 | src Config 20 | want Config 21 | wantErr bool 22 | }{ 23 | {"overwrite string", Config{Version: "a"}, Config{Version: "b"}, Config{Version: "b"}, false}, 24 | {"default string", Config{Version: "a"}, Config{Version: ""}, Config{Version: "a"}, false}, 25 | 26 | {"overwrite list", Config{Branches: sv.BranchesConfig{Skip: []string{"a", "b"}}}, Config{Branches: sv.BranchesConfig{Skip: []string{"c", "d"}}}, Config{Branches: sv.BranchesConfig{Skip: []string{"c", "d"}}}, false}, 27 | {"overwrite list with empty", Config{Branches: sv.BranchesConfig{Skip: []string{"a", "b"}}}, Config{Branches: sv.BranchesConfig{Skip: make([]string, 0)}}, Config{Branches: sv.BranchesConfig{Skip: make([]string, 0)}}, false}, 28 | {"default list", Config{Branches: sv.BranchesConfig{Skip: []string{"a", "b"}}}, Config{Branches: sv.BranchesConfig{Skip: nil}}, Config{Branches: sv.BranchesConfig{Skip: []string{"a", "b"}}}, false}, 29 | 30 | {"overwrite pointer bool false", Config{Branches: sv.BranchesConfig{SkipDetached: &boolFalse}}, Config{Branches: sv.BranchesConfig{SkipDetached: &boolTrue}}, Config{Branches: sv.BranchesConfig{SkipDetached: &boolTrue}}, false}, 31 | {"overwrite pointer bool true", Config{Branches: sv.BranchesConfig{SkipDetached: &boolTrue}}, Config{Branches: sv.BranchesConfig{SkipDetached: &boolFalse}}, Config{Branches: sv.BranchesConfig{SkipDetached: &boolFalse}}, false}, 32 | {"default pointer bool", Config{Branches: sv.BranchesConfig{SkipDetached: &boolTrue}}, Config{Branches: sv.BranchesConfig{SkipDetached: nil}}, Config{Branches: sv.BranchesConfig{SkipDetached: &boolTrue}}, false}, 33 | 34 | {"merge maps", Config{CommitMessage: sv.CommitMessageConfig{Footer: map[string]sv.CommitMessageFooterConfig{"issue": {Key: "jira"}}}}, Config{CommitMessage: sv.CommitMessageConfig{Footer: map[string]sv.CommitMessageFooterConfig{"issue2": {Key: "jira2"}}}}, Config{CommitMessage: sv.CommitMessageConfig{Footer: map[string]sv.CommitMessageFooterConfig{"issue": {Key: "jira"}, "issue2": {Key: "jira2"}}}}, false}, 35 | {"default maps", Config{CommitMessage: sv.CommitMessageConfig{Footer: map[string]sv.CommitMessageFooterConfig{"issue": {Key: "jira"}}}}, Config{CommitMessage: sv.CommitMessageConfig{Footer: nil}}, Config{CommitMessage: sv.CommitMessageConfig{Footer: map[string]sv.CommitMessageFooterConfig{"issue": {Key: "jira"}}}}, false}, 36 | {"merge empty maps", Config{CommitMessage: sv.CommitMessageConfig{Footer: map[string]sv.CommitMessageFooterConfig{"issue": {Key: "jira"}}}}, Config{CommitMessage: sv.CommitMessageConfig{Footer: map[string]sv.CommitMessageFooterConfig{}}}, Config{CommitMessage: sv.CommitMessageConfig{Footer: map[string]sv.CommitMessageFooterConfig{"issue": {Key: "jira"}}}}, false}, 37 | 38 | {"overwrite release notes header", Config{ReleaseNotes: sv.ReleaseNotesConfig{Headers: map[string]string{"a": "aa"}}}, Config{ReleaseNotes: sv.ReleaseNotesConfig{Headers: map[string]string{"b": "bb"}}}, Config{ReleaseNotes: sv.ReleaseNotesConfig{Headers: map[string]string{"b": "bb"}}}, false}, 39 | 40 | {"overwrite tag config", Config{Version: "a", Tag: sv.TagConfig{Pattern: &nonEmptyStr, Filter: &nonEmptyStr}}, Config{Version: "", Tag: sv.TagConfig{Pattern: &emptyStr, Filter: &emptyStr}}, Config{Version: "a", Tag: sv.TagConfig{Pattern: &emptyStr, Filter: &emptyStr}}, false}, 41 | } 42 | for _, tt := range tests { 43 | t.Run(tt.name, func(t *testing.T) { 44 | if err := merge(&tt.dst, tt.src); (err != nil) != tt.wantErr { 45 | t.Errorf("merge() error = %v, wantErr %v", err, tt.wantErr) 46 | } 47 | if !reflect.DeepEqual(tt.dst, tt.want) { 48 | t.Errorf("merge() = %v, want %v", tt.dst, tt.want) 49 | } 50 | }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g= 2 | github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= 3 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 4 | github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= 5 | github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= 6 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 7 | github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= 8 | github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= 9 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 10 | github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= 11 | github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= 12 | github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= 13 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 14 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 15 | github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= 16 | github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= 17 | github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= 18 | github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= 19 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 20 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 21 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 22 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 23 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 24 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 25 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 26 | github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= 27 | github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= 28 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 29 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 30 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 31 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 32 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 33 | github.com/urfave/cli/v2 v2.24.1 h1:/QYYr7g0EhwXEML8jO+8OYt5trPnLHS0p3mrgExJ5NU= 34 | github.com/urfave/cli/v2 v2.24.1/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= 35 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= 36 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= 37 | golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 38 | golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 39 | golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= 40 | golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 41 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 42 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 43 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 44 | gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 45 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 46 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 47 | -------------------------------------------------------------------------------- /cmd/git-sv/prompt.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "regexp" 7 | 8 | "github.com/manifoldco/promptui" 9 | ) 10 | 11 | type commitType struct { 12 | Type string 13 | Description string 14 | Example string 15 | } 16 | 17 | func promptType(types []string) (commitType, error) { 18 | defaultTypes := map[string]commitType{ 19 | "build": {Type: "build", Description: "changes that affect the build system or external dependencies", Example: "gradle, maven, go mod, npm"}, 20 | "ci": {Type: "ci", Description: "changes to our CI configuration files and scripts", Example: "Circle, BrowserStack, SauceLabs"}, 21 | "chore": {Type: "chore", Description: "update something without impacting the user", Example: "gitignore"}, 22 | "docs": {Type: "docs", Description: "documentation only changes"}, 23 | "feat": {Type: "feat", Description: "a new feature"}, 24 | "fix": {Type: "fix", Description: "a bug fix"}, 25 | "perf": {Type: "perf", Description: "a code change that improves performance"}, 26 | "refactor": {Type: "refactor", Description: "a code change that neither fixes a bug nor adds a feature"}, 27 | "style": {Type: "style", Description: "changes that do not affect the meaning of the code", Example: "white-space, formatting, missing semi-colons, etc"}, 28 | "test": {Type: "test", Description: "adding missing tests or correcting existing tests"}, 29 | "revert": {Type: "revert", Description: "revert a single commit"}, 30 | } 31 | 32 | var items []commitType 33 | for _, t := range types { 34 | if v, exists := defaultTypes[t]; exists { 35 | items = append(items, v) 36 | } else { 37 | items = append(items, commitType{Type: t}) 38 | } 39 | } 40 | 41 | template := &promptui.SelectTemplates{ 42 | Label: "{{ . }}", 43 | Active: "> {{ .Type | white }} - {{ .Description | faint }}", 44 | Inactive: " {{ .Type | white }} - {{ .Description | faint }}", 45 | Selected: `{{ "type:" | faint }} {{ .Type | white }}`, 46 | Details: ` 47 | {{ "Type:" | faint }} {{ .Type }} 48 | {{ "Description:" | faint }} {{ .Description }} 49 | {{ "Example:" | faint }} {{ .Example }}`, 50 | } 51 | 52 | i, err := promptSelect("type", items, template) 53 | if err != nil { 54 | return commitType{}, err 55 | } 56 | return items[i], nil 57 | } 58 | 59 | func promptScope(values []string) (string, error) { 60 | if len(values) > 0 { 61 | selected, err := promptSelect("scope", values, nil) 62 | if err != nil { 63 | return "", err 64 | } 65 | return values[selected], nil 66 | } 67 | return promptText("scope", "^[a-z0-9-]*$", "") 68 | } 69 | 70 | func promptSubject() (string, error) { 71 | return promptText("subject", "^[a-z].+$", "") 72 | } 73 | 74 | func promptBody() (string, error) { 75 | return promptText("body (leave empty to finish)", "^.*$", "") 76 | } 77 | 78 | func promptIssueID(issueLabel, issueRegex, defaultValue string) (string, error) { 79 | return promptText(issueLabel, "^("+issueRegex+")?$", defaultValue) 80 | } 81 | 82 | func promptBreakingChanges() (string, error) { 83 | return promptText("Breaking change description", "[a-z].+", "") 84 | } 85 | 86 | func promptSelect(label string, items interface{}, template *promptui.SelectTemplates) (int, error) { 87 | if items == nil || reflect.TypeOf(items).Kind() != reflect.Slice { 88 | return 0, fmt.Errorf("items %v is not a slice", items) 89 | } 90 | 91 | prompt := promptui.Select{ 92 | Label: label, 93 | Size: reflect.ValueOf(items).Len(), 94 | Items: items, 95 | Templates: template, 96 | } 97 | 98 | index, _, err := prompt.Run() 99 | return index, err 100 | } 101 | 102 | func promptText(label, regex, defaultValue string) (string, error) { 103 | validate := func(input string) error { 104 | regex := regexp.MustCompile(regex) 105 | if !regex.MatchString(input) { 106 | return fmt.Errorf("invalid value, expected: %s", regex) 107 | } 108 | return nil 109 | } 110 | 111 | prompt := promptui.Prompt{ 112 | Label: label, 113 | Default: defaultValue, 114 | Validate: validate, 115 | } 116 | 117 | return prompt.Run() 118 | } 119 | 120 | func promptConfirm(label string) (bool, error) { 121 | r, err := promptText(label+" [y/n]", "^y|n$", "") 122 | if err != nil { 123 | return false, err 124 | } 125 | return r == "y", nil 126 | } 127 | -------------------------------------------------------------------------------- /sv/semver_test.go: -------------------------------------------------------------------------------- 1 | package sv 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/Masterminds/semver/v3" 8 | ) 9 | 10 | func TestSemVerCommitsProcessorImpl_NextVersion(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | ignoreUnknown bool 14 | version *semver.Version 15 | commits []GitCommitLog 16 | want *semver.Version 17 | wantUpdated bool 18 | }{ 19 | {"no update", true, version("0.0.0"), []GitCommitLog{}, version("0.0.0"), false}, 20 | {"no update without version", true, nil, []GitCommitLog{}, nil, false}, 21 | {"no update on unknown type", true, version("0.0.0"), []GitCommitLog{commitlog("a", map[string]string{}, "a")}, version("0.0.0"), false}, 22 | {"no update on unmapped known type", false, version("0.0.0"), []GitCommitLog{commitlog("none", map[string]string{}, "a")}, version("0.0.0"), false}, 23 | {"update patch on unknown type", false, version("0.0.0"), []GitCommitLog{commitlog("a", map[string]string{}, "a")}, version("0.0.1"), true}, 24 | {"patch update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{}, "a")}, version("0.0.1"), true}, 25 | {"patch update without version", false, nil, []GitCommitLog{commitlog("patch", map[string]string{}, "a")}, nil, true}, 26 | {"minor update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{}, "a"), commitlog("minor", map[string]string{}, "a")}, version("0.1.0"), true}, 27 | {"major update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{}, "a"), commitlog("major", map[string]string{}, "a")}, version("1.0.0"), true}, 28 | {"breaking change update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{}, "a"), commitlog("patch", map[string]string{"breaking-change": "break"}, "a")}, version("1.0.0"), true}, 29 | } 30 | for _, tt := range tests { 31 | t.Run(tt.name, func(t *testing.T) { 32 | p := NewSemVerCommitsProcessor(VersioningConfig{UpdateMajor: []string{"major"}, UpdateMinor: []string{"minor"}, UpdatePatch: []string{"patch"}, IgnoreUnknown: tt.ignoreUnknown}, CommitMessageConfig{Types: []string{"major", "minor", "patch", "none"}}) 33 | got, gotUpdated := p.NextVersion(tt.version, tt.commits) 34 | if !reflect.DeepEqual(got, tt.want) { 35 | t.Errorf("SemVerCommitsProcessorImpl.NextVersion() Version = %v, want %v", got, tt.want) 36 | } 37 | if tt.wantUpdated != gotUpdated { 38 | t.Errorf("SemVerCommitsProcessorImpl.NextVersion() Updated = %v, want %v", gotUpdated, tt.wantUpdated) 39 | } 40 | }) 41 | } 42 | } 43 | 44 | func TestToVersion(t *testing.T) { 45 | tests := []struct { 46 | name string 47 | input string 48 | want *semver.Version 49 | wantErr bool 50 | }{ 51 | {"empty version", "", version("0.0.0"), false}, 52 | {"invalid version", "abc", nil, true}, 53 | {"valid version", "1.2.3", version("1.2.3"), false}, 54 | } 55 | for _, tt := range tests { 56 | t.Run(tt.name, func(t *testing.T) { 57 | got, err := ToVersion(tt.input) 58 | if (err != nil) != tt.wantErr { 59 | t.Errorf("ToVersion() error = %v, wantErr %v", err, tt.wantErr) 60 | return 61 | } 62 | if !reflect.DeepEqual(got, tt.want) { 63 | t.Errorf("ToVersion() = %v, want %v", got, tt.want) 64 | } 65 | }) 66 | } 67 | } 68 | 69 | func TestIsValidVersion(t *testing.T) { 70 | tests := []struct { 71 | name string 72 | value string 73 | want bool 74 | }{ 75 | {"simple version", "1.0.0", true}, 76 | {"with v prefix version", "v1.0.0", true}, 77 | {"prerelease version", "1.0.0-alpha", true}, 78 | {"prerelease version", "1.0.0-alpha.1", true}, 79 | {"prerelease version", "1.0.0-0.3.7", true}, 80 | {"prerelease version", "1.0.0-x.7.z.92", true}, 81 | {"prerelease version", "1.0.0-x-y-z.-", true}, 82 | {"metadata version", "1.0.0-alpha+001", true}, 83 | {"metadata version", "1.0.0+20130313144700", true}, 84 | {"metadata version", "1.0.0-beta+exp.sha.5114f85", true}, 85 | {"metadata version", "1.0.0+21AF26D3-117B344092BD", true}, 86 | {"incomplete version", "1", true}, 87 | {"invalid version", "invalid", false}, 88 | {"invalid prefix version", "random1.0.0", false}, 89 | } 90 | for _, tt := range tests { 91 | t.Run(tt.name, func(t *testing.T) { 92 | if got := IsValidVersion(tt.value); got != tt.want { 93 | t.Errorf("IsValidVersion(%s) = %v, want %v", tt.value, got, tt.want) 94 | } 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /sv/formatter_test.go: -------------------------------------------------------------------------------- 1 | package sv 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/Masterminds/semver/v3" 10 | ) 11 | 12 | var templatesFS = os.DirFS("../cmd/git-sv/resources/templates") 13 | 14 | var dateChangelog = `## v1.0.0 (2020-05-01) 15 | ` 16 | 17 | var nonVersioningChangelog = `## abc (2020-05-01) 18 | ` 19 | 20 | var emptyDateChangelog = `## v1.0.0 21 | ` 22 | 23 | var emptyVersionChangelog = `## 2020-05-01 24 | ` 25 | 26 | var fullChangeLog = `## v1.0.0 (2020-05-01) 27 | 28 | ### Features 29 | 30 | - subject text () 31 | 32 | ### Bug Fixes 33 | 34 | - subject text () 35 | 36 | ### Build 37 | 38 | - subject text () 39 | 40 | ### Breaking Changes 41 | 42 | - break change message 43 | ` 44 | 45 | func TestOutputFormatterImpl_FormatReleaseNote(t *testing.T) { 46 | date, _ := time.Parse("2006-01-02", "2020-05-01") 47 | 48 | tests := []struct { 49 | name string 50 | input ReleaseNote 51 | want string 52 | wantErr bool 53 | }{ 54 | {"with date", emptyReleaseNote("1.0.0", date.Truncate(time.Minute)), dateChangelog, false}, 55 | {"without date", emptyReleaseNote("1.0.0", time.Time{}.Truncate(time.Minute)), emptyDateChangelog, false}, 56 | {"without version", emptyReleaseNote("", date.Truncate(time.Minute)), emptyVersionChangelog, false}, 57 | {"non versioning tag", emptyReleaseNote("abc", date.Truncate(time.Minute)), nonVersioningChangelog, false}, 58 | {"full changelog", fullReleaseNote("1.0.0", date.Truncate(time.Minute)), fullChangeLog, false}, 59 | } 60 | for _, tt := range tests { 61 | t.Run(tt.name, func(t *testing.T) { 62 | got, err := NewOutputFormatter(templatesFS).FormatReleaseNote(tt.input) 63 | if got != tt.want { 64 | t.Errorf("OutputFormatterImpl.FormatReleaseNote() = %v, want %v", got, tt.want) 65 | } 66 | 67 | if (err != nil) != tt.wantErr { 68 | t.Errorf("OutputFormatterImpl.FormatReleaseNote() error = %v, wantErr %v", err, tt.wantErr) 69 | } 70 | }) 71 | } 72 | } 73 | 74 | func emptyReleaseNote(tag string, date time.Time) ReleaseNote { 75 | v, _ := semver.NewVersion(tag) 76 | return ReleaseNote{ 77 | Version: v, 78 | Tag: tag, 79 | Date: date, 80 | } 81 | } 82 | 83 | func fullReleaseNote(tag string, date time.Time) ReleaseNote { 84 | v, _ := semver.NewVersion(tag) 85 | sections := []ReleaseNoteSection{ 86 | newReleaseNoteCommitsSection("Features", []string{"feat"}, []GitCommitLog{commitlog("feat", map[string]string{}, "a")}), 87 | newReleaseNoteCommitsSection("Bug Fixes", []string{"fix"}, []GitCommitLog{commitlog("fix", map[string]string{}, "a")}), 88 | newReleaseNoteCommitsSection("Build", []string{"build"}, []GitCommitLog{commitlog("build", map[string]string{}, "a")}), 89 | ReleaseNoteBreakingChangeSection{"Breaking Changes", []string{"break change message"}}, 90 | } 91 | return releaseNote(v, tag, date, sections, map[string]struct{}{"a": {}}) 92 | } 93 | 94 | func Test_checkTemplatesExecution(t *testing.T) { 95 | tpls := NewOutputFormatter(templatesFS).templates 96 | tests := []struct { 97 | template string 98 | variables interface{} 99 | }{ 100 | {"changelog-md.tpl", changelogVariables("v1.0.0", "v1.0.1")}, 101 | {"releasenotes-md.tpl", releaseNotesVariables("v1.0.0")}, 102 | } 103 | for _, tt := range tests { 104 | t.Run(tt.template, func(t *testing.T) { 105 | var b bytes.Buffer 106 | err := tpls.ExecuteTemplate(&b, tt.template, tt.variables) 107 | if err != nil { 108 | t.Errorf("invalid template err = %v", err) 109 | return 110 | } 111 | if len(b.Bytes()) <= 0 { 112 | t.Errorf("empty template") 113 | } 114 | }) 115 | } 116 | } 117 | 118 | func releaseNotesVariables(release string) releaseNoteTemplateVariables { 119 | return releaseNoteTemplateVariables{ 120 | Release: release, 121 | Date: time.Date(2006, 1, 02, 0, 0, 0, 0, time.UTC), 122 | Sections: []ReleaseNoteSection{ 123 | newReleaseNoteCommitsSection("Features", []string{"feat"}, []GitCommitLog{commitlog("feat", map[string]string{}, "a")}), 124 | newReleaseNoteCommitsSection("Bug Fixes", []string{"fix"}, []GitCommitLog{commitlog("fix", map[string]string{}, "a")}), 125 | newReleaseNoteCommitsSection("Build", []string{"build"}, []GitCommitLog{commitlog("build", map[string]string{}, "a")}), 126 | ReleaseNoteBreakingChangeSection{"Breaking Changes", []string{"break change message"}}, 127 | }, 128 | } 129 | } 130 | 131 | func changelogVariables(releases ...string) []releaseNoteTemplateVariables { 132 | var variables []releaseNoteTemplateVariables 133 | for _, r := range releases { 134 | variables = append(variables, releaseNotesVariables(r)) 135 | } 136 | return variables 137 | 138 | } 139 | -------------------------------------------------------------------------------- /sv/releasenotes.go: -------------------------------------------------------------------------------- 1 | package sv 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/Masterminds/semver/v3" 7 | ) 8 | 9 | // ReleaseNoteProcessor release note processor interface. 10 | type ReleaseNoteProcessor interface { 11 | Create(version *semver.Version, tag string, date time.Time, commits []GitCommitLog) ReleaseNote 12 | } 13 | 14 | // ReleaseNoteProcessorImpl release note based on commit log. 15 | type ReleaseNoteProcessorImpl struct { 16 | cfg ReleaseNotesConfig 17 | } 18 | 19 | // NewReleaseNoteProcessor ReleaseNoteProcessor constructor. 20 | func NewReleaseNoteProcessor(cfg ReleaseNotesConfig) *ReleaseNoteProcessorImpl { 21 | return &ReleaseNoteProcessorImpl{cfg: cfg} 22 | } 23 | 24 | // Create create a release note based on commits. 25 | func (p ReleaseNoteProcessorImpl) Create(version *semver.Version, tag string, date time.Time, commits []GitCommitLog) ReleaseNote { 26 | mapping := commitSectionMapping(p.cfg.Sections) 27 | 28 | sections := make(map[string]ReleaseNoteCommitsSection) 29 | authors := make(map[string]struct{}) 30 | var breakingChanges []string 31 | for _, commit := range commits { 32 | authors[commit.AuthorName] = struct{}{} 33 | if sectionCfg, exists := mapping[commit.Message.Type]; exists { 34 | section, sexists := sections[sectionCfg.Name] 35 | if !sexists { 36 | section = ReleaseNoteCommitsSection{Name: sectionCfg.Name, Types: sectionCfg.CommitTypes} 37 | } 38 | section.Items = append(section.Items, commit) 39 | sections[sectionCfg.Name] = section 40 | } 41 | if commit.Message.BreakingMessage() != "" { 42 | // TODO: if no message found, should use description instead? 43 | breakingChanges = append(breakingChanges, commit.Message.BreakingMessage()) 44 | } 45 | } 46 | 47 | var breakingChangeSection ReleaseNoteBreakingChangeSection 48 | if bcCfg := p.cfg.sectionConfig(ReleaseNotesSectionTypeBreakingChanges); bcCfg != nil && len(breakingChanges) > 0 { 49 | breakingChangeSection = ReleaseNoteBreakingChangeSection{Name: bcCfg.Name, Messages: breakingChanges} 50 | } 51 | return ReleaseNote{Version: version, Tag: tag, Date: date.Truncate(time.Minute), Sections: p.toReleaseNoteSections(sections, breakingChangeSection), AuthorsNames: authors} 52 | } 53 | 54 | func (p ReleaseNoteProcessorImpl) toReleaseNoteSections(commitSections map[string]ReleaseNoteCommitsSection, breakingChange ReleaseNoteBreakingChangeSection) []ReleaseNoteSection { 55 | hasBreaking := 0 56 | if breakingChange.Name != "" { 57 | hasBreaking = 1 58 | } 59 | 60 | sections := make([]ReleaseNoteSection, len(commitSections)+hasBreaking) 61 | i := 0 62 | for _, cfg := range p.cfg.Sections { 63 | if cfg.SectionType == ReleaseNotesSectionTypeBreakingChanges && hasBreaking > 0 { 64 | sections[i] = breakingChange 65 | i++ 66 | } 67 | if s, exists := commitSections[cfg.Name]; cfg.SectionType == ReleaseNotesSectionTypeCommits && exists { 68 | sections[i] = s 69 | i++ 70 | } 71 | } 72 | 73 | return sections 74 | } 75 | 76 | func commitSectionMapping(sections []ReleaseNotesSectionConfig) map[string]ReleaseNotesSectionConfig { 77 | mapping := make(map[string]ReleaseNotesSectionConfig) 78 | for _, section := range sections { 79 | if section.SectionType == ReleaseNotesSectionTypeCommits { 80 | for _, commitType := range section.CommitTypes { 81 | mapping[commitType] = section 82 | } 83 | } 84 | } 85 | return mapping 86 | } 87 | 88 | // ReleaseNote release note. 89 | type ReleaseNote struct { 90 | Version *semver.Version 91 | Tag string 92 | Date time.Time 93 | Sections []ReleaseNoteSection 94 | AuthorsNames map[string]struct{} 95 | } 96 | 97 | // ReleaseNoteSection section in release notes. 98 | type ReleaseNoteSection interface { 99 | SectionType() string 100 | SectionName() string 101 | } 102 | 103 | // ReleaseNoteBreakingChangeSection breaking change section. 104 | type ReleaseNoteBreakingChangeSection struct { 105 | Name string 106 | Messages []string 107 | } 108 | 109 | // SectionType section type. 110 | func (ReleaseNoteBreakingChangeSection) SectionType() string { 111 | return ReleaseNotesSectionTypeBreakingChanges 112 | } 113 | 114 | // SectionName section name. 115 | func (s ReleaseNoteBreakingChangeSection) SectionName() string { 116 | return s.Name 117 | } 118 | 119 | // ReleaseNoteCommitsSection release note section. 120 | type ReleaseNoteCommitsSection struct { 121 | Name string 122 | Types []string 123 | Items []GitCommitLog 124 | } 125 | 126 | // SectionType section type. 127 | func (ReleaseNoteCommitsSection) SectionType() string { 128 | return ReleaseNotesSectionTypeCommits 129 | } 130 | 131 | // SectionName section name. 132 | func (s ReleaseNoteCommitsSection) SectionName() string { 133 | return s.Name 134 | } 135 | 136 | // HasMultipleTypes return true if has more than one commit type. 137 | func (s ReleaseNoteCommitsSection) HasMultipleTypes() bool { 138 | return len(s.Types) > 1 139 | } 140 | -------------------------------------------------------------------------------- /cmd/git-sv/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "os/exec" 8 | "reflect" 9 | "strings" 10 | 11 | "github.com/bvieira/sv4git/v2/sv" 12 | "github.com/imdario/mergo" 13 | "github.com/kelseyhightower/envconfig" 14 | "gopkg.in/yaml.v3" 15 | ) 16 | 17 | // EnvConfig env vars for cli configuration. 18 | type EnvConfig struct { 19 | Home string `envconfig:"SV4GIT_HOME" default:""` 20 | } 21 | 22 | func loadEnvConfig() EnvConfig { 23 | var c EnvConfig 24 | err := envconfig.Process("", &c) 25 | if err != nil { 26 | log.Fatal("failed to load env config, error: ", err.Error()) 27 | } 28 | return c 29 | } 30 | 31 | // Config cli yaml config. 32 | type Config struct { 33 | Version string `yaml:"version"` 34 | Versioning sv.VersioningConfig `yaml:"versioning"` 35 | Tag sv.TagConfig `yaml:"tag"` 36 | ReleaseNotes sv.ReleaseNotesConfig `yaml:"release-notes"` 37 | Branches sv.BranchesConfig `yaml:"branches"` 38 | CommitMessage sv.CommitMessageConfig `yaml:"commit-message"` 39 | } 40 | 41 | func getRepoPath() (string, error) { 42 | cmd := exec.Command("git", "rev-parse", "--show-toplevel") 43 | out, err := cmd.CombinedOutput() 44 | if err != nil { 45 | return "", combinedOutputErr(err, out) 46 | } 47 | return strings.TrimSpace(string(out)), nil 48 | } 49 | 50 | func combinedOutputErr(err error, out []byte) error { 51 | msg := strings.Split(string(out), "\n") 52 | return fmt.Errorf("%v - %s", err, msg[0]) 53 | } 54 | 55 | func readConfig(filepath string) (Config, error) { 56 | content, rerr := os.ReadFile(filepath) 57 | if rerr != nil { 58 | return Config{}, rerr 59 | } 60 | 61 | var cfg Config 62 | cerr := yaml.Unmarshal(content, &cfg) 63 | if cerr != nil { 64 | return Config{}, fmt.Errorf("could not parse config from path: %s, error: %v", filepath, cerr) 65 | } 66 | 67 | return cfg, nil 68 | } 69 | 70 | func defaultConfig() Config { 71 | skipDetached := false 72 | pattern := "%d.%d.%d" 73 | filter := "" 74 | return Config{ 75 | Version: "1.1", 76 | Versioning: sv.VersioningConfig{ 77 | UpdateMajor: []string{}, 78 | UpdateMinor: []string{"feat"}, 79 | UpdatePatch: []string{"build", "ci", "chore", "docs", "fix", "perf", "refactor", "style", "test"}, 80 | IgnoreUnknown: false, 81 | }, 82 | Tag: sv.TagConfig{ 83 | Pattern: &pattern, 84 | Filter: &filter, 85 | }, 86 | ReleaseNotes: sv.ReleaseNotesConfig{ 87 | Sections: []sv.ReleaseNotesSectionConfig{ 88 | {Name: "Features", SectionType: sv.ReleaseNotesSectionTypeCommits, CommitTypes: []string{"feat"}}, 89 | {Name: "Bug Fixes", SectionType: sv.ReleaseNotesSectionTypeCommits, CommitTypes: []string{"fix"}}, 90 | {Name: "Breaking Changes", SectionType: sv.ReleaseNotesSectionTypeBreakingChanges}, 91 | }, 92 | }, 93 | Branches: sv.BranchesConfig{ 94 | Prefix: "([a-z]+\\/)?", 95 | Suffix: "(-.*)?", 96 | DisableIssue: false, 97 | Skip: []string{"master", "main", "developer"}, 98 | SkipDetached: &skipDetached, 99 | }, 100 | CommitMessage: sv.CommitMessageConfig{ 101 | Types: []string{"build", "ci", "chore", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test"}, 102 | Scope: sv.CommitMessageScopeConfig{}, 103 | Footer: map[string]sv.CommitMessageFooterConfig{ 104 | "issue": {Key: "jira", KeySynonyms: []string{"Jira", "JIRA"}}, 105 | }, 106 | Issue: sv.CommitMessageIssueConfig{Regex: "[A-Z]+-[0-9]+"}, 107 | HeaderSelector: "", 108 | }, 109 | } 110 | } 111 | 112 | func merge(dst *Config, src Config) error { 113 | err := mergo.Merge(dst, src, mergo.WithOverride, mergo.WithTransformers(&mergeTransformer{})) 114 | if err == nil { 115 | if len(src.ReleaseNotes.Headers) > 0 { // mergo is merging maps, ReleaseNotes.Headers should be overwritten 116 | dst.ReleaseNotes.Headers = src.ReleaseNotes.Headers 117 | } 118 | } 119 | return err 120 | } 121 | 122 | type mergeTransformer struct{} 123 | 124 | func (t *mergeTransformer) Transformer(typ reflect.Type) func(dst, src reflect.Value) error { 125 | if typ.Kind() == reflect.Slice { 126 | return func(dst, src reflect.Value) error { 127 | if dst.CanSet() && !src.IsNil() { 128 | dst.Set(src) 129 | } 130 | return nil 131 | } 132 | } 133 | 134 | if typ.Kind() == reflect.Ptr { 135 | return func(dst, src reflect.Value) error { 136 | if dst.CanSet() && !src.IsNil() { 137 | dst.Set(src) 138 | } 139 | return nil 140 | } 141 | } 142 | return nil 143 | } 144 | 145 | func migrateConfig(cfg Config, filename string) Config { 146 | if cfg.ReleaseNotes.Headers == nil { 147 | return cfg 148 | } 149 | warnf("config 'release-notes.headers' on %s is deprecated, please use 'sections' instead!", filename) 150 | 151 | return Config{ 152 | Version: cfg.Version, 153 | Versioning: cfg.Versioning, 154 | Tag: cfg.Tag, 155 | ReleaseNotes: sv.ReleaseNotesConfig{ 156 | Sections: migrateReleaseNotesConfig(cfg.ReleaseNotes.Headers), 157 | }, 158 | Branches: cfg.Branches, 159 | CommitMessage: cfg.CommitMessage, 160 | } 161 | } 162 | 163 | func migrateReleaseNotesConfig(headers map[string]string) []sv.ReleaseNotesSectionConfig { 164 | order := []string{"feat", "fix", "refactor", "perf", "test", "build", "ci", "chore", "docs", "style"} 165 | var sections []sv.ReleaseNotesSectionConfig 166 | for _, key := range order { 167 | if name, exists := headers[key]; exists { 168 | sections = append(sections, sv.ReleaseNotesSectionConfig{Name: name, SectionType: sv.ReleaseNotesSectionTypeCommits, CommitTypes: []string{key}}) 169 | } 170 | } 171 | if name, exists := headers["breaking-change"]; exists { 172 | sections = append(sections, sv.ReleaseNotesSectionConfig{Name: name, SectionType: sv.ReleaseNotesSectionTypeBreakingChanges}) 173 | } 174 | return sections 175 | } 176 | -------------------------------------------------------------------------------- /sv/git.go: -------------------------------------------------------------------------------- 1 | package sv 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "errors" 7 | "fmt" 8 | "os" 9 | "os/exec" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "github.com/Masterminds/semver/v3" 15 | ) 16 | 17 | const ( 18 | logSeparator = "###" 19 | endLine = "~~~" 20 | ) 21 | 22 | // Git commands. 23 | type Git interface { 24 | LastTag() string 25 | Log(lr LogRange) ([]GitCommitLog, error) 26 | Commit(header, body, footer string) error 27 | Tag(version semver.Version) (string, error) 28 | Tags() ([]GitTag, error) 29 | Branch() string 30 | IsDetached() (bool, error) 31 | } 32 | 33 | // GitCommitLog description of a single commit log. 34 | type GitCommitLog struct { 35 | Date string `json:"date,omitempty"` 36 | Timestamp int `json:"timestamp,omitempty"` 37 | AuthorName string `json:"authorName,omitempty"` 38 | Hash string `json:"hash,omitempty"` 39 | Message CommitMessage `json:"message,omitempty"` 40 | } 41 | 42 | // GitTag git tag info. 43 | type GitTag struct { 44 | Name string 45 | Date time.Time 46 | } 47 | 48 | // LogRangeType type of log range. 49 | type LogRangeType string 50 | 51 | // constants for log range type. 52 | const ( 53 | TagRange LogRangeType = "tag" 54 | DateRange LogRangeType = "date" 55 | HashRange LogRangeType = "hash" 56 | ) 57 | 58 | // LogRange git log range. 59 | type LogRange struct { 60 | rangeType LogRangeType 61 | start string 62 | end string 63 | } 64 | 65 | // NewLogRange LogRange constructor. 66 | func NewLogRange(t LogRangeType, start, end string) LogRange { 67 | return LogRange{rangeType: t, start: start, end: end} 68 | } 69 | 70 | // GitImpl git command implementation. 71 | type GitImpl struct { 72 | messageProcessor MessageProcessor 73 | tagCfg TagConfig 74 | } 75 | 76 | // NewGit constructor. 77 | func NewGit(messageProcessor MessageProcessor, cfg TagConfig) *GitImpl { 78 | return &GitImpl{ 79 | messageProcessor: messageProcessor, 80 | tagCfg: cfg, 81 | } 82 | } 83 | 84 | // LastTag get last tag, if no tag found, return empty. 85 | func (g GitImpl) LastTag() string { 86 | cmd := exec.Command("git", "for-each-ref", "refs/tags/"+*g.tagCfg.Filter, "--sort", "-creatordate", "--format", "%(refname:short)", "--count", "1") 87 | out, err := cmd.CombinedOutput() 88 | if err != nil { 89 | return "" 90 | } 91 | return strings.TrimSpace(strings.Trim(string(out), "\n")) 92 | } 93 | 94 | // Log return git log. 95 | func (g GitImpl) Log(lr LogRange) ([]GitCommitLog, error) { 96 | format := "--pretty=format:\"%ad" + logSeparator + "%at" + logSeparator + "%cN" + logSeparator + "%h" + logSeparator + "%s" + logSeparator + "%b" + endLine + "\"" 97 | params := []string{"log", "--date=short", format} 98 | 99 | if lr.start != "" || lr.end != "" { 100 | switch lr.rangeType { 101 | case DateRange: 102 | params = append(params, "--since", lr.start, "--until", addDay(lr.end)) 103 | default: 104 | if lr.start == "" { 105 | params = append(params, lr.end) 106 | } else { 107 | params = append(params, lr.start+".."+str(lr.end, "HEAD")) 108 | } 109 | } 110 | } 111 | 112 | cmd := exec.Command("git", params...) 113 | out, err := cmd.CombinedOutput() 114 | if err != nil { 115 | return nil, combinedOutputErr(err, out) 116 | } 117 | logs, parseErr := parseLogOutput(g.messageProcessor, string(out)) 118 | if parseErr != nil { 119 | return nil, parseErr 120 | } 121 | return logs, nil 122 | } 123 | 124 | // Commit runs git commit. 125 | func (g GitImpl) Commit(header, body, footer string) error { 126 | cmd := exec.Command("git", "commit", "-m", header, "-m", "", "-m", body, "-m", "", "-m", footer) 127 | cmd.Stdout = os.Stdout 128 | cmd.Stderr = os.Stderr 129 | return cmd.Run() 130 | } 131 | 132 | // Tag create a git tag. 133 | func (g GitImpl) Tag(version semver.Version) (string, error) { 134 | tag := fmt.Sprintf(*g.tagCfg.Pattern, version.Major(), version.Minor(), version.Patch()) 135 | tagMsg := fmt.Sprintf("Version %d.%d.%d", version.Major(), version.Minor(), version.Patch()) 136 | 137 | tagCommand := exec.Command("git", "tag", "-a", tag, "-m", tagMsg) 138 | if out, err := tagCommand.CombinedOutput(); err != nil { 139 | return tag, combinedOutputErr(err, out) 140 | } 141 | 142 | pushCommand := exec.Command("git", "push", "origin", tag) 143 | if out, err := pushCommand.CombinedOutput(); err != nil { 144 | return tag, combinedOutputErr(err, out) 145 | } 146 | return tag, nil 147 | } 148 | 149 | // Tags list repository tags. 150 | func (g GitImpl) Tags() ([]GitTag, error) { 151 | cmd := exec.Command("git", "for-each-ref", "--sort", "creatordate", "--format", "%(creatordate:iso8601)#%(refname:short)", "refs/tags/"+*g.tagCfg.Filter) 152 | out, err := cmd.CombinedOutput() 153 | if err != nil { 154 | return nil, combinedOutputErr(err, out) 155 | } 156 | return parseTagsOutput(string(out)) 157 | } 158 | 159 | // Branch get git branch. 160 | func (GitImpl) Branch() string { 161 | cmd := exec.Command("git", "symbolic-ref", "--short", "HEAD") 162 | out, err := cmd.CombinedOutput() 163 | if err != nil { 164 | return "" 165 | } 166 | return strings.TrimSpace(strings.Trim(string(out), "\n")) 167 | } 168 | 169 | // IsDetached check if is detached. 170 | func (GitImpl) IsDetached() (bool, error) { 171 | cmd := exec.Command("git", "symbolic-ref", "-q", "HEAD") 172 | out, err := cmd.CombinedOutput() 173 | if output := string(out); err != nil { //-q: do not issue an error message if the is not a symbolic ref, but a detached HEAD; instead exit with non-zero status silently. 174 | if output == "" { 175 | return true, nil 176 | } 177 | return false, errors.New(output) 178 | } 179 | return false, nil 180 | } 181 | 182 | func parseTagsOutput(input string) ([]GitTag, error) { 183 | scanner := bufio.NewScanner(strings.NewReader(input)) 184 | var result []GitTag 185 | for scanner.Scan() { 186 | if line := strings.TrimSpace(scanner.Text()); line != "" { 187 | values := strings.Split(line, "#") 188 | date, _ := time.Parse("2006-01-02 15:04:05 -0700", values[0]) // ignore invalid dates 189 | result = append(result, GitTag{Name: values[1], Date: date}) 190 | } 191 | } 192 | return result, nil 193 | } 194 | 195 | func parseLogOutput(messageProcessor MessageProcessor, log string) ([]GitCommitLog, error) { 196 | scanner := bufio.NewScanner(strings.NewReader(log)) 197 | scanner.Split(splitAt([]byte(endLine))) 198 | var logs []GitCommitLog 199 | for scanner.Scan() { 200 | if text := strings.TrimSpace(strings.Trim(scanner.Text(), "\"")); text != "" { 201 | log, err := parseCommitLog(messageProcessor, text) 202 | if err != nil { 203 | return nil, err 204 | } 205 | logs = append(logs, log) 206 | } 207 | } 208 | return logs, nil 209 | } 210 | 211 | func parseCommitLog(messageProcessor MessageProcessor, commit string) (GitCommitLog, error) { 212 | content := strings.Split(strings.Trim(commit, "\""), logSeparator) 213 | 214 | timestamp, _ := strconv.Atoi(content[1]) 215 | message, err := messageProcessor.Parse(content[4], content[5]) 216 | 217 | if err != nil { 218 | return GitCommitLog{}, err 219 | } 220 | 221 | return GitCommitLog{ 222 | Date: content[0], 223 | Timestamp: timestamp, 224 | AuthorName: content[2], 225 | Hash: content[3], 226 | Message: message, 227 | }, nil 228 | } 229 | 230 | func splitAt(b []byte) func(data []byte, atEOF bool) (advance int, token []byte, err error) { 231 | return func(data []byte, atEOF bool) (advance int, token []byte, err error) { 232 | dataLen := len(data) 233 | 234 | if atEOF && dataLen == 0 { 235 | return 0, nil, nil 236 | } 237 | 238 | if i := bytes.Index(data, b); i >= 0 { 239 | return i + len(b), data[0:i], nil 240 | } 241 | 242 | if atEOF { 243 | return dataLen, data, nil 244 | } 245 | 246 | return 0, nil, nil 247 | } 248 | } 249 | 250 | func addDay(value string) string { 251 | if value == "" { 252 | return value 253 | } 254 | 255 | t, err := time.Parse("2006-01-02", value) 256 | if err != nil { // keep original value if is not date format 257 | return value 258 | } 259 | 260 | return t.AddDate(0, 0, 1).Format("2006-01-02") 261 | } 262 | 263 | func str(value, defaultValue string) string { 264 | if value != "" { 265 | return value 266 | } 267 | return defaultValue 268 | } 269 | 270 | func combinedOutputErr(err error, out []byte) error { 271 | msg := strings.Split(string(out), "\n") 272 | return fmt.Errorf("%v - %s", err, msg[0]) 273 | } 274 | -------------------------------------------------------------------------------- /cmd/git-sv/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "io/fs" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/bvieira/sv4git/v2/sv" 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | // Version for git-sv. 15 | var Version = "source" 16 | 17 | const ( 18 | configFilename = "config.yml" 19 | repoConfigFilename = ".sv4git.yml" 20 | configDir = ".sv4git" 21 | ) 22 | 23 | var ( 24 | //go:embed resources/templates/*.tpl 25 | defaultTemplatesFS embed.FS 26 | ) 27 | 28 | func templateFS(filepath string) fs.FS { 29 | if _, err := os.Stat(filepath); err != nil { 30 | defaultTemplatesFS, _ := fs.Sub(defaultTemplatesFS, "resources/templates") 31 | return defaultTemplatesFS 32 | } 33 | return os.DirFS(filepath) 34 | } 35 | 36 | func main() { 37 | log.SetFlags(0) 38 | 39 | repoPath, rerr := getRepoPath() 40 | if rerr != nil { 41 | log.Fatal("failed to discovery repository top level, error: ", rerr) 42 | } 43 | 44 | cfg := loadCfg(repoPath) 45 | messageProcessor := sv.NewMessageProcessor(cfg.CommitMessage, cfg.Branches) 46 | git := sv.NewGit(messageProcessor, cfg.Tag) 47 | semverProcessor := sv.NewSemVerCommitsProcessor(cfg.Versioning, cfg.CommitMessage) 48 | releasenotesProcessor := sv.NewReleaseNoteProcessor(cfg.ReleaseNotes) 49 | outputFormatter := sv.NewOutputFormatter(templateFS(filepath.Join(repoPath, configDir, "templates"))) 50 | 51 | app := cli.NewApp() 52 | app.Name = "sv" 53 | app.Version = Version 54 | app.Usage = "semantic version for git" 55 | app.Commands = []*cli.Command{ 56 | { 57 | Name: "config", 58 | Aliases: []string{"cfg"}, 59 | Usage: "cli configuration", 60 | Subcommands: []*cli.Command{ 61 | { 62 | Name: "default", 63 | Usage: "show default config", 64 | Action: configDefaultHandler(), 65 | }, 66 | { 67 | Name: "show", 68 | Usage: "show current config", 69 | Action: configShowHandler(cfg), 70 | }, 71 | }, 72 | }, 73 | { 74 | Name: "current-version", 75 | Aliases: []string{"cv"}, 76 | Usage: "get last released version from git", 77 | Action: currentVersionHandler(git), 78 | }, 79 | { 80 | Name: "next-version", 81 | Aliases: []string{"nv"}, 82 | Usage: "generate the next version based on git commit messages", 83 | Action: nextVersionHandler(git, semverProcessor), 84 | }, 85 | { 86 | Name: "commit-log", 87 | Aliases: []string{"cl"}, 88 | Usage: "list all commit logs according to range as jsons", 89 | Description: "The range filter is used based on git log filters, check https://git-scm.com/docs/git-log for more info. When flag range is \"tag\" and start is empty, last tag created will be used instead. When flag range is \"date\", if \"end\" is YYYY-MM-DD the range will be inclusive.", 90 | Action: commitLogHandler(git), 91 | Flags: []cli.Flag{ 92 | &cli.StringFlag{Name: "t", Aliases: []string{"tag"}, Usage: "get commit log from a specific tag"}, 93 | &cli.StringFlag{Name: "r", Aliases: []string{"range"}, Usage: "type of range of commits, use: tag, date or hash", Value: string(sv.TagRange)}, 94 | &cli.StringFlag{Name: "s", Aliases: []string{"start"}, Usage: "start range of git log revision range, if date, the value is used on since flag instead"}, 95 | &cli.StringFlag{Name: "e", Aliases: []string{"end"}, Usage: "end range of git log revision range, if date, the value is used on until flag instead"}, 96 | }, 97 | }, 98 | { 99 | Name: "commit-notes", 100 | Aliases: []string{"cn"}, 101 | Usage: "generate a commit notes according to range", 102 | Description: "The range filter is used based on git log filters, check https://git-scm.com/docs/git-log for more info. When flag range is \"tag\" and start is empty, last tag created will be used instead. When flag range is \"date\", if \"end\" is YYYY-MM-DD the range will be inclusive.", 103 | Action: commitNotesHandler(git, releasenotesProcessor, outputFormatter), 104 | Flags: []cli.Flag{ 105 | &cli.StringFlag{Name: "r", Aliases: []string{"range"}, Usage: "type of range of commits, use: tag, date or hash", Required: true}, 106 | &cli.StringFlag{Name: "s", Aliases: []string{"start"}, Usage: "start range of git log revision range, if date, the value is used on since flag instead"}, 107 | &cli.StringFlag{Name: "e", Aliases: []string{"end"}, Usage: "end range of git log revision range, if date, the value is used on until flag instead"}, 108 | }, 109 | }, 110 | { 111 | Name: "release-notes", 112 | Aliases: []string{"rn"}, 113 | Usage: "generate release notes", 114 | Action: releaseNotesHandler(git, semverProcessor, releasenotesProcessor, outputFormatter), 115 | Flags: []cli.Flag{&cli.StringFlag{Name: "t", Aliases: []string{"tag"}, Usage: "get release note from tag"}}, 116 | }, 117 | { 118 | Name: "changelog", 119 | Aliases: []string{"cgl"}, 120 | Usage: "generate changelog", 121 | Action: changelogHandler(git, semverProcessor, releasenotesProcessor, outputFormatter), 122 | Flags: []cli.Flag{ 123 | &cli.IntFlag{Name: "size", Value: 10, Aliases: []string{"n"}, Usage: "get changelog from last 'n' tags"}, 124 | &cli.BoolFlag{Name: "all", Usage: "ignore size parameter, get changelog for every tag"}, 125 | &cli.BoolFlag{Name: "add-next-version", Usage: "add next version on change log (commits since last tag, but only if there is a new version to release)"}, 126 | &cli.BoolFlag{Name: "semantic-version-only", Usage: "only show tags 'SemVer-ish'"}, 127 | }, 128 | }, 129 | { 130 | Name: "tag", 131 | Aliases: []string{"tg"}, 132 | Usage: "generate tag with version based on git commit messages", 133 | Action: tagHandler(git, semverProcessor), 134 | }, 135 | { 136 | Name: "commit", 137 | Aliases: []string{"cmt"}, 138 | Usage: "execute git commit with convetional commit message helper", 139 | Action: commitHandler(cfg, git, messageProcessor), 140 | Flags: []cli.Flag{ 141 | &cli.BoolFlag{Name: "no-scope", Aliases: []string{"nsc"}, Usage: "do not prompt for commit scope"}, 142 | &cli.BoolFlag{Name: "no-body", Aliases: []string{"nbd"}, Usage: "do not prompt for commit body"}, 143 | &cli.BoolFlag{Name: "no-issue", Aliases: []string{"nis"}, Usage: "do not prompt for commit issue, will try to recover from branch if enabled"}, 144 | &cli.BoolFlag{Name: "no-breaking", Aliases: []string{"nbc"}, Usage: "do not prompt for breaking changes"}, 145 | &cli.StringFlag{Name: "type", Aliases: []string{"t"}, Usage: "define commit type"}, 146 | &cli.StringFlag{Name: "scope", Aliases: []string{"s"}, Usage: "define commit scope"}, 147 | &cli.StringFlag{Name: "description", Aliases: []string{"d"}, Usage: "define commit description"}, 148 | &cli.StringFlag{Name: "breaking-change", Aliases: []string{"b"}, Usage: "define commit breaking change message"}, 149 | }, 150 | }, 151 | { 152 | Name: "validate-commit-message", 153 | Aliases: []string{"vcm"}, 154 | Usage: "use as prepare-commit-message hook to validate and enhance commit message", 155 | Action: validateCommitMessageHandler(git, messageProcessor), 156 | Flags: []cli.Flag{ 157 | &cli.StringFlag{Name: "path", Required: true, Usage: "git working directory"}, 158 | &cli.StringFlag{Name: "file", Required: true, Usage: "name of the file that contains the commit log message"}, 159 | &cli.StringFlag{Name: "source", Required: true, Usage: "source of the commit message"}, 160 | }, 161 | }, 162 | } 163 | 164 | if apperr := app.Run(os.Args); apperr != nil { 165 | log.Fatal("ERROR: ", apperr) 166 | } 167 | } 168 | 169 | func loadCfg(repoPath string) Config { 170 | cfg := defaultConfig() 171 | 172 | envCfg := loadEnvConfig() 173 | if envCfg.Home != "" { 174 | homeCfgFilepath := filepath.Join(envCfg.Home, configFilename) 175 | if homeCfg, err := readConfig(homeCfgFilepath); err == nil { 176 | if merr := merge(&cfg, migrateConfig(homeCfg, homeCfgFilepath)); merr != nil { 177 | log.Fatal("failed to merge user config, error: ", merr) 178 | } 179 | } 180 | } 181 | 182 | repoCfgFilepath := filepath.Join(repoPath, repoConfigFilename) 183 | if repoCfg, err := readConfig(repoCfgFilepath); err == nil { 184 | if merr := merge(&cfg, migrateConfig(repoCfg, repoCfgFilepath)); merr != nil { 185 | log.Fatal("failed to merge repo config, error: ", merr) 186 | } 187 | if len(repoCfg.ReleaseNotes.Headers) > 0 { // mergo is merging maps, headers will be overwritten 188 | cfg.ReleaseNotes.Headers = repoCfg.ReleaseNotes.Headers 189 | } 190 | } 191 | 192 | return cfg 193 | } 194 | -------------------------------------------------------------------------------- /sv/message.go: -------------------------------------------------------------------------------- 1 | package sv 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | const ( 11 | breakingChangeFooterKey = "BREAKING CHANGE" 12 | breakingChangeMetadataKey = "breaking-change" 13 | issueMetadataKey = "issue" 14 | messageRegexGroupName = "header" 15 | ) 16 | 17 | // CommitMessage is a message using conventional commits. 18 | type CommitMessage struct { 19 | Type string `json:"type,omitempty"` 20 | Scope string `json:"scope,omitempty"` 21 | Description string `json:"description,omitempty"` 22 | Body string `json:"body,omitempty"` 23 | IsBreakingChange bool `json:"isBreakingChange,omitempty"` 24 | Metadata map[string]string `json:"metadata,omitempty"` 25 | } 26 | 27 | // NewCommitMessage commit message constructor. 28 | func NewCommitMessage(ctype, scope, description, body, issue, breakingChanges string) CommitMessage { 29 | metadata := make(map[string]string) 30 | if issue != "" { 31 | metadata[issueMetadataKey] = issue 32 | } 33 | if breakingChanges != "" { 34 | metadata[breakingChangeMetadataKey] = breakingChanges 35 | } 36 | return CommitMessage{Type: ctype, Scope: scope, Description: description, Body: body, IsBreakingChange: breakingChanges != "", Metadata: metadata} 37 | } 38 | 39 | // Issue return issue from metadata. 40 | func (m CommitMessage) Issue() string { 41 | return m.Metadata[issueMetadataKey] 42 | } 43 | 44 | // BreakingMessage return breaking change message from metadata. 45 | func (m CommitMessage) BreakingMessage() string { 46 | return m.Metadata[breakingChangeMetadataKey] 47 | } 48 | 49 | // MessageProcessor interface. 50 | type MessageProcessor interface { 51 | SkipBranch(branch string, detached bool) bool 52 | Validate(message string) error 53 | ValidateType(ctype string) error 54 | ValidateScope(scope string) error 55 | ValidateDescription(description string) error 56 | Enhance(branch string, message string) (string, error) 57 | IssueID(branch string) (string, error) 58 | Format(msg CommitMessage) (string, string, string) 59 | Parse(subject, body string) (CommitMessage, error) 60 | } 61 | 62 | // NewMessageProcessor MessageProcessorImpl constructor. 63 | func NewMessageProcessor(mcfg CommitMessageConfig, bcfg BranchesConfig) *MessageProcessorImpl { 64 | return &MessageProcessorImpl{ 65 | messageCfg: mcfg, 66 | branchesCfg: bcfg, 67 | } 68 | } 69 | 70 | // MessageProcessorImpl process validate message hook. 71 | type MessageProcessorImpl struct { 72 | messageCfg CommitMessageConfig 73 | branchesCfg BranchesConfig 74 | } 75 | 76 | // SkipBranch check if branch should be ignored. 77 | func (p MessageProcessorImpl) SkipBranch(branch string, detached bool) bool { 78 | return contains(branch, p.branchesCfg.Skip) || (p.branchesCfg.SkipDetached != nil && *p.branchesCfg.SkipDetached && detached) 79 | } 80 | 81 | // Validate commit message. 82 | func (p MessageProcessorImpl) Validate(message string) error { 83 | subject, body := splitCommitMessageContent(message) 84 | msg, parseErr := p.Parse(subject, body) 85 | 86 | if parseErr != nil { 87 | return parseErr 88 | } 89 | 90 | if !regexp.MustCompile(`^[a-z+]+(\(.+\))?!?: .+$`).MatchString(subject) { 91 | return fmt.Errorf("subject [%s] should be valid according with conventional commits", subject) 92 | } 93 | 94 | if err := p.ValidateType(msg.Type); err != nil { 95 | return err 96 | } 97 | 98 | if err := p.ValidateScope(msg.Scope); err != nil { 99 | return err 100 | } 101 | 102 | if err := p.ValidateDescription(msg.Description); err != nil { 103 | return err 104 | } 105 | 106 | return nil 107 | } 108 | 109 | // ValidateType check if commit type is valid. 110 | func (p MessageProcessorImpl) ValidateType(ctype string) error { 111 | if ctype == "" || !contains(ctype, p.messageCfg.Types) { 112 | return fmt.Errorf("message type should be one of [%v]", strings.Join(p.messageCfg.Types, ", ")) 113 | } 114 | return nil 115 | } 116 | 117 | // ValidateScope check if commit scope is valid. 118 | func (p MessageProcessorImpl) ValidateScope(scope string) error { 119 | if len(p.messageCfg.Scope.Values) > 0 && !contains(scope, p.messageCfg.Scope.Values) { 120 | return fmt.Errorf("message scope should one of [%v]", strings.Join(p.messageCfg.Scope.Values, ", ")) 121 | } 122 | return nil 123 | } 124 | 125 | // ValidateDescription check if commit description is valid. 126 | func (p MessageProcessorImpl) ValidateDescription(description string) error { 127 | if !regexp.MustCompile("^[a-z]+.*$").MatchString(description) { 128 | return fmt.Errorf("description [%s] should begins with lowercase letter", description) 129 | } 130 | return nil 131 | } 132 | 133 | // Enhance add metadata on commit message. 134 | func (p MessageProcessorImpl) Enhance(branch string, message string) (string, error) { 135 | if p.branchesCfg.DisableIssue || p.messageCfg.IssueFooterConfig().Key == "" || hasIssueID(message, p.messageCfg.IssueFooterConfig()) { 136 | return "", nil // enhance disabled 137 | } 138 | 139 | issue, err := p.IssueID(branch) 140 | if err != nil { 141 | return "", err 142 | } 143 | if issue == "" { 144 | return "", fmt.Errorf("could not find issue id using configured regex") 145 | } 146 | 147 | footer := formatIssueFooter(p.messageCfg.IssueFooterConfig(), issue) 148 | if !hasFooter(message) { 149 | return "\n" + footer, nil 150 | } 151 | 152 | return footer, nil 153 | } 154 | 155 | func formatIssueFooter(cfg CommitMessageFooterConfig, issue string) string { 156 | if !strings.HasPrefix(issue, cfg.AddValuePrefix) { 157 | issue = cfg.AddValuePrefix + issue 158 | } 159 | if cfg.UseHash { 160 | return fmt.Sprintf("%s #%s", cfg.Key, strings.TrimPrefix(issue, "#")) 161 | } 162 | return fmt.Sprintf("%s: %s", cfg.Key, issue) 163 | } 164 | 165 | // IssueID try to extract issue id from branch, return empty if not found. 166 | func (p MessageProcessorImpl) IssueID(branch string) (string, error) { 167 | if p.branchesCfg.DisableIssue || p.messageCfg.Issue.Regex == "" { 168 | return "", nil 169 | } 170 | 171 | rstr := fmt.Sprintf("^%s(%s)%s$", p.branchesCfg.Prefix, p.messageCfg.Issue.Regex, p.branchesCfg.Suffix) 172 | r, err := regexp.Compile(rstr) 173 | if err != nil { 174 | return "", fmt.Errorf("could not compile issue regex: %s, error: %v", rstr, err.Error()) 175 | } 176 | 177 | groups := r.FindStringSubmatch(branch) 178 | if len(groups) != 4 { 179 | return "", nil 180 | } 181 | return groups[2], nil 182 | } 183 | 184 | // Format a commit message returning header, body and footer. 185 | func (p MessageProcessorImpl) Format(msg CommitMessage) (string, string, string) { 186 | var header strings.Builder 187 | header.WriteString(msg.Type) 188 | if msg.Scope != "" { 189 | header.WriteString("(" + msg.Scope + ")") 190 | } 191 | header.WriteString(": ") 192 | header.WriteString(msg.Description) 193 | 194 | var footer strings.Builder 195 | if msg.BreakingMessage() != "" { 196 | footer.WriteString(fmt.Sprintf("%s: %s", breakingChangeFooterKey, msg.BreakingMessage())) 197 | } 198 | if issue, exists := msg.Metadata[issueMetadataKey]; exists && p.messageCfg.IssueFooterConfig().Key != "" { 199 | if footer.Len() > 0 { 200 | footer.WriteString("\n") 201 | } 202 | footer.WriteString(formatIssueFooter(p.messageCfg.IssueFooterConfig(), issue)) 203 | } 204 | 205 | return header.String(), msg.Body, footer.String() 206 | } 207 | 208 | func removeCarriage(commit string) string { 209 | return regexp.MustCompile(`\r`).ReplaceAllString(commit, "") 210 | } 211 | 212 | // Parse a commit message. 213 | func (p MessageProcessorImpl) Parse(subject, body string) (CommitMessage, error) { 214 | preparedSubject, err := p.prepareHeader(subject) 215 | commitBody := removeCarriage(body) 216 | 217 | if err != nil { 218 | return CommitMessage{}, err 219 | } 220 | 221 | commitType, scope, description, hasBreakingChange := parseSubjectMessage(preparedSubject) 222 | 223 | metadata := make(map[string]string) 224 | for key, mdCfg := range p.messageCfg.Footer { 225 | if mdCfg.Key != "" { 226 | prefixes := append([]string{mdCfg.Key}, mdCfg.KeySynonyms...) 227 | for _, prefix := range prefixes { 228 | if tagValue := extractFooterMetadata(prefix, commitBody, mdCfg.UseHash); tagValue != "" { 229 | metadata[key] = tagValue 230 | break 231 | } 232 | } 233 | } 234 | } 235 | if tagValue := extractFooterMetadata(breakingChangeFooterKey, commitBody, false); tagValue != "" { 236 | metadata[breakingChangeMetadataKey] = tagValue 237 | hasBreakingChange = true 238 | } 239 | 240 | return CommitMessage{ 241 | Type: commitType, 242 | Scope: scope, 243 | Description: description, 244 | Body: commitBody, 245 | IsBreakingChange: hasBreakingChange, 246 | Metadata: metadata, 247 | }, nil 248 | } 249 | 250 | func (p MessageProcessorImpl) prepareHeader(header string) (string, error) { 251 | if p.messageCfg.HeaderSelector == "" { 252 | return header, nil 253 | } 254 | 255 | regex, err := regexp.Compile(p.messageCfg.HeaderSelector) 256 | if err != nil { 257 | return "", fmt.Errorf("invalid regex on header-selector %s, error: %s", p.messageCfg.HeaderSelector, err.Error()) 258 | } 259 | 260 | index := regex.SubexpIndex(messageRegexGroupName) 261 | if index < 0 { 262 | return "", fmt.Errorf("could not find %s regex group on header-selector regex", messageRegexGroupName) 263 | } 264 | 265 | match := regex.FindStringSubmatch(header) 266 | 267 | if match == nil || len(match) < index { 268 | return "", fmt.Errorf("could not find %s regex group in match result for '%s'", messageRegexGroupName, header) 269 | } 270 | 271 | return match[index], nil 272 | } 273 | 274 | func parseSubjectMessage(message string) (string, string, string, bool) { 275 | regex := regexp.MustCompile(`([a-z]+)(\((.*)\))?(!)?: (.*)`) 276 | result := regex.FindStringSubmatch(message) 277 | if len(result) != 6 { 278 | return "", "", message, false 279 | } 280 | return result[1], result[3], strings.TrimSpace(result[5]), result[4] == "!" 281 | } 282 | 283 | func extractFooterMetadata(key, text string, useHash bool) string { 284 | var regex *regexp.Regexp 285 | if useHash { 286 | regex = regexp.MustCompile(key + " (#.*)") 287 | } else { 288 | regex = regexp.MustCompile(key + ": (.*)") 289 | } 290 | 291 | result := regex.FindStringSubmatch(text) 292 | if len(result) < 2 { 293 | return "" 294 | } 295 | return result[1] 296 | } 297 | 298 | func hasFooter(message string) bool { 299 | r := regexp.MustCompile("^[a-zA-Z-]+: .*|^[a-zA-Z-]+ #.*|^" + breakingChangeFooterKey + ": .*") 300 | 301 | scanner := bufio.NewScanner(strings.NewReader(message)) 302 | lines := 0 303 | for scanner.Scan() { 304 | if lines > 0 && r.MatchString(scanner.Text()) { 305 | return true 306 | } 307 | lines++ 308 | } 309 | 310 | return false 311 | } 312 | 313 | func hasIssueID(message string, issueConfig CommitMessageFooterConfig) bool { 314 | var r *regexp.Regexp 315 | if issueConfig.UseHash { 316 | r = regexp.MustCompile(fmt.Sprintf("(?m)^%s #.+$", issueConfig.Key)) 317 | } else { 318 | r = regexp.MustCompile(fmt.Sprintf("(?m)^%s: .+$", issueConfig.Key)) 319 | } 320 | return r.MatchString(message) 321 | } 322 | 323 | func contains(value string, content []string) bool { 324 | for _, v := range content { 325 | if value == v { 326 | return true 327 | } 328 | } 329 | return false 330 | } 331 | 332 | func splitCommitMessageContent(content string) (string, string) { 333 | scanner := bufio.NewScanner(strings.NewReader(content)) 334 | 335 | scanner.Scan() 336 | subject := scanner.Text() 337 | 338 | var body strings.Builder 339 | first := true 340 | for scanner.Scan() { 341 | if !first { 342 | body.WriteString("\n") 343 | } 344 | body.WriteString(scanner.Text()) 345 | first = false 346 | } 347 | 348 | return subject, body.String() 349 | } 350 | -------------------------------------------------------------------------------- /cmd/git-sv/handlers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "sort" 9 | "strings" 10 | "time" 11 | 12 | "github.com/Masterminds/semver/v3" 13 | "github.com/bvieira/sv4git/v2/sv" 14 | "github.com/urfave/cli/v2" 15 | "gopkg.in/yaml.v3" 16 | ) 17 | 18 | func configDefaultHandler() func(c *cli.Context) error { 19 | cfg := defaultConfig() 20 | return func(c *cli.Context) error { 21 | content, err := yaml.Marshal(&cfg) 22 | if err != nil { 23 | return err 24 | } 25 | fmt.Println(string(content)) 26 | return nil 27 | } 28 | } 29 | 30 | func configShowHandler(cfg Config) func(c *cli.Context) error { 31 | return func(c *cli.Context) error { 32 | content, err := yaml.Marshal(&cfg) 33 | if err != nil { 34 | return err 35 | } 36 | fmt.Println(string(content)) 37 | return nil 38 | } 39 | } 40 | 41 | func currentVersionHandler(git sv.Git) func(c *cli.Context) error { 42 | return func(c *cli.Context) error { 43 | lastTag := git.LastTag() 44 | 45 | currentVer, err := sv.ToVersion(lastTag) 46 | if err != nil { 47 | return fmt.Errorf("error parsing version: %s from git tag, message: %v", lastTag, err) 48 | } 49 | fmt.Printf("%d.%d.%d\n", currentVer.Major(), currentVer.Minor(), currentVer.Patch()) 50 | return nil 51 | } 52 | } 53 | 54 | func nextVersionHandler(git sv.Git, semverProcessor sv.SemVerCommitsProcessor) func(c *cli.Context) error { 55 | return func(c *cli.Context) error { 56 | lastTag := git.LastTag() 57 | 58 | currentVer, err := sv.ToVersion(lastTag) 59 | if err != nil { 60 | return fmt.Errorf("error parsing version: %s from git tag, message: %v", lastTag, err) 61 | } 62 | 63 | commits, err := git.Log(sv.NewLogRange(sv.TagRange, lastTag, "")) 64 | if err != nil { 65 | return fmt.Errorf("error getting git log, message: %v", err) 66 | } 67 | 68 | nextVer, _ := semverProcessor.NextVersion(currentVer, commits) 69 | fmt.Printf("%d.%d.%d\n", nextVer.Major(), nextVer.Minor(), nextVer.Patch()) 70 | return nil 71 | } 72 | } 73 | 74 | func commitLogHandler(git sv.Git) func(c *cli.Context) error { 75 | return func(c *cli.Context) error { 76 | var commits []sv.GitCommitLog 77 | var err error 78 | tagFlag := c.String("t") 79 | rangeFlag := c.String("r") 80 | startFlag := c.String("s") 81 | endFlag := c.String("e") 82 | if tagFlag != "" && (rangeFlag != string(sv.TagRange) || startFlag != "" || endFlag != "") { 83 | return fmt.Errorf("cannot define tag flag with range, start or end flags") 84 | } 85 | 86 | if tagFlag != "" { 87 | commits, err = getTagCommits(git, tagFlag) 88 | } else { 89 | r, rerr := logRange(git, rangeFlag, startFlag, endFlag) 90 | if rerr != nil { 91 | return rerr 92 | } 93 | commits, err = git.Log(r) 94 | } 95 | if err != nil { 96 | return fmt.Errorf("error getting git log, message: %v", err) 97 | } 98 | 99 | for _, commit := range commits { 100 | content, err := json.Marshal(commit) 101 | if err != nil { 102 | return err 103 | } 104 | fmt.Println(string(content)) 105 | } 106 | return nil 107 | } 108 | } 109 | 110 | func getTagCommits(git sv.Git, tag string) ([]sv.GitCommitLog, error) { 111 | prev, _, err := getTags(git, tag) 112 | if err != nil { 113 | return nil, err 114 | } 115 | return git.Log(sv.NewLogRange(sv.TagRange, prev, tag)) 116 | } 117 | 118 | func logRange(git sv.Git, rangeFlag, startFlag, endFlag string) (sv.LogRange, error) { 119 | switch rangeFlag { 120 | case string(sv.TagRange): 121 | return sv.NewLogRange(sv.TagRange, str(startFlag, git.LastTag()), endFlag), nil 122 | case string(sv.DateRange): 123 | return sv.NewLogRange(sv.DateRange, startFlag, endFlag), nil 124 | case string(sv.HashRange): 125 | return sv.NewLogRange(sv.HashRange, startFlag, endFlag), nil 126 | default: 127 | return sv.LogRange{}, fmt.Errorf("invalid range: %s, expected: %s, %s or %s", rangeFlag, sv.TagRange, sv.DateRange, sv.HashRange) 128 | } 129 | } 130 | 131 | func commitNotesHandler(git sv.Git, rnProcessor sv.ReleaseNoteProcessor, outputFormatter sv.OutputFormatter) func(c *cli.Context) error { 132 | return func(c *cli.Context) error { 133 | var date time.Time 134 | 135 | rangeFlag := c.String("r") 136 | lr, err := logRange(git, rangeFlag, c.String("s"), c.String("e")) 137 | if err != nil { 138 | return err 139 | } 140 | 141 | commits, err := git.Log(lr) 142 | if err != nil { 143 | return fmt.Errorf("error getting git log from range: %s, message: %v", rangeFlag, err) 144 | } 145 | 146 | if len(commits) > 0 { 147 | date, _ = time.Parse("2006-01-02", commits[0].Date) 148 | } 149 | 150 | output, err := outputFormatter.FormatReleaseNote(rnProcessor.Create(nil, "", date, commits)) 151 | if err != nil { 152 | return fmt.Errorf("could not format release notes, message: %v", err) 153 | } 154 | fmt.Println(output) 155 | return nil 156 | } 157 | } 158 | 159 | func releaseNotesHandler(git sv.Git, semverProcessor sv.SemVerCommitsProcessor, rnProcessor sv.ReleaseNoteProcessor, outputFormatter sv.OutputFormatter) func(c *cli.Context) error { 160 | return func(c *cli.Context) error { 161 | var commits []sv.GitCommitLog 162 | var rnVersion *semver.Version 163 | var tag string 164 | var date time.Time 165 | var err error 166 | 167 | if tag = c.String("t"); tag != "" { 168 | rnVersion, date, commits, err = getTagVersionInfo(git, tag) 169 | } else { 170 | // TODO: should generate release notes if version was not updated? 171 | rnVersion, _, date, commits, err = getNextVersionInfo(git, semverProcessor) 172 | } 173 | 174 | if err != nil { 175 | return err 176 | } 177 | 178 | releasenote := rnProcessor.Create(rnVersion, tag, date, commits) 179 | output, err := outputFormatter.FormatReleaseNote(releasenote) 180 | if err != nil { 181 | return fmt.Errorf("could not format release notes, message: %v", err) 182 | } 183 | fmt.Println(output) 184 | return nil 185 | } 186 | } 187 | 188 | func getTagVersionInfo(git sv.Git, tag string) (*semver.Version, time.Time, []sv.GitCommitLog, error) { 189 | tagVersion, _ := sv.ToVersion(tag) 190 | 191 | previousTag, currentTag, err := getTags(git, tag) 192 | if err != nil { 193 | return nil, time.Time{}, nil, fmt.Errorf("error listing tags, message: %v", err) 194 | } 195 | 196 | commits, err := git.Log(sv.NewLogRange(sv.TagRange, previousTag, tag)) 197 | if err != nil { 198 | return nil, time.Time{}, nil, fmt.Errorf("error getting git log from tag: %s, message: %v", tag, err) 199 | } 200 | 201 | return tagVersion, currentTag.Date, commits, nil 202 | } 203 | 204 | func getTags(git sv.Git, tag string) (string, sv.GitTag, error) { 205 | tags, err := git.Tags() 206 | if err != nil { 207 | return "", sv.GitTag{}, err 208 | } 209 | 210 | index := find(tag, tags) 211 | if index < 0 { 212 | return "", sv.GitTag{}, fmt.Errorf("tag: %s not found, check tag filter", tag) 213 | } 214 | 215 | previousTag := "" 216 | if index > 0 { 217 | previousTag = tags[index-1].Name 218 | } 219 | return previousTag, tags[index], nil 220 | } 221 | 222 | func find(tag string, tags []sv.GitTag) int { 223 | for i := 0; i < len(tags); i++ { 224 | if tag == tags[i].Name { 225 | return i 226 | } 227 | } 228 | return -1 229 | } 230 | 231 | func getNextVersionInfo(git sv.Git, semverProcessor sv.SemVerCommitsProcessor) (*semver.Version, bool, time.Time, []sv.GitCommitLog, error) { 232 | lastTag := git.LastTag() 233 | 234 | commits, err := git.Log(sv.NewLogRange(sv.TagRange, lastTag, "")) 235 | if err != nil { 236 | return nil, false, time.Time{}, nil, fmt.Errorf("error getting git log, message: %v", err) 237 | } 238 | 239 | currentVer, _ := sv.ToVersion(lastTag) 240 | version, updated := semverProcessor.NextVersion(currentVer, commits) 241 | 242 | return version, updated, time.Now(), commits, nil 243 | } 244 | 245 | func tagHandler(git sv.Git, semverProcessor sv.SemVerCommitsProcessor) func(c *cli.Context) error { 246 | return func(c *cli.Context) error { 247 | lastTag := git.LastTag() 248 | 249 | currentVer, err := sv.ToVersion(lastTag) 250 | if err != nil { 251 | return fmt.Errorf("error parsing version: %s from git tag, message: %v", lastTag, err) 252 | } 253 | 254 | commits, err := git.Log(sv.NewLogRange(sv.TagRange, lastTag, "")) 255 | if err != nil { 256 | return fmt.Errorf("error getting git log, message: %v", err) 257 | } 258 | 259 | nextVer, _ := semverProcessor.NextVersion(currentVer, commits) 260 | tagname, err := git.Tag(*nextVer) 261 | fmt.Println(tagname) 262 | if err != nil { 263 | return fmt.Errorf("error generating tag version: %s, message: %v", nextVer.String(), err) 264 | } 265 | return nil 266 | } 267 | } 268 | 269 | func getCommitType(cfg Config, p sv.MessageProcessor, input string) (string, error) { 270 | if input == "" { 271 | t, err := promptType(cfg.CommitMessage.Types) 272 | return t.Type, err 273 | } 274 | return input, p.ValidateType(input) 275 | } 276 | 277 | func getCommitScope(cfg Config, p sv.MessageProcessor, input string, noScope bool) (string, error) { 278 | if input == "" && !noScope { 279 | return promptScope(cfg.CommitMessage.Scope.Values) 280 | } 281 | return input, p.ValidateScope(input) 282 | } 283 | 284 | func getCommitDescription(p sv.MessageProcessor, input string) (string, error) { 285 | if input == "" { 286 | return promptSubject() 287 | } 288 | return input, p.ValidateDescription(input) 289 | } 290 | 291 | func getCommitBody(noBody bool) (string, error) { 292 | if noBody { 293 | return "", nil 294 | } 295 | 296 | var fullBody strings.Builder 297 | for body, err := promptBody(); body != "" || err != nil; body, err = promptBody() { 298 | if err != nil { 299 | return "", err 300 | } 301 | if fullBody.Len() > 0 { 302 | fullBody.WriteString("\n") 303 | } 304 | if body != "" { 305 | fullBody.WriteString(body) 306 | } 307 | } 308 | return fullBody.String(), nil 309 | } 310 | 311 | func getCommitIssue(cfg Config, p sv.MessageProcessor, branch string, noIssue bool) (string, error) { 312 | branchIssue, err := p.IssueID(branch) 313 | if err != nil { 314 | return "", err 315 | } 316 | 317 | if cfg.CommitMessage.IssueFooterConfig().Key == "" || cfg.CommitMessage.Issue.Regex == "" { 318 | return "", nil 319 | } 320 | 321 | if noIssue { 322 | return branchIssue, nil 323 | } 324 | 325 | return promptIssueID("issue id", cfg.CommitMessage.Issue.Regex, branchIssue) 326 | } 327 | 328 | func getCommitBreakingChange(noBreaking bool, input string) (string, error) { 329 | if noBreaking { 330 | return "", nil 331 | } 332 | 333 | if strings.TrimSpace(input) != "" { 334 | return input, nil 335 | } 336 | 337 | hasBreakingChanges, err := promptConfirm("has breaking change?") 338 | if err != nil { 339 | return "", err 340 | } 341 | if !hasBreakingChanges { 342 | return "", nil 343 | } 344 | 345 | return promptBreakingChanges() 346 | } 347 | 348 | func commitHandler(cfg Config, git sv.Git, messageProcessor sv.MessageProcessor) func(c *cli.Context) error { 349 | return func(c *cli.Context) error { 350 | noBreaking := c.Bool("no-breaking") 351 | noBody := c.Bool("no-body") 352 | noIssue := c.Bool("no-issue") 353 | noScope := c.Bool("no-scope") 354 | inputType := c.String("type") 355 | inputScope := c.String("scope") 356 | inputDescription := c.String("description") 357 | inputBreakingChange := c.String("breaking-change") 358 | 359 | ctype, err := getCommitType(cfg, messageProcessor, inputType) 360 | if err != nil { 361 | return err 362 | } 363 | 364 | scope, err := getCommitScope(cfg, messageProcessor, inputScope, noScope) 365 | if err != nil { 366 | return err 367 | } 368 | 369 | subject, err := getCommitDescription(messageProcessor, inputDescription) 370 | if err != nil { 371 | return err 372 | } 373 | 374 | fullBody, err := getCommitBody(noBody) 375 | if err != nil { 376 | return err 377 | } 378 | 379 | issue, err := getCommitIssue(cfg, messageProcessor, git.Branch(), noIssue) 380 | if err != nil { 381 | return err 382 | } 383 | 384 | breakingChange, err := getCommitBreakingChange(noBreaking, inputBreakingChange) 385 | if err != nil { 386 | return err 387 | } 388 | 389 | header, body, footer := messageProcessor.Format(sv.NewCommitMessage(ctype, scope, subject, fullBody, issue, breakingChange)) 390 | 391 | err = git.Commit(header, body, footer) 392 | if err != nil { 393 | return fmt.Errorf("error executing git commit, message: %v", err) 394 | } 395 | return nil 396 | } 397 | } 398 | 399 | func changelogHandler(git sv.Git, semverProcessor sv.SemVerCommitsProcessor, rnProcessor sv.ReleaseNoteProcessor, formatter sv.OutputFormatter) func(c *cli.Context) error { 400 | return func(c *cli.Context) error { 401 | tags, err := git.Tags() 402 | if err != nil { 403 | return err 404 | } 405 | sort.Slice(tags, func(i, j int) bool { 406 | return tags[i].Date.After(tags[j].Date) 407 | }) 408 | 409 | var releaseNotes []sv.ReleaseNote 410 | 411 | size := c.Int("size") 412 | all := c.Bool("all") 413 | addNextVersion := c.Bool("add-next-version") 414 | semanticVersionOnly := c.Bool("semantic-version-only") 415 | 416 | if addNextVersion { 417 | rnVersion, updated, date, commits, uerr := getNextVersionInfo(git, semverProcessor) 418 | if uerr != nil { 419 | return uerr 420 | } 421 | if updated { 422 | releaseNotes = append(releaseNotes, rnProcessor.Create(rnVersion, "", date, commits)) 423 | } 424 | } 425 | for i, tag := range tags { 426 | if !all && i >= size { 427 | break 428 | } 429 | 430 | previousTag := "" 431 | if i+1 < len(tags) { 432 | previousTag = tags[i+1].Name 433 | } 434 | 435 | if semanticVersionOnly && !sv.IsValidVersion(tag.Name) { 436 | continue 437 | } 438 | 439 | commits, err := git.Log(sv.NewLogRange(sv.TagRange, previousTag, tag.Name)) 440 | if err != nil { 441 | return fmt.Errorf("error getting git log from tag: %s, message: %v", tag.Name, err) 442 | } 443 | 444 | currentVer, _ := sv.ToVersion(tag.Name) 445 | releaseNotes = append(releaseNotes, rnProcessor.Create(currentVer, tag.Name, tag.Date, commits)) 446 | } 447 | 448 | output, err := formatter.FormatChangelog(releaseNotes) 449 | if err != nil { 450 | return fmt.Errorf("could not format changelog, message: %v", err) 451 | } 452 | fmt.Println(output) 453 | 454 | return nil 455 | } 456 | } 457 | 458 | func validateCommitMessageHandler(git sv.Git, messageProcessor sv.MessageProcessor) func(c *cli.Context) error { 459 | return func(c *cli.Context) error { 460 | branch := git.Branch() 461 | detached, derr := git.IsDetached() 462 | 463 | if messageProcessor.SkipBranch(branch, derr == nil && detached) { 464 | warnf("commit message validation skipped, branch in ignore list or detached...") 465 | return nil 466 | } 467 | 468 | if source := c.String("source"); source == "merge" { 469 | warnf("commit message validation skipped, ignoring source: %s...", source) 470 | return nil 471 | } 472 | 473 | filepath := filepath.Join(c.String("path"), c.String("file")) 474 | 475 | commitMessage, err := readFile(filepath) 476 | if err != nil { 477 | return fmt.Errorf("failed to read commit message, error: %s", err.Error()) 478 | } 479 | 480 | if err := messageProcessor.Validate(commitMessage); err != nil { 481 | return fmt.Errorf("invalid commit message, error: %s", err.Error()) 482 | } 483 | 484 | msg, err := messageProcessor.Enhance(branch, commitMessage) 485 | if err != nil { 486 | warnf("could not enhance commit message, %s", err.Error()) 487 | return nil 488 | } 489 | if msg == "" { 490 | return nil 491 | } 492 | 493 | if err := appendOnFile(msg, filepath); err != nil { 494 | return fmt.Errorf("failed to append meta-informations on footer, error: %s", err.Error()) 495 | } 496 | 497 | return nil 498 | } 499 | } 500 | 501 | func readFile(filepath string) (string, error) { 502 | f, err := os.ReadFile(filepath) 503 | if err != nil { 504 | return "", err 505 | } 506 | return string(f), nil 507 | } 508 | 509 | func appendOnFile(message, filepath string) error { 510 | f, err := os.OpenFile(filepath, os.O_APPEND|os.O_WRONLY, 0644) 511 | if err != nil { 512 | return err 513 | } 514 | defer f.Close() 515 | 516 | _, err = f.WriteString(message) 517 | return err 518 | } 519 | 520 | func str(value, defaultValue string) string { 521 | if value != "" { 522 | return value 523 | } 524 | return defaultValue 525 | } 526 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |

sv4git

3 |

A command line tool (CLI) to validate commit messages, bump version, create tags and generate changelogs!

4 |

5 | Release 6 | Go Reference 7 | GitHub stars 8 | GitHub release (latest by date) 9 | GitHub all releases 10 | Software License 11 | GitHub Actions Status 12 | Go Report Card 13 | Conventional Commits 14 |

15 |

16 | 17 | ## Getting Started 18 | 19 | ### Pre Requirements 20 | 21 | - Git 2.17+ 22 | 23 | ### Installing 24 | 25 | - Download the latest release and add the binary to your path. 26 | - Optional: Set `SV4GIT_HOME` to define user configs. Check the [Config](#config) topic for more information. 27 | 28 | If you want to install from source using `go install`, just run: 29 | 30 | ```bash 31 | # keep in mind that with this, it will compile from source and won't show the version on cli -h. 32 | go install github.com/bvieira/sv4git/v2/cmd/git-sv@latest 33 | 34 | # if you want to add the version on the binary, run this command instead. 35 | SV4GIT_VERSION=$(go list -f '{{ .Version }}' -m github.com/bvieira/sv4git/v2@latest | sed 's/v//') && go install --ldflags "-X main.Version=$SV4GIT_VERSION" github.com/bvieira/sv4git/v2/cmd/git-sv@v$SV4GIT_VERSION 36 | ``` 37 | 38 | ### Config 39 | 40 | #### YAML 41 | 42 | There are 3 config levels when using sv4git: [default](#default), [user](#user), [repository](#repository). All of them are merged considering the follow priority: **repository > user > default**. 43 | 44 | To see the current config, run: 45 | 46 | ```bash 47 | git sv cfg show 48 | ``` 49 | 50 | ##### Configuration Types 51 | 52 | ###### Default 53 | 54 | To check the default configuration, run: 55 | 56 | ```bash 57 | git sv cfg default 58 | ``` 59 | 60 | ###### User 61 | 62 | For user config, it is necessary to define the `SV4GIT_HOME` environment variable, eg.: 63 | 64 | ```bash 65 | SV4GIT_HOME=/home/myuser/.sv4git # myuser is just an example. 66 | ``` 67 | 68 | And create a `config.yml` file inside it, eg.: 69 | 70 | ```bash 71 | .sv4git 72 | └── config.yml 73 | ``` 74 | 75 | ###### Repository 76 | 77 | Create a `.sv4git.yml` file on the root of your repository, eg.: [.sv4git.yml](.sv4git.yml). 78 | 79 | ##### Configuration format 80 | 81 | ```yml 82 | version: "1.1" #config version 83 | 84 | versioning: # versioning bump 85 | update-major: [] # Commit types used to bump major. 86 | update-minor: [feat] # Commit types used to bump minor. 87 | update-patch: [build, ci, chore, fix, perf, refactor, test] # Commit types used to bump patch. 88 | # When type is not present on update rules and is unknown (not mapped on commit message types); 89 | # if ignore-unknown=false bump patch, if ignore-unknown=true do not bump version 90 | ignore-unknown: false 91 | 92 | tag: 93 | pattern: '%d.%d.%d' # Pattern used to create git tag. 94 | filter: '' # Enables you to filter for considerable tags using git pattern syntax 95 | 96 | release-notes: 97 | # Deprecated!!! please use 'sections' instead! 98 | # Headers names for release notes markdown. To disable a section just remove the header 99 | # line. It's possible to add other commit types, the release note will be created 100 | # respecting the following order: feat, fix, refactor, perf, test, build, ci, chore, docs, style, breaking-change. 101 | headers: 102 | breaking-change: Breaking Changes 103 | feat: Features 104 | fix: Bug Fixes 105 | 106 | sections: # Array with each section of release note. Check template section for more information. 107 | - name: Features # Name used on section. 108 | section-type: commits # Type of the section, supported types: commits, breaking-changes. 109 | commit-types: [feat] # Commit types for commit section-type, one commit type cannot be in more than one section. 110 | - name: Bug Fixes 111 | section-type: commits 112 | commit-types: [fix] 113 | - name: Breaking Changes 114 | section-type: breaking-changes 115 | 116 | branches: # Git branches config. 117 | prefix: ([a-z]+\/)? # Prefix used on branch name, it should be a regex group. 118 | suffix: (-.*)? # Suffix used on branch name, it should be a regex group. 119 | disable-issue: false # Set true if there is no need to recover issue id from branch name. 120 | skip: [master, main, developer] # List of branch names ignored on commit message validation. 121 | skip-detached: false # Set true if a detached branch should be ignored on commit message validation. 122 | 123 | commit-message: 124 | types: [build, ci, chore, docs, feat, fix, perf, refactor, revert, style, test] # Supported commit types. 125 | header-selector: '' # You can put in a regex here to select only a certain part of the commit message. Please define a regex group 'header'. 126 | scope: 127 | # Define supported scopes, if blank, scope will not be validated, if not, only scope listed will be valid. 128 | # Don't forget to add "" on your list if you need to define scopes and keep it optional. 129 | values: [] 130 | footer: 131 | issue: # Use "issue: {}" if you wish to disable issue footer. 132 | key: jira # Name used to define an issue on footer metadata. 133 | key-synonyms: [Jira, JIRA] # Supported variations for footer metadata. 134 | use-hash: false # If false, use : separator. If true, use # separator. 135 | add-value-prefix: '' # Add a prefix to issue value. 136 | issue: 137 | regex: '[A-Z]+-[0-9]+' # Regex for issue id. 138 | ``` 139 | 140 | #### Templates 141 | 142 | **sv4git** uses *go templates* to format the output for `release-notes` and `changelog`, to see how the default template is configured check [template directory](cmd/git-sv/resources/templates). On v2.7.0+, its possible to overwrite the default configuration by adding `.sv4git/templates` on your repository. The cli expects that at least 2 files exists on your directory: `changelog-md.tpl` and `releasenotes-md.tpl`. 143 | 144 | ```bash 145 | .sv4git 146 | └── templates 147 | ├── changelog-md.tpl 148 | └── releasenotes-md.tpl 149 | ``` 150 | 151 | Everything inside `.sv4git/templates` will be loaded, so it's possible to add more files to be used as needed. 152 | 153 | ##### Variables 154 | 155 | To execute the template the `releasenotes-md.tpl` will receive a single **ReleaseNote** and `changelog-md.tpl` will receive a list of **ReleaseNote** as variables. 156 | 157 | Each **ReleaseNoteSection** will be configured according with `release-notes.section` from config file. The order for each section will be maintained and the **SectionType** is defined according with `section-type` attribute as described on the table below. 158 | 159 | | section-type | ReleaseNoteSection | 160 | | -- | -- | 161 | | commits | ReleaseNoteCommitsSection | 162 | | breaking-changes | ReleaseNoteBreakingChangeSection | 163 | 164 | > :warning: currently only `commits` and `breaking-changes` are supported as `section-types`, using a different value for this field will make the section to be removed from the template variables. 165 | 166 | Check below the variables available: 167 | 168 | ```go 169 | ReleaseNote 170 | Release string // 'v' followed by version if present, if not tag will be used instead. 171 | Tag string // Current tag, if available. 172 | Version *Version // Version from tag or next version according with semver. 173 | Date time.Time 174 | Sections []ReleaseNoteSection // ReleaseNoteCommitsSection or ReleaseNoteBreakingChangeSection 175 | AuthorNames []string // Author names recovered from commit message (user.name from git) 176 | 177 | Version 178 | Major int 179 | Minor int 180 | Patch int 181 | Prerelease string 182 | Metadata string 183 | Original string 184 | 185 | ReleaseNoteCommitsSection // SectionType == commits 186 | SectionType string 187 | SectionName string 188 | Types []string 189 | Items []GitCommitLog 190 | HasMultipleTypes bool 191 | 192 | ReleaseNoteBreakingChangeSection // SectionType == breaking-changes 193 | SectionType string 194 | SectionName string 195 | Messages []string 196 | 197 | GitCommitLog 198 | Date string 199 | Timestamp int 200 | AuthorName string 201 | Hash string 202 | Message CommitMessage 203 | 204 | CommitMessage 205 | Type string 206 | Scope string 207 | Description string 208 | Body string 209 | IsBreakingChange bool 210 | Metadata map[string]string 211 | ``` 212 | 213 | ##### Functions 214 | 215 | Beside the [go template functions](https://pkg.go.dev/text/template#hdr-Functions), the folowing functions are availiable to use in the templates. Check [formatter_functions.go](sv/formatter_functions.go) to see the functions implementation. 216 | 217 | ###### timefmt 218 | 219 | **Usage:** timefmt time "2006-01-02" 220 | 221 | Receive a time.Time and a layout string and returns a textual representation of the time according with the layout provided. Check for more information. 222 | 223 | ###### getsection 224 | 225 | **Usage:** getsection sections "Features" 226 | 227 | Receive a list of ReleaseNoteSection and a Section name and returns a section with the provided name. If no section is found, it will return `nil`. 228 | 229 | ### Running 230 | 231 | Run `git-sv` to get the list of available parameters: 232 | 233 | ```bash 234 | git-sv 235 | ``` 236 | 237 | #### Run as git command 238 | 239 | If `git-sv` is configured on your path, you can use it like a git command: 240 | 241 | ```bash 242 | git sv 243 | git sv current-version 244 | git sv next-version 245 | ``` 246 | 247 | #### Usage 248 | 249 | Use `--help` or `-h` to get usage information, don't forget that some commands have unique options too: 250 | 251 | ```bash 252 | # sv help 253 | git-sv -h 254 | 255 | # sv release-notes command help 256 | git-sv rn -h 257 | ``` 258 | 259 | ##### Available commands 260 | 261 | | Variable | description | has options or subcommands | 262 | | ---------------------------- | -------------------------------------------------------------- | :------------------------: | 263 | | config, cfg | Show config information. | :heavy_check_mark: | 264 | | current-version, cv | Get last released version from git. | :x: | 265 | | next-version, nv | Generate the next version based on git commit messages. | :x: | 266 | | commit-log, cl | List all commit logs according to range as jsons. | :heavy_check_mark: | 267 | | commit-notes, cn | Generate a commit notes according to range. | :heavy_check_mark: | 268 | | release-notes, rn | Generate release notes. | :heavy_check_mark: | 269 | | changelog, cgl | Generate changelog. | :heavy_check_mark: | 270 | | tag, tg | Generate tag with version based on git commit messages. | :x: | 271 | | commit, cmt | Execute git commit with convetional commit message helper. | :heavy_check_mark: | 272 | | validate-commit-message, vcm | Use as prepare-commit-message hook to validate commit message. | :heavy_check_mark: | 273 | | help, h | Shows a list of commands or help for one command. | :x: | 274 | 275 | ##### Use range 276 | 277 | Commands like `commit-log` and `commit-notes` has a range option. Supported range types are: `tag`, `date` and `hash`. 278 | 279 | By default, it's used [--date=short](https://git-scm.com/docs/git-log#Documentation/git-log.txt---dateltformatgt) at `git log`, all dates returned from it will be in `YYYY-MM-DD` format. 280 | 281 | Range `tag` will use `git for-each-ref refs/tags` to get the last tag available if `start` is empty, the others types won't use the existing tags. It's recommended to always use a start limit in a old repository with a lot of commits. This behavior was maintained to not break the retrocompatibility. 282 | 283 | Range `date` use git log `--since` and `--until`. It's possible to use all supported formats from [git log](https://git-scm.com/docs/git-log#Documentation/git-log.txt---sinceltdategt). If `end` is in `YYYY-MM-DD` format, `sv` will add a day on git log command to make the end date inclusive. 284 | 285 | Range `tag` and `hash` are used on git log [revision range](https://git-scm.com/docs/git-log#Documentation/git-log.txt-ltrevisionrangegt). If `end` is empty, `HEAD` will be used instead. 286 | 287 | ```bash 288 | # get commit log as json using a inclusive range 289 | git-sv commit-log --range hash --start 7ea9306~1 --end c444318 290 | 291 | # return all commits after last tag 292 | git-sv commit-log --range tag 293 | ``` 294 | 295 | ##### Use validate-commit-message as prepare-commit-msg hook 296 | 297 | Configure your `.git/hooks/prepare-commit-msg`: 298 | 299 | ```bash 300 | #!/bin/sh 301 | 302 | COMMIT_MSG_FILE=$1 303 | COMMIT_SOURCE=$2 304 | SHA1=$3 305 | 306 | git sv vcm --path "$(pwd)" --file "$COMMIT_MSG_FILE" --source "$COMMIT_SOURCE" 307 | ``` 308 | 309 | **Tip**: you can configure a directory as your global git templates using the command below: 310 | 311 | ```bash 312 | git config --global init.templatedir '' 313 | ``` 314 | 315 | Check [git config docs](https://git-scm.com/docs/git-config#Documentation/git-config.txt-inittemplateDir) for more information! 316 | 317 | ## Development 318 | 319 | ### Makefile 320 | 321 | Run `make` to get the list of available actions: 322 | 323 | ```bash 324 | make 325 | ``` 326 | 327 | #### Make configs 328 | 329 | | Variable | description | 330 | | ---------- | ----------------------- | 331 | | BUILDOS | Build OS. | 332 | | BUILDARCH | Build arch. | 333 | | ECHOFLAGS | Flags used on echo. | 334 | | BUILDENVS | Var envs used on build. | 335 | | BUILDFLAGS | Flags used on build. | 336 | 337 | | Parameters | description | 338 | | ---------- | ------------------------------------ | 339 | | args | Parameters that will be used on run. | 340 | 341 | ```bash 342 | #variables 343 | BUILDOS="linux" BUILDARCH="amd64" make build 344 | 345 | #parameters 346 | make run args="-h" 347 | ``` 348 | 349 | ### Build 350 | 351 | ```bash 352 | make build 353 | ``` 354 | 355 | The binary will be created on `bin/$BUILDOS_$BUILDARCH/git-sv`. 356 | 357 | ### Tests 358 | 359 | ```bash 360 | make test 361 | ``` 362 | 363 | ### Run 364 | 365 | ```bash 366 | #without args 367 | make run 368 | 369 | #with args 370 | make run args="-h" 371 | ``` 372 | -------------------------------------------------------------------------------- /sv/message_test.go: -------------------------------------------------------------------------------- 1 | package sv 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | var ccfg = CommitMessageConfig{ 9 | Types: []string{"feat", "fix"}, 10 | Scope: CommitMessageScopeConfig{}, 11 | Footer: map[string]CommitMessageFooterConfig{ 12 | "issue": {Key: "jira", KeySynonyms: []string{"Jira"}}, 13 | "refs": {Key: "Refs", UseHash: true}, 14 | }, 15 | Issue: CommitMessageIssueConfig{Regex: "[A-Z]+-[0-9]+"}, 16 | } 17 | 18 | var ccfgHash = CommitMessageConfig{ 19 | Types: []string{"feat", "fix"}, 20 | Scope: CommitMessageScopeConfig{}, 21 | Footer: map[string]CommitMessageFooterConfig{ 22 | "issue": {Key: "jira", KeySynonyms: []string{"Jira"}, UseHash: true}, 23 | "refs": {Key: "Refs", UseHash: true}, 24 | }, 25 | Issue: CommitMessageIssueConfig{Regex: "[A-Z]+-[0-9]+"}, 26 | } 27 | 28 | var ccfgGitIssue = CommitMessageConfig{ 29 | Types: []string{"feat", "fix"}, 30 | Scope: CommitMessageScopeConfig{}, 31 | Footer: map[string]CommitMessageFooterConfig{ 32 | "issue": {Key: "issue", KeySynonyms: []string{"Issue"}, UseHash: false, AddValuePrefix: "#"}, 33 | }, 34 | Issue: CommitMessageIssueConfig{Regex: "#?[0-9]+"}, 35 | } 36 | 37 | var ccfgEmptyIssue = CommitMessageConfig{ 38 | Types: []string{"feat", "fix"}, 39 | Scope: CommitMessageScopeConfig{}, 40 | Footer: map[string]CommitMessageFooterConfig{ 41 | "issue": {}, 42 | }, 43 | Issue: CommitMessageIssueConfig{Regex: "[A-Z]+-[0-9]+"}, 44 | } 45 | 46 | var ccfgWithScope = CommitMessageConfig{ 47 | Types: []string{"feat", "fix"}, 48 | Scope: CommitMessageScopeConfig{Values: []string{"", "scope"}}, 49 | Footer: map[string]CommitMessageFooterConfig{ 50 | "issue": {Key: "jira", KeySynonyms: []string{"Jira"}}, 51 | "refs": {Key: "Refs", UseHash: true}, 52 | }, 53 | Issue: CommitMessageIssueConfig{Regex: "[A-Z]+-[0-9]+"}, 54 | } 55 | 56 | func newBranchCfg(skipDetached bool) BranchesConfig { 57 | return BranchesConfig{ 58 | Prefix: "([a-z]+\\/)?", 59 | Suffix: "(-.*)?", 60 | Skip: []string{"develop", "master"}, 61 | SkipDetached: &skipDetached, 62 | } 63 | } 64 | 65 | func newCommitMessageCfg(headerSelector string) CommitMessageConfig { 66 | return CommitMessageConfig{ 67 | Types: []string{"feat", "fix"}, 68 | Scope: CommitMessageScopeConfig{Values: []string{"", "scope"}}, 69 | Footer: map[string]CommitMessageFooterConfig{ 70 | "issue": {Key: "jira", KeySynonyms: []string{"Jira"}}, 71 | "refs": {Key: "Refs", UseHash: true}, 72 | }, 73 | Issue: CommitMessageIssueConfig{Regex: "[A-Z]+-[0-9]+"}, 74 | HeaderSelector: headerSelector, 75 | } 76 | } 77 | 78 | // messages samples start. 79 | var fullMessage = `fix: correct minor typos in code 80 | 81 | see the issue for details 82 | 83 | on typos fixed. 84 | 85 | Reviewed-by: Z 86 | Refs #133` 87 | 88 | var fullMessageWithJira = `fix: correct minor typos in code 89 | 90 | see the issue for details 91 | 92 | on typos fixed. 93 | 94 | Reviewed-by: Z 95 | Refs #133 96 | jira: JIRA-456` 97 | 98 | var fullMessageRefs = `fix: correct minor typos in code 99 | 100 | see the issue for details 101 | 102 | on typos fixed. 103 | 104 | Refs #133` 105 | 106 | var subjectAndBodyMessage = `fix: correct minor typos in code 107 | 108 | see the issue for details 109 | 110 | on typos fixed.` 111 | 112 | var subjectAndFooterMessage = `refactor!: drop support for Node 6 113 | 114 | BREAKING CHANGE: refactor to use JavaScript features not available in Node 6.` 115 | 116 | // multiline samples end 117 | 118 | func TestMessageProcessorImpl_SkipBranch(t *testing.T) { 119 | tests := []struct { 120 | name string 121 | bcfg BranchesConfig 122 | branch string 123 | detached bool 124 | want bool 125 | }{ 126 | {"normal branch", newBranchCfg(false), "JIRA-123", false, false}, 127 | {"dont ignore detached branch", newBranchCfg(false), "JIRA-123", true, false}, 128 | {"ignore branch on skip list", newBranchCfg(false), "master", false, true}, 129 | {"ignore detached branch", newBranchCfg(true), "JIRA-123", true, true}, 130 | {"null skip detached", BranchesConfig{Skip: []string{}}, "JIRA-123", true, false}, 131 | } 132 | for _, tt := range tests { 133 | t.Run(tt.name, func(t *testing.T) { 134 | p := NewMessageProcessor(ccfg, tt.bcfg) 135 | if got := p.SkipBranch(tt.branch, tt.detached); got != tt.want { 136 | t.Errorf("MessageProcessorImpl.SkipBranch() = %v, want %v", got, tt.want) 137 | } 138 | }) 139 | } 140 | } 141 | 142 | func TestMessageProcessorImpl_Validate(t *testing.T) { 143 | tests := []struct { 144 | name string 145 | cfg CommitMessageConfig 146 | message string 147 | wantErr bool 148 | }{ 149 | {"single line valid message", ccfg, "feat: add something", false}, 150 | {"single line valid message with scope", ccfg, "feat(scope): add something", false}, 151 | {"single line valid scope from list", ccfgWithScope, "feat(scope): add something", false}, 152 | {"single line invalid scope from list", ccfgWithScope, "feat(invalid): add something", true}, 153 | {"single line invalid type message", ccfg, "something: add something", true}, 154 | {"single line invalid type message", ccfg, "feat?: add something", true}, 155 | 156 | {"multi line valid message", ccfg, `feat: add something 157 | 158 | team: x`, false}, 159 | 160 | {"multi line invalid message", ccfg, `feat add something 161 | 162 | team: x`, true}, 163 | 164 | {"support ! for breaking change", ccfg, "feat!: add something", false}, 165 | {"support ! with scope for breaking change", ccfg, "feat(scope)!: add something", false}, 166 | } 167 | for _, tt := range tests { 168 | t.Run(tt.name, func(t *testing.T) { 169 | p := NewMessageProcessor(tt.cfg, newBranchCfg(false)) 170 | if err := p.Validate(tt.message); (err != nil) != tt.wantErr { 171 | t.Errorf("MessageProcessorImpl.Validate() error = %v, wantErr %v", err, tt.wantErr) 172 | } 173 | }) 174 | } 175 | } 176 | 177 | func TestMessageProcessorImpl_ValidateType(t *testing.T) { 178 | tests := []struct { 179 | name string 180 | cfg CommitMessageConfig 181 | ctype string 182 | wantErr bool 183 | }{ 184 | {"valid type", ccfg, "feat", false}, 185 | {"invalid type", ccfg, "aaa", true}, 186 | {"empty type", ccfg, "", true}, 187 | } 188 | for _, tt := range tests { 189 | t.Run(tt.name, func(t *testing.T) { 190 | p := NewMessageProcessor(tt.cfg, newBranchCfg(false)) 191 | if err := p.ValidateType(tt.ctype); (err != nil) != tt.wantErr { 192 | t.Errorf("MessageProcessorImpl.ValidateType() error = %v, wantErr %v", err, tt.wantErr) 193 | } 194 | }) 195 | } 196 | } 197 | 198 | func TestMessageProcessorImpl_ValidateScope(t *testing.T) { 199 | tests := []struct { 200 | name string 201 | cfg CommitMessageConfig 202 | scope string 203 | wantErr bool 204 | }{ 205 | {"any scope", ccfg, "aaa", false}, 206 | {"valid scope with scope list", ccfgWithScope, "scope", false}, 207 | {"invalid scope with scope list", ccfgWithScope, "aaa", true}, 208 | } 209 | for _, tt := range tests { 210 | t.Run(tt.name, func(t *testing.T) { 211 | p := NewMessageProcessor(tt.cfg, newBranchCfg(false)) 212 | if err := p.ValidateScope(tt.scope); (err != nil) != tt.wantErr { 213 | t.Errorf("MessageProcessorImpl.ValidateScope() error = %v, wantErr %v", err, tt.wantErr) 214 | } 215 | }) 216 | } 217 | } 218 | 219 | func TestMessageProcessorImpl_ValidateDescription(t *testing.T) { 220 | tests := []struct { 221 | name string 222 | cfg CommitMessageConfig 223 | description string 224 | wantErr bool 225 | }{ 226 | {"empty description", ccfg, "", true}, 227 | {"sigle letter description", ccfg, "a", false}, 228 | {"number description", ccfg, "1", true}, 229 | {"valid description", ccfg, "add some feature", false}, 230 | {"invalid capital letter description", ccfg, "Add some feature", true}, 231 | } 232 | for _, tt := range tests { 233 | t.Run(tt.name, func(t *testing.T) { 234 | p := NewMessageProcessor(tt.cfg, newBranchCfg(false)) 235 | if err := p.ValidateDescription(tt.description); (err != nil) != tt.wantErr { 236 | t.Errorf("MessageProcessorImpl.ValidateDescription() error = %v, wantErr %v", err, tt.wantErr) 237 | } 238 | }) 239 | } 240 | } 241 | 242 | func TestMessageProcessorImpl_Enhance(t *testing.T) { 243 | tests := []struct { 244 | name string 245 | cfg CommitMessageConfig 246 | branch string 247 | message string 248 | want string 249 | wantErr bool 250 | }{ 251 | {"issue on branch name", ccfg, "JIRA-123", "fix: fix something", "\njira: JIRA-123", false}, 252 | {"issue on branch name with description", ccfg, "JIRA-123-some-description", "fix: fix something", "\njira: JIRA-123", false}, 253 | {"issue on branch name with prefix", ccfg, "feature/JIRA-123", "fix: fix something", "\njira: JIRA-123", false}, 254 | {"with footer", ccfg, "JIRA-123", fullMessage, "jira: JIRA-123", false}, 255 | {"with issue on footer", ccfg, "JIRA-123", fullMessageWithJira, "", false}, 256 | {"issue on branch name with prefix and description", ccfg, "feature/JIRA-123-some-description", "fix: fix something", "\njira: JIRA-123", false}, 257 | {"no issue on branch name", ccfg, "branch", "fix: fix something", "", true}, 258 | {"unexpected branch name", ccfg, "feature /JIRA-123", "fix: fix something", "", true}, 259 | {"issue on branch name using hash", ccfgHash, "JIRA-123-some-description", "fix: fix something", "\njira #JIRA-123", false}, 260 | {"numeric issue on branch name", ccfgGitIssue, "#13", "fix: fix something", "\nissue: #13", false}, 261 | {"numeric issue on branch name without hash", ccfgGitIssue, "13", "fix: fix something", "\nissue: #13", false}, 262 | {"numeric issue on branch name with description without hash", ccfgGitIssue, "13-some-fix", "fix: fix something", "\nissue: #13", false}, 263 | } 264 | for _, tt := range tests { 265 | t.Run(tt.name, func(t *testing.T) { 266 | got, err := NewMessageProcessor(tt.cfg, newBranchCfg(false)).Enhance(tt.branch, tt.message) 267 | if (err != nil) != tt.wantErr { 268 | t.Errorf("MessageProcessorImpl.Enhance() error = %v, wantErr %v", err, tt.wantErr) 269 | return 270 | } 271 | if got != tt.want { 272 | t.Errorf("MessageProcessorImpl.Enhance() = %v, want %v", got, tt.want) 273 | } 274 | }) 275 | } 276 | } 277 | 278 | func TestMessageProcessorImpl_IssueID(t *testing.T) { 279 | p := NewMessageProcessor(ccfg, newBranchCfg(false)) 280 | 281 | tests := []struct { 282 | name string 283 | branch string 284 | want string 285 | wantErr bool 286 | }{ 287 | {"simple branch", "JIRA-123", "JIRA-123", false}, 288 | {"branch with prefix", "feature/JIRA-123", "JIRA-123", false}, 289 | {"branch with prefix and posfix", "feature/JIRA-123-some-description", "JIRA-123", false}, 290 | {"branch not found", "feature/wrong123-some-description", "", false}, 291 | {"empty branch", "", "", false}, 292 | {"unexpected branch name", "feature /JIRA-123", "", false}, 293 | } 294 | for _, tt := range tests { 295 | t.Run(tt.name, func(t *testing.T) { 296 | got, err := p.IssueID(tt.branch) 297 | if (err != nil) != tt.wantErr { 298 | t.Errorf("MessageProcessorImpl.IssueID() error = %v, wantErr %v", err, tt.wantErr) 299 | return 300 | } 301 | if got != tt.want { 302 | t.Errorf("MessageProcessorImpl.IssueID() = %v, want %v", got, tt.want) 303 | } 304 | }) 305 | } 306 | } 307 | 308 | const ( 309 | multilineBody = `a 310 | b 311 | c` 312 | fullFooter = `BREAKING CHANGE: breaks 313 | jira: JIRA-123` 314 | ) 315 | 316 | func Test_hasIssueID(t *testing.T) { 317 | cfgColon := CommitMessageFooterConfig{Key: "jira"} 318 | cfgHash := CommitMessageFooterConfig{Key: "jira", UseHash: true} 319 | cfgEmpty := CommitMessageFooterConfig{} 320 | 321 | tests := []struct { 322 | name string 323 | message string 324 | issueCfg CommitMessageFooterConfig 325 | want bool 326 | }{ 327 | {"single line without issue", "feat: something", cfgColon, false}, 328 | {"multi line without issue", `feat: something 329 | 330 | yay`, cfgColon, false}, 331 | {"multi line without jira issue", `feat: something 332 | 333 | jira1: JIRA-123`, cfgColon, false}, 334 | {"multi line with issue", `feat: something 335 | 336 | jira: JIRA-123`, cfgColon, true}, 337 | {"multi line with issue and hash", `feat: something 338 | 339 | jira #JIRA-123`, cfgHash, true}, 340 | {"empty config", `feat: something 341 | 342 | jira #JIRA-123`, cfgEmpty, false}, 343 | } 344 | for _, tt := range tests { 345 | t.Run(tt.name, func(t *testing.T) { 346 | if got := hasIssueID(tt.message, tt.issueCfg); got != tt.want { 347 | t.Errorf("hasIssueID() = %v, want %v", got, tt.want) 348 | } 349 | }) 350 | } 351 | } 352 | 353 | func Test_hasFooter(t *testing.T) { 354 | tests := []struct { 355 | name string 356 | message string 357 | want bool 358 | }{ 359 | {"simple message", "feat: add something", false}, 360 | {"full messsage", fullMessage, true}, 361 | {"full messsage with refs", fullMessageRefs, true}, 362 | {"subject and footer message", subjectAndFooterMessage, true}, 363 | {"subject and body message", subjectAndBodyMessage, false}, 364 | } 365 | for _, tt := range tests { 366 | t.Run(tt.name, func(t *testing.T) { 367 | if got := hasFooter(tt.message); got != tt.want { 368 | t.Errorf("hasFooter() = %v, want %v", got, tt.want) 369 | } 370 | }) 371 | } 372 | } 373 | 374 | // conventional commit tests 375 | 376 | var completeBody = `some descriptions 377 | 378 | jira: JIRA-123 379 | BREAKING CHANGE: this change breaks everything` 380 | 381 | var bodyWithCarriage = "some description\r\nmore description\r\n\r\njira: JIRA-123\r" 382 | var expectedBodyWithCarriage = "some description\nmore description\n\njira: JIRA-123" 383 | 384 | var issueOnlyBody = `some descriptions 385 | 386 | jira: JIRA-456` 387 | 388 | var issueSynonymsBody = `some descriptions 389 | 390 | Jira: JIRA-789` 391 | 392 | var hashMetadataBody = `some descriptions 393 | 394 | Jira: JIRA-999 395 | Refs #123` 396 | 397 | func TestMessageProcessorImpl_Parse(t *testing.T) { 398 | tests := []struct { 399 | name string 400 | cfg CommitMessageConfig 401 | subject string 402 | body string 403 | want CommitMessage 404 | }{ 405 | {"simple message", ccfg, "feat: something awesome", "", CommitMessage{Type: "feat", Scope: "", Description: "something awesome", Body: "", IsBreakingChange: false, Metadata: map[string]string{}}}, 406 | {"message with scope", ccfg, "feat(scope): something awesome", "", CommitMessage{Type: "feat", Scope: "scope", Description: "something awesome", Body: "", IsBreakingChange: false, Metadata: map[string]string{}}}, 407 | {"unmapped type", ccfg, "unkn: something unknown", "", CommitMessage{Type: "unkn", Scope: "", Description: "something unknown", Body: "", IsBreakingChange: false, Metadata: map[string]string{}}}, 408 | {"jira and breaking change metadata", ccfg, "feat: something new", completeBody, CommitMessage{Type: "feat", Scope: "", Description: "something new", Body: completeBody, IsBreakingChange: true, Metadata: map[string]string{issueMetadataKey: "JIRA-123", breakingChangeMetadataKey: "this change breaks everything"}}}, 409 | {"jira only metadata", ccfg, "feat: something new", issueOnlyBody, CommitMessage{Type: "feat", Scope: "", Description: "something new", Body: issueOnlyBody, IsBreakingChange: false, Metadata: map[string]string{issueMetadataKey: "JIRA-456"}}}, 410 | {"jira synonyms metadata", ccfg, "feat: something new", issueSynonymsBody, CommitMessage{Type: "feat", Scope: "", Description: "something new", Body: issueSynonymsBody, IsBreakingChange: false, Metadata: map[string]string{issueMetadataKey: "JIRA-789"}}}, 411 | {"breaking change with exclamation mark", ccfg, "feat!: something new", "", CommitMessage{Type: "feat", Scope: "", Description: "something new", Body: "", IsBreakingChange: true, Metadata: map[string]string{}}}, 412 | {"hash metadata", ccfg, "feat: something new", hashMetadataBody, CommitMessage{Type: "feat", Scope: "", Description: "something new", Body: hashMetadataBody, IsBreakingChange: false, Metadata: map[string]string{issueMetadataKey: "JIRA-999", "refs": "#123"}}}, 413 | {"empty issue cfg", ccfgEmptyIssue, "feat: something new", hashMetadataBody, CommitMessage{Type: "feat", Scope: "", Description: "something new", Body: hashMetadataBody, IsBreakingChange: false, Metadata: map[string]string{}}}, 414 | {"carriage return on body", ccfg, "feat: something new", bodyWithCarriage, CommitMessage{Type: "feat", Scope: "", Description: "something new", Body: expectedBodyWithCarriage, IsBreakingChange: false, Metadata: map[string]string{issueMetadataKey: "JIRA-123"}}}, 415 | } 416 | for _, tt := range tests { 417 | t.Run(tt.name, func(t *testing.T) { 418 | if got, err := NewMessageProcessor(tt.cfg, newBranchCfg(false)).Parse(tt.subject, tt.body); !reflect.DeepEqual(got, tt.want) && err == nil { 419 | t.Errorf("MessageProcessorImpl.Parse() = [%+v], want [%+v]", got, tt.want) 420 | } 421 | }) 422 | } 423 | } 424 | 425 | func TestMessageProcessorImpl_Format(t *testing.T) { 426 | tests := []struct { 427 | name string 428 | cfg CommitMessageConfig 429 | msg CommitMessage 430 | wantHeader string 431 | wantBody string 432 | wantFooter string 433 | }{ 434 | {"simple message", ccfg, NewCommitMessage("feat", "", "something", "", "", ""), "feat: something", "", ""}, 435 | {"with issue", ccfg, NewCommitMessage("feat", "", "something", "", "JIRA-123", ""), "feat: something", "", "jira: JIRA-123"}, 436 | {"with issue using hash", ccfgHash, NewCommitMessage("feat", "", "something", "", "JIRA-123", ""), "feat: something", "", "jira #JIRA-123"}, 437 | {"with issue using double hash", ccfgHash, NewCommitMessage("feat", "", "something", "", "#JIRA-123", ""), "feat: something", "", "jira #JIRA-123"}, 438 | {"with breaking change", ccfg, NewCommitMessage("feat", "", "something", "", "", "breaks"), "feat: something", "", "BREAKING CHANGE: breaks"}, 439 | {"with scope", ccfg, NewCommitMessage("feat", "scope", "something", "", "", ""), "feat(scope): something", "", ""}, 440 | {"with body", ccfg, NewCommitMessage("feat", "", "something", "body", "", ""), "feat: something", "body", ""}, 441 | {"with multiline body", ccfg, NewCommitMessage("feat", "", "something", multilineBody, "", ""), "feat: something", multilineBody, ""}, 442 | {"full message", ccfg, NewCommitMessage("feat", "scope", "something", multilineBody, "JIRA-123", "breaks"), "feat(scope): something", multilineBody, fullFooter}, 443 | {"config without issue key", ccfgEmptyIssue, NewCommitMessage("feat", "", "something", "", "JIRA-123", ""), "feat: something", "", ""}, 444 | {"with issue and issue prefix", ccfgGitIssue, NewCommitMessage("feat", "", "something", "", "123", ""), "feat: something", "", "issue: #123"}, 445 | {"with #issue and issue prefix", ccfgGitIssue, NewCommitMessage("feat", "", "something", "", "#123", ""), "feat: something", "", "issue: #123"}, 446 | } 447 | for _, tt := range tests { 448 | t.Run(tt.name, func(t *testing.T) { 449 | got, got1, got2 := NewMessageProcessor(tt.cfg, newBranchCfg(false)).Format(tt.msg) 450 | if got != tt.wantHeader { 451 | t.Errorf("MessageProcessorImpl.Format() header got = %v, want %v", got, tt.wantHeader) 452 | } 453 | if got1 != tt.wantBody { 454 | t.Errorf("MessageProcessorImpl.Format() body got = %v, want %v", got1, tt.wantBody) 455 | } 456 | if got2 != tt.wantFooter { 457 | t.Errorf("MessageProcessorImpl.Format() footer got = %v, want %v", got2, tt.wantFooter) 458 | } 459 | }) 460 | } 461 | } 462 | 463 | var expectedBodyFullMessage = ` 464 | see the issue for details 465 | 466 | on typos fixed. 467 | 468 | Reviewed-by: Z 469 | Refs #133` 470 | 471 | func Test_splitCommitMessageContent(t *testing.T) { 472 | tests := []struct { 473 | name string 474 | content string 475 | wantSubject string 476 | wantBody string 477 | }{ 478 | {"single line commit", "feat: something", "feat: something", ""}, 479 | {"multi line commit", fullMessage, "fix: correct minor typos in code", expectedBodyFullMessage}, 480 | } 481 | for _, tt := range tests { 482 | t.Run(tt.name, func(t *testing.T) { 483 | got, got1 := splitCommitMessageContent(tt.content) 484 | if got != tt.wantSubject { 485 | t.Errorf("splitCommitMessageContent() subject got = %v, want %v", got, tt.wantSubject) 486 | } 487 | if got1 != tt.wantBody { 488 | t.Errorf("splitCommitMessageContent() body got1 = [%v], want [%v]", got1, tt.wantBody) 489 | } 490 | }) 491 | } 492 | } 493 | 494 | func Test_parseSubjectMessage(t *testing.T) { 495 | tests := []struct { 496 | name string 497 | message string 498 | wantType string 499 | wantScope string 500 | wantDescription string 501 | wantHasBreakingChange bool 502 | }{ 503 | {"valid commit", "feat: something", "feat", "", "something", false}, 504 | {"valid commit with scope", "feat(scope): something", "feat", "scope", "something", false}, 505 | {"valid commit with breaking change", "feat(scope)!: something", "feat", "scope", "something", true}, 506 | {"missing description", "feat: ", "feat", "", "", false}, 507 | } 508 | for _, tt := range tests { 509 | t.Run(tt.name, func(t *testing.T) { 510 | ctype, scope, description, hasBreakingChange := parseSubjectMessage(tt.message) 511 | if ctype != tt.wantType { 512 | t.Errorf("parseSubjectMessage() type got = %v, want %v", ctype, tt.wantType) 513 | } 514 | if scope != tt.wantScope { 515 | t.Errorf("parseSubjectMessage() scope got = %v, want %v", scope, tt.wantScope) 516 | } 517 | if description != tt.wantDescription { 518 | t.Errorf("parseSubjectMessage() description got = %v, want %v", description, tt.wantDescription) 519 | } 520 | if hasBreakingChange != tt.wantHasBreakingChange { 521 | t.Errorf("parseSubjectMessage() hasBreakingChange got = %v, want %v", hasBreakingChange, tt.wantHasBreakingChange) 522 | } 523 | }) 524 | } 525 | } 526 | 527 | func Test_prepareHeader(t *testing.T) { 528 | tests := []struct { 529 | name string 530 | headerSelector string 531 | commitHeader string 532 | wantHeader string 533 | wantError bool 534 | }{ 535 | {"conventional without selector", "", "feat: something", "feat: something", false}, 536 | {"conventional with scope without selector", "", "feat(scope): something", "feat(scope): something", false}, 537 | {"non-conventional without selector", "", "something", "something", false}, 538 | {"matching conventional with selector with group", "Merged PR (\\d+): (?P
.*)", "Merged PR 123: feat: something", "feat: something", false}, 539 | {"matching non-conventional with selector with group", "Merged PR (\\d+): (?P
.*)", "Merged PR 123: something", "something", false}, 540 | {"matching non-conventional with selector without group", "Merged PR (\\d+): (.*)", "Merged PR 123: something", "", true}, 541 | {"non-matching non-conventional with selector with group", "Merged PR (\\d+): (?P
.*)", "something", "", true}, 542 | {"matching non-conventional with invalid regex", "Merged PR (\\d+): (?
.*)", "Merged PR 123: something", "", true}, 543 | } 544 | for _, tt := range tests { 545 | t.Run(tt.name, func(t *testing.T) { 546 | msgProcessor := NewMessageProcessor(newCommitMessageCfg(tt.headerSelector), newBranchCfg(false)) 547 | header, err := msgProcessor.prepareHeader(tt.commitHeader) 548 | 549 | if tt.wantError && err == nil { 550 | t.Errorf("prepareHeader() err got = %v, want not nil", err) 551 | } 552 | if header != tt.wantHeader { 553 | t.Errorf("prepareHeader() header got = %v, want %v", header, tt.wantHeader) 554 | } 555 | }) 556 | } 557 | } 558 | 559 | func Test_removeCarriage(t *testing.T) { 560 | tests := []struct { 561 | name string 562 | commit string 563 | want string 564 | }{ 565 | {"normal string", "normal string", "normal string"}, 566 | {"break line", "normal\nstring", "normal\nstring"}, 567 | {"carriage return", "normal\r\nstring", "normal\nstring"}, 568 | } 569 | for _, tt := range tests { 570 | t.Run(tt.name, func(t *testing.T) { 571 | if got := removeCarriage(tt.commit); got != tt.want { 572 | t.Errorf("removeCarriage() = %v, want %v", got, tt.want) 573 | } 574 | }) 575 | } 576 | } 577 | --------------------------------------------------------------------------------