diff --git a/docs/content/en/docs-dev/user-guide/plan-preview.md b/docs/content/en/docs-dev/user-guide/plan-preview.md index c0a212d5df..e3379738a6 100644 --- a/docs/content/en/docs-dev/user-guide/plan-preview.md +++ b/docs/content/en/docs-dev/user-guide/plan-preview.md @@ -38,7 +38,8 @@ pipectl plan-preview \ --repo-remote-url={ REPO_REMOTE_GIT_SSH_URL } \ --head-branch={ HEAD_BRANCH } \ --head-commit={ HEAD_COMMIT } \ - --base-branch={ BASE_BRANCH } + --base-branch={ BASE_BRANCH } \ + --sort-label-keys={ SORT_LABEL_KEYS } ``` You can run it locally or integrate it to your CI system to run automatically when a new pull request is opened/updated. Use `--help` to see more options. @@ -47,6 +48,13 @@ You can run it locally or integrate it to your CI system to run automatically wh pipectl plan-preview --help ``` +### Order of the results + +By default, the results are sorted by PipedID and Application Name. + +If you want to sort the results by labels, add `--sort-label-keys` option. For example, when you run with `--sort-label-keys=env,team`, the results will be sorted by PipedID, `env` label, `team` label, and then Application Name. + + ## GitHub Actions If you are using GitHub Actions, you can seamlessly integrate our prepared [actions-plan-preview](https://github.com/pipe-cd/actions-plan-preview) to your workflows. This automatically comments the plan-preview result on the pull request when it is opened or updated. You can also trigger to run plan-preview manually by leave a comment `/pipecd plan-preview` on the pull request. diff --git a/pkg/app/pipectl/cmd/planpreview/planpreview.go b/pkg/app/pipectl/cmd/planpreview/planpreview.go index 41cd6debe7..28fd6dc80a 100644 --- a/pkg/app/pipectl/cmd/planpreview/planpreview.go +++ b/pkg/app/pipectl/cmd/planpreview/planpreview.go @@ -20,6 +20,7 @@ import ( "fmt" "io" "os" + "sort" "strings" "time" @@ -49,6 +50,7 @@ type command struct { timeout time.Duration pipedHandleTimeout time.Duration checkInterval time.Duration + sortLabelKeys []string clientOptions *client.Options } @@ -75,6 +77,7 @@ func NewCommand() *cobra.Command { cmd.Flags().StringVar(&c.out, "out", c.out, "Write planpreview result to the given path.") cmd.Flags().DurationVar(&c.timeout, "timeout", c.timeout, "Maximum amount of time this command has to complete. Default is 10m.") cmd.Flags().DurationVar(&c.pipedHandleTimeout, "piped-handle-timeout", c.pipedHandleTimeout, "Maximum amount of time that a Piped can take to handle. Default is 5m.") + cmd.Flags().StringSliceVar(&c.sortLabelKeys, "sort-label-keys", c.sortLabelKeys, "The application label keys to sort the results by. If not specified, the results will be sorted by only PipedID and ApplicationName.") cmd.MarkFlagRequired("repo-remote-url") cmd.MarkFlagRequired("head-branch") @@ -147,11 +150,32 @@ func (c *command) run(ctx context.Context, _ cli.Input) error { fmt.Printf("Failed to retrieve plan-preview results: %v\n", err) return err } + sortResults(results, c.sortLabelKeys) return printResults(results, os.Stdout, c.out) } } } +// sortResults sorts the given results by pipedID and the given sortLabelKeys. +// If sortLabelKeys is not specified or the all values of sortLabelKeys are the same, it sorts by pipedID and ApplicationName. +func sortResults(allResults []*model.PlanPreviewCommandResult, sortLabelKeys []string) { + sort.SliceStable(allResults, func(i, j int) bool { + return allResults[i].PipedId < allResults[j].PipedId + }) + for _, resultsPerPiped := range allResults { + results := resultsPerPiped.Results + sort.SliceStable(results, func(i, j int) bool { + a, b := results[i], results[j] + for _, key := range sortLabelKeys { + if a.Labels[key] != b.Labels[key] { + return a.Labels[key] < b.Labels[key] + } + } + return a.ApplicationName < b.ApplicationName + }) + } +} + func printResults(results []*model.PlanPreviewCommandResult, stdout io.Writer, outFile string) error { r := convert(results) diff --git a/pkg/app/pipectl/cmd/planpreview/planpreview_test.go b/pkg/app/pipectl/cmd/planpreview/planpreview_test.go index 9360101110..5ab4c2804c 100644 --- a/pkg/app/pipectl/cmd/planpreview/planpreview_test.go +++ b/pkg/app/pipectl/cmd/planpreview/planpreview_test.go @@ -244,3 +244,123 @@ NOTE: An error occurred while building plan-preview for applications of the foll }) } } +func TestSortResults(t *testing.T) { + t.Parallel() + testcases := []struct { + name string + results []*model.PlanPreviewCommandResult + sortLabelKeys []string + expected []*model.PlanPreviewCommandResult + }{ + { + name: "sort by pipedID and application name", + results: []*model.PlanPreviewCommandResult{ + { + PipedId: "piped-2", + Results: []*model.ApplicationPlanPreviewResult{ + {ApplicationName: "app-2"}, + {ApplicationName: "app-1"}, + }, + }, + { + PipedId: "piped-1", + Results: []*model.ApplicationPlanPreviewResult{ + {ApplicationName: "app-2"}, + {ApplicationName: "app-1"}, + }, + }, + }, + sortLabelKeys: []string{}, + expected: []*model.PlanPreviewCommandResult{ + { + PipedId: "piped-1", + Results: []*model.ApplicationPlanPreviewResult{ + {ApplicationName: "app-1"}, + {ApplicationName: "app-2"}, + }, + }, + { + PipedId: "piped-2", + Results: []*model.ApplicationPlanPreviewResult{ + {ApplicationName: "app-1"}, + {ApplicationName: "app-2"}, + }, + }, + }, + }, + { + name: "sort by label keys", + results: []*model.PlanPreviewCommandResult{ + { + PipedId: "piped-1", + Results: []*model.ApplicationPlanPreviewResult{ + {ApplicationName: "app-1", Labels: map[string]string{"env": "staging"}}, + {ApplicationName: "app-1", Labels: map[string]string{"env": "prod"}}, + }, + }, + { + PipedId: "piped-2", + Results: []*model.ApplicationPlanPreviewResult{ + {ApplicationName: "app-3", Labels: map[string]string{"env": "staging"}}, + {ApplicationName: "app-3", Labels: map[string]string{"env": "prod"}}, + {ApplicationName: "app-2", Labels: map[string]string{"env": "staging"}}, + {ApplicationName: "app-2", Labels: map[string]string{"env": "prod"}}, + }, + }, + }, + sortLabelKeys: []string{"env"}, + expected: []*model.PlanPreviewCommandResult{ + { + PipedId: "piped-1", + Results: []*model.ApplicationPlanPreviewResult{ + {ApplicationName: "app-1", Labels: map[string]string{"env": "prod"}}, + {ApplicationName: "app-1", Labels: map[string]string{"env": "staging"}}, + }, + }, + { + PipedId: "piped-2", + Results: []*model.ApplicationPlanPreviewResult{ + {ApplicationName: "app-2", Labels: map[string]string{"env": "prod"}}, + {ApplicationName: "app-3", Labels: map[string]string{"env": "prod"}}, + {ApplicationName: "app-2", Labels: map[string]string{"env": "staging"}}, + {ApplicationName: "app-3", Labels: map[string]string{"env": "staging"}}, + }, + }, + }, + }, + { + name: "sort by multiple label keys", + results: []*model.PlanPreviewCommandResult{ + { + PipedId: "piped-1", + Results: []*model.ApplicationPlanPreviewResult{ + {ApplicationName: "app-1", Labels: map[string]string{"env": "prod", "team": "team-2"}}, + {ApplicationName: "app-1", Labels: map[string]string{"env": "staging", "team": "team-1"}}, + {ApplicationName: "app-1", Labels: map[string]string{"env": "prod", "team": "team-1"}}, + {ApplicationName: "app-2", Labels: map[string]string{"env": "prod", "team": "team-2"}}, + }, + }, + }, + sortLabelKeys: []string{"env", "team"}, + expected: []*model.PlanPreviewCommandResult{ + { + PipedId: "piped-1", + Results: []*model.ApplicationPlanPreviewResult{ + {ApplicationName: "app-1", Labels: map[string]string{"env": "prod", "team": "team-1"}}, + {ApplicationName: "app-1", Labels: map[string]string{"env": "prod", "team": "team-2"}}, + {ApplicationName: "app-2", Labels: map[string]string{"env": "prod", "team": "team-2"}}, + {ApplicationName: "app-1", Labels: map[string]string{"env": "staging", "team": "team-1"}}, + }, + }, + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + sortResults(tc.results, tc.sortLabelKeys) + assert.Equal(t, tc.expected, tc.results) + }) + } +}