diff --git a/changelog/fragments/1666956886-Add-changelong-lint-command.yaml b/changelog/fragments/1666956886-Add-changelong-lint-command.yaml new file mode 100644 index 0000000..6b4fbbf --- /dev/null +++ b/changelog/fragments/1666956886-Add-changelong-lint-command.yaml @@ -0,0 +1,31 @@ +# Kind can be one of: +# - breaking-change: a change to previously-documented behavior +# - deprecation: functionality that is being removed in a later release +# - bug-fix: fixes a problem in a previous version +# - enhancement: extends functionality but does not break or fix existing behavior +# - feature: new functionality +# - known-issue: problems that we are aware of in a given version +# - security: impacts on the security of a product or a user’s deployment. +# - upgrade: important information for someone upgrading from a prior version +# - other: does not fit into any of the other categories +kind: feature + +# Change summary; a 80ish characters long description of the change. +summary: Add changelong lint command to validate the fields + +# Long description; in case the summary is not enough to describe the change +# this field accommodate a description without length limits. +#description: + +# Affected component; a word indicating the component this changeset affects. +component: + +# PR number; optional; the PR number that added the changeset. +# If not present is automatically filled by the tooling finding the PR where this changelog fragment has been added. +# NOTE: the tooling supports backports, so it's able to fill the original PR number instead of the backport PR number. +# Please provide it if you are adding a fragment for a different PR. +#pr: 1234 + +# Issue number; optional; the GitHub issue related to this changeset (either closes or is part of). +# If not present is automatically filled by the tooling with the issue linked to the PR number. +#issue: 1234 diff --git a/cmd/changelog_lint.go b/cmd/changelog_lint.go new file mode 100644 index 0000000..dad8e8d --- /dev/null +++ b/cmd/changelog_lint.go @@ -0,0 +1,66 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package cmd + +import ( + "fmt" + "log" + + "github.com/elastic/elastic-agent-changelog-tool/internal/changelog" + "github.com/spf13/afero" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func ChangelogLintCmd(fs afero.Fs) *cobra.Command { + + lintCmd := &cobra.Command{ + Use: "changelog_lint", + Short: "Lint the consolidated changelog", + Args: func(cmd *cobra.Command, args []string) error { + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + dest := viper.GetString("changelog_destination") + + version, err := cmd.Flags().GetString("version") + if err != nil { + return fmt.Errorf("error parsing flag 'version': %w", err) + } + + relaxed, err := cmd.Flags().GetBool("relaxed") + if err != nil { + return fmt.Errorf("error parsing flag 'relaxed': %w", err) + } + + linter := changelog.NewLinter(fs) + errs := linter.Lint(dest, version) + + for _, err := range errs { + log.Println(err) + } + + if !relaxed && len(errs) > 0 { + log.Fatal("Linting failed.") + } + + log.Println("Linting done.") + + return nil + }, + } + + lintCmd.Flags().VisitAll(viperOverrides(lintCmd)) + + lintCmd.Flags().String("version", "", "The version of the consolidated changelog subject to linting") + lintCmd.Flags().Bool("relaxed", false, "Relaxed mode will only log erros, without terminating execution") + err := lintCmd.MarkFlagRequired("version") + if err != nil { + // NOTE: the only case this error appear is when the flag is not defined + log.Fatal(err) + } + + return lintCmd +} diff --git a/internal/changelog/linter.go b/internal/changelog/linter.go new file mode 100644 index 0000000..42ef146 --- /dev/null +++ b/internal/changelog/linter.go @@ -0,0 +1,118 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package changelog + +import ( + "fmt" + + "github.com/spf13/afero" + "github.com/spf13/viper" +) + +func NewLinter(fs afero.Fs) Linter { + return newLinter(fs) +} + +type Linter struct { + fs afero.Fs + entryValidators entryValidators + errors []error +} + +func newLinter(fs afero.Fs) Linter { + return Linter{ + fs: fs, + entryValidators: defaultEntryValidators, + } +} + +type LinterErrors []error + +func (l Linter) Lint(dest, version string) []error { + c, err := FromFile(l.fs, fmt.Sprintf("./%s/%s.yaml", dest, version)) + if err != nil { + return []error{fmt.Errorf("error loading changelog from file: %w", err)} + } + + for _, entry := range c.Entries { + for _, validator := range l.entryValidators { + err := validator(entry) + if err != nil { + l.errors = append(l.errors, err) + } + } + } + + return l.errors +} + +type entryValidationFn func(Entry) error +type entryValidators map[string]entryValidationFn + +var defaultEntryValidators = entryValidators{ + "pr_multipleids": validator_PRMultipleIDs, + "pr_noids": validator_PRnoIDs, + "issue_noids": validator_IssueNoIDs, + "component_valid": validator_componentValid(viper.GetStringSlice("components")), +} + +func validator_PRMultipleIDs(entry Entry) error { + if len(entry.LinkedPR) > 1 { + return fmt.Errorf("changelog entry: %s has multiple PR ids", entry.File.Name) + } + + return nil +} + +func validator_PRnoIDs(entry Entry) error { + if len(entry.LinkedPR) == 0 { + return fmt.Errorf("changelog entry: %s has no PR id", entry.File.Name) + } + + return nil +} + +func validator_IssueNoIDs(entry Entry) error { + if len(entry.LinkedIssue) == 0 { + return fmt.Errorf("changelog entry: %s has no issue id", entry.File.Name) + } + + return nil +} + +func validator_componentValid(configComponents []string) entryValidationFn { + return func(entry Entry) error { + switch len(configComponents) { + case 0: + return nil + case 1: + c := configComponents[0] + + if c != entry.Component && len(entry.Component) > 0 { + return fmt.Errorf("changelog entry: %s -> component [%s] not found in config: %s", entry.File.Name, entry.Component, configComponents) + } + default: + var match string + + if entry.Component == "" { + return fmt.Errorf("changelog entry: %s -> component cannot be assumed, choose it from config: %s", entry.File.Name, configComponents) + } + + match = "" + for _, c := range configComponents { + if entry.Component != c { + continue + } + match = entry.Component + } + + if match == "" { + return fmt.Errorf("changelog entry: %s -> component [%s] not found in config: %s", entry.File.Name, entry.Component, configComponents) + } + } + + return nil + } +} diff --git a/internal/changelog/linter_test.go b/internal/changelog/linter_test.go new file mode 100644 index 0000000..a32fd30 --- /dev/null +++ b/internal/changelog/linter_test.go @@ -0,0 +1,152 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package changelog + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPRMultipleIDs(t *testing.T) { + testcases := []struct { + name string + entry Entry + validatorFunc func(entry Entry) error + expectedErr error + }{ + { + "pr multiple ids: 1 id", + Entry{ + LinkedPR: []string{"1"}, + }, + validator_PRMultipleIDs, + nil, + }, + { + "pr multiple ids: multiple ids", + Entry{ + LinkedPR: []string{"1", "2"}, + }, + validator_PRMultipleIDs, + fmt.Errorf("changelog entry: %s has multiple PR ids", ""), + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + err := tc.validatorFunc(tc.entry) + require.Equal(t, err, tc.expectedErr) + }) + } +} + +func TestPRnoIDs(t *testing.T) { + testcases := []struct { + name string + entry Entry + validatorFunc func(entry Entry) error + expectedErr error + }{ + { + "pr multiple ids: error", + Entry{ + LinkedPR: []string{}, + }, + validator_PRnoIDs, + fmt.Errorf("changelog entry: %s has no PR id", ""), + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + err := tc.validatorFunc(tc.entry) + require.Equal(t, err, tc.expectedErr) + }) + } +} + +func TestIssueNoIDs(t *testing.T) { + testcases := []struct { + name string + entry Entry + validatorFunc func(entry Entry) error + expectedErr error + }{ + { + "issue no ids: error", + Entry{ + LinkedIssue: []string{}, + }, + validator_IssueNoIDs, + fmt.Errorf("changelog entry: %s has no issue id", ""), + }, + { + "component valid: invalid component", + Entry{ + Component: "invalid_component", + }, + validator_componentValid([]string{"beats"}), + fmt.Errorf("changelog entry: %s -> component [%s] not found in config: [%s]", "", "invalid_component", "beats"), + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + err := tc.validatorFunc(tc.entry) + require.Equal(t, err, tc.expectedErr) + }) + } +} + +func TestComponentValid(t *testing.T) { + testcases := []struct { + name string + entry Entry + validatorFunc func(entry Entry) error + expectedErr error + }{ + { + "component valid: beats", + Entry{ + Component: "beats", + }, + validator_componentValid([]string{"beats"}), + nil, + }, + { + "component valid: not found in config", + Entry{ + Component: "agent", + }, + validator_componentValid([]string{"beats"}), + fmt.Errorf("changelog entry: %s -> component [%s] not found in config: [%s]", "", "agent", "beats"), + }, + { + "component valid: no component", + Entry{ + Component: "", + }, + validator_componentValid([]string{"beats", "agent"}), + fmt.Errorf("changelog entry: %s -> component cannot be assumed, choose it from config: %s", "", []string{"beats", "agent"}), + }, + { + "component valid: invalid component", + Entry{ + Component: "invalid_component", + }, + validator_componentValid([]string{"beats"}), + fmt.Errorf("changelog entry: %s -> component [%s] not found in config: [%s]", "", "invalid_component", "beats"), + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + err := tc.validatorFunc(tc.entry) + require.Equal(t, err, tc.expectedErr) + }) + } +} diff --git a/main.go b/main.go index 694d6ff..f4ec5f0 100644 --- a/main.go +++ b/main.go @@ -19,6 +19,7 @@ func main() { rootCmd := cmd.RootCmd() rootCmd.AddCommand(cmd.BuildCmd(appFs)) + rootCmd.AddCommand(cmd.ChangelogLintCmd(appFs)) rootCmd.AddCommand(cmd.CleanupCmd(appFs)) rootCmd.AddCommand(cmd.FindPRCommand(appFs)) rootCmd.AddCommand(cmd.NewCmd())