diff --git a/.drone.yml b/.drone.yml index 29769000..fa6a43da 100644 --- a/.drone.yml +++ b/.drone.yml @@ -2,19 +2,27 @@ kind: pipeline name: default services: -- name: docker - image: docker:dind - privileged: true - volumes: - - name: dockersock - path: /var/run + - name: docker + image: docker:dind + privileged: true + volumes: + - name: dockersock + path: /var/run steps: - - name: enforce + - name: build image: autonomy/build-container:latest pull: always commands: - make build + volumes: + - name: dockersock + path: /var/run + + - name: conform + image: autonomy/build-container:latest + pull: always + commands: - build/conform-linux-amd64 enforce volumes: - name: dockersock @@ -59,5 +67,5 @@ steps: - push volumes: -- name: dockersock - temp: {} + - name: dockersock + temp: {} diff --git a/Dockerfile b/Dockerfile index 7e41396f..47b8838d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,7 @@ ARG GOLANG_IMAGE FROM ${GOLANG_IMAGE} AS common - ENV CGO_ENABLED 0 ENV GO111MODULES on - WORKDIR /conform COPY go.mod ./ COPY go.sum ./ @@ -34,7 +32,15 @@ COPY ./hack ./hack RUN chmod +x ./hack/test.sh RUN ./hack/test.sh --all +FROM alpine:3.9 as ca-certificates +RUN apk add --update --no-cache ca-certificates + FROM scratch AS image +LABEL com.github.actions.name="Conform" +LABEL com.github.actions.description="Policy enforcement for your pipelines." +LABEL com.github.actions.icon="shield" +LABEL com.github.actions.color="yellow" +COPY --from=ca-certificates /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=build /conform-linux-amd64 /conform ENTRYPOINT [ "/conform" ] CMD [ "enforce" ] diff --git a/Makefile b/Makefile index 3b10b6e6..2b075cd7 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ SHA := $(shell gitmeta git sha) TAG := $(shell gitmeta image tag) BUILT := $(shell gitmeta built) -GOLANG_IMAGE ?= golang:1.12.3 +GOLANG_IMAGE ?= golang:1.12.6 COMMON_ARGS := -f ./Dockerfile --build-arg GOLANG_IMAGE=$(GOLANG_IMAGE) --build-arg SHA=$(SHA) --build-arg TAG=$(TAG) --build-arg BUILT="$(BUILT)" . diff --git a/go.mod b/go.mod index 8347cf69..a1780abf 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,8 @@ require ( github.com/fsnotify/fsnotify v1.4.2 // indirect github.com/gliderlabs/ssh v0.1.1 // indirect github.com/google/go-cmp v0.2.0 // indirect + github.com/google/go-github v17.0.0+incompatible + github.com/google/go-querystring v1.0.0 // indirect github.com/hashicorp/hcl v0.0.0-20170509225359-392dba7d905e // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect diff --git a/go.sum b/go.sum index 8b54d8bf..e5f7d146 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,10 @@ github.com/gliderlabs/ssh v0.1.1 h1:j3L6gSLQalDETeEg/Jg0mGY0/y/N6zI2xX1978P0Uqw= github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= +github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/hashicorp/hcl v0.0.0-20170509225359-392dba7d905e h1:KJWs1uTCkN3E/J5ofCH9Pf8KKsibTFc3fv0CA9+WsVo= github.com/hashicorp/hcl v0.0.0-20170509225359-392dba7d905e/go.mod h1:oZtUIOe8dh44I2q6ScRibXws4Ajl+d+nod3AaR9vL5w= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= diff --git a/hack/golangci-lint.yaml b/hack/golangci-lint.yaml index 03cc7e3a..3e32149e 100644 --- a/hack/golangci-lint.yaml +++ b/hack/golangci-lint.yaml @@ -104,7 +104,6 @@ linters: disable-all: false fast: false - issues: # List of regexps of issue texts to exclude, empty list by default. # But independently from this option we use default exclude patterns, diff --git a/internal/enforcer/enforcer.go b/internal/enforcer/enforcer.go index f05965a8..fae36786 100644 --- a/internal/enforcer/enforcer.go +++ b/internal/enforcer/enforcer.go @@ -7,6 +7,7 @@ package enforcer import ( "fmt" "io/ioutil" + "log" "os" "text/tabwriter" @@ -59,45 +60,44 @@ func (c *Conform) Enforce(setters ...policy.Option) { const padding = 8 w := tabwriter.NewWriter(os.Stdout, 0, 0, padding, ' ', 0) - fmt.Fprintln(w, "POLICY\tSTATUS\tMESSAGE\t") + fmt.Fprintln(w, "POLICY\tCHECK\tSTATUS\tMESSAGE\t") - var failed bool + pass := true for _, p := range c.Policies { - if errs := c.enforce(p, opts); errs != nil { - failed = true - for _, err := range errs { - fmt.Fprintf(w, "%s\t%s\t%v\t\n", p.Type, "FAILED", err) + report, err := c.enforce(p, opts) + if err != nil { + log.Fatal(err) + } + for _, check := range report.Checks() { + if len(check.Errors()) != 0 { + for _, err := range check.Errors() { + fmt.Fprintf(w, "%s\t%s\t%s\t%v\t\n", p.Type, check.Name(), "FAILED", err) + } + } else { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t\n", p.Type, check.Name(), "PASS", "") } - } else { - fmt.Fprintf(w, "%s\t%s\t%s\t\n", p.Type, "PASS", "") } } // nolint: errcheck w.Flush() - if failed { + if !pass { os.Exit(1) } } -func (c *Conform) enforce(declaration *PolicyDeclaration, opts *policy.Options) []error { +func (c *Conform) enforce(declaration *PolicyDeclaration, opts *policy.Options) (*policy.Report, error) { if _, ok := policyMap[declaration.Type]; !ok { - return []error{errors.Errorf("Policy %q is not defined", declaration.Type)} + return nil, errors.Errorf("Policy %q is not defined", declaration.Type) } p := policyMap[declaration.Type] err := mapstructure.Decode(declaration.Spec, p) if err != nil { - return []error{errors.Errorf("Internal error: %v", err)} - } - - report := p.Compliance(opts) - - if !report.Valid() { - return report.Errors + return nil, errors.Errorf("Internal error: %v", err) } - return nil + return p.Compliance(opts) } diff --git a/internal/policy/commit/check_conventional_commit.go b/internal/policy/commit/check_conventional_commit.go new file mode 100644 index 00000000..9ac1488b --- /dev/null +++ b/internal/policy/commit/check_conventional_commit.go @@ -0,0 +1,113 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package commit + +import ( + "regexp" + "strings" + + "github.com/autonomy/conform/internal/policy" + "github.com/pkg/errors" +) + +// Conventional implements the policy.Policy interface and enforces commit +// messages to conform the Conventional Commit standard. +type Conventional struct { + Types []string `mapstructure:"types"` + Scopes []string `mapstructure:"scopes"` +} + +// HeaderRegex is the regular expression used for Conventional Commits +// 1.0.0-beta.1. +var HeaderRegex = regexp.MustCompile(`^(\w*)(\(([^)]+)\))?:\s{1}(.*)($|\n{2})`) + +const ( + // TypeFeat is a commit of the type fix patches a bug in your codebase + // (this correlates with PATCH in semantic versioning). + TypeFeat = "feat" + + // TypeFix is a commit of the type feat introduces a new feature to the + // codebase (this correlates with MINOR in semantic versioning). + TypeFix = "fix" +) + +// ConventionalCommitCheck ensures that the commit message is a valid +// conventional commit. +type ConventionalCommitCheck struct { + errors []error +} + +// Name returns the name of the check. +func (c ConventionalCommitCheck) Name() string { + return "Conventional Commit" +} + +// Message returns to check message. +func (c ConventionalCommitCheck) Message() string { + if len(c.errors) != 0 { + return c.errors[0].Error() + } + return "Commit message is a valid conventional commit" +} + +// Errors returns any violations of the check. +func (c ConventionalCommitCheck) Errors() []error { + return c.errors +} + +// ValidateConventionalCommit returns the commit type. +// nolint: gocyclo +func (c Commit) ValidateConventionalCommit() policy.Check { + check := &ConventionalCommitCheck{} + groups := parseHeader(c.msg) + if len(groups) != 6 { + check.errors = append(check.errors, errors.Errorf("Invalid conventional commits format: %q", c.msg)) + return check + } + + c.Conventional.Types = append(c.Conventional.Types, TypeFeat, TypeFix) + typeIsValid := false + for _, t := range c.Conventional.Types { + if t == groups[1] { + typeIsValid = true + } + } + if !typeIsValid { + check.errors = append(check.errors, errors.Errorf("Invalid type %q: allowed types are %v", groups[1], c.Conventional.Types)) + return check + } + + // Scope is optional. + if groups[3] != "" { + scopeIsValid := false + for _, scope := range c.Conventional.Scopes { + if scope == groups[3] { + scopeIsValid = true + break + } + } + if !scopeIsValid { + check.errors = append(check.errors, errors.Errorf("Invalid scope %q: allowed scopes are %v", groups[3], c.Conventional.Scopes)) + return check + } + } + + if len(groups[4]) <= 72 && len(groups[4]) != 0 { + return check + } + check.errors = append(check.errors, errors.Errorf("Invalid description: %s", groups[4])) + + return check +} + +func parseHeader(msg string) []string { + // To circumvent any policy violation due to the leading \n that GitHub + // prefixes to the commit message on a squash merge, we remove it from the + // message. + header := strings.Split(strings.TrimPrefix(msg, "\n"), "\n")[0] + groups := HeaderRegex.FindStringSubmatch(header) + + return groups +} diff --git a/internal/policy/commit/check_dco.go b/internal/policy/commit/check_dco.go new file mode 100644 index 00000000..fcb092d2 --- /dev/null +++ b/internal/policy/commit/check_dco.go @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package commit + +import ( + "regexp" + "strings" + + "github.com/autonomy/conform/internal/policy" + "github.com/pkg/errors" +) + +// DCORegex is the regular expression used for Developer Certificate of Origin. +var DCORegex = regexp.MustCompile(`^Signed-off-by: ([^<]+) <([^<>@]+@[^<>]+)>$`) + +// DCOCheck ensures that the commit message contains a +// Developer Certificate of Origin. +type DCOCheck struct { + errors []error +} + +// Name returns the name of the check. +func (d DCOCheck) Name() string { + return "DCO" +} + +// Message returns to check message. +func (d DCOCheck) Message() string { + if len(d.errors) != 0 { + return d.errors[0].Error() + } + return "Developer Certificate of Origin was found" +} + +// Errors returns any violations of the check. +func (d DCOCheck) Errors() []error { + return d.errors +} + +// ValidateDCO checks the commit message for a Developer Certificate of Origin. +func (c Commit) ValidateDCO() policy.Check { + check := &DCOCheck{} + for _, line := range strings.Split(c.msg, "\n") { + if DCORegex.MatchString(strings.TrimSpace(line)) { + return check + } + } + + check.errors = append(check.errors, errors.Errorf("Commit does not have a DCO")) + + return check +} diff --git a/internal/policy/commit/check_gpg_signature.go b/internal/policy/commit/check_gpg_signature.go new file mode 100644 index 00000000..ec430f54 --- /dev/null +++ b/internal/policy/commit/check_gpg_signature.go @@ -0,0 +1,53 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package commit + +import ( + "github.com/autonomy/conform/internal/git" + "github.com/autonomy/conform/internal/policy" + "github.com/pkg/errors" +) + +// GPGCheck ensures that the commit is cryptographically signed using GPG. +type GPGCheck struct { + errors []error +} + +// Name returns the name of the check. +func (g GPGCheck) Name() string { + return "GPG" +} + +// Message returns to check message. +func (g GPGCheck) Message() string { + if len(g.errors) != 0 { + return g.errors[0].Error() + } + return "GPG signature found" +} + +// Errors returns any violations of the check. +func (g GPGCheck) Errors() []error { + return g.errors +} + +// ValidateGPGSign checks the commit message for a GPG signature. +func (c Commit) ValidateGPGSign(g *git.Git) policy.Check { + check := &GPGCheck{} + + ok, err := g.HasGPGSignature() + if err != nil { + check.errors = append(check.errors, err) + return check + } + + if ok { + return check + } + + check.errors = append(check.errors, errors.Errorf("Commit does not have a GPG signature")) + + return check +} diff --git a/internal/policy/commit/check_header_length.go b/internal/policy/commit/check_header_length.go new file mode 100644 index 00000000..927e490a --- /dev/null +++ b/internal/policy/commit/check_header_length.go @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package commit + +import ( + "fmt" + "strings" + + "github.com/autonomy/conform/internal/policy" + "github.com/pkg/errors" +) + +// MaxNumberOfCommitCharacters is the default maximium number of characters +// allowed in a commit header. +var MaxNumberOfCommitCharacters = 89 + +// HeaderLengthCheck enforces a maximum number of charcters on the commit +// header. +type HeaderLengthCheck struct { + headerLength int + errors []error +} + +// Name returns the name of the check. +func (h HeaderLengthCheck) Name() string { + return "Header Length" +} + +// Message returns to check message. +func (h HeaderLengthCheck) Message() string { + return fmt.Sprintf("Header is %d characters", h.headerLength) +} + +// Errors returns any violations of the check. +func (h HeaderLengthCheck) Errors() []error { + return h.errors +} + +// ValidateHeaderLength checks the header length. +func (c Commit) ValidateHeaderLength() policy.Check { + check := &HeaderLengthCheck{} + + if c.HeaderLength != 0 { + MaxNumberOfCommitCharacters = c.HeaderLength + } + + header := strings.Split(strings.TrimPrefix(c.msg, "\n"), "\n")[0] + check.headerLength = len(header) + if check.headerLength > MaxNumberOfCommitCharacters { + check.errors = append(check.errors, errors.Errorf("Commit header is %d characters", len(header))) + } + + return check +} diff --git a/internal/policy/commit/check_imperative_verb.go b/internal/policy/commit/check_imperative_verb.go new file mode 100644 index 00000000..238245c2 --- /dev/null +++ b/internal/policy/commit/check_imperative_verb.go @@ -0,0 +1,68 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package commit + +import ( + "strings" + + "github.com/autonomy/conform/internal/policy" + "github.com/pkg/errors" + "gopkg.in/jdkato/prose.v2" +) + +// ImperativeCheck enforces that the first word of a commit message header is +// and imperative verb. +type ImperativeCheck struct { + errors []error +} + +// Name returns the name of the check. +func (i ImperativeCheck) Name() string { + return "Imperative Mood" +} + +// Message returns to check message. +func (i ImperativeCheck) Message() string { + if len(i.errors) != 0 { + return i.errors[0].Error() + } + return "Commit begins with imperative verb" +} + +// Errors returns any violations of the check. +func (i ImperativeCheck) Errors() []error { + return i.errors +} + +// ValidateImperative checks the commit message for a GPG signature. +func (c Commit) ValidateImperative() policy.Check { + check := &ImperativeCheck{} + var ( + word string + err error + ) + if word, err = c.firstWord(); err != nil { + check.errors = append(check.errors, err) + return check + } + doc, err := prose.NewDocument("I " + strings.ToLower(word)) + if err != nil { + check.errors = append(check.errors, errors.Errorf("Failed to create document: %v", err)) + return check + } + if len(doc.Tokens()) != 2 { + check.errors = append(check.errors, errors.Errorf("Expected 2 tokens, got %d", len(doc.Tokens()))) + return check + } + tokens := doc.Tokens() + tok := tokens[1] + for _, tag := range []string{"VBD", "VBG", "VBZ"} { + if tok.Tag == tag { + check.errors = append(check.errors, errors.Errorf("First word of commit must be an imperative verb: %q is invalid", word)) + } + } + + return check +} diff --git a/internal/policy/commit/commit.go b/internal/policy/commit/commit.go index ac8d8572..ae8078b0 100644 --- a/internal/policy/commit/commit.go +++ b/internal/policy/commit/commit.go @@ -12,7 +12,6 @@ import ( "github.com/autonomy/conform/internal/git" "github.com/autonomy/conform/internal/policy" "github.com/pkg/errors" - prose "gopkg.in/jdkato/prose.v2" ) // Commit implements the policy.Policy interface and enforces commit @@ -29,194 +28,73 @@ type Commit struct { Imperative bool `mapstructure:"imperative"` // Conventional is the user specified settings for conventional commits. Conventional *Conventional `mapstructure:"conventional"` -} -// Conventional implements the policy.Policy interface and enforces commit -// messages to conform the Conventional Commit standard. -type Conventional struct { - Types []string `mapstructure:"types"` - Scopes []string `mapstructure:"scopes"` + msg string } -// MaxNumberOfCommitCharacters is the default maximium number of characters -// allowed in a commit header. -var MaxNumberOfCommitCharacters = 89 - -// DCORegex is the regular expression used for Developer Certificate of Origin. -var DCORegex = regexp.MustCompile(`^Signed-off-by: ([^<]+) <([^<>@]+@[^<>]+)>$`) - // FirstWordRegex is theregular expression used to find the first word in a // commit. var FirstWordRegex = regexp.MustCompile(`^\s*([a-zA-Z0-9]+)`) -// HeaderRegex is the regular expression used for Conventional Commits -// 1.0.0-beta.1. -var HeaderRegex = regexp.MustCompile(`^(\w*)(\(([^)]+)\))?:\s{1}(.*)($|\n{2})`) - -const ( - // TypeFeat is a commit of the type fix patches a bug in your codebase - // (this correlates with PATCH in semantic versioning). - TypeFeat = "feat" - - // TypeFix is a commit of the type feat introduces a new feature to the - // codebase (this correlates with MINOR in semantic versioning). - TypeFix = "fix" -) - // Compliance implements the policy.Policy.Compliance function. // nolint: gocyclo -func (c *Commit) Compliance(options *policy.Options) (report policy.Report) { +func (c *Commit) Compliance(options *policy.Options) (*policy.Report, error) { var err error - report = policy.Report{} + report := &policy.Report{} + + // Setup the policy for all checks. var g *git.Git if g, err = git.NewGit(); err != nil { - report.Errors = append(report.Errors, errors.Errorf("failed to open git repo: %v", err)) - return + return report, errors.Errorf("failed to open git repo: %v", err) } var msg string if options.CommitMsgFile != nil { var contents []byte if contents, err = ioutil.ReadFile(*options.CommitMsgFile); err != nil { - report.Errors = append(report.Errors, errors.Errorf("failed to read commit message file: %v", err)) - return + return report, errors.Errorf("failed to read commit message file: %v", err) } msg = string(contents) } else if msg, err = g.Message(); err != nil { - report.Errors = append(report.Errors, errors.Errorf("failed to get commit message: %v", err)) - return + return report, errors.Errorf("failed to get commit message: %v", err) } + c.msg = msg if c.HeaderLength != 0 { - MaxNumberOfCommitCharacters = c.HeaderLength + report.AddCheck(c.ValidateHeaderLength()) } - ValidateHeaderLength(&report, msg) if c.DCO { - ValidateDCO(&report, msg) + report.AddCheck(c.ValidateDCO()) } if c.GPG { - ValidateGPGSign(&report, g) - } - - var word string - if word, err = firstWord(msg, &report); err != nil { - return + report.AddCheck(c.ValidateGPGSign(g)) } - - if c.Conventional != nil { - groups := parseHeader(msg) - if len(groups) != 6 { - report.Errors = append(report.Errors, errors.Errorf("Invalid conventional commits format: %s", msg)) - return - } - if word, err = firstWord(groups[4], &report); err != nil { - return - } - - ValidateType(&report, groups, c.Conventional.Types) - ValidateScope(&report, groups, c.Conventional.Scopes) - ValidateDescription(&report, groups) - } - if c.Imperative { - ValidateImperative(&report, word) - } - - return report -} - -// ValidateHeaderLength checks the header length. -func ValidateHeaderLength(report *policy.Report, msg string) { - header := strings.Split(strings.TrimPrefix(msg, "\n"), "\n")[0] - if len(header) > MaxNumberOfCommitCharacters { - report.Errors = append(report.Errors, errors.Errorf("Commit header is %d characters", len(header))) - } -} - -// ValidateDCO checks the commit message for a Developer Certificate of Origin. -func ValidateDCO(report *policy.Report, msg string) { - for _, line := range strings.Split(msg, "\n") { - if DCORegex.MatchString(strings.TrimSpace(line)) { - return - } - } - - report.Errors = append(report.Errors, errors.Errorf("Commit does not have a DCO")) -} - -// ValidateGPGSign checks the commit message for a GPG signature. -func ValidateGPGSign(report *policy.Report, g *git.Git) { - var err error - var ok bool - if ok, err = g.HasGPGSignature(); !ok { - if err != nil { - report.Errors = append(report.Errors, errors.Errorf("Commit does not have a GPG signature: %v", err)) - } - report.Errors = append(report.Errors, errors.Errorf("Commit does not have a GPG signature")) - } -} - -// ValidateImperative checks the commit message for a GPG signature. -func ValidateImperative(report *policy.Report, word string) { - doc, err := prose.NewDocument("I " + strings.ToLower(word)) - if err != nil { - report.Errors = append(report.Errors, errors.Errorf("Failed to create document: %v", err)) - } - if len(doc.Tokens()) != 2 { - report.Errors = append(report.Errors, errors.Errorf("Expected 2 tokens, got %d", len(doc.Tokens()))) - return - } - tokens := doc.Tokens() - tok := tokens[1] - for _, tag := range []string{"VBD", "VBG", "VBZ"} { - if tok.Tag == tag { - report.Errors = append(report.Errors, errors.Errorf("First word of commit must be an imperative verb: %q", word)) - } + report.AddCheck(c.ValidateImperative()) } -} -// ValidateType returns the commit type. -func ValidateType(report *policy.Report, groups []string, types []string) { - types = append(types, TypeFeat, TypeFix) - for _, t := range types { - if t == groups[1] { - return - } - } - report.Errors = append(report.Errors, errors.Errorf("Invalid type: %s, allowed types are: %v", groups[1], types)) -} - -// ValidateScope returns the commit scope. -func ValidateScope(report *policy.Report, groups []string, scopes []string) { - // Scope is optional. - if groups[3] == "" { - return - } - for _, scope := range scopes { - if scope == groups[3] { - return - } + if c.Conventional != nil { + report.AddCheck(c.ValidateConventionalCommit()) } - report.Errors = append(report.Errors, errors.Errorf("Invalid scope: %s, allowed scopes are: %v", groups[3], scopes)) -} -// ValidateDescription returns the commit description. -func ValidateDescription(report *policy.Report, groups []string) { - if len(groups[4]) <= 72 && len(groups[4]) != 0 { - return - } - report.Errors = append(report.Errors, errors.Errorf("Invalid description: %s", groups[4])) + return report, nil } -func firstWord(msg string, report *policy.Report) (string, error) { +func (c Commit) firstWord() (string, error) { var header string var groups []string + var msg string + if c.Conventional != nil { + groups = parseHeader(c.msg) + msg = groups[4] + } else { + msg = c.msg + } if header = strings.Split(strings.TrimPrefix(msg, "\n"), "\n")[0]; header == "" { - report.Errors = append(report.Errors, errors.Errorf("Invalid conventional commits (empty)")) return "", errors.Errorf("Invalid msg: %s", msg) } if groups = FirstWordRegex.FindStringSubmatch(header); groups == nil { @@ -224,13 +102,3 @@ func firstWord(msg string, report *policy.Report) (string, error) { } return groups[0], nil } - -func parseHeader(msg string) []string { - // To circumvent any policy violation due to the leading \n that GitHub - // prefixes to the commit message on a squash merge, we remove it from the - // message. - header := strings.Split(strings.TrimPrefix(msg, "\n"), "\n")[0] - groups := HeaderRegex.FindStringSubmatch(header) - - return groups -} diff --git a/internal/policy/commit/commit_test.go b/internal/policy/commit/commit_test.go index 4614c728..b5f434f6 100644 --- a/internal/policy/commit/commit_test.go +++ b/internal/policy/commit/commit_test.go @@ -65,7 +65,10 @@ func TestConventionalCommitPolicy(t *testing.T) { if err != nil { tt.Error(err) } - report := runCompliance() + report, err := runCompliance() + if err != nil { + t.Error(err) + } if test.ExpectValid { if !report.Valid() { @@ -105,10 +108,12 @@ func TestValidateDCO(t *testing.T) { ExpectValid: false, }, } { + // Fixes scopelint error. + test := test t.Run(test.Name, func(tt *testing.T) { var report policy.Report - - ValidateDCO(&report, test.CommitMessage) + c := Commit{msg: test.CommitMessage} + report.AddCheck(c.ValidateDCO()) if test.ExpectValid { if !report.Valid() { @@ -123,7 +128,90 @@ func TestValidateDCO(t *testing.T) { } } -func runCompliance() *policy.Report { +func TestValidConventionalCommitPolicy(t *testing.T) { + dir, err := ioutil.TempDir("", "test") + if err != nil { + log.Fatal(err) + } + defer RemoveAll(dir) + err = os.Chdir(dir) + if err != nil { + t.Error(err) + } + err = initRepo() + if err != nil { + t.Error(err) + } + err = createValidCommit() + if err != nil { + t.Error(err) + } + report, err := runCompliance() + if err != nil { + t.Error(err) + } + if !report.Valid() { + t.Errorf("Report is invalid with valid conventional commit") + } +} + +// nolint: dupl +func TestInvalidConventionalCommitPolicy(t *testing.T) { + dir, err := ioutil.TempDir("", "test") + if err != nil { + log.Fatal(err) + } + defer RemoveAll(dir) + err = os.Chdir(dir) + if err != nil { + t.Error(err) + } + err = initRepo() + if err != nil { + t.Error(err) + } + err = createInvalidCommit() + if err != nil { + t.Error(err) + } + report, err := runCompliance() + if err != nil { + t.Error(err) + } + if report.Valid() { + t.Errorf("Report is valid with invalid conventional commit") + } +} + +// nolint: dupl +func TestEmptyConventionalCommitPolicy(t *testing.T) { + dir, err := ioutil.TempDir("", "test") + if err != nil { + log.Fatal(err) + } + defer RemoveAll(dir) + err = os.Chdir(dir) + if err != nil { + t.Error(err) + } + err = initRepo() + if err != nil { + t.Error(err) + } + err = createEmptyCommit() + if err != nil { + t.Error(err) + } + report, err := runCompliance() + if err != nil { + t.Error(err) + } + if report.Valid() { + t.Error("Report is valid with invalid conventional commit") + } +} + +func runCompliance() (*policy.Report, error) { c := &Commit{ Conventional: &Conventional{ Types: []string{"type"}, @@ -131,9 +219,7 @@ func runCompliance() *policy.Report { }, } - report := c.Compliance(&policy.Options{}) - - return &report + return c.Compliance(&policy.Options{}) } func initRepo() error { diff --git a/internal/policy/license/license.go b/internal/policy/license/license.go index 89f31fba..b3adefe6 100644 --- a/internal/policy/license/license.go +++ b/internal/policy/license/license.go @@ -6,6 +6,7 @@ package license import ( "bytes" + "fmt" "io/ioutil" "os" "path/filepath" @@ -32,16 +33,45 @@ type License struct { } // Compliance implements the policy.Policy.Compliance function. -// nolint: gocyclo -func (l *License) Compliance(options *policy.Options) (report policy.Report) { - var err error +func (l *License) Compliance(options *policy.Options) (*policy.Report, error) { + report := &policy.Report{} + + return report, nil +} + +// HeaderCheck enforces a license header on source code files. +type HeaderCheck struct { + errors []error +} + +// Name returns the name of the check. +func (l HeaderCheck) Name() string { + return "file license header" +} + +// Message returns to check message. +func (l HeaderCheck) Message() string { + if len(l.errors) != 0 { + return fmt.Sprintf("Found %d files without license header", len(l.errors)) + } + return "All files have a valid license header" +} + +// Errors returns any violations of the check. +func (l HeaderCheck) Errors() []error { + return l.errors +} - report = policy.Report{} +// ValidateLicenseHeader checks the header of a file and ensures it contains the +// provided value. +// nolint: gocyclo +func (l License) ValidateLicenseHeader(report *policy.Report, name string, contents, value []byte) policy.Check { + check := HeaderCheck{} if l.Header == "" { - report.Errors = append(report.Errors, errors.New("Header is not defined")) - return report + check.errors = append(check.errors, errors.New("Header is not defined")) + return check } - err = filepath.Walk(".", func(path string, info os.FileInfo, err error) error { + err := filepath.Walk(".", func(path string, info os.FileInfo, err error) error { if err != nil { return err } @@ -79,28 +109,22 @@ func (l *License) Compliance(options *policy.Options) (report policy.Report) { if strings.HasSuffix(info.Name(), suffix) { var contents []byte if contents, err = ioutil.ReadFile(path); err != nil { - report.Errors = append(report.Errors, errors.Errorf("Failed to open %s", path)) + check.errors = append(check.errors, errors.Errorf("Failed to open %s", path)) return nil } - ValidateLicenseHeader(&report, info.Name(), contents, []byte(l.Header)) - return nil + // ValidateLicenseHeader(&report, info.Name(), contents, []byte(l.Header)) + if bytes.HasPrefix(contents, value) { + continue + } + check.errors = append(check.errors, errors.Errorf("File %s does not contain a license header", name)) } } } return nil }) if err != nil { - report.Errors = append(report.Errors, errors.Errorf("Failed to walk directory: %v", err)) + check.errors = append(check.errors, errors.Errorf("Failed to walk directory: %v", err)) } - return report -} - -// ValidateLicenseHeader checks the header of a file and ensures it contains the -// provided value. -func ValidateLicenseHeader(report *policy.Report, name string, contents, value []byte) { - if bytes.HasPrefix(contents, value) { - return - } - report.Errors = append(report.Errors, errors.Errorf("File %s does not contain a license header", name)) + return check } diff --git a/internal/policy/policy.go b/internal/policy/policy.go index 2c5983ac..fd22353c 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -6,15 +6,37 @@ package policy // Report summarizes the compliance of a policy. type Report struct { - Errors []error + checks []Check +} + +// Check defines a policy check. +type Check interface { + Name() string + Message() string + Errors() []error } // Policy is an interface that policies must implement. type Policy interface { - Compliance(*Options) Report + Compliance(*Options) (*Report, error) } // Valid checks if a report is valid. -func (r Report) Valid() bool { - return len(r.Errors) == 0 +func (r *Report) Valid() bool { + for _, check := range r.checks { + if len(check.Errors()) != 0 { + return false + } + } + return true +} + +// Checks returns the checks executed by a policy. +func (r *Report) Checks() []Check { + return r.checks +} + +// AddCheck adds a check to the policy report. +func (r *Report) AddCheck(c Check) { + r.checks = append(r.checks, c) }