Skip to content

Commit 6e99924

Browse files
Add changelog lint cmd (#119)
Co-authored-by: Edoardo Tenani <[email protected]>
1 parent 5b8321d commit 6e99924

File tree

5 files changed

+368
-0
lines changed

5 files changed

+368
-0
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Kind can be one of:
2+
# - breaking-change: a change to previously-documented behavior
3+
# - deprecation: functionality that is being removed in a later release
4+
# - bug-fix: fixes a problem in a previous version
5+
# - enhancement: extends functionality but does not break or fix existing behavior
6+
# - feature: new functionality
7+
# - known-issue: problems that we are aware of in a given version
8+
# - security: impacts on the security of a product or a user’s deployment.
9+
# - upgrade: important information for someone upgrading from a prior version
10+
# - other: does not fit into any of the other categories
11+
kind: feature
12+
13+
# Change summary; a 80ish characters long description of the change.
14+
summary: Add changelong lint command to validate the fields
15+
16+
# Long description; in case the summary is not enough to describe the change
17+
# this field accommodate a description without length limits.
18+
#description:
19+
20+
# Affected component; a word indicating the component this changeset affects.
21+
component:
22+
23+
# PR number; optional; the PR number that added the changeset.
24+
# If not present is automatically filled by the tooling finding the PR where this changelog fragment has been added.
25+
# NOTE: the tooling supports backports, so it's able to fill the original PR number instead of the backport PR number.
26+
# Please provide it if you are adding a fragment for a different PR.
27+
#pr: 1234
28+
29+
# Issue number; optional; the GitHub issue related to this changeset (either closes or is part of).
30+
# If not present is automatically filled by the tooling with the issue linked to the PR number.
31+
#issue: 1234

cmd/changelog_lint.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
// or more contributor license agreements. Licensed under the Elastic License 2.0;
3+
// you may not use this file except in compliance with the Elastic License 2.0.
4+
5+
package cmd
6+
7+
import (
8+
"fmt"
9+
"log"
10+
11+
"github.com/elastic/elastic-agent-changelog-tool/internal/changelog"
12+
"github.com/spf13/afero"
13+
"github.com/spf13/cobra"
14+
"github.com/spf13/viper"
15+
)
16+
17+
func ChangelogLintCmd(fs afero.Fs) *cobra.Command {
18+
19+
lintCmd := &cobra.Command{
20+
Use: "changelog_lint",
21+
Short: "Lint the consolidated changelog",
22+
Args: func(cmd *cobra.Command, args []string) error {
23+
return nil
24+
},
25+
RunE: func(cmd *cobra.Command, args []string) error {
26+
dest := viper.GetString("changelog_destination")
27+
28+
version, err := cmd.Flags().GetString("version")
29+
if err != nil {
30+
return fmt.Errorf("error parsing flag 'version': %w", err)
31+
}
32+
33+
relaxed, err := cmd.Flags().GetBool("relaxed")
34+
if err != nil {
35+
return fmt.Errorf("error parsing flag 'relaxed': %w", err)
36+
}
37+
38+
linter := changelog.NewLinter(fs)
39+
errs := linter.Lint(dest, version)
40+
41+
for _, err := range errs {
42+
log.Println(err)
43+
}
44+
45+
if !relaxed && len(errs) > 0 {
46+
log.Fatal("Linting failed.")
47+
}
48+
49+
log.Println("Linting done.")
50+
51+
return nil
52+
},
53+
}
54+
55+
lintCmd.Flags().VisitAll(viperOverrides(lintCmd))
56+
57+
lintCmd.Flags().String("version", "", "The version of the consolidated changelog subject to linting")
58+
lintCmd.Flags().Bool("relaxed", false, "Relaxed mode will only log erros, without terminating execution")
59+
err := lintCmd.MarkFlagRequired("version")
60+
if err != nil {
61+
// NOTE: the only case this error appear is when the flag is not defined
62+
log.Fatal(err)
63+
}
64+
65+
return lintCmd
66+
}

internal/changelog/linter.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
// or more contributor license agreements. Licensed under the Elastic License 2.0;
3+
// you may not use this file except in compliance with the Elastic License 2.0.
4+
5+
package changelog
6+
7+
import (
8+
"fmt"
9+
10+
"github.com/spf13/afero"
11+
"github.com/spf13/viper"
12+
)
13+
14+
func NewLinter(fs afero.Fs) Linter {
15+
return newLinter(fs)
16+
}
17+
18+
type Linter struct {
19+
fs afero.Fs
20+
entryValidators entryValidators
21+
errors []error
22+
}
23+
24+
func newLinter(fs afero.Fs) Linter {
25+
return Linter{
26+
fs: fs,
27+
entryValidators: defaultEntryValidators,
28+
}
29+
}
30+
31+
type LinterErrors []error
32+
33+
func (l Linter) Lint(dest, version string) []error {
34+
c, err := FromFile(l.fs, fmt.Sprintf("./%s/%s.yaml", dest, version))
35+
if err != nil {
36+
return []error{fmt.Errorf("error loading changelog from file: %w", err)}
37+
}
38+
39+
for _, entry := range c.Entries {
40+
for _, validator := range l.entryValidators {
41+
err := validator(entry)
42+
if err != nil {
43+
l.errors = append(l.errors, err)
44+
}
45+
}
46+
}
47+
48+
return l.errors
49+
}
50+
51+
type entryValidationFn func(Entry) error
52+
type entryValidators map[string]entryValidationFn
53+
54+
var defaultEntryValidators = entryValidators{
55+
"pr_multipleids": validator_PRMultipleIDs,
56+
"pr_noids": validator_PRnoIDs,
57+
"issue_noids": validator_IssueNoIDs,
58+
"component_valid": validator_componentValid(viper.GetStringSlice("components")),
59+
}
60+
61+
func validator_PRMultipleIDs(entry Entry) error {
62+
if len(entry.LinkedPR) > 1 {
63+
return fmt.Errorf("changelog entry: %s has multiple PR ids", entry.File.Name)
64+
}
65+
66+
return nil
67+
}
68+
69+
func validator_PRnoIDs(entry Entry) error {
70+
if len(entry.LinkedPR) == 0 {
71+
return fmt.Errorf("changelog entry: %s has no PR id", entry.File.Name)
72+
}
73+
74+
return nil
75+
}
76+
77+
func validator_IssueNoIDs(entry Entry) error {
78+
if len(entry.LinkedIssue) == 0 {
79+
return fmt.Errorf("changelog entry: %s has no issue id", entry.File.Name)
80+
}
81+
82+
return nil
83+
}
84+
85+
func validator_componentValid(configComponents []string) entryValidationFn {
86+
return func(entry Entry) error {
87+
switch len(configComponents) {
88+
case 0:
89+
return nil
90+
case 1:
91+
c := configComponents[0]
92+
93+
if c != entry.Component && len(entry.Component) > 0 {
94+
return fmt.Errorf("changelog entry: %s -> component [%s] not found in config: %s", entry.File.Name, entry.Component, configComponents)
95+
}
96+
default:
97+
var match string
98+
99+
if entry.Component == "" {
100+
return fmt.Errorf("changelog entry: %s -> component cannot be assumed, choose it from config: %s", entry.File.Name, configComponents)
101+
}
102+
103+
match = ""
104+
for _, c := range configComponents {
105+
if entry.Component != c {
106+
continue
107+
}
108+
match = entry.Component
109+
}
110+
111+
if match == "" {
112+
return fmt.Errorf("changelog entry: %s -> component [%s] not found in config: %s", entry.File.Name, entry.Component, configComponents)
113+
}
114+
}
115+
116+
return nil
117+
}
118+
}

internal/changelog/linter_test.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
// or more contributor license agreements. Licensed under the Elastic License 2.0;
3+
// you may not use this file except in compliance with the Elastic License 2.0.
4+
5+
package changelog
6+
7+
import (
8+
"fmt"
9+
"testing"
10+
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestPRMultipleIDs(t *testing.T) {
15+
testcases := []struct {
16+
name string
17+
entry Entry
18+
validatorFunc func(entry Entry) error
19+
expectedErr error
20+
}{
21+
{
22+
"pr multiple ids: 1 id",
23+
Entry{
24+
LinkedPR: []string{"1"},
25+
},
26+
validator_PRMultipleIDs,
27+
nil,
28+
},
29+
{
30+
"pr multiple ids: multiple ids",
31+
Entry{
32+
LinkedPR: []string{"1", "2"},
33+
},
34+
validator_PRMultipleIDs,
35+
fmt.Errorf("changelog entry: %s has multiple PR ids", ""),
36+
},
37+
}
38+
39+
for _, tc := range testcases {
40+
t.Run(tc.name, func(t *testing.T) {
41+
err := tc.validatorFunc(tc.entry)
42+
require.Equal(t, err, tc.expectedErr)
43+
})
44+
}
45+
}
46+
47+
func TestPRnoIDs(t *testing.T) {
48+
testcases := []struct {
49+
name string
50+
entry Entry
51+
validatorFunc func(entry Entry) error
52+
expectedErr error
53+
}{
54+
{
55+
"pr multiple ids: error",
56+
Entry{
57+
LinkedPR: []string{},
58+
},
59+
validator_PRnoIDs,
60+
fmt.Errorf("changelog entry: %s has no PR id", ""),
61+
},
62+
}
63+
64+
for _, tc := range testcases {
65+
t.Run(tc.name, func(t *testing.T) {
66+
err := tc.validatorFunc(tc.entry)
67+
require.Equal(t, err, tc.expectedErr)
68+
})
69+
}
70+
}
71+
72+
func TestIssueNoIDs(t *testing.T) {
73+
testcases := []struct {
74+
name string
75+
entry Entry
76+
validatorFunc func(entry Entry) error
77+
expectedErr error
78+
}{
79+
{
80+
"issue no ids: error",
81+
Entry{
82+
LinkedIssue: []string{},
83+
},
84+
validator_IssueNoIDs,
85+
fmt.Errorf("changelog entry: %s has no issue id", ""),
86+
},
87+
{
88+
"component valid: invalid component",
89+
Entry{
90+
Component: "invalid_component",
91+
},
92+
validator_componentValid([]string{"beats"}),
93+
fmt.Errorf("changelog entry: %s -> component [%s] not found in config: [%s]", "", "invalid_component", "beats"),
94+
},
95+
}
96+
97+
for _, tc := range testcases {
98+
t.Run(tc.name, func(t *testing.T) {
99+
err := tc.validatorFunc(tc.entry)
100+
require.Equal(t, err, tc.expectedErr)
101+
})
102+
}
103+
}
104+
105+
func TestComponentValid(t *testing.T) {
106+
testcases := []struct {
107+
name string
108+
entry Entry
109+
validatorFunc func(entry Entry) error
110+
expectedErr error
111+
}{
112+
{
113+
"component valid: beats",
114+
Entry{
115+
Component: "beats",
116+
},
117+
validator_componentValid([]string{"beats"}),
118+
nil,
119+
},
120+
{
121+
"component valid: not found in config",
122+
Entry{
123+
Component: "agent",
124+
},
125+
validator_componentValid([]string{"beats"}),
126+
fmt.Errorf("changelog entry: %s -> component [%s] not found in config: [%s]", "", "agent", "beats"),
127+
},
128+
{
129+
"component valid: no component",
130+
Entry{
131+
Component: "",
132+
},
133+
validator_componentValid([]string{"beats", "agent"}),
134+
fmt.Errorf("changelog entry: %s -> component cannot be assumed, choose it from config: %s", "", []string{"beats", "agent"}),
135+
},
136+
{
137+
"component valid: invalid component",
138+
Entry{
139+
Component: "invalid_component",
140+
},
141+
validator_componentValid([]string{"beats"}),
142+
fmt.Errorf("changelog entry: %s -> component [%s] not found in config: [%s]", "", "invalid_component", "beats"),
143+
},
144+
}
145+
146+
for _, tc := range testcases {
147+
t.Run(tc.name, func(t *testing.T) {
148+
err := tc.validatorFunc(tc.entry)
149+
require.Equal(t, err, tc.expectedErr)
150+
})
151+
}
152+
}

main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ func main() {
1919

2020
rootCmd := cmd.RootCmd()
2121
rootCmd.AddCommand(cmd.BuildCmd(appFs))
22+
rootCmd.AddCommand(cmd.ChangelogLintCmd(appFs))
2223
rootCmd.AddCommand(cmd.CleanupCmd(appFs))
2324
rootCmd.AddCommand(cmd.FindPRCommand(appFs))
2425
rootCmd.AddCommand(cmd.NewCmd())

0 commit comments

Comments
 (0)