diff --git a/README.md b/README.md index 4a6c77da9b..077e767357 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,16 @@ Built packages can also be published to the global package registry service. For details on how to enable dependency management, see the [HOWTO guide](https://github.com/elastic/elastic-package/blob/main/docs/howto/dependency_management.md). +### `elastic-package changelog` + +_Context: package_ + +Use this command to work with the changelog of the package. + +You can use this command to modify the changelog following the expected format and good practices. +This can be useful when introducing changelog entries for changes done by automated processes. + + ### `elastic-package check` _Context: package_ diff --git a/cmd/changelog.go b/cmd/changelog.go new file mode 100644 index 0000000000..4ac9d77ef6 --- /dev/null +++ b/cmd/changelog.go @@ -0,0 +1,174 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package cmd + +import ( + "io/ioutil" + "path/filepath" + + "github.com/Masterminds/semver" + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/elastic/elastic-package/internal/cobraext" + "github.com/elastic/elastic-package/internal/packages" + "github.com/elastic/elastic-package/internal/packages/changelog" +) + +const changelogLongDescription = `Use this command to work with the changelog of the package. + +You can use this command to modify the changelog following the expected format and good practices. +This can be useful when introducing changelog entries for changes done by automated processes. +` + +const changelogAddLongDescription = `Use this command to add an entry to the changelog file. + +The entry added will include the given description, type and link. It is added on top of the +last entry in the current version + +Alternatively, you can start a new version indicating the specific version, or if it should +be the next major, minor or patch version. +` + +func setupChangelogCommand() *cobraext.Command { + addChangelogCmd := &cobra.Command{ + Use: "add", + Short: "Add an entry to the changelog file", + Long: changelogAddLongDescription, + RunE: changelogAddCmd, + } + addChangelogCmd.Flags().String(cobraext.ChangelogAddNextFlagName, "", cobraext.ChangelogAddNextFlagDescription) + addChangelogCmd.Flags().String(cobraext.ChangelogAddVersionFlagName, "", cobraext.ChangelogAddVersionFlagDescription) + addChangelogCmd.Flags().String(cobraext.ChangelogAddDescriptionFlagName, "", cobraext.ChangelogAddDescriptionFlagDescription) + addChangelogCmd.MarkFlagRequired(cobraext.ChangelogAddDescriptionFlagName) + addChangelogCmd.Flags().String(cobraext.ChangelogAddTypeFlagName, "", cobraext.ChangelogAddTypeFlagDescription) + addChangelogCmd.MarkFlagRequired(cobraext.ChangelogAddTypeFlagName) + addChangelogCmd.Flags().String(cobraext.ChangelogAddLinkFlagName, "", cobraext.ChangelogAddLinkFlagDescription) + addChangelogCmd.MarkFlagRequired(cobraext.ChangelogAddLinkFlagName) + + cmd := &cobra.Command{ + Use: "changelog", + Short: "Utilities to work with the changelog of the package", + Long: changelogLongDescription, + } + cmd.AddCommand(addChangelogCmd) + + return cobraext.NewCommand(cmd, cobraext.ContextPackage) +} + +func changelogAddCmd(cmd *cobra.Command, args []string) error { + packageRoot, err := packages.MustFindPackageRoot() + if err != nil { + return errors.Wrap(err, "locating package root failed") + } + + version, _ := cmd.Flags().GetString(cobraext.ChangelogAddVersionFlagName) + nextMode, _ := cmd.Flags().GetString(cobraext.ChangelogAddNextFlagName) + if version != "" && nextMode != "" { + return errors.Errorf("flags %q and %q cannot be used at the same time", + cobraext.ChangelogAddVersionFlagName, + cobraext.ChangelogAddNextFlagName) + } + if version == "" { + v, err := changelogCmdVersion(nextMode, packageRoot) + if err != nil { + return err + } + version = v.String() + } + + description, _ := cmd.Flags().GetString(cobraext.ChangelogAddDescriptionFlagName) + changeType, _ := cmd.Flags().GetString(cobraext.ChangelogAddTypeFlagName) + link, _ := cmd.Flags().GetString(cobraext.ChangelogAddLinkFlagName) + + entry := changelog.Revision{ + Version: version, + Changes: []changelog.Entry{ + { + Description: description, + Type: changeType, + Link: link, + }, + }, + } + + err = patchChangelogFile(packageRoot, entry) + if err != nil { + return err + } + + err = setManifestVersion(packageRoot, version) + if err != nil { + return err + } + + return nil +} + +func changelogCmdVersion(nextMode, packageRoot string) (*semver.Version, error) { + revisions, err := changelog.ReadChangelogFromPackageRoot(packageRoot) + if err != nil { + return nil, errors.Wrap(err, "failed to read current changelog") + } + if len(revisions) == 0 { + return semver.MustParse("0.0.0"), nil + } + + version, err := semver.NewVersion(revisions[0].Version) + if err != nil { + return nil, errors.Wrapf(err, "invalid version in changelog %q", revisions[0].Version) + } + + switch nextMode { + case "": + break + case "major": + v := version.IncMajor() + version = &v + case "minor": + v := version.IncMinor() + version = &v + case "patch": + v := version.IncPatch() + version = &v + default: + return nil, errors.Errorf("invalid value for %q: %s", + cobraext.ChangelogAddNextFlagName, nextMode) + } + + return version, nil +} + +// patchChangelogFile looks for the proper place to add the new revision in the changelog, +// trying to conserve original format and comments. +func patchChangelogFile(packageRoot string, patch changelog.Revision) error { + changelogPath := filepath.Join(packageRoot, changelog.PackageChangelogFile) + d, err := ioutil.ReadFile(changelogPath) + if err != nil { + return err + } + + d, err = changelog.PatchYAML(d, patch) + if err != nil { + return err + } + + return ioutil.WriteFile(changelogPath, d, 0644) +} + +func setManifestVersion(packageRoot string, version string) error { + manifestPath := filepath.Join(packageRoot, packages.PackageManifestFile) + d, err := ioutil.ReadFile(manifestPath) + if err != nil { + return err + } + + d, err = changelog.SetManifestVersion(d, version) + if err != nil { + return err + } + + return ioutil.WriteFile(manifestPath, d, 0644) +} diff --git a/cmd/root.go b/cmd/root.go index 6d6f5e3a7c..0997b70eec 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -16,6 +16,7 @@ import ( var commands = []*cobraext.Command{ setupBuildCommand(), + setupChangelogCommand(), setupCheckCommand(), setupCleanCommand(), setupCreateCommand(), diff --git a/internal/cobraext/const.go b/internal/cobraext/const.go index ede12e9b65..c0e5b799f3 100644 --- a/internal/cobraext/const.go +++ b/internal/cobraext/const.go @@ -9,6 +9,21 @@ const ( BuildZipFlagName = "zip" BuildZipFlagDescription = "archive the built package" + ChangelogAddNextFlagName = "next" + ChangelogAddNextFlagDescription = "changelog entry is added in the next `major`, `minor` or `patch` version" + + ChangelogAddVersionFlagName = "version" + ChangelogAddVersionFlagDescription = "changelog entry is added in the given version" + + ChangelogAddDescriptionFlagName = "description" + ChangelogAddDescriptionFlagDescription = "description for the changelog entry" + + ChangelogAddTypeFlagName = "type" + ChangelogAddTypeFlagDescription = "type of change (bugfix, enhancement or breaking-change) for the changelog entry" + + ChangelogAddLinkFlagName = "link" + ChangelogAddLinkFlagDescription = "link to the pull request or issue with more information about the changelog entry" + CheckConditionFlagName = "check-condition" CheckConditionFlagDescription = "check if the condition is met for the package, but don't install the package (e.g. kibana.version=7.10.0)" diff --git a/internal/packages/changelog/testdata/changelog-one-patch-multiple.yml b/internal/packages/changelog/testdata/changelog-one-patch-multiple.yml new file mode 100644 index 0000000000..ae5cea826d --- /dev/null +++ b/internal/packages/changelog/testdata/changelog-one-patch-multiple.yml @@ -0,0 +1,12 @@ +# newer versions go on top +- version: "1.0.0" + changes: + - description: One change + type: enhancement + link: http://github.com/elastic/elastic-package + - description: Other change + type: enhancement + link: http://github.com/elastic/elastic-package + - description: Initial version + type: enhancement + link: http://github.com/elastic/elastic-package diff --git a/internal/packages/changelog/testdata/changelog-one-patch-next-major.yml b/internal/packages/changelog/testdata/changelog-one-patch-next-major.yml new file mode 100644 index 0000000000..dc3c77e2ba --- /dev/null +++ b/internal/packages/changelog/testdata/changelog-one-patch-next-major.yml @@ -0,0 +1,11 @@ +# newer versions go on top +- version: "2.0.0" + changes: + - description: One change + type: enhancement + link: http://github.com/elastic/elastic-package +- version: "1.0.0" + changes: + - description: Initial version + type: enhancement + link: http://github.com/elastic/elastic-package diff --git a/internal/packages/changelog/testdata/changelog-one-patch-same-version.yml b/internal/packages/changelog/testdata/changelog-one-patch-same-version.yml new file mode 100644 index 0000000000..3103f072cd --- /dev/null +++ b/internal/packages/changelog/testdata/changelog-one-patch-same-version.yml @@ -0,0 +1,9 @@ +# newer versions go on top +- version: "1.0.0" + changes: + - description: One change + type: enhancement + link: http://github.com/elastic/elastic-package + - description: Initial version + type: enhancement + link: http://github.com/elastic/elastic-package diff --git a/internal/packages/changelog/testdata/changelog-one.yml b/internal/packages/changelog/testdata/changelog-one.yml new file mode 100644 index 0000000000..308f139728 --- /dev/null +++ b/internal/packages/changelog/testdata/changelog-one.yml @@ -0,0 +1,6 @@ +# newer versions go on top +- version: "1.0.0" + changes: + - description: Initial version + type: enhancement + link: http://github.com/elastic/elastic-package diff --git a/internal/packages/changelog/yaml.go b/internal/packages/changelog/yaml.go new file mode 100644 index 0000000000..6f0a8d6072 --- /dev/null +++ b/internal/packages/changelog/yaml.go @@ -0,0 +1,171 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package changelog + +import ( + "github.com/Masterminds/semver" + "github.com/pkg/errors" + "gopkg.in/yaml.v3" + + "github.com/elastic/elastic-package/internal/formatter" +) + +// PatchYAML looks for the proper place to add the new revision in the changelog, +// trying to conserve original format and comments. +func PatchYAML(d []byte, patch Revision) ([]byte, error) { + var nodes []yaml.Node + err := yaml.Unmarshal(d, &nodes) + if err != nil { + return nil, err + } + + patchVersion, err := semver.NewVersion(patch.Version) + if err != nil { + return nil, err + } + + patched := false + var result []yaml.Node + for _, node := range nodes { + if patched { + result = append(result, node) + continue + } + + var entry Revision + err := node.Decode(&entry) + if err != nil { + result = append(result, node) + continue + } + + foundVersion, err := semver.NewVersion(entry.Version) + if err != nil { + return nil, err + } + + if foundVersion.GreaterThan(patchVersion) { + return nil, errors.New("cannot add change to old version") + } + + var newNode yaml.Node + if patchVersion.Equal(foundVersion) { + // Add the change to current entry. + entry.Changes = append(patch.Changes, entry.Changes...) + err := newNode.Encode(entry) + if err != nil { + return nil, err + } + // Keep comments of the original node. + newNode.HeadComment = node.HeadComment + newNode.LineComment = node.LineComment + newNode.FootComment = node.FootComment + // Quote version to keep common style. + setYamlMapValueStyle(&newNode, "version", yaml.DoubleQuotedStyle) + result = append(result, newNode) + patched = true + continue + } + + // Add the change before first entry + err = newNode.Encode(patch) + if err != nil { + return nil, err + } + // If there is a comment on top, leave it there. + if node.HeadComment != "" { + newNode.HeadComment = node.HeadComment + node.HeadComment = "" + } + // Quote version to keep common style. + setYamlMapValueStyle(&newNode, "version", yaml.DoubleQuotedStyle) + result = append(result, newNode, node) + patched = true + } + + if !patched { + return nil, errors.New("changelog entry was not added, this is probably a bug") + } + + d, err = formatResult(result) + if err != nil { + return nil, errors.Wrap(err, "failed to format manifest") + } + return d, nil +} + +func SetManifestVersion(d []byte, version string) ([]byte, error) { + var node yaml.Node + err := yaml.Unmarshal(d, &node) + if err != nil { + return nil, errors.Wrap(err, "failed to decode manifest") + } + + // Manifest is a document, with a single element, that should be a map. + if len(node.Content) == 0 || node.Content[0].Kind != yaml.MappingNode { + return nil, errors.Wrap(err, "unexpected manifest content") + } + + setYamlMapValue(node.Content[0], "version", version) + + d, err = formatResult(&node) + if err != nil { + return nil, errors.Wrap(err, "failed to format manifest") + } + return d, nil +} + +func formatResult(result interface{}) ([]byte, error) { + d, err := yaml.Marshal(result) + if err != nil { + return nil, errors.New("failed to encode") + } + d, _, err = formatter.YAMLFormatter(d) + if err != nil { + return nil, errors.New("failed to format") + } + return d, nil +} + +// setYamlMapValueStyle changes the style of one value in a YAML map. If the key +// is not found, it does nothing. +func setYamlMapValueStyle(node *yaml.Node, key string, style yaml.Style) { + // Check first if this is a map. + if node == nil || node.Kind != yaml.MappingNode { + return + } + // Look for the key, the value will be the next one. + var keyIdx int + for keyIdx = range node.Content { + child := node.Content[keyIdx] + if child.Kind == yaml.ScalarNode && child.Value == key { + break + } + } + valueIdx := keyIdx + 1 + if valueIdx < len(node.Content) { + node.Content[valueIdx].Style = style + } +} + +// setYamlMapValue sets a value in a map. +func setYamlMapValue(node *yaml.Node, key string, value string) { + // Check first if this is a map. + if node == nil || node.Kind != yaml.MappingNode { + return + } + // Look for the key, the value will be the next one. + var keyIdx int + for keyIdx = range node.Content { + child := node.Content[keyIdx] + if child.Kind == yaml.ScalarNode && child.Value == key { + break + } + } + valueIdx := keyIdx + 1 + if valueIdx < len(node.Content) { + node.Content[valueIdx].Value = value + } +} diff --git a/internal/packages/changelog/yaml_test.go b/internal/packages/changelog/yaml_test.go new file mode 100644 index 0000000000..c1ccb923ac --- /dev/null +++ b/internal/packages/changelog/yaml_test.go @@ -0,0 +1,105 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package changelog + +import ( + "errors" + "io/ioutil" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPatchYAML(t *testing.T) { + cases := []struct { + title string + original string + expected string + patch Revision + }{ + { + title: "Change in current version", + original: "testdata/changelog-one.yml", + expected: "testdata/changelog-one-patch-same-version.yml", + patch: Revision{ + Version: "1.0.0", + Changes: []Entry{ + { + Description: "One change", + Type: "enhancement", + Link: "http://github.com/elastic/elastic-package", + }, + }, + }, + }, + { + title: "Change in next major", + original: "testdata/changelog-one.yml", + expected: "testdata/changelog-one-patch-next-major.yml", + patch: Revision{ + Version: "2.0.0", + Changes: []Entry{ + { + Description: "One change", + Type: "enhancement", + Link: "http://github.com/elastic/elastic-package", + }, + }, + }, + }, + { + title: "Multiple changes", + original: "testdata/changelog-one.yml", + expected: "testdata/changelog-one-patch-multiple.yml", + patch: Revision{ + Version: "1.0.0", + Changes: []Entry{ + { + Description: "One change", + Type: "enhancement", + Link: "http://github.com/elastic/elastic-package", + }, + { + Description: "Other change", + Type: "enhancement", + Link: "http://github.com/elastic/elastic-package", + }, + }, + }, + }, + } + + for _, c := range cases { + t.Run(c.title, func(t *testing.T) { + d, err := ioutil.ReadFile(c.original) + require.NoError(t, err) + + result, err := PatchYAML(d, c.patch) + require.NoError(t, err) + + expected, err := ioutil.ReadFile(c.expected) + if errors.Is(err, os.ErrNotExist) { + err := ioutil.WriteFile(c.expected, result, 0644) + require.NoError(t, err) + t.Skip("file generated, run again") + } + require.NoError(t, err) + + assert.Equal(t, string(expected), string(result)) + }) + } +} + +func TestManifestVersion(t *testing.T) { + manifest := "name: test\nversion: 1.0.0\ncategories:\n - custom\n" + expected := "name: test\nversion: 1.1.0\ncategories:\n - custom\n" + + result, err := SetManifestVersion([]byte(manifest), "1.1.0") + require.NoError(t, err) + + assert.Equal(t, string(expected), string(result)) +}