diff --git a/.github/workflows/breaking-changes.yaml b/.github/workflows/breaking-changes.yaml new file mode 100644 index 0000000000..7e4ab1648f --- /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 + 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@v5 + with: + go-version-file: go.mod + - name: Install dependencies + run: go mod download + - name: Run breaking changes validator + run: | + set -e + go run ./tools/cmd/breakvalidator generate > 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 + 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@v5 + with: + go-version-file: go.mod + - name: Download manifest + uses: actions/download-artifact@v4 + with: + name: breaking-changes-manifest + - 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 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/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 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) 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/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 new file mode 100644 index 0000000000..3fe27f57c9 --- /dev/null +++ b/tools/cmd/breakvalidator/main.go @@ -0,0 +1,51 @@ +// 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 ( + "os" + + "github.com/spf13/cobra" +) + +type flagData struct { + Type string `json:"type,omitempty"` + Default string `json:"default,omitempty"` + Short string `json:"short,omitempty"` +} + +type cmdData struct { + Aliases []string `json:"aliases,omitempty"` + Flags map[string]flagData `json:"flags,omitempty"` +} + +func buildRootCmd() *cobra.Command { + rootCmd := &cobra.Command{ + Use: "breakvalidator", + Short: "CLI tool to validate breaking changes in the CLI.", + } + rootCmd.AddCommand(buildGenerateCmd()) + rootCmd.AddCommand(buildValidateCmd()) + + return rootCmd +} + +func main() { + 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/validate_test.go b/tools/cmd/breakvalidator/validate_test.go new file mode 100644 index 0000000000..f2f73bdec6 --- /dev/null +++ b/tools/cmd/breakvalidator/validate_test.go @@ -0,0 +1,227 @@ +// 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 ( + "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) + } + }) + } +}