diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..59a291f --- /dev/null +++ b/Makefile @@ -0,0 +1,43 @@ +.PHONY: usage build run test + +OK_COLOR=\033[32;01m +NO_COLOR=\033[0m +ERROR_COLOR=\033[31;01m +WARN_COLOR=\033[33;01m + +PKGS = $(shell go list ./...) +BIN = git-sv + +ECHOFLAGS ?= + +BUILDOS ?= linux +BUILDARCH ?= amd64 +BUILDENVS ?= CGO_ENABLED=0 GOOS=$(BUILDOS) GOARCH=$(BUILDARCH) +BUILDFLAGS ?= -a -installsuffix cgo --ldflags '-extldflags "-lm -lstdc++ -static"' + +usage: Makefile + @echo $(ECHOFLAGS) "to use make call:" + @echo $(ECHOFLAGS) " make " + @echo $(ECHOFLAGS) "" + @echo $(ECHOFLAGS) "list of available actions:" + @sed -n 's/^##//p' $< | column -t -s ':' | sed -e 's/^/ /' + +## build: build git-sv +build: test + @echo $(ECHOFLAGS) "$(OK_COLOR)==> Building binary ($(BUILDOS)/$(BUILDARCH)/$(BIN))...$(NO_COLOR)" + @$(BUILDENVS) go build -v $(BUILDFLAGS) -o bin/$(BUILDOS)_$(BUILDARCH)/$(BIN) ./cmd/git-sv + +## test: run unit tests +test: + @echo $(ECHOFLAGS) "$(OK_COLOR)==> Running tests...$(NO_COLOR)" + @go test $(PKGS) + +## run: run gitlabels-cli +run: + @echo $(ECHOFLAGS) "$(OK_COLOR)==> Running bin/$(BUILDOS)_$(BUILDARCH)/$(BIN)...$(NO_COLOR)" + @./bin/$(BUILDOS)_$(BUILDARCH)/$(BIN) $(args) + +## tidy: execute go mod tidy +tidy: + @echo $(ECHOFLAGS) "$(OK_COLOR)==> runing tidy" + @go mod tidy \ No newline at end of file diff --git a/cmd/git-sv/config.go b/cmd/git-sv/config.go new file mode 100644 index 0000000..9e41477 --- /dev/null +++ b/cmd/git-sv/config.go @@ -0,0 +1,27 @@ +package main + +import ( + "log" + + "github.com/kelseyhightower/envconfig" +) + +// Config env vars for cli configuration +type Config struct { + MajorVersionTypes []string `envconfig:"MAJOR_VERSION_TYPES" default:""` + MinorVersionTypes []string `envconfig:"MINOR_VERSION_TYPES" default:"feat"` + PatchVersionTypes []string `envconfig:"PATCH_VERSION_TYPES" default:"build,ci,docs,fix,perf,refactor,style,test"` + IncludeUnknownTypeAsPatch bool `envconfig:"INCLUDE_UNKNOWN_TYPE_AS_PATCH" default:"true"` + CommitMessageMetadata map[string]string `envconfig:"COMMIT_MESSAGE_METADATA" default:"breakingchange:BREAKING CHANGE,issueid:jira"` + TagPattern string `envconfig:"TAG_PATTERN" default:"v%d.%d.%d"` + ReleaseNotesTags map[string]string `envconfig:"RELEASE_NOTES_TAGS" default:"fix:Bug Fixes,feat:Features"` +} + +func loadConfig() Config { + var c Config + err := envconfig.Process("SV", &c) + if err != nil { + log.Fatal(err.Error()) + } + return c +} diff --git a/cmd/git-sv/handlers.go b/cmd/git-sv/handlers.go new file mode 100644 index 0000000..47147be --- /dev/null +++ b/cmd/git-sv/handlers.go @@ -0,0 +1,85 @@ +package main + +import ( + "encoding/json" + "fmt" + "sv4git/sv" + + "github.com/urfave/cli" +) + +func currentVersionHandler(git sv.Git) func(c *cli.Context) error { + return func(c *cli.Context) error { + describe := git.Describe() + + currentVer, err := sv.ToVersion(describe) + if err != nil { + return err + } + fmt.Printf("%d.%d.%d\n", currentVer.Major(), currentVer.Minor(), currentVer.Patch()) + return nil + } +} + +func nextVersionHandler(git sv.Git, semverProcessor sv.SemVerCommitsProcessor) func(c *cli.Context) error { + return func(c *cli.Context) error { + describe := git.Describe() + + currentVer, err := sv.ToVersion(describe) + if err != nil { + return fmt.Errorf("error parsing version: %s from describe, message: %v", describe, err) + } + + commits, err := git.Log(describe) + if err != nil { + return fmt.Errorf("error getting git log, message: %v", err) + } + + nextVer := semverProcessor.NexVersion(currentVer, commits) + fmt.Printf("%d.%d.%d\n", nextVer.Major(), nextVer.Minor(), nextVer.Patch()) + return nil + } +} + +func commitLogHandler(git sv.Git, semverProcessor sv.SemVerCommitsProcessor) func(c *cli.Context) error { + return func(c *cli.Context) error { + describe := git.Describe() + + commits, err := git.Log(describe) + if err != nil { + return fmt.Errorf("error getting git log, message: %v", err) + } + + for _, commit := range commits { + content, err := json.Marshal(commit) + if err != nil { + return err + } + fmt.Println(string(content)) + } + return nil + } +} + +func releaseNotesHandler(git sv.Git, semverProcessor sv.SemVerCommitsProcessor, rnProcessor sv.ReleaseNoteProcessor) func(c *cli.Context) error { + return func(c *cli.Context) error { + + describe := git.Describe() + + currentVer, err := sv.ToVersion(describe) + if err != nil { + return fmt.Errorf("error parsing version: %s from describe, message: %v", describe, err) + } + + commits, err := git.Log(describe) + if err != nil { + return fmt.Errorf("error getting git log, message: %v", err) + } + + nextVer := semverProcessor.NexVersion(currentVer, commits) + + releasenote := rnProcessor.Get(commits) + fmt.Println(rnProcessor.Format(releasenote, nextVer)) + return nil + } +} diff --git a/cmd/git-sv/main.go b/cmd/git-sv/main.go new file mode 100644 index 0000000..f60a3ab --- /dev/null +++ b/cmd/git-sv/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "log" + "os" + "sv4git/sv" + + "github.com/urfave/cli" +) + +func main() { + cfg := loadConfig() + + git := sv.NewGit(cfg.CommitMessageMetadata, cfg.TagPattern) + semverProcessor := sv.NewSemVerCommitsProcessor(cfg.IncludeUnknownTypeAsPatch, cfg.MajorVersionTypes, cfg.MinorVersionTypes, cfg.PatchVersionTypes) + releasenotesProcessor := sv.NewReleaseNoteProcessor(cfg.ReleaseNotesTags) + + app := cli.NewApp() + app.Name = "sv" + app.Usage = "semantic version for git" + app.Commands = []cli.Command{ + { + Name: "current-version", + Aliases: []string{"cv"}, + Usage: "get last released version from git", + Action: currentVersionHandler(git), + }, + { + Name: "next-version", + Aliases: []string{"nv"}, + Usage: "generate the next version based on git commit messages", + Action: nextVersionHandler(git, semverProcessor), + }, + { + Name: "commit-log", + Aliases: []string{"cl"}, + Usage: "list all commit logs since last version as jsons", + Action: commitLogHandler(git, semverProcessor), + }, + { + Name: "release-notes", + Aliases: []string{"rn"}, + Usage: "generate release notes", + Action: releaseNotesHandler(git, semverProcessor, releasenotesProcessor), + }, + } + + apperr := app.Run(os.Args) + if apperr != nil { + log.Fatal(apperr) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3fdbad0 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module sv4git + +go 1.13 + +require ( + github.com/Masterminds/semver v1.5.0 + github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect + github.com/kelseyhightower/envconfig v1.4.0 + github.com/urfave/cli v1.22.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..30d1ebf --- /dev/null +++ b/go.sum @@ -0,0 +1,19 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= +github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/urfave/cli v1.22.1 h1:+mkCCcOFKPnCmVYVcURKps1Xe+3zP90gSYGNfRkjoIY= +github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/sv/git.go b/sv/git.go new file mode 100644 index 0000000..0af4296 --- /dev/null +++ b/sv/git.go @@ -0,0 +1,150 @@ +package sv + +import ( + "bufio" + "bytes" + "fmt" + "os/exec" + "regexp" + "strings" + + "github.com/Masterminds/semver" +) + +const ( + logSeparator = "##" + endLine = "~~" + breakingChangesTag = "BREAKING CHANGE:" + issueIDTag = "jira:" +) + +// Git commands +type Git interface { + Describe() string + Log(lastTag string) ([]GitCommitLog, error) + Tag(version semver.Version) error +} + +// GitCommitLog description of a single commit log +type GitCommitLog struct { + Hash string `json:"hash,omitempty"` + Type string `json:"type,omitempty"` + Scope string `json:"scope,omitempty"` + Subject string `json:"subject,omitempty"` + Body string `json:"body,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// GitImpl git command implementation +type GitImpl struct { + messageMetadata map[string]string + tagPattern string +} + +// NewGit constructor +func NewGit(messageMetadata map[string]string, tagPattern string) *GitImpl { + return &GitImpl{messageMetadata: messageMetadata, tagPattern: tagPattern} +} + +// Describe runs git describe, it no tag found, return empty +func (GitImpl) Describe() string { + cmd := exec.Command("git", "describe", "--abbrev=0") + out, err := cmd.CombinedOutput() + if err != nil { + return "" + } + return strings.TrimSpace(strings.Trim(string(out), "\n")) +} + +// Log return git log +func (g GitImpl) Log(lastTag string) ([]GitCommitLog, error) { + format := "--pretty=format:\"%h" + logSeparator + "%s" + logSeparator + "%b" + endLine + "\"" + cmd := exec.Command("git", "log", format) + if lastTag != "" { + cmd = exec.Command("git", "log", lastTag+"..HEAD", format) + } + + out, err := cmd.CombinedOutput() + if err != nil { + return nil, err + } + return parseLogOutput(g.messageMetadata, string(out)), nil +} + +// Tag create a git tag +func (g GitImpl) Tag(version semver.Version) error { + tagMsg := fmt.Sprintf("-v \"Version %d.%d.%d\"", version.Major(), version.Minor(), version.Patch()) + cmd := exec.Command("git", "tag", "-a "+fmt.Sprintf(g.tagPattern, version.Major(), version.Minor(), version.Patch()), tagMsg) + return cmd.Run() +} + +func parseLogOutput(messageMetadata map[string]string, log string) []GitCommitLog { + scanner := bufio.NewScanner(strings.NewReader(log)) + scanner.Split(splitAt([]byte(endLine))) + var logs []GitCommitLog + for scanner.Scan() { + if text := strings.TrimSpace(strings.Trim(scanner.Text(), "\"")); text != "" { + logs = append(logs, parseCommitLog(messageMetadata, text)) + } + } + return logs +} + +func parseCommitLog(messageMetadata map[string]string, commit string) GitCommitLog { + content := strings.Split(strings.Trim(commit, "\""), logSeparator) + commitType, scope, subject := parseCommitLogMessage(content[1]) + + metadata := make(map[string]string) + for k, v := range messageMetadata { + if tagValue := extractTag(v, content[2]); tagValue != "" { + metadata[k] = tagValue + } + } + + return GitCommitLog{ + Hash: content[0], + Type: commitType, + Scope: scope, + Subject: subject, + Body: content[2], + Metadata: metadata, + } +} + +func parseCommitLogMessage(message string) (string, string, string) { + regex := regexp.MustCompile("([a-z]+)(\\((.*)\\))?: (.*)") + result := regex.FindStringSubmatch(message) + if len(result) != 5 { + return "", "", message + } + return result[1], result[3], strings.TrimSpace(result[4]) +} + +func extractTag(tag, text string) string { + regex := regexp.MustCompile(tag + ": (.*)") + result := regex.FindStringSubmatch(text) + if len(result) < 2 { + return "" + } + return result[1] +} + +func splitAt(b []byte) func(data []byte, atEOF bool) (advance int, token []byte, err error) { + return func(data []byte, atEOF bool) (advance int, token []byte, err error) { + dataLen := len(data) + + if atEOF && dataLen == 0 { + return 0, nil, nil + } + + if i := bytes.Index(data, b); i >= 0 { + return i + len(b), data[0:i], nil + } + + if atEOF { + return dataLen, data, nil + } + + return 0, nil, nil + } +} diff --git a/sv/releasenotes.go b/sv/releasenotes.go new file mode 100644 index 0000000..8b1b08a --- /dev/null +++ b/sv/releasenotes.go @@ -0,0 +1,98 @@ +package sv + +import ( + "bytes" + "fmt" + "text/template" + "time" + + "github.com/Masterminds/semver" +) + +type releaseNoteTemplate struct { + Version string + Date string + Sections map[string]ReleaseNoteSection + BreakingChanges []string +} + +const markdownTemplate = `# v{{.Version}} ({{.Date}}) + +{{if .Sections.feat}}## {{.Sections.feat.Name}} +{{range $k,$v := .Sections.feat.Items}} +- {{if $v.Scope}}**{{$v.Scope}}:** {{end}}{{$v.Subject}} ({{$v.Hash}}) {{if $v.Metadata.issueid}}({{$v.Metadata.issueid}}){{end}}{{end}}{{end}} + +{{if .Sections.fix}}## {{.Sections.fix.Name}} +{{range $k,$v := .Sections.fix.Items}} +- {{if $v.Scope}}**{{$v.Scope}}:** {{end}}{{$v.Subject}} ({{$v.Hash}}) {{if $v.Metadata.issueid}}({{$v.Metadata.issueid}}){{end}}{{end}}{{end}} + +{{if .BreakingChanges}}## Breaking Changes +{{range $k,$v := .BreakingChanges}} +- {{$v}}{{end}} +{{end}}` + +// ReleaseNoteProcessor release note processor interface. +type ReleaseNoteProcessor interface { + Get(commits []GitCommitLog) ReleaseNote + Format(releasenote ReleaseNote, version semver.Version) string +} + +// ReleaseNoteProcessorImpl release note based on commit log. +type ReleaseNoteProcessorImpl struct { + tags map[string]string + template *template.Template +} + +// NewReleaseNoteProcessor ReleaseNoteProcessor constructor. +func NewReleaseNoteProcessor(tags map[string]string) *ReleaseNoteProcessorImpl { + template := template.Must(template.New("markdown").Parse(markdownTemplate)) + return &ReleaseNoteProcessorImpl{tags: tags, template: template} +} + +// Get generate a release note based on commits. +func (p ReleaseNoteProcessorImpl) Get(commits []GitCommitLog) ReleaseNote { + sections := make(map[string]ReleaseNoteSection) + var breakingChanges []string + for _, commit := range commits { + if name, exists := p.tags[commit.Type]; exists { + section, sexists := sections[commit.Type] + if !sexists { + section = ReleaseNoteSection{Name: name} + } + section.Items = append(section.Items, commit) + sections[commit.Type] = section + } + if value, exists := commit.Metadata[BreakingChangeTag]; exists { + breakingChanges = append(breakingChanges, value) + } + } + + return ReleaseNote{Date: time.Now(), Sections: sections, BreakingChanges: breakingChanges} +} + +// Format format a release note. +func (p ReleaseNoteProcessorImpl) Format(releasenote ReleaseNote, version semver.Version) string { + templateVars := releaseNoteTemplate{ + Version: fmt.Sprintf("%d.%d.%d", version.Major(), version.Minor(), version.Patch()), + Date: releasenote.Date.Format("2006-01-02"), + Sections: releasenote.Sections, + BreakingChanges: releasenote.BreakingChanges, + } + + var b bytes.Buffer + p.template.Execute(&b, templateVars) + return b.String() +} + +// ReleaseNote release note. +type ReleaseNote struct { + Date time.Time + Sections map[string]ReleaseNoteSection + BreakingChanges []string +} + +// ReleaseNoteSection release note section. +type ReleaseNoteSection struct { + Name string + Items []GitCommitLog +} diff --git a/sv/semver.go b/sv/semver.go new file mode 100644 index 0000000..51b3f97 --- /dev/null +++ b/sv/semver.go @@ -0,0 +1,96 @@ +package sv + +import "github.com/Masterminds/semver" + +type versionType int + +const ( + none versionType = iota + patch + minor + major +) + +// ToVersion parse string to semver.Version +func ToVersion(value string) (semver.Version, error) { + version := value + if version == "" { + version = "0.0.0" + } + v, err := semver.NewVersion(version) + return *v, err +} + +// BreakingChangeTag breaking change tag from commit metadata +const BreakingChangeTag string = "breakingchange" + +// SemVerCommitsProcessor interface +type SemVerCommitsProcessor interface { + NexVersion(version semver.Version, commits []GitCommitLog) semver.Version +} + +// SemVerCommitsProcessorImpl process versions using commit log +type SemVerCommitsProcessorImpl struct { + MajorVersionTypes map[string]struct{} + MinorVersionTypes map[string]struct{} + PatchVersionTypes map[string]struct{} + IncludeUnknownTypeAsPatch bool +} + +// NewSemVerCommitsProcessor SemanticVersionCommitsProcessorImpl constructor +func NewSemVerCommitsProcessor(unknownAsPatch bool, majorTypes, minorTypes, patchTypes []string) *SemVerCommitsProcessorImpl { + return &SemVerCommitsProcessorImpl{ + IncludeUnknownTypeAsPatch: unknownAsPatch, + MajorVersionTypes: toMap(majorTypes), + MinorVersionTypes: toMap(minorTypes), + PatchVersionTypes: toMap(patchTypes), + } +} + +// NexVersion calculates next version based on commit log +func (p SemVerCommitsProcessorImpl) NexVersion(version semver.Version, commits []GitCommitLog) semver.Version { + var versionToUpdate = none + for _, commit := range commits { + if v := p.versionTypeToUpdate(commit); v > versionToUpdate { + versionToUpdate = v + } + } + + switch versionToUpdate { + case major: + return version.IncMajor() + case minor: + return version.IncMinor() + case patch: + return version.IncPatch() + default: + return version + } +} + +func (p SemVerCommitsProcessorImpl) versionTypeToUpdate(commit GitCommitLog) versionType { + if _, exists := commit.Metadata[BreakingChangeTag]; exists { + return major + } + if _, exists := p.MajorVersionTypes[commit.Type]; exists { + return major + } + if _, exists := p.MinorVersionTypes[commit.Type]; exists { + return minor + } + if _, exists := p.PatchVersionTypes[commit.Type]; exists { + return patch + } + if p.IncludeUnknownTypeAsPatch { + return patch + } + return none +} + +func toMap(values []string) map[string]struct{} { + result := make(map[string]struct{}) + for _, v := range values { + result[v] = struct{}{} + } + return result +}