From 03caad0cb1a02e8bf688557f21c3bd58b2c69fdc Mon Sep 17 00:00:00 2001 From: Andrew Rynhard Date: Wed, 5 Jul 2017 21:39:19 -0700 Subject: [PATCH] feat(policy): add policy enforcement; enforce git commit policy (#17) feat(policy): add policy enforcement; enforce git commit policy --- conform.yaml | 16 ++++++ conform/config/config.go | 12 +++++ conform/enforce.go | 89 +++++++++++------------------- conform/git/git.go | 98 +++++++++++++++++++++++++++++++++- conform/git/policy.go | 96 +++++++++++++++++++++++++++++++++ conform/policy/policy.go | 12 +++++ conform/utilities/utilities.go | 15 ++++++ 7 files changed, 279 insertions(+), 59 deletions(-) create mode 100644 conform/git/policy.go create mode 100644 conform/policy/policy.go create mode 100644 conform/utilities/utilities.go diff --git a/conform.yaml b/conform.yaml index 8f033cb7..aef24491 100644 --- a/conform.yaml +++ b/conform.yaml @@ -3,6 +3,22 @@ metadata: default: image +policies: + git: + types: + - "docs" + - "style" + - "refactor" + - "perf" + - "test" + - "chore" + scopes: + - "ci" + - "docker" + - "git" + - "policy" + - "*" + scripts: init : | #!/bin/bash diff --git a/conform/config/config.go b/conform/config/config.go index 27b2c0df..6a1d7956 100644 --- a/conform/config/config.go +++ b/conform/config/config.go @@ -12,6 +12,7 @@ type Config struct { Debug bool Default *string `yaml:"default"` Metadata *Metadata `yaml:"metadata"` + Policies *Policies `yaml:"policies"` Scripts map[string]string `yaml:"scripts"` Templates map[string]string `yaml:"templates"` Rules map[string]*Rule `yaml:"rules"` @@ -23,6 +24,17 @@ type Metadata struct { Registry *string `yaml:"registry"` } +// Policies contains policies that are enforced. +type Policies struct { + Git *Git `yaml:"git"` +} + +// Git contains git specific policies. +type Git struct { + Types []string `yaml:"types"` + Scopes []string `yaml:"scopes"` +} + // Rule contains rules. type Rule struct { Templates []string `yaml:"templates"` diff --git a/conform/enforce.go b/conform/enforce.go index 56864714..9efc1894 100644 --- a/conform/enforce.go +++ b/conform/enforce.go @@ -5,13 +5,13 @@ import ( "fmt" "os" "os/exec" - "strconv" "strings" "text/template" "github.com/Masterminds/sprig" "github.com/autonomy/conform/conform/config" "github.com/autonomy/conform/conform/git" + "github.com/autonomy/conform/conform/policy" ) // Enforcer performs all the build actions for a rule. @@ -29,10 +29,6 @@ func NewEnforcer(rule string) (enforcer *Enforcer, err error) { if err != nil { return } - err = exportAll(gitInfo) - if err != nil { - return - } date := []byte{} if gitInfo.IsTag { date, err = exec.Command("/bin/date").Output() @@ -146,8 +142,38 @@ func (e *Enforcer) ExecuteScript(script string) error { return fmt.Errorf("Script %q is not defined in conform.yaml", script) } +// EnforcePolicies enforces all defined polcies. In the case that the working +// tree is dirty, all git policies are skipped. +func (e *Enforcer) EnforcePolicies() { + if !e.GitInfo.IsDirty { + enforceGitPolicy( + e.GitInfo, + &git.ConventionalCommitsOptions{ + Message: e.GitInfo.Message, + Types: e.config.Policies.Git.Types, + Scopes: e.config.Policies.Git.Scopes, + }, + ) + } +} + +func enforceGitPolicy(p policy.Policy, opts *git.ConventionalCommitsOptions) { + report, err := p.Compliance(opts) + if err != nil { + fmt.Print(err) + os.Exit(1) + } + if !report.Valid { + for _, err := range report.Errors { + fmt.Printf("%s", err) + os.Exit(1) + } + } +} + // ExecuteRule performs all the relevant actions specified in its' declaration. func (e *Enforcer) ExecuteRule() error { + e.EnforcePolicies() if t, ok := e.config.Rules[e.rule]; ok { fmt.Printf("Enforcing %q\n", e.rule) for _, s := range t.Before { @@ -177,59 +203,6 @@ func (e *Enforcer) ExecuteRule() error { return fmt.Errorf("Rule %q is not defined in conform.yaml", e.rule) } -func exportAll(gitInfo *git.Info) (err error) { - fmt.Printf("Branch: %s\n", gitInfo.Branch) - err = ExportConformVar("branch", gitInfo.Branch) - if err != nil { - return - } - fmt.Printf("SHA: %s\n", gitInfo.SHA) - err = ExportConformVar("sha", gitInfo.SHA) - if err != nil { - return - } - fmt.Printf("Tag: %s\n", gitInfo.Tag) - err = ExportConformVar("tag", gitInfo.Tag) - if err != nil { - return - } - fmt.Printf("IsTag: %s\n", strconv.FormatBool(gitInfo.IsTag)) - err = ExportConformVar("is_tag", strconv.FormatBool(gitInfo.IsTag)) - if err != nil { - return - } - fmt.Printf("Prerelease: %s\n", gitInfo.Prerelease) - err = ExportConformVar("prerelease", gitInfo.Prerelease) - if err != nil { - return - } - fmt.Printf("IsPrerelease: %s\n", strconv.FormatBool(gitInfo.IsPrerelease)) - err = ExportConformVar("is_prerelease", strconv.FormatBool(gitInfo.IsPrerelease)) - if err != nil { - return - } - fmt.Printf("Status: \n%s\n", strings.TrimRight(gitInfo.Status, "\n")) - err = ExportConformVar("status", strconv.FormatBool(gitInfo.IsDirty)) - if err != nil { - return - } - fmt.Printf("IsDirty: %s\n", strconv.FormatBool(gitInfo.IsDirty)) - err = ExportConformVar("is_dirty", strconv.FormatBool(gitInfo.IsDirty)) - if err != nil { - return - } - - return -} - -// ExportConformVar exports variable prefixed with CONFORM_ -func ExportConformVar(name, value string) (err error) { - variable := fmt.Sprintf("CONFORM_%s", strings.ToUpper(name)) - err = os.Setenv(variable, value) - - return -} - // FormatImageNameDirty formats the image name. func (e *Enforcer) FormatImageNameDirty() string { return fmt.Sprintf("%s:%s", *e.config.Metadata.Repository, "dirty") diff --git a/conform/git/git.go b/conform/git/git.go index 0f9ae71e..6e32ae25 100644 --- a/conform/git/git.go +++ b/conform/git/git.go @@ -1,8 +1,12 @@ package git import ( + "fmt" + "strconv" + "strings" + "github.com/Masterminds/semver" - // git "github.com/libgit2/git2go" + "github.com/autonomy/conform/conform/utilities" "gopkg.in/src-d/go-git.v4" "gopkg.in/src-d/go-git.v4/plumbing" ) @@ -14,6 +18,7 @@ type Info struct { Tag string Prerelease string Status string + Message string IsTag bool IsPrerelease bool IsDirty bool @@ -51,12 +56,18 @@ func NewInfo() (info *Info, err error) { return } + message, err := Message(repo, isDirty) + if err != nil { + return + } + info = &Info{ Branch: branch, SHA: sha, Tag: tag, Prerelease: prerelease, Status: status, + Message: message, IsTag: isTag, IsPrerelease: isPrerelease, IsDirty: isDirty, @@ -75,6 +86,12 @@ func Branch(repo *git.Repository) (branch string, err error) { branch = ref.Name().Short() } + fmt.Printf("Branch: %s\n", branch) + err = utilities.ExportConformVar("branch", branch) + if err != nil { + return + } + return } @@ -86,6 +103,12 @@ func SHA(repo *git.Repository) (sha string, err error) { } sha = ref.Hash().String()[0:7] + fmt.Printf("SHA: %s\n", sha) + err = utilities.ExportConformVar("sha", sha) + if err != nil { + return + } + return } @@ -111,6 +134,17 @@ func Tag(repo *git.Repository) (tag string, isTag bool, err error) { return } + fmt.Printf("Tag: %s\n", tag) + err = utilities.ExportConformVar("tag", tag) + if err != nil { + return + } + fmt.Printf("IsTag: %s\n", strconv.FormatBool(isTag)) + err = utilities.ExportConformVar("is_tag", strconv.FormatBool(isTag)) + if err != nil { + return + } + return } @@ -128,6 +162,17 @@ func Prerelease(tag string, isTag bool) (prerelease string, isPrerelease bool, e } } + fmt.Printf("Prerelease: %s\n", prerelease) + err = utilities.ExportConformVar("prerelease", prerelease) + if err != nil { + return + } + fmt.Printf("IsPrerelease: %s\n", strconv.FormatBool(isPrerelease)) + err = utilities.ExportConformVar("is_prerelease", strconv.FormatBool(isPrerelease)) + if err != nil { + return + } + return } @@ -138,6 +183,9 @@ func Status(repo *git.Repository) (status string, isDirty bool, err error) { return } worktreeStatus, err := worktree.Status() + if err != nil { + return + } if worktreeStatus.IsClean() { status = " nothing to commit, working tree clean" } else { @@ -145,5 +193,53 @@ func Status(repo *git.Repository) (status string, isDirty bool, err error) { status = worktreeStatus.String() } + fmt.Printf("Status: \n%s\n", strings.TrimRight(status, "\n")) + err = utilities.ExportConformVar("status", strconv.FormatBool(isDirty)) + if err != nil { + return + } + fmt.Printf("IsDirty: %s\n", strconv.FormatBool(isDirty)) + err = utilities.ExportConformVar("is_dirty", strconv.FormatBool(isDirty)) + if err != nil { + return + } + + return +} + +// Message returns the commit message. In the case that a commit has multiple +// parents, the message of the last parent is returned. +func Message(repo *git.Repository, isDirty bool) (message string, err error) { + ref, err := repo.Head() + if err != nil { + return + } + commit, err := repo.CommitObject(ref.Hash()) + if err != nil { + return + } + if commit.NumParents() != 1 { + parents := commit.Parents() + for i := 1; i <= commit.NumParents(); i++ { + next, err := parents.Next() + if err != nil { + return "", err + } + if i == commit.NumParents() { + message = next.Message + } + } + } else { + message = commit.Message + } + + if !isDirty { + fmt.Printf("Message: %s\n", strings.TrimRight(message, "\n")) + err = utilities.ExportConformVar("message", message) + if err != nil { + return + } + } + return } diff --git a/conform/git/policy.go b/conform/git/policy.go new file mode 100644 index 00000000..e2297b60 --- /dev/null +++ b/conform/git/policy.go @@ -0,0 +1,96 @@ +package git + +import ( + "fmt" + "regexp" + "strings" + + "github.com/autonomy/conform/conform/policy" +) + +// ConventionalCommitsOneDotZeroDotZeroBeta1 is the regular expression used for Conventional Commits 1.0.0-beta.1. +const ConventionalCommitsOneDotZeroDotZeroBeta1 = `^(\w*)\(([^)]+)\):\s{1}(.*)($|\n{2})` + +// TypeFeat is a commit of the type fix patches a bug in your codebase (this correlates with PATCH in semantic versioning). +const TypeFeat = "feat" + +// TypeFix is a commit of the type feat introduces a new feature to the codebase (this correlates with MINOR in semantic versioning). +const TypeFix = "fix" + +// ConventionalCommitsOptions are the configurable options used to check the copliance of conventional commits policy. +type ConventionalCommitsOptions struct { + Message string + Types []string + Scopes []string +} + +// Compliance implements the policy.Policy interface. +func (i *Info) Compliance(obj interface{}) (report *policy.Report, err error) { + opts := obj.(*ConventionalCommitsOptions) + report = &policy.Report{Valid: true} + re, err := regexp.Compile(ConventionalCommitsOneDotZeroDotZeroBeta1) + if err != nil { + return + } + lines := strings.Split(opts.Message, "\n") + groups := re.FindStringSubmatch(lines[0]) + if len(groups) != 5 { + err = fmt.Errorf("Invalid commit format") + return + } + validType, err := ValidateType(groups, opts.Types) + if !validType { + report.Valid = false + report.Errors = append(report.Errors, err) + } + validScope, err := ValidateScope(groups, opts.Scopes) + if !validScope { + report.Valid = false + report.Errors = append(report.Errors, err) + } + validDescription, err := ValidateDescription(groups) + if !validDescription { + report.Valid = false + report.Errors = append(report.Errors, err) + } + + return +} + +// ValidateType returns the commit type. +func ValidateType(groups []string, types []string) (valid bool, err error) { + types = append(types, TypeFeat, TypeFix) + for _, t := range types { + if t == groups[1] { + valid = true + } + } + if !valid { + err = fmt.Errorf("Invalid type: %s", groups[1]) + } + + return +} + +// ValidateScope returns the commit scope. +func ValidateScope(groups []string, scopes []string) (valid bool, err error) { + for _, scope := range scopes { + if scope == groups[2] { + valid = true + } + } + if !valid { + err = fmt.Errorf("Invalid scope: %s", groups[2]) + } + + return +} + +// ValidateDescription returns the commit description. +func ValidateDescription(groups []string) (valid bool, err error) { + if groups[3] != "" { + valid = true + } + + return +} diff --git a/conform/policy/policy.go b/conform/policy/policy.go new file mode 100644 index 00000000..a0964721 --- /dev/null +++ b/conform/policy/policy.go @@ -0,0 +1,12 @@ +package policy + +// Report summarizes the compliance of a policy. +type Report struct { + Valid bool + Errors []error +} + +// Policy is an interface used for enforcing policies. +type Policy interface { + Compliance(interface{}) (report *Report, err error) +} diff --git a/conform/utilities/utilities.go b/conform/utilities/utilities.go new file mode 100644 index 00000000..5bdfa137 --- /dev/null +++ b/conform/utilities/utilities.go @@ -0,0 +1,15 @@ +package utilities + +import ( + "fmt" + "os" + "strings" +) + +// ExportConformVar exports variable prefixed with CONFORM_ +func ExportConformVar(name, value string) (err error) { + variable := fmt.Sprintf("CONFORM_%s", strings.ToUpper(name)) + err = os.Setenv(variable, value) + + return +}