From fa7df19996ece307285da44c73f210c6cbec9207 Mon Sep 17 00:00:00 2001 From: Danny Zhu Date: Sat, 28 Mar 2020 18:34:24 +0000 Subject: [PATCH] feat(policy): add checks for header case and last character It's common for commit message guidelines to include restrictions on the leading case and trailing punctuation of the header; this adds checks to enforce those. The desired case can be specified as either "upper" or "lower"; the punctuation check is implemented as a general check for disallowing the last character in the header to be from any given set of characters. Now that there are several header-related checks, this also adds a level to the config to put all of those checks in one place in the configuration. Signed-off-by: Danny Zhu --- .conform.yaml | 7 +- README.md | 25 ++++--- internal/policy/commit/check_body.go | 4 -- internal/policy/commit/check_header_case.go | 69 +++++++++++++++++++ .../commit/check_header_last_character.go | 50 ++++++++++++++ internal/policy/commit/check_header_length.go | 10 ++- internal/policy/commit/commit.go | 51 ++++++++++---- 7 files changed, 180 insertions(+), 36 deletions(-) create mode 100644 internal/policy/commit/check_header_case.go create mode 100644 internal/policy/commit/check_header_last_character.go diff --git a/.conform.yaml b/.conform.yaml index f21cfcdd..277f3871 100644 --- a/.conform.yaml +++ b/.conform.yaml @@ -1,10 +1,13 @@ policies: - type: commit spec: - headerLength: 89 + header: + length: 89 + imperative: true + case: lower + invalidLastCharacters: . dco: true gpg: false - imperative: true maximumOfOneCommit: true requireCommitBody: true conventional: diff --git a/README.md b/README.md index 61369a00..cacc5e83 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,13 @@ Now, create a file named `.conform.yaml` with the following contents: policies: - type: commit spec: - headerLength: 89 + header: + length: 89 + imperative: true + case: lower + invalidLastCharacters: . dco: true gpg: false - imperative: true maximumOfOneCommit: true requireCommitBody: true conventional: @@ -71,14 +74,16 @@ In the same directory, run: ```bash $ conform enforce -POLICY CHECK STATUS MESSAGE -commit Header Length PASS -commit DCO PASS -commit Imperative Mood PASS -commit Conventional Commit PASS -commit Number of Commits PASS -commit Commit Body PASS -license File Header PASS +POLICY CHECK STATUS MESSAGE +commit Header Length PASS +commit Imperative Mood PASS +commit Header Case PASS +commit Header Last Character PASS +commit DCO PASS +commit Conventional Commit PASS +commit Number of Commits PASS +commit Commit Body PASS +license File Header PASS ``` To setup a `commit-msg` hook: diff --git a/internal/policy/commit/check_body.go b/internal/policy/commit/check_body.go index aacda402..8dccd818 100644 --- a/internal/policy/commit/check_body.go +++ b/internal/policy/commit/check_body.go @@ -43,10 +43,6 @@ func (h Body) Errors() []error { func (c Commit) ValidateBody() policy.Check { check := &Body{} - if c.HeaderLength != 0 { - MaxNumberOfCommitCharacters = c.HeaderLength - } - lines := strings.Split(strings.TrimPrefix(c.msg, "\n"), "\n") valid := false for _, line := range lines[1:] { diff --git a/internal/policy/commit/check_header_case.go b/internal/policy/commit/check_header_case.go new file mode 100644 index 00000000..a6a02515 --- /dev/null +++ b/internal/policy/commit/check_header_case.go @@ -0,0 +1,69 @@ +/* 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 ( + "unicode" + "unicode/utf8" + + "github.com/pkg/errors" + "github.com/talos-systems/conform/internal/policy" +) + +// HeaderCaseCheck enforces the case of the first word in the header. +type HeaderCaseCheck struct { + headerCase string + errors []error +} + +// Name returns the name of the check. +func (h HeaderCaseCheck) Name() string { + return "Header Case" +} + +// Message returns to check message. +func (h HeaderCaseCheck) Message() string { + if len(h.errors) != 0 { + return h.errors[0].Error() + } + return "Header case is valid" +} + +// Errors returns any violations of the check. +func (h HeaderCaseCheck) Errors() []error { + return h.errors +} + +// ValidateHeaderCase checks the header length. +func (c Commit) ValidateHeaderCase() policy.Check { + check := &HeaderCaseCheck{headerCase: c.Header.Case} + + firstWord, err := c.firstWord() + if err != nil { + check.errors = append(check.errors, err) + return check + } + + first, _ := utf8.DecodeRuneInString(firstWord) + if first == utf8.RuneError { + check.errors = append(check.errors, errors.New("Header does not start with valid UTF-8 text")) + return check + } + + var valid bool + switch c.Header.Case { + case "upper": + valid = unicode.IsUpper(first) + case "lower": + valid = unicode.IsLower(first) + default: + check.errors = append(check.errors, errors.Errorf("Invalid configured case %s", c.Header.Case)) + return check + } + if !valid { + check.errors = append(check.errors, errors.Errorf("Commit header case is not %s", c.Header.Case)) + } + return check +} diff --git a/internal/policy/commit/check_header_last_character.go b/internal/policy/commit/check_header_last_character.go new file mode 100644 index 00000000..dc92f809 --- /dev/null +++ b/internal/policy/commit/check_header_last_character.go @@ -0,0 +1,50 @@ +/* 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" + "unicode/utf8" + + "github.com/pkg/errors" + "github.com/talos-systems/conform/internal/policy" +) + +// HeaderLastCharacterCheck enforces that the last character of the header isn't in some set. +type HeaderLastCharacterCheck struct { + errors []error +} + +// Name returns the name of the check. +func (h HeaderLastCharacterCheck) Name() string { + return "Header Last Character" +} + +// Message returns to check message. +func (h HeaderLastCharacterCheck) Message() string { + if len(h.errors) != 0 { + return h.errors[0].Error() + } + return "Header last character is valid" +} + +// Errors returns any violations of the check. +func (h HeaderLastCharacterCheck) Errors() []error { + return h.errors +} + +// ValidateHeaderLastCharacter checks the last character of the header. +func (c Commit) ValidateHeaderLastCharacter() policy.Check { + check := &HeaderLastCharacterCheck{} + + switch last, _ := utf8.DecodeLastRuneInString(c.header()); { + case last == utf8.RuneError: + check.errors = append(check.errors, errors.New("Header does not end with valid UTF-8 text")) + case strings.ContainsRune(c.Header.InvalidLastCharacters, last): + check.errors = append(check.errors, errors.Errorf("Commit header ends in %q", last)) + } + + return check +} diff --git a/internal/policy/commit/check_header_length.go b/internal/policy/commit/check_header_length.go index 60fccd0a..84e61dfe 100644 --- a/internal/policy/commit/check_header_length.go +++ b/internal/policy/commit/check_header_length.go @@ -6,7 +6,6 @@ package commit import ( "fmt" - "strings" "github.com/pkg/errors" "github.com/talos-systems/conform/internal/policy" @@ -42,14 +41,13 @@ func (h HeaderLengthCheck) Errors() []error { func (c Commit) ValidateHeaderLength() policy.Check { check := &HeaderLengthCheck{} - if c.HeaderLength != 0 { - MaxNumberOfCommitCharacters = c.HeaderLength + if c.Header.Length != 0 { + MaxNumberOfCommitCharacters = c.Header.Length } - header := strings.Split(strings.TrimPrefix(c.msg, "\n"), "\n")[0] - check.headerLength = len(header) + check.headerLength = len(c.header()) if check.headerLength > MaxNumberOfCommitCharacters { - check.errors = append(check.errors, errors.Errorf("Commit header is %d characters", len(header))) + check.errors = append(check.errors, errors.Errorf("Commit header is %d characters", check.headerLength)) } return check diff --git a/internal/policy/commit/commit.go b/internal/policy/commit/commit.go index 489ef110..096ee274 100644 --- a/internal/policy/commit/commit.go +++ b/internal/policy/commit/commit.go @@ -14,18 +14,26 @@ import ( "github.com/talos-systems/conform/internal/policy" ) +// HeaderChecks is the configuration for checks on the header of a commit. +type HeaderChecks struct { + // Length is the maximum length of the commit subject. + Length int `mapstructure:"length"` + // Imperative enforces the use of imperative verbs as the first word of a + // commit message. + Imperative bool `mapstructure:"imperative"` + // HeaderCase is the case that the first word of the header must have ("upper" or "lower"). + Case string `mapstructure:"case"` + // HeaderInvalidLastCharacters is a string containing all invalid last characters for the header. + InvalidLastCharacters string `mapstructure:"invalidLastCharacters"` +} + // Commit implements the policy.Policy interface and enforces commit // messages to conform the Conventional Commit standard. type Commit struct { - // HeaderLength is the maximum length of the commit subject. - HeaderLength int `mapstructure:"headerLength"` // DCO enables the Developer Certificate of Origin check. DCO bool `mapstructure:"dco"` // GPG enables the GPG signature check. GPG bool `mapstructure:"gpg"` - // Imperative enforces the use of imperative verbs as the first word of a - // commit message. - Imperative bool `mapstructure:"imperative"` // MaximumOfOneCommit enforces that the current commit is only one commit // ahead of a specified ref. MaximumOfOneCommit bool `mapstructure:"maximumOfOneCommit"` @@ -33,6 +41,8 @@ type Commit struct { RequireCommitBody bool `mapstructure:"requireCommitBody"` // Conventional is the user specified settings for conventional commits. Conventional *Conventional `mapstructure:"conventional"` + // Header is the user specified settings for the header of each commit. + Header *HeaderChecks `mapstructure:"header"` msg string } @@ -67,8 +77,22 @@ func (c *Commit) Compliance(options *policy.Options) (*policy.Report, error) { } c.msg = msg - if c.HeaderLength != 0 { - report.AddCheck(c.ValidateHeaderLength()) + if c.Header != nil { + if c.Header.Length != 0 { + report.AddCheck(c.ValidateHeaderLength()) + } + + if c.Header.Imperative { + report.AddCheck(c.ValidateImperative()) + } + + if c.Header.Case != "" { + report.AddCheck(c.ValidateHeaderCase()) + } + + if c.Header.InvalidLastCharacters != "" { + report.AddCheck(c.ValidateHeaderLastCharacter()) + } } if c.DCO { @@ -79,10 +103,6 @@ func (c *Commit) Compliance(options *policy.Options) (*policy.Report, error) { report.AddCheck(c.ValidateGPGSign(g)) } - if c.Imperative { - report.AddCheck(c.ValidateImperative()) - } - if c.Conventional != nil { report.AddCheck(c.ValidateConventionalCommit()) } @@ -99,7 +119,6 @@ func (c *Commit) Compliance(options *policy.Options) (*policy.Report, error) { } func (c Commit) firstWord() (string, error) { - var header string var groups []string var msg string if c.Conventional != nil { @@ -111,11 +130,15 @@ func (c Commit) firstWord() (string, error) { } else { msg = c.msg } - if header = strings.Split(strings.TrimPrefix(msg, "\n"), "\n")[0]; header == "" { + if msg == "" { return "", errors.Errorf("Invalid msg: %s", msg) } - if groups = FirstWordRegex.FindStringSubmatch(header); groups == nil { + if groups = FirstWordRegex.FindStringSubmatch(msg); groups == nil { return "", errors.Errorf("Invalid msg: %s", msg) } return groups[0], nil } + +func (c Commit) header() string { + return strings.Split(strings.TrimPrefix(c.msg, "\n"), "\n")[0] +}