From 3a81caff426646b45281397c8e09cedc9ad0c10c Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Wed, 30 Jul 2025 11:26:30 +0100 Subject: [PATCH 01/10] CLOUDP-279514 Detect and block breaking changes --- .github/workflows/breaking-changes.yaml | 55 ++++++ .github/workflows/close-jira.yml | 3 + tools/cmd/breakvalidator/main.go | 185 ++++++++++++++++++++ tools/cmd/breakvalidator/main_test.go | 213 ++++++++++++++++++++++++ 4 files changed, 456 insertions(+) create mode 100644 .github/workflows/breaking-changes.yaml create mode 100644 tools/cmd/breakvalidator/main.go create mode 100644 tools/cmd/breakvalidator/main_test.go diff --git a/.github/workflows/breaking-changes.yaml b/.github/workflows/breaking-changes.yaml new file mode 100644 index 0000000000..f806d170cd --- /dev/null +++ b/.github/workflows/breaking-changes.yaml @@ -0,0 +1,55 @@ +name: Detect Breaking Changes + +on: + pull_request: + +jobs: + breaking-changes-manifest: + name: Generate Breaking Changes Manifest + runs-on: ubuntu-latest + outputs: + breaking-changes: ${{ steps.breakvalidator.outputs.JSON }} + steps: + - uses: GitHubSecurityLab/actions-permissions/monitor@v1 + with: + config: ${{ vars.PERMISSIONS_CONFIG }} + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} + - name: Install Go + uses: actions/setup-go@v4 + with: + go-version-file: go.mod + cache: true + - name: Install dependencies + run: go mod download + - name: Run breaking changes validator + id: breakvalidator + run: | + { + echo "JSON<> "$GITHUB_OUTPUT" + detect-breaking-changes: + name: Detect Breaking Changes + runs-on: ubuntu-latest + needs: breaking-changes-manifest + steps: + - uses: GitHubSecurityLab/actions-permissions/monitor@v1 + with: + config: ${{ vars.PERMISSIONS_CONFIG }} + - name: Checkout code + uses: actions/checkout@v4 + - name: Install Go + uses: actions/setup-go@v4 + with: + go-version-file: go.mod + cache: true + - name: Run breaking changes validator + env: + JSON: ${{ needs.breaking-changes-manifest.outputs.breaking-changes }} + run: | + echo "$JSON" > main.json + go run tools/cmd/breakvalidator/main.go validate < main.json diff --git a/.github/workflows/close-jira.yml b/.github/workflows/close-jira.yml index b16a528e82..a70a9e90d3 100644 --- a/.github/workflows/close-jira.yml +++ b/.github/workflows/close-jira.yml @@ -10,6 +10,9 @@ jobs: runs-on: ubuntu-latest if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'auto_close_jira') steps: + - uses: GitHubSecurityLab/actions-permissions/monitor@v1 + with: + config: ${{ vars.PERMISSIONS_CONFIG }} - name: set jira key (branch name) if: ${{ startsWith(github.event.pull_request.head.ref, 'CLOUDP') }} env: diff --git a/tools/cmd/breakvalidator/main.go b/tools/cmd/breakvalidator/main.go new file mode 100644 index 0000000000..babc6d5278 --- /dev/null +++ b/tools/cmd/breakvalidator/main.go @@ -0,0 +1,185 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "slices" + + "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/cli/root" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +var ( + errFlagDeleted = errors.New("flag was deleted") + errFlagTypeChanged = errors.New("flag type changed") + errFlagDefaultChanged = errors.New("flag default value changed") + errCmdDeleted = errors.New("command deleted") + errCmdRemovedAlias = errors.New("command alias removed") + errFlagShortChanged = errors.New("flag shorthand changed") +) + +type flagData struct { + Type string `json:"type"` + Default string `json:"default"` + Short string `json:"short"` +} + +type cmdData struct { + Aliases []string `json:"aliases"` + Flags map[string]flagData `json:"flags"` +} + +func generateCmd(cmd *cobra.Command) cmdData { + data := cmdData{} + if len(cmd.Aliases) > 0 { + data.Aliases = cmd.Aliases + } + flags := false + data.Flags = map[string]flagData{} + cmd.Flags().VisitAll(func(f *pflag.Flag) { + data.Flags[f.Name] = flagData{ + Type: f.Value.Type(), + Default: f.DefValue, + Short: f.Shorthand, + } + flags = true + }) + if !flags { + data.Flags = nil + } + return data +} + +func generateCmds(cmd *cobra.Command) map[string]cmdData { + data := map[string]cmdData{} + data[cmd.CommandPath()] = generateCmd(cmd) + for _, c := range cmd.Commands() { + for k, v := range generateCmds(c) { + data[k] = v + } + } + return data +} + +func generateCmdRun(output io.Writer) error { + cliCmd := root.Builder() + data := generateCmds(cliCmd) + return json.NewEncoder(output).Encode(data) +} + +func compareFlags(cmdPath string, mainFlags, changedFlags map[string]flagData) []error { + if mainFlags == nil { + return nil + } + + changes := []error{} + + for flagName, flagValue := range mainFlags { + changedFlagValue, ok := changedFlags[flagName] + if !ok { + changes = append(changes, fmt.Errorf("%w: %s --%s", errFlagDeleted, cmdPath, flagName)) + continue + } + + if flagValue.Type != changedFlagValue.Type { + changes = append(changes, fmt.Errorf("%w: %s --%s", errFlagTypeChanged, cmdPath, flagName)) + } + + if flagValue.Default != changedFlagValue.Default { + changes = append(changes, fmt.Errorf("%w: %s --%s", errFlagDefaultChanged, cmdPath, flagName)) + } + + if flagValue.Short != changedFlagValue.Short { + changes = append(changes, fmt.Errorf("%w: %s --%s", errFlagShortChanged, cmdPath, flagName)) + } + } + + return changes +} + +func compareCmds(changedData, mainData map[string]cmdData) error { + changes := []error{} + for cmdPath, mv := range mainData { + cv, ok := changedData[cmdPath] + if !ok { + changes = append(changes, fmt.Errorf("%w: %s", errCmdDeleted, cmdPath)) + continue + } + + if mv.Aliases != nil { + mainAliases := mv.Aliases + changedAliases := cv.Aliases + + for _, alias := range mainAliases { + if !slices.Contains(changedAliases, alias) { + changes = append(changes, fmt.Errorf("%w: %s", errCmdRemovedAlias, cmdPath)) + } + } + } + + changes = append(changes, compareFlags(cmdPath, mv.Flags, cv.Flags)...) + } + + if len(changes) > 0 { + return errors.Join(changes...) + } + + return nil +} + +func validateCmdRun(output io.Writer, input io.Reader) error { + var inputData map[string]cmdData + if err := json.NewDecoder(input).Decode(&inputData); err != nil { + return err + } + + cliCmd := root.Builder() + generatedData := generateCmds(cliCmd) + + err := compareCmds(generatedData, inputData) + if err != nil { + return err + } + + fmt.Fprintln(output, "no breaking changes detected") + return nil +} + +func buildCmd() *cobra.Command { + generateCmd := &cobra.Command{ + Use: "generate", + Short: "Generate the CLI command structure.", + RunE: func(cmd *cobra.Command, _ []string) error { + return generateCmdRun(cmd.OutOrStdout()) + }, + } + + validateCmd := &cobra.Command{ + Use: "validate", + Short: "Validate the CLI command structure.", + RunE: func(cmd *cobra.Command, _ []string) error { + return validateCmdRun(cmd.OutOrStdout(), cmd.InOrStdin()) + }, + } + + rootCmd := &cobra.Command{ + Use: "breakvalidator", + Short: "CLI tool to validate breaking changes in the CLI.", + } + rootCmd.AddCommand(generateCmd) + rootCmd.AddCommand(validateCmd) + + return rootCmd +} + +func main() { + rootCmd := buildCmd() + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} diff --git a/tools/cmd/breakvalidator/main_test.go b/tools/cmd/breakvalidator/main_test.go new file mode 100644 index 0000000000..15659396e3 --- /dev/null +++ b/tools/cmd/breakvalidator/main_test.go @@ -0,0 +1,213 @@ +package main + +import ( + "errors" + "testing" +) + +func TestCompareCmds(t *testing.T) { + testCases := []struct { + name string + mainData map[string]cmdData + changedData map[string]cmdData + expectedErr error + }{ + { + name: "no changes", + mainData: map[string]cmdData{ + "cmd1": { + Aliases: []string{"cmda"}, + Flags: map[string]flagData{ + "flag1": { + Type: "string", + Default: "default1", + }, + }, + }, + "cmd2": {}, + }, + changedData: map[string]cmdData{ + "cmd1": { + Aliases: []string{"cmda"}, + Flags: map[string]flagData{ + "flag1": { + Type: "string", + Default: "default1", + }, + }, + }, + "cmd2": { + Aliases: []string{"cmdb"}, + }, + }, + }, + { + name: "alias removed", + mainData: map[string]cmdData{ + "cmd1": { + Aliases: []string{"cmda"}, + Flags: map[string]flagData{ + "flag1": { + Type: "string", + Default: "default1", + }, + }, + }, + "cmd2": { + Aliases: []string{"cmdb"}, + }, + }, + changedData: map[string]cmdData{ + "cmd1": { + Aliases: []string{"cmda"}, + Flags: map[string]flagData{ + "flag1": { + Type: "string", + Default: "default1", + }, + }, + }, + "cmd2": {}, + }, + expectedErr: errCmdRemovedAlias, + }, + { + name: "deleted command", + mainData: map[string]cmdData{ + "cmd1": { + Aliases: []string{"cmda"}, + Flags: map[string]flagData{ + "flag1": { + Type: "string", + Default: "default1", + }, + }, + }, + "cmd2": {}, + }, + changedData: map[string]cmdData{ + "cmd2": {}, + }, + expectedErr: errCmdDeleted, + }, + { + name: "deleted flag", + mainData: map[string]cmdData{ + "cmd1": { + Aliases: []string{"cmda"}, + Flags: map[string]flagData{ + "flag1": { + Type: "string", + Default: "default1", + }, + }, + }, + "cmd2": {}, + }, + changedData: map[string]cmdData{ + "cmd1": { + Aliases: []string{"cmda"}, + }, + "cmd2": {}, + }, + expectedErr: errFlagDeleted, + }, + { + name: "changed flag type", + mainData: map[string]cmdData{ + "cmd1": { + Aliases: []string{"cmda"}, + Flags: map[string]flagData{ + "flag1": { + Type: "string", + Default: "default1", + }, + }, + }, + "cmd2": {}, + }, + changedData: map[string]cmdData{ + "cmd1": { + Aliases: []string{"cmda"}, + Flags: map[string]flagData{ + "flag1": { + Type: "int", + Default: "default1", + }, + }, + }, + "cmd2": {}, + }, + expectedErr: errFlagTypeChanged, + }, + { + name: "changed flag default value", + mainData: map[string]cmdData{ + "cmd1": { + Aliases: []string{"cmda"}, + Flags: map[string]flagData{ + "flag1": { + Type: "string", + Default: "default1", + }, + }, + }, + "cmd2": {}, + }, + changedData: map[string]cmdData{ + "cmd1": { + Aliases: []string{"cmda"}, + Flags: map[string]flagData{ + "flag1": { + Type: "string", + Default: "default2", + }, + }, + }, + "cmd2": {}, + }, + expectedErr: errFlagDefaultChanged, + }, + { + name: "changed flag shorthand", + mainData: map[string]cmdData{ + "cmd1": { + Aliases: []string{"cmda"}, + Flags: map[string]flagData{ + "flag1": { + Type: "string", + Default: "default1", + Short: "f1", + }, + }, + }, + "cmd2": {}, + }, + changedData: map[string]cmdData{ + "cmd1": { + Aliases: []string{"cmda"}, + Flags: map[string]flagData{ + "flag1": { + Type: "string", + Default: "default1", + Short: "f2", + }, + }, + }, + "cmd2": {}, + }, + expectedErr: errFlagShortChanged, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + err := compareCmds(testCase.changedData, testCase.mainData) + if err != nil && !errors.Is(err, testCase.expectedErr) { + t.Fatalf("compareCmds failed: %v", err) + } else if err == nil && testCase.expectedErr != nil { + t.Fatalf("compareCmds should have failed: %v", err) + } + }) + } +} From 5d0c1731418aa5b1f24bf1efc2566449dc41e82f Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Wed, 30 Jul 2025 12:02:23 +0100 Subject: [PATCH 02/10] fix --- .github/workflows/breaking-changes.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/breaking-changes.yaml b/.github/workflows/breaking-changes.yaml index f806d170cd..97f950383b 100644 --- a/.github/workflows/breaking-changes.yaml +++ b/.github/workflows/breaking-changes.yaml @@ -27,6 +27,7 @@ jobs: - name: Run breaking changes validator id: breakvalidator run: | + set -e { echo "JSON< main.json go run tools/cmd/breakvalidator/main.go validate < main.json From 660f1e6e3eb5e94382761487a21c6b9b4565cd9c Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Wed, 30 Jul 2025 12:20:45 +0100 Subject: [PATCH 03/10] fix --- .github/workflows/breaking-changes.yaml | 8 +++----- scripts/add-copy.sh | 1 + tools/cmd/breakvalidator/main.go | 14 ++++++++++++++ tools/cmd/breakvalidator/main_test.go | 14 ++++++++++++++ 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/.github/workflows/breaking-changes.yaml b/.github/workflows/breaking-changes.yaml index 97f950383b..454f50faf0 100644 --- a/.github/workflows/breaking-changes.yaml +++ b/.github/workflows/breaking-changes.yaml @@ -18,10 +18,9 @@ jobs: with: ref: ${{ github.event.pull_request.base.sha }} - name: Install Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: go-version-file: go.mod - cache: true - name: Install dependencies run: go mod download - name: Run breaking changes validator @@ -30,7 +29,7 @@ jobs: set -e { echo "JSON<> "$GITHUB_OUTPUT" detect-breaking-changes: @@ -44,10 +43,9 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - name: Install Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: go-version-file: go.mod - cache: true - name: Run breaking changes validator env: JSON: ${{ needs.breaking-changes-manifest.outputs.breaking-changes }} diff --git a/scripts/add-copy.sh b/scripts/add-copy.sh index dd36384c2d..f97e4fd585 100755 --- a/scripts/add-copy.sh +++ b/scripts/add-copy.sh @@ -21,6 +21,7 @@ find_files() { \( \ -wholename '*mock*' \ -o -wholename '*third_party*' \ + -o -wholename '*docs/command*' \ \) -prune \ \) \ \( -name '*.go' -o -name '*.sh' \) diff --git a/tools/cmd/breakvalidator/main.go b/tools/cmd/breakvalidator/main.go index babc6d5278..903ae547e0 100644 --- a/tools/cmd/breakvalidator/main.go +++ b/tools/cmd/breakvalidator/main.go @@ -1,3 +1,17 @@ +// Copyright 2025 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package main import ( diff --git a/tools/cmd/breakvalidator/main_test.go b/tools/cmd/breakvalidator/main_test.go index 15659396e3..f2f73bdec6 100644 --- a/tools/cmd/breakvalidator/main_test.go +++ b/tools/cmd/breakvalidator/main_test.go @@ -1,3 +1,17 @@ +// Copyright 2025 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package main import ( From fee1184ba803c159432ffa818de48fc5b6d7378f Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Wed, 30 Jul 2025 14:26:09 +0100 Subject: [PATCH 04/10] fix --- .github/workflows/breaking-changes.yaml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/breaking-changes.yaml b/.github/workflows/breaking-changes.yaml index 454f50faf0..f58f91d20d 100644 --- a/.github/workflows/breaking-changes.yaml +++ b/.github/workflows/breaking-changes.yaml @@ -31,7 +31,12 @@ jobs: echo "JSON<> "$GITHUB_OUTPUT" + } > main.json + - name: Upload manifest + uses: actions/upload-artifact@v4 + with: + name: breaking-changes-manifest + path: main.json detect-breaking-changes: name: Detect Breaking Changes runs-on: ubuntu-latest @@ -46,10 +51,12 @@ jobs: uses: actions/setup-go@v5 with: go-version-file: go.mod + - name: Download manifest + uses: actions/download-artifact@v4 + with: + name: breaking-changes-manifest + path: main.json - name: Run breaking changes validator - env: - JSON: ${{ needs.breaking-changes-manifest.outputs.breaking-changes }} run: | set -e - echo "$JSON" > main.json go run tools/cmd/breakvalidator/main.go validate < main.json From 4f5ea9b3cf4626f4c0b26f7dd4dd90224148d5ed Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Wed, 30 Jul 2025 14:28:07 +0100 Subject: [PATCH 05/10] fix --- .github/workflows/breaking-changes.yaml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/breaking-changes.yaml b/.github/workflows/breaking-changes.yaml index f58f91d20d..e6b6af71c3 100644 --- a/.github/workflows/breaking-changes.yaml +++ b/.github/workflows/breaking-changes.yaml @@ -27,11 +27,7 @@ jobs: id: breakvalidator run: | set -e - { - echo "JSON< main.json + go run tools/cmd/breakvalidator/main.go generate > main.json - name: Upload manifest uses: actions/upload-artifact@v4 with: From 89cf3549bb178c12081d68c49803235165ebc26b Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Wed, 30 Jul 2025 15:25:07 +0100 Subject: [PATCH 06/10] fix --- .github/workflows/breaking-changes.yaml | 5 +- tools/cmd/breakvalidator/generate.go | 73 ++++++++ tools/cmd/breakvalidator/generate_test.go | 48 ++++++ tools/cmd/breakvalidator/main.go | 156 +----------------- tools/cmd/breakvalidator/validate.go | 153 +++++++++++++++++ .../{main_test.go => validate_test.go} | 0 6 files changed, 281 insertions(+), 154 deletions(-) create mode 100644 tools/cmd/breakvalidator/generate.go create mode 100644 tools/cmd/breakvalidator/generate_test.go create mode 100644 tools/cmd/breakvalidator/validate.go rename tools/cmd/breakvalidator/{main_test.go => validate_test.go} (100%) diff --git a/.github/workflows/breaking-changes.yaml b/.github/workflows/breaking-changes.yaml index e6b6af71c3..53eb31808d 100644 --- a/.github/workflows/breaking-changes.yaml +++ b/.github/workflows/breaking-changes.yaml @@ -27,7 +27,7 @@ jobs: id: breakvalidator run: | set -e - go run tools/cmd/breakvalidator/main.go generate > main.json + go run tools/cmd/breakvalidator generate > main.json - name: Upload manifest uses: actions/upload-artifact@v4 with: @@ -55,4 +55,5 @@ jobs: - name: Run breaking changes validator run: | set -e - go run tools/cmd/breakvalidator/main.go validate < main.json + go run tools/cmd/breakvalidator generate > changed.json + go run tools/cmd/breakvalidator validate -m main.json -c changed.json diff --git a/tools/cmd/breakvalidator/generate.go b/tools/cmd/breakvalidator/generate.go new file mode 100644 index 0000000000..60ed5f5b25 --- /dev/null +++ b/tools/cmd/breakvalidator/generate.go @@ -0,0 +1,73 @@ +// Copyright 2025 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "encoding/json" + "io" + + "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/cli/root" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +func generateCmd(cmd *cobra.Command) cmdData { + data := cmdData{} + if len(cmd.Aliases) > 0 { + data.Aliases = cmd.Aliases + } + flags := false + data.Flags = map[string]flagData{} + cmd.Flags().VisitAll(func(f *pflag.Flag) { + data.Flags[f.Name] = flagData{ + Type: f.Value.Type(), + Default: f.DefValue, + Short: f.Shorthand, + } + flags = true + }) + if !flags { + data.Flags = nil + } + return data +} + +func generateCmds(cmd *cobra.Command) map[string]cmdData { + data := map[string]cmdData{} + data[cmd.CommandPath()] = generateCmd(cmd) + for _, c := range cmd.Commands() { + for k, v := range generateCmds(c) { + data[k] = v + } + } + return data +} + +func generateCmdRun(output io.Writer) error { + cliCmd := root.Builder() + data := generateCmds(cliCmd) + return json.NewEncoder(output).Encode(data) +} + +func buildGenerateCmd() *cobra.Command { + generateCmd := &cobra.Command{ + Use: "generate", + Short: "Generate the CLI command structure.", + RunE: func(cmd *cobra.Command, _ []string) error { + return generateCmdRun(cmd.OutOrStdout()) + }, + } + return generateCmd +} diff --git a/tools/cmd/breakvalidator/generate_test.go b/tools/cmd/breakvalidator/generate_test.go new file mode 100644 index 0000000000..45239a6002 --- /dev/null +++ b/tools/cmd/breakvalidator/generate_test.go @@ -0,0 +1,48 @@ +// Copyright 2025 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "reflect" + "testing" + + "github.com/spf13/cobra" +) + +func TestGenerateCmds(t *testing.T) { + cliCmd := &cobra.Command{ + Use: "test", + Aliases: []string{"testa"}, + } + cliCmd.Flags().StringP("flag1", "f", "default1", "flag1") + generatedData := generateCmds(cliCmd) + + expectedData := map[string]cmdData{ + "test": { + Aliases: []string{"testa"}, + Flags: map[string]flagData{ + "flag1": { + Type: "string", + Default: "default1", + Short: "f", + }, + }, + }, + } + + if !reflect.DeepEqual(generatedData, expectedData) { + t.Fatalf("got: %v, expected: %v", generatedData, expectedData) + } +} diff --git a/tools/cmd/breakvalidator/main.go b/tools/cmd/breakvalidator/main.go index 903ae547e0..b4fe20b33b 100644 --- a/tools/cmd/breakvalidator/main.go +++ b/tools/cmd/breakvalidator/main.go @@ -15,25 +15,9 @@ package main import ( - "encoding/json" - "errors" - "fmt" - "io" "os" - "slices" - "github.com/mongodb/mongodb-atlas-cli/atlascli/internal/cli/root" "github.com/spf13/cobra" - "github.com/spf13/pflag" -) - -var ( - errFlagDeleted = errors.New("flag was deleted") - errFlagTypeChanged = errors.New("flag type changed") - errFlagDefaultChanged = errors.New("flag default value changed") - errCmdDeleted = errors.New("command deleted") - errCmdRemovedAlias = errors.New("command alias removed") - errFlagShortChanged = errors.New("flag shorthand changed") ) type flagData struct { @@ -47,151 +31,19 @@ type cmdData struct { Flags map[string]flagData `json:"flags"` } -func generateCmd(cmd *cobra.Command) cmdData { - data := cmdData{} - if len(cmd.Aliases) > 0 { - data.Aliases = cmd.Aliases - } - flags := false - data.Flags = map[string]flagData{} - cmd.Flags().VisitAll(func(f *pflag.Flag) { - data.Flags[f.Name] = flagData{ - Type: f.Value.Type(), - Default: f.DefValue, - Short: f.Shorthand, - } - flags = true - }) - if !flags { - data.Flags = nil - } - return data -} - -func generateCmds(cmd *cobra.Command) map[string]cmdData { - data := map[string]cmdData{} - data[cmd.CommandPath()] = generateCmd(cmd) - for _, c := range cmd.Commands() { - for k, v := range generateCmds(c) { - data[k] = v - } - } - return data -} - -func generateCmdRun(output io.Writer) error { - cliCmd := root.Builder() - data := generateCmds(cliCmd) - return json.NewEncoder(output).Encode(data) -} - -func compareFlags(cmdPath string, mainFlags, changedFlags map[string]flagData) []error { - if mainFlags == nil { - return nil - } - - changes := []error{} - - for flagName, flagValue := range mainFlags { - changedFlagValue, ok := changedFlags[flagName] - if !ok { - changes = append(changes, fmt.Errorf("%w: %s --%s", errFlagDeleted, cmdPath, flagName)) - continue - } - - if flagValue.Type != changedFlagValue.Type { - changes = append(changes, fmt.Errorf("%w: %s --%s", errFlagTypeChanged, cmdPath, flagName)) - } - - if flagValue.Default != changedFlagValue.Default { - changes = append(changes, fmt.Errorf("%w: %s --%s", errFlagDefaultChanged, cmdPath, flagName)) - } - - if flagValue.Short != changedFlagValue.Short { - changes = append(changes, fmt.Errorf("%w: %s --%s", errFlagShortChanged, cmdPath, flagName)) - } - } - - return changes -} - -func compareCmds(changedData, mainData map[string]cmdData) error { - changes := []error{} - for cmdPath, mv := range mainData { - cv, ok := changedData[cmdPath] - if !ok { - changes = append(changes, fmt.Errorf("%w: %s", errCmdDeleted, cmdPath)) - continue - } - - if mv.Aliases != nil { - mainAliases := mv.Aliases - changedAliases := cv.Aliases - - for _, alias := range mainAliases { - if !slices.Contains(changedAliases, alias) { - changes = append(changes, fmt.Errorf("%w: %s", errCmdRemovedAlias, cmdPath)) - } - } - } - - changes = append(changes, compareFlags(cmdPath, mv.Flags, cv.Flags)...) - } - - if len(changes) > 0 { - return errors.Join(changes...) - } - - return nil -} - -func validateCmdRun(output io.Writer, input io.Reader) error { - var inputData map[string]cmdData - if err := json.NewDecoder(input).Decode(&inputData); err != nil { - return err - } - - cliCmd := root.Builder() - generatedData := generateCmds(cliCmd) - - err := compareCmds(generatedData, inputData) - if err != nil { - return err - } - - fmt.Fprintln(output, "no breaking changes detected") - return nil -} - -func buildCmd() *cobra.Command { - generateCmd := &cobra.Command{ - Use: "generate", - Short: "Generate the CLI command structure.", - RunE: func(cmd *cobra.Command, _ []string) error { - return generateCmdRun(cmd.OutOrStdout()) - }, - } - - validateCmd := &cobra.Command{ - Use: "validate", - Short: "Validate the CLI command structure.", - RunE: func(cmd *cobra.Command, _ []string) error { - return validateCmdRun(cmd.OutOrStdout(), cmd.InOrStdin()) - }, - } - +func buildRootCmd() *cobra.Command { rootCmd := &cobra.Command{ Use: "breakvalidator", Short: "CLI tool to validate breaking changes in the CLI.", } - rootCmd.AddCommand(generateCmd) - rootCmd.AddCommand(validateCmd) + rootCmd.AddCommand(buildGenerateCmd()) + rootCmd.AddCommand(buildValidateCmd()) return rootCmd } func main() { - rootCmd := buildCmd() + rootCmd := buildRootCmd() err := rootCmd.Execute() if err != nil { os.Exit(1) diff --git a/tools/cmd/breakvalidator/validate.go b/tools/cmd/breakvalidator/validate.go new file mode 100644 index 0000000000..64b7b97d2b --- /dev/null +++ b/tools/cmd/breakvalidator/validate.go @@ -0,0 +1,153 @@ +// Copyright 2025 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "slices" + + "github.com/spf13/cobra" +) + +var ( + errFlagDeleted = errors.New("flag was deleted") + errFlagTypeChanged = errors.New("flag type changed") + errFlagDefaultChanged = errors.New("flag default value changed") + errCmdDeleted = errors.New("command deleted") + errCmdRemovedAlias = errors.New("command alias removed") + errFlagShortChanged = errors.New("flag shorthand changed") +) + +func compareFlags(cmdPath string, mainFlags, changedFlags map[string]flagData) []error { + if mainFlags == nil { + return nil + } + + changes := []error{} + + for flagName, flagValue := range mainFlags { + changedFlagValue, ok := changedFlags[flagName] + if !ok { + changes = append(changes, fmt.Errorf("%w: %s --%s", errFlagDeleted, cmdPath, flagName)) + continue + } + + if flagValue.Type != changedFlagValue.Type { + changes = append(changes, fmt.Errorf("%w: %s --%s", errFlagTypeChanged, cmdPath, flagName)) + } + + if flagValue.Default != changedFlagValue.Default { + changes = append(changes, fmt.Errorf("%w: %s --%s", errFlagDefaultChanged, cmdPath, flagName)) + } + + if flagValue.Short != changedFlagValue.Short { + changes = append(changes, fmt.Errorf("%w: %s --%s", errFlagShortChanged, cmdPath, flagName)) + } + } + + return changes +} + +func compareCmds(changedData, mainData map[string]cmdData) error { + changes := []error{} + for cmdPath, mv := range mainData { + cv, ok := changedData[cmdPath] + if !ok { + changes = append(changes, fmt.Errorf("%w: %s", errCmdDeleted, cmdPath)) + continue + } + + if mv.Aliases != nil { + mainAliases := mv.Aliases + changedAliases := cv.Aliases + + for _, alias := range mainAliases { + if !slices.Contains(changedAliases, alias) { + changes = append(changes, fmt.Errorf("%w: %s", errCmdRemovedAlias, cmdPath)) + } + } + } + + changes = append(changes, compareFlags(cmdPath, mv.Flags, cv.Flags)...) + } + + if len(changes) > 0 { + return errors.Join(changes...) + } + + return nil +} + +func validateCmdRun(output io.Writer, mainFile, changedFile string) error { + mainBuffer, err := os.ReadFile(mainFile) + if err != nil { + return err + } + changedBuffer, err := os.ReadFile(changedFile) + if err != nil { + return err + } + + var mainData, changedData map[string]cmdData + if err := json.Unmarshal(mainBuffer, &mainData); err != nil { + return err + } + if err := json.Unmarshal(changedBuffer, &changedData); err != nil { + return err + } + + err = compareCmds(changedData, mainData) + if err != nil { + return err + } + + fmt.Fprintln(output, "no breaking changes detected") + return nil +} + +func buildValidateCmd() *cobra.Command { + var mainFile, changedFile string + validateCmd := &cobra.Command{ + Use: "validate", + Short: "Validate the CLI command structure.", + PreRunE: func(_ *cobra.Command, _ []string) error { + if mainFile == "" { + return errors.New("--main flag is required") + } + if changedFile == "" { + return errors.New("--changed flag is required") + } + if _, err := os.Stat(mainFile); os.IsNotExist(err) { + return fmt.Errorf("file does not exist: %s", mainFile) + } + if _, err := os.Stat(changedFile); os.IsNotExist(err) { + return fmt.Errorf("file does not exist: %s", changedFile) + } + return nil + }, + RunE: func(cmd *cobra.Command, _ []string) error { + return validateCmdRun(cmd.OutOrStdout(), mainFile, changedFile) + }, + } + validateCmd.Flags().StringVarP(&mainFile, "main", "m", "", "Main file") + validateCmd.Flags().StringVarP(&changedFile, "changed", "c", "", "Changed file") + _ = validateCmd.MarkFlagRequired("main") + _ = validateCmd.MarkFlagRequired("changed") + return validateCmd +} diff --git a/tools/cmd/breakvalidator/main_test.go b/tools/cmd/breakvalidator/validate_test.go similarity index 100% rename from tools/cmd/breakvalidator/main_test.go rename to tools/cmd/breakvalidator/validate_test.go From af871198116e11fc2a51de0e7263f8299f0c712d Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Wed, 30 Jul 2025 15:36:51 +0100 Subject: [PATCH 07/10] fix --- .github/workflows/breaking-changes.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/breaking-changes.yaml b/.github/workflows/breaking-changes.yaml index 53eb31808d..2e271e927b 100644 --- a/.github/workflows/breaking-changes.yaml +++ b/.github/workflows/breaking-changes.yaml @@ -27,7 +27,7 @@ jobs: id: breakvalidator run: | set -e - go run tools/cmd/breakvalidator generate > main.json + go run ./tools/cmd/breakvalidator generate > main.json - name: Upload manifest uses: actions/upload-artifact@v4 with: @@ -55,5 +55,5 @@ jobs: - name: Run breaking changes validator run: | set -e - go run tools/cmd/breakvalidator generate > changed.json - go run tools/cmd/breakvalidator validate -m main.json -c changed.json + go run ./tools/cmd/breakvalidator generate > changed.json + go run ./tools/cmd/breakvalidator validate -m main.json -c changed.json From 7c30d6abbd3516589d1836874d9429748ea6219f Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Wed, 30 Jul 2025 15:50:31 +0100 Subject: [PATCH 08/10] fix --- .github/workflows/breaking-changes.yaml | 4 ---- tools/cmd/breakvalidator/main.go | 10 +++++----- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/workflows/breaking-changes.yaml b/.github/workflows/breaking-changes.yaml index 2e271e927b..7e4ab1648f 100644 --- a/.github/workflows/breaking-changes.yaml +++ b/.github/workflows/breaking-changes.yaml @@ -7,8 +7,6 @@ jobs: breaking-changes-manifest: name: Generate Breaking Changes Manifest runs-on: ubuntu-latest - outputs: - breaking-changes: ${{ steps.breakvalidator.outputs.JSON }} steps: - uses: GitHubSecurityLab/actions-permissions/monitor@v1 with: @@ -24,7 +22,6 @@ jobs: - name: Install dependencies run: go mod download - name: Run breaking changes validator - id: breakvalidator run: | set -e go run ./tools/cmd/breakvalidator generate > main.json @@ -51,7 +48,6 @@ jobs: uses: actions/download-artifact@v4 with: name: breaking-changes-manifest - path: main.json - name: Run breaking changes validator run: | set -e diff --git a/tools/cmd/breakvalidator/main.go b/tools/cmd/breakvalidator/main.go index b4fe20b33b..3fe27f57c9 100644 --- a/tools/cmd/breakvalidator/main.go +++ b/tools/cmd/breakvalidator/main.go @@ -21,14 +21,14 @@ import ( ) type flagData struct { - Type string `json:"type"` - Default string `json:"default"` - Short string `json:"short"` + Type string `json:"type,omitempty"` + Default string `json:"default,omitempty"` + Short string `json:"short,omitempty"` } type cmdData struct { - Aliases []string `json:"aliases"` - Flags map[string]flagData `json:"flags"` + Aliases []string `json:"aliases,omitempty"` + Flags map[string]flagData `json:"flags,omitempty"` } func buildRootCmd() *cobra.Command { From 139ac4110a8ec7d76c14a36010c2e1d90d870a41 Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Wed, 30 Jul 2025 14:19:39 +0100 Subject: [PATCH 09/10] CLOUDP-279514 test pr check --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 11d5dc59fc..86d98ced54 100644 --- a/README.md +++ b/README.md @@ -75,3 +75,5 @@ See our [CONTRIBUTING.md](CONTRIBUTING.md) guide. ## License MongoDB Atlas CLI is released under the Apache 2.0 license. See [LICENSE](LICENSE) + + \ No newline at end of file From 4ef2e65717560bf882181b7ce81cc53d5ab45eb9 Mon Sep 17 00:00:00 2001 From: Filipe C Menezes Date: Wed, 30 Jul 2025 16:01:51 +0100 Subject: [PATCH 10/10] force fail --- docs/command/atlas-accessLists-create.txt | 4 ---- internal/cli/accesslists/create.go | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/docs/command/atlas-accessLists-create.txt b/docs/command/atlas-accessLists-create.txt index f6bd387293..dc5951f528 100644 --- a/docs/command/atlas-accessLists-create.txt +++ b/docs/command/atlas-accessLists-create.txt @@ -57,10 +57,6 @@ Options - Type - Required - Description - * - --comment - - string - - false - - Optional description or comment for the entry. * - --currentIp - - false diff --git a/internal/cli/accesslists/create.go b/internal/cli/accesslists/create.go index 0603078c6a..21d97e9766 100644 --- a/internal/cli/accesslists/create.go +++ b/internal/cli/accesslists/create.go @@ -200,7 +200,7 @@ The command doesn't overwrite existing entries in the access list. Instead, it a } cmd.Flags().StringVar(&opts.entryType, flag.TypeFlag, ipAddress, usage.AccessListType) - cmd.Flags().StringVar(&opts.comment, flag.Comment, "", usage.Comment) + // cmd.Flags().StringVar(&opts.comment, flag.Comment, "", usage.Comment) cmd.Flags().StringVar(&opts.deleteAfter, flag.DeleteAfter, "", usage.AccessListsDeleteAfter) cmd.Flags().BoolVar(&opts.currentIP, flag.CurrentIP, false, usage.CurrentIP)