diff --git a/cmd/changes_submit_plan.go b/cmd/changes_submit_plan.go index cb43ec91..6f936a43 100644 --- a/cmd/changes_submit_plan.go +++ b/cmd/changes_submit_plan.go @@ -135,20 +135,39 @@ func SubmitPlan(cmd *cobra.Command, args []string) error { title := changeTitle(viper.GetString("title")) tfPlanOutput := tryLoadText(ctx, viper.GetString("terraform-plan-output")) codeChangesOutput := tryLoadText(ctx, viper.GetString("code-changes-diff")) + // Detect the repository URL if it wasn't provided + repoUrl := viper.GetString("repo") + if repoUrl == "" { + repoUrl, err = DetectRepoURL(AllDetectors) + if err != nil { + log.WithContext(ctx).WithError(err).WithFields(lf).Debug("Failed to detect repository URL. Use the --repo flag to specify it manually if you require it") + } + } + tags, err := parseTagsArgument() + if err != nil { + return loggedError{ + err: err, + fields: lf, + message: "Failed to parse tags", + } + } + properties := &sdp.ChangeProperties{ + Title: title, + Description: viper.GetString("description"), + TicketLink: viper.GetString("ticket-link"), + Owner: viper.GetString("owner"), + RawPlan: tfPlanOutput, + CodeChanges: codeChangesOutput, + Repo: repoUrl, + Tags: tags, + } if changeUuid == uuid.Nil { log.WithContext(ctx).WithFields(lf).Debug("Creating a new change") + createResponse, err := client.CreateChange(ctx, &connect.Request[sdp.CreateChangeRequest]{ Msg: &sdp.CreateChangeRequest{ - Properties: &sdp.ChangeProperties{ - Title: title, - Description: viper.GetString("description"), - TicketLink: viper.GetString("ticket-link"), - Owner: viper.GetString("owner"), - // CcEmails: viper.GetString("cc-emails"), - RawPlan: tfPlanOutput, - CodeChanges: codeChangesOutput, - }, + Properties: properties, }, }) if err != nil { @@ -177,16 +196,8 @@ func SubmitPlan(cmd *cobra.Command, args []string) error { _, err := client.UpdateChange(ctx, &connect.Request[sdp.UpdateChangeRequest]{ Msg: &sdp.UpdateChangeRequest{ - UUID: changeUuid[:], - Properties: &sdp.ChangeProperties{ - Title: title, - Description: viper.GetString("description"), - TicketLink: viper.GetString("ticket-link"), - Owner: viper.GetString("owner"), - // CcEmails: viper.GetString("cc-emails"), - RawPlan: tfPlanOutput, - CodeChanges: codeChangesOutput, - }, + UUID: changeUuid[:], + Properties: properties, }, }) if err != nil { @@ -287,16 +298,11 @@ func init() { changesCmd.AddCommand(submitPlanCmd) addAPIFlags(submitPlanCmd) + addChangeCreationFlags(submitPlanCmd) + submitPlanCmd.PersistentFlags().String("frontend", "", "The frontend base URL") _ = submitPlanCmd.PersistentFlags().MarkDeprecated("frontend", "This flag is no longer used and will be removed in a future release. Use the '--app' flag instead.") // MarkDeprecated only errors if the flag doesn't exist, we fall back to using app - submitPlanCmd.PersistentFlags().String("title", "", "Short title for this change. If this is not specified, overmind will try to come up with one for you.") - submitPlanCmd.PersistentFlags().String("description", "", "Quick description of the change.") - submitPlanCmd.PersistentFlags().String("ticket-link", "*", "Link to the ticket for this change. Usually this would be the link to something like the pull request, since the CLI uses this as a unique identifier for the change, meaning that multiple runs with the same ticket link will update the same change.") - submitPlanCmd.PersistentFlags().String("owner", "", "The owner of this change.") - // submitPlanCmd.PersistentFlags().String("cc-emails", "", "A comma-separated list of emails to keep updated with the status of this change.") - - submitPlanCmd.PersistentFlags().String("terraform-plan-output", "", "Filename of cached terraform plan output for this change.") - submitPlanCmd.PersistentFlags().String("code-changes-diff", "", "Filename of the code diff of this change.") + submitPlanCmd.PersistentFlags().Int32("blast-radius-link-depth", 0, "Used in combination with '--blast-radius-max-items' to customise how many levels are traversed when calculating the blast radius. Larger numbers will result in a more comprehensive blast radius, but may take longer to calculate. Defaults to the account level settings.") submitPlanCmd.PersistentFlags().Int32("blast-radius-max-items", 0, "Used in combination with '--blast-radius-link-depth' to customise how many items are included in the blast radius. Larger numbers will result in a more comprehensive blast radius, but may take longer to calculate. Defaults to the account level settings.") } diff --git a/cmd/flags.go b/cmd/flags.go new file mode 100644 index 00000000..e4fab4fc --- /dev/null +++ b/cmd/flags.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// This file contains re-usable sets of flags that should be used when creating +// commands + +// Adds flags for selecting a change by UUID, frontend URL or ticket link +func addChangeUuidFlags(cmd *cobra.Command) { + cmd.PersistentFlags().String("change", "", "The frontend URL of the change to get") + cmd.PersistentFlags().String("ticket-link", "", "Link to the ticket for this change.") + cmd.PersistentFlags().String("uuid", "", "The UUID of the change that should be displayed.") + cmd.MarkFlagsMutuallyExclusive("change", "ticket-link", "uuid") +} + +// Adds flags that should be present when creating a change +func addChangeCreationFlags(cmd *cobra.Command) { + cmd.PersistentFlags().String("title", "", "Short title for this change. If this is not specified, overmind will try to come up with one for you.") + cmd.PersistentFlags().String("description", "", "Quick description of the change.") + cmd.PersistentFlags().String("ticket-link", "*", "Link to the ticket for this change. Usually this would be the link to something like the pull request, since the CLI uses this as a unique identifier for the change, meaning that multiple runs with the same ticket link will update the same change.") + cmd.PersistentFlags().String("owner", "", "The owner of this change.") + cmd.PersistentFlags().String("repo", "", "The repository URL that this change should be linked to. This will be automatically detected is possible from the Git config or CI environment.") + cmd.PersistentFlags().String("terraform-plan-output", "", "Filename of cached terraform plan output for this change.") + cmd.PersistentFlags().String("code-changes-diff", "", "Filename of the code diff of this change.") + cmd.PersistentFlags().StringSlice("tags", []string{}, "Tags to apply to this change, these should be specified in key=value format. Multiple tags can be specified by repeating the flag or using a comma separated list.") +} + +func parseTagsArgument() (map[string]string, error) { + tags := map[string]string{} + for _, tag := range viper.GetStringSlice("tags") { + parts := strings.SplitN(tag, "=", 2) + if len(parts) != 2 { + return tags, fmt.Errorf("invalid tag format: %s", tag) + } + tags[parts[0]] = parts[1] + } + return tags, nil +} + +// Adds common flags to API commands e.g. timeout +func addAPIFlags(cmd *cobra.Command) { + cmd.PersistentFlags().String("timeout", "10m", "How long to wait for responses") + cmd.PersistentFlags().String("app", "https://app.overmind.tech", "The overmind instance to connect to.") +} + +// Adds terraform-related flags to a command +func addTerraformBaseFlags(cmd *cobra.Command) { + cmd.PersistentFlags().Bool("reset-stored-config", false, "[deprecated: this is now autoconfigured from local terraform files] Set this to reset the sources config stored in Overmind and input fresh values.") + cmd.PersistentFlags().String("aws-config", "", "[deprecated: this is now autoconfigured from local terraform files] The chosen AWS config method, best set through the initial wizard when running the CLI. Options: 'profile_input', 'aws_profile', 'defaults', 'managed'.") + cmd.PersistentFlags().String("aws-profile", "", "[deprecated: this is now autoconfigured from local terraform files] Set this to the name of the AWS profile to use.") + cobra.CheckErr(cmd.PersistentFlags().MarkHidden("reset-stored-config")) + cobra.CheckErr(cmd.PersistentFlags().MarkHidden("aws-config")) + cobra.CheckErr(cmd.PersistentFlags().MarkHidden("aws-profile")) + cmd.PersistentFlags().Bool("only-use-managed-sources", false, "Set this to skip local autoconfiguration and only use the managed sources as configured in Overmind.") +} diff --git a/cmd/repo.go b/cmd/repo.go new file mode 100644 index 00000000..c08fe7a9 --- /dev/null +++ b/cmd/repo.go @@ -0,0 +1,252 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "strings" + + "gopkg.in/ini.v1" +) + +var AllDetectors = []RepoDetector{ + &RepoDetectorGithubActions{}, + &RepoDetectorJenkins{}, + &RepoDetectorGitlab{}, + &RepoDetectorCircleCI{}, + &RepoDetectorAzureDevOps{}, + &RepoDetectorSpacelift{}, + &RepoDetectorGitConfig{}, +} + +// Detects the URL of the repository that the user is working in based on the +// environment variables that are set in the user's shell. You should usually +// pass in `AllDetectors` to this function, though you can pass in a subset of +// detectors if you want to. +// +// Returns the URL of the repository that the user is working in, or an error if +// the URL could not be detected. +func DetectRepoURL(detectors []RepoDetector) (string, error) { + var errs []error + + for _, detector := range detectors { + if detector == nil { + continue + } + + envVars := make(map[string]string) + for _, requiredVar := range detector.RequiredEnvVars() { + if val, ok := os.LookupEnv(requiredVar); !ok { + // If any of the required environment variables are not set, move on to the next detector + break + } else { + envVars[requiredVar] = val + } + } + + repoURL, err := detector.DetectRepoURL(envVars) + if err != nil { + errs = append(errs, err) + continue + } + if repoURL == "" { + continue + } + + return repoURL, nil + } + + if len(errs) > 0 { + return "", errors.Join(errs...) + } + + return "", errors.New("no repository URL detected") +} + +// RepoDetector is an interface for detecting the URL of the repository that the +// user is working in. Implementations should be able to detect the URL of the +// repository based on the environment variables that are set in the user's +// shell. +type RepoDetector interface { + // Returns a list of environment variables that are required for the + // implementation to detect the repository URL. + // + // This detector will only be run if all variables are present. If this is + // an empty slice the detector will always run. + RequiredEnvVars() []string + + // DetectRepoURL detects the URL of the repository that the user is working + // in based on the environment variables that are set. The set of + // environment variables that were returned by RequiredEnvVars() will be + // passed in as a map, along with their values. + // + // This means that if RequiredEnvVars() returns ["GIT_DIR"], then + // DetectRepoURL will be called with a map containing the value of the + // GIT_DIR environment variable. i.e. envVars["GIT_DIR"] will contain the + // value of the GIT_DIR environment variable. + DetectRepoURL(envVars map[string]string) (string, error) +} + +// Detects the repository URL based on the environment variables that are set in +// Github Actions by default. +// +// https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables +type RepoDetectorGithubActions struct{} + +func (d *RepoDetectorGithubActions) RequiredEnvVars() []string { + return []string{"GITHUB_SERVER_URL", "GITHUB_REPOSITORY"} +} + +func (d *RepoDetectorGithubActions) DetectRepoURL(envVars map[string]string) (string, error) { + serverURL, ok := envVars["GITHUB_SERVER_URL"] + if !ok { + return "", errors.New("GITHUB_SERVER_URL not set") + } + + repo, ok := envVars["GITHUB_REPOSITORY"] + if !ok { + return "", errors.New("GITHUB_REPOSITORY not set") + } + + return serverURL + "/" + repo, nil +} + +// Detects the repository URL based on the environment variables that are set in +// Jenkins Git plugin by default. +// +// https://wiki.jenkins.io/JENKINS/Git-Plugin.html +type RepoDetectorJenkins struct{} + +func (d *RepoDetectorJenkins) RequiredEnvVars() []string { + return []string{"GIT_URL"} +} + +func (d *RepoDetectorJenkins) DetectRepoURL(envVars map[string]string) (string, error) { + gitURL, ok := envVars["GIT_URL"] + if !ok { + return "", errors.New("GIT_URL not set") + } + + return gitURL, nil +} + +// Detects the repository URL based on teh default env vars from Gitlab +// +// https://docs.gitlab.com/ee/ci/variables/predefined_variables.html +type RepoDetectorGitlab struct{} + +func (d *RepoDetectorGitlab) RequiredEnvVars() []string { + return []string{"CI_SERVER_URL", "CI_PROJECT_PATH"} +} + +func (d *RepoDetectorGitlab) DetectRepoURL(envVars map[string]string) (string, error) { + serverURL, ok := envVars["CI_SERVER_URL"] + if !ok { + return "", errors.New("CI_SERVER_URL not set") + } + + projectPath, ok := envVars["CI_PROJECT_PATH"] + if !ok { + return "", errors.New("CI_PROJECT_PATH not set") + } + + return serverURL + "/" + projectPath, nil +} + +// Detects the repository URL based on the environment variables that are set in +// CircleCI by default. +// +// https://circleci.com/docs/variables/ +type RepoDetectorCircleCI struct{} + +func (d *RepoDetectorCircleCI) RequiredEnvVars() []string { + return []string{"CIRCLE_REPOSITORY_URL"} +} + +func (d *RepoDetectorCircleCI) DetectRepoURL(envVars map[string]string) (string, error) { + repoURL, ok := envVars["CIRCLE_REPOSITORY_URL"] + if !ok { + return "", errors.New("CIRCLE_REPOSITORY_URL not set") + } + + return repoURL, nil +} + +// Detects the repository URL based on the environment variables that are set in +// Azure DevOps by default. +// +// https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops +type RepoDetectorAzureDevOps struct{} + +func (d *RepoDetectorAzureDevOps) RequiredEnvVars() []string { + return []string{"BUILD_REPOSITORY_URI"} +} + +func (d *RepoDetectorAzureDevOps) DetectRepoURL(envVars map[string]string) (string, error) { + repoURL, ok := envVars["BUILD_REPOSITORY_URI"] + if !ok { + return "", errors.New("BUILD_REPOSITORY_URI not set") + } + + return repoURL, nil +} + +// Detects the repository URL based on the environment variables that are set in +// Spacelift by default. +// +// https://docs.spacelift.io/concepts/configuration/environment.html#environment-variables +// +// Note that since Spacelift doesn't expose the full URL, you just get the last +// bit i.e. username/repo +type RepoDetectorSpacelift struct{} + +func (d *RepoDetectorSpacelift) RequiredEnvVars() []string { + return []string{"TF_VAR_spacelift_repository"} +} + +func (d *RepoDetectorSpacelift) DetectRepoURL(envVars map[string]string) (string, error) { + repoURL, ok := envVars["TF_VAR_spacelift_repository"] + if !ok { + return "", errors.New("TF_VAR_spacelift_repository not set") + } + + return repoURL, nil +} + +type RepoDetectorGitConfig struct { + // Optional override path to the gitconfig file, only used for testing + gitconfigPath string +} + +func (d *RepoDetectorGitConfig) RequiredEnvVars() []string { + return []string{""} +} + +// Load the .git/config file and extract the remote URL from it +func (d *RepoDetectorGitConfig) DetectRepoURL(envVars map[string]string) (string, error) { + var gitConfigPath string + if d.gitconfigPath != "" { + gitConfigPath = d.gitconfigPath + } else { + gitConfigPath = ".git/config" + } + + // Try to read the .git/config file + gitConfig, err := ini.Load(gitConfigPath) + if err != nil { + return "", fmt.Errorf("could not open .git/config to determine repo: %w", err) + } + + for _, section := range gitConfig.Sections() { + if strings.HasPrefix(section.Name(), "remote") { + urlKey, err := section.GetKey("url") + if err != nil { + continue + } + + return urlKey.String(), nil + } + } + + return "", fmt.Errorf("could not find remote URL in %v", gitConfigPath) +} diff --git a/cmd/repo_test.go b/cmd/repo_test.go new file mode 100644 index 00000000..c28eb4b4 --- /dev/null +++ b/cmd/repo_test.go @@ -0,0 +1,491 @@ +package cmd + +import ( + "errors" + "os" + "testing" +) + +type testDetector struct { + requiredEnvVarsCallback func() []string + repoURLCallback func(map[string]string) (string, error) +} + +func (d *testDetector) RequiredEnvVars() []string { + return d.requiredEnvVarsCallback() +} + +func (d *testDetector) DetectRepoURL(envVars map[string]string) (string, error) { + return d.repoURLCallback(envVars) +} + +func TestDetectRepoURL(t *testing.T) { + t.Parallel() + + t.Run("no detectors", func(t *testing.T) { + t.Parallel() + detectors := []RepoDetector{} + + repoURL, err := DetectRepoURL(detectors) + if err == nil { + t.Fatal("expected error") + } + if repoURL != "" { + t.Fatalf("expected empty repoURL, got %q", repoURL) + } + }) + + t.Run("with a failing detector", func(t *testing.T) { + t.Parallel() + detectors := []RepoDetector{ + &testDetector{ + requiredEnvVarsCallback: func() []string { + return []string{"FOO"} + }, + repoURLCallback: func(map[string]string) (string, error) { + return "", errors.New("failed to detect repo URL") + }, + }, + } + + repoURL, err := DetectRepoURL(detectors) + if err == nil { + t.Fatal("expected error") + } + if repoURL != "" { + t.Fatalf("expected empty repoURL, got %q", repoURL) + } + }) + + t.Run("with multiple failing detectors", func(t *testing.T) { + t.Parallel() + detectors := []RepoDetector{ + &testDetector{ + requiredEnvVarsCallback: func() []string { + return []string{"FOO"} + }, + repoURLCallback: func(map[string]string) (string, error) { + return "", errors.New("mint") + }, + }, + &testDetector{ + requiredEnvVarsCallback: func() []string { + return []string{"BAR"} + }, + repoURLCallback: func(map[string]string) (string, error) { + return "", errors.New("choc") + }, + }, + } + + repoURL, err := DetectRepoURL(detectors) + if err == nil { + t.Fatal("expected error") + } + if repoURL != "" { + t.Fatalf("expected empty repoURL, got %q", repoURL) + } + if err.Error() != "mint\nchoc" { + t.Fatalf("expected error to contain both messages, got %q", err.Error()) + } + }) + + t.Run("with a successful detector", func(t *testing.T) { + t.Parallel() + detectors := []RepoDetector{ + &testDetector{ + requiredEnvVarsCallback: func() []string { + return []string{"FOO"} + }, + repoURLCallback: func(map[string]string) (string, error) { + return "https://example.com/foo", nil + }, + }, + } + + repoURL, err := DetectRepoURL(detectors) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if repoURL != "https://example.com/foo" { + t.Fatalf("expected repoURL to be %q, got %q", "https://example.com/foo", repoURL) + } + }) + + t.Run("with multiple detectors, one successful", func(t *testing.T) { + t.Parallel() + detectors := []RepoDetector{ + &testDetector{ + requiredEnvVarsCallback: func() []string { + return []string{"FOO"} + }, + repoURLCallback: func(map[string]string) (string, error) { + return "", nil + }, + }, + &testDetector{ + requiredEnvVarsCallback: func() []string { + return []string{"BAR"} + }, + repoURLCallback: func(map[string]string) (string, error) { + return "https://example.com/bar", nil + }, + }, + } + + repoURL, err := DetectRepoURL(detectors) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if repoURL != "https://example.com/bar" { + t.Fatalf("expected repoURL to be %q, got %q", "https://example.com/bar", repoURL) + } + }) +} + +func TestRepoDetectorGithubActions(t *testing.T) { + t.Parallel() + + t.Run("with valid values", func(t *testing.T) { + t.Parallel() + + envVars := map[string]string{ + "GITHUB_REPOSITORY": "owner/repo", + "GITHUB_SERVER_URL": "https://github.com", + } + + detector := &RepoDetectorGithubActions{} + + repoURL, err := detector.DetectRepoURL(envVars) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectedRepoUrl := "https://github.com/owner/repo" + if repoURL != expectedRepoUrl { + t.Fatalf("expected repoURL to be %q, got %q", expectedRepoUrl, repoURL) + } + }) + + t.Run("with missing GITHUB_REPOSITORY", func(t *testing.T) { + t.Parallel() + + envVars := map[string]string{ + "GITHUB_SERVER_URL": "https://github.com", + } + + detector := &RepoDetectorGithubActions{} + + repoURL, err := detector.DetectRepoURL(envVars) + if err == nil { + t.Fatal("expected error") + } + if repoURL != "" { + t.Fatalf("expected empty repoURL, got %q", repoURL) + } + }) +} + +func TestRepoDetectorJenkins(t *testing.T) { + t.Parallel() + t.Run("with valid GIT_URL", func(t *testing.T) { + t.Parallel() + envVars := map[string]string{ + "GIT_URL": "https://example.com/repo.git", + } + detector := &RepoDetectorJenkins{} + repoURL, err := detector.DetectRepoURL(envVars) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expectedRepoUrl := "https://example.com/repo.git" + if repoURL != expectedRepoUrl { + t.Fatalf("expected repoURL to be %q, got %q", expectedRepoUrl, repoURL) + } + }) + + t.Run("missing GIT_URL", func(t *testing.T) { + t.Parallel() + envVars := map[string]string{} + detector := &RepoDetectorJenkins{} + _, err := detector.DetectRepoURL(envVars) + if err == nil { + t.Fatal("expected error") + } + expectedError := "GIT_URL not set" + if err.Error() != expectedError { + t.Fatalf("expected error to be %q, got %q", expectedError, err.Error()) + } + }) +} + +func TestRepoDetectorGitlab(t *testing.T) { + t.Parallel() + t.Run("with valid CI_SERVER_URL and CI_PROJECT_PATH", func(t *testing.T) { + t.Parallel() + envVars := map[string]string{ + "CI_SERVER_URL": "https://gitlab.com", + "CI_PROJECT_PATH": "owner/repo", + } + detector := &RepoDetectorGitlab{} + repoURL, err := detector.DetectRepoURL(envVars) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expectedRepoUrl := "https://gitlab.com/owner/repo" + if repoURL != expectedRepoUrl { + t.Fatalf("expected repoURL to be %q, got %q", expectedRepoUrl, repoURL) + } + }) + + t.Run("missing CI_SERVER_URL", func(t *testing.T) { + t.Parallel() + envVars := map[string]string{ + "CI_PROJECT_PATH": "owner/repo", + } + detector := &RepoDetectorGitlab{} + _, err := detector.DetectRepoURL(envVars) + if err == nil { + t.Fatal("expected error") + } + expectedError := "CI_SERVER_URL not set" + if err.Error() != expectedError { + t.Fatalf("expected error to be %q, got %q", expectedError, err.Error()) + } + }) + + t.Run("missing CI_PROJECT_PATH", func(t *testing.T) { + t.Parallel() + envVars := map[string]string{ + "CI_SERVER_URL": "https://gitlab.com", + } + detector := &RepoDetectorGitlab{} + _, err := detector.DetectRepoURL(envVars) + if err == nil { + t.Fatal("expected error") + } + expectedError := "CI_PROJECT_PATH not set" + if err.Error() != expectedError { + t.Fatalf("expected error to be %q, got %q", expectedError, err.Error()) + } + }) +} + +func TestRepoDetectorCircleCI(t *testing.T) { + t.Parallel() + t.Run("with valid CIRCLE_REPOSITORY_URL", func(t *testing.T) { + t.Parallel() + envVars := map[string]string{ + "CIRCLE_REPOSITORY_URL": "https://example.com/repo.git", + } + detector := &RepoDetectorCircleCI{} + repoURL, err := detector.DetectRepoURL(envVars) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expectedRepoUrl := "https://example.com/repo.git" + if repoURL != expectedRepoUrl { + t.Fatalf("expected repoURL to be %q, got %q", expectedRepoUrl, repoURL) + } + }) + + t.Run("missing CIRCLE_REPOSITORY_URL", func(t *testing.T) { + t.Parallel() + envVars := map[string]string{} + detector := &RepoDetectorCircleCI{} + _, err := detector.DetectRepoURL(envVars) + if err == nil { + t.Fatal("expected error") + } + expectedError := "CIRCLE_REPOSITORY_URL not set" + if err.Error() != expectedError { + t.Fatalf("expected error to be %q, got %q", expectedError, err.Error()) + } + }) +} + +func TestRepoDetectorAzureDevOps(t *testing.T) { + t.Parallel() + t.Run("with valid BUILD_REPOSITORY_URI", func(t *testing.T) { + t.Parallel() + envVars := map[string]string{ + "BUILD_REPOSITORY_URI": "https://dev.azure.com/organization/project/_git/repo", + } + detector := &RepoDetectorAzureDevOps{} + repoURL, err := detector.DetectRepoURL(envVars) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expectedRepoUrl := "https://dev.azure.com/organization/project/_git/repo" + if repoURL != expectedRepoUrl { + t.Fatalf("expected repoURL to be %q, got %q", expectedRepoUrl, repoURL) + } + }) + + t.Run("missing BUILD_REPOSITORY_URI", func(t *testing.T) { + t.Parallel() + envVars := map[string]string{} + detector := &RepoDetectorAzureDevOps{} + _, err := detector.DetectRepoURL(envVars) + if err == nil { + t.Fatal("expected error") + } + expectedError := "BUILD_REPOSITORY_URI not set" + if err.Error() != expectedError { + t.Fatalf("expected error to be %q, got %q", expectedError, err.Error()) + } + }) +} + +func TestRepoDetectorGitConfig(t *testing.T) { + t.Parallel() + + t.Run("With a simple gitconfig", func(t *testing.T) { + t.Parallel() + + gitconfig := `[core] + repositoryformatversion = 0 + filemode = true + bare = false + logallrefupdates = true + ignorecase = true + precomposeunicode = true +[remote "origin"] + url = git@github.com:overmindtech/cli.git` + + // Write gitconfig to a temporary file + gitConfigFile, err := os.CreateTemp("", "gitconfig") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + t.Cleanup(func() { + os.Remove(gitConfigFile.Name()) + }) + + _, err = gitConfigFile.WriteString(gitconfig) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + detector := RepoDetectorGitConfig{ + gitconfigPath: gitConfigFile.Name(), + } + + url, err := detector.DetectRepoURL(map[string]string{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectedUrl := "git@github.com:overmindtech/cli.git" + + if url != expectedUrl { + t.Fatalf("expected url to be %q, got %q", expectedUrl, url) + } + }) + + t.Run("with no gitconfig", func(t *testing.T) { + t.Parallel() + + detector := RepoDetectorGitConfig{ + gitconfigPath: "nonexistent-path", + } + + _, err := detector.DetectRepoURL(map[string]string{}) + if err == nil { + t.Fatal("expected error") + } + }) + + t.Run("with a gitconfig with no remote", func(t *testing.T) { + t.Parallel() + + gitconfig := `[core] + repositoryformatversion = 0 + filemode = true + bare = false + logallrefupdates = true + ignorecase = true + precomposeunicode = true` + + // Write gitconfig to a temporary file + gitConfigFile, err := os.CreateTemp("", "gitconfig") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + t.Cleanup(func() { + os.Remove(gitConfigFile.Name()) + }) + + _, err = gitConfigFile.WriteString(gitconfig) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + detector := RepoDetectorGitConfig{ + gitconfigPath: gitConfigFile.Name(), + } + + _, err = detector.DetectRepoURL(map[string]string{}) + if err == nil { + t.Fatal("expected error") + } + }) + + t.Run("with an empty gitconfig", func(t *testing.T) { + t.Parallel() + + gitconfig := `` + + // Write gitconfig to a temporary file + gitConfigFile, err := os.CreateTemp("", "gitconfig") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + t.Cleanup(func() { + os.Remove(gitConfigFile.Name()) + }) + + _, err = gitConfigFile.WriteString(gitconfig) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + detector := RepoDetectorGitConfig{ + gitconfigPath: gitConfigFile.Name(), + } + + _, err = detector.DetectRepoURL(map[string]string{}) + if err == nil { + t.Fatal("expected error") + } + }) + + t.Run("with a gitconfig that isn't a valid ini file", func(t *testing.T) { + t.Parallel() + + gitconfig := `not a valid ini file! =======` + + // Write gitconfig to a temporary file + gitConfigFile, err := os.CreateTemp("", "gitconfig") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + t.Cleanup(func() { + os.Remove(gitConfigFile.Name()) + }) + + _, err = gitConfigFile.WriteString(gitconfig) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + detector := RepoDetectorGitConfig{ + gitconfigPath: gitConfigFile.Name(), + } + + _, err = detector.DetectRepoURL(map[string]string{}) + if err == nil { + t.Fatal("expected error") + } + }) +} diff --git a/cmd/root.go b/cmd/root.go index dff77bbf..c5f4c47c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -255,19 +255,6 @@ func parseChangeUrl(changeUrlString string) (uuid.UUID, error) { return changeUuid, nil } -func addChangeUuidFlags(cmd *cobra.Command) { - cmd.PersistentFlags().String("change", "", "The frontend URL of the change to get") - cmd.PersistentFlags().String("ticket-link", "", "Link to the ticket for this change.") - cmd.PersistentFlags().String("uuid", "", "The UUID of the change that should be displayed.") - cmd.MarkFlagsMutuallyExclusive("change", "ticket-link", "uuid") -} - -// Adds common flags to API commands e.g. timeout -func addAPIFlags(cmd *cobra.Command) { - cmd.PersistentFlags().String("timeout", "10m", "How long to wait for responses") - cmd.PersistentFlags().String("app", "https://app.overmind.tech", "The overmind instance to connect to.") -} - type flagError struct { usage string } diff --git a/cmd/terraform.go b/cmd/terraform.go index e66444a7..34d03b80 100644 --- a/cmd/terraform.go +++ b/cmd/terraform.go @@ -30,6 +30,8 @@ everything that happened, including any unexpected repercussions.`, func init() { rootCmd.AddCommand(terraformCmd) + + addChangeCreationFlags(terraformCmd) } var applyOnlyArgs = []string{ diff --git a/cmd/terraform_plan.go b/cmd/terraform_plan.go index 84c79cba..2869d576 100644 --- a/cmd/terraform_plan.go +++ b/cmd/terraform_plan.go @@ -219,20 +219,34 @@ func TerraformPlanImpl(ctx context.Context, cmd *cobra.Command, oi sdp.OvermindI codeChangesOutput := tryLoadText(ctx, viper.GetString("code-changes-diff")) + // Detect the repository URL if it wasn't provided + repoUrl := viper.GetString("repo") + if repoUrl == "" { + repoUrl, _ = DetectRepoURL(AllDetectors) + } + tags, err := parseTagsArgument() + if err != nil { + uploadChangesSpinner.Fail(fmt.Sprintf("Uploading planned changes: failed to parse tags: %v", err)) + return nil + } + + properties := &sdp.ChangeProperties{ + Title: title, + Description: viper.GetString("description"), + TicketLink: ticketLink, + Owner: viper.GetString("owner"), + RawPlan: string(tfPlanOutput), + CodeChanges: codeChangesOutput, + Repo: repoUrl, + Tags: tags, + } + if changeUuid == uuid.Nil { uploadChangesSpinner.UpdateText("Uploading planned changes (new)") log.Debug("Creating a new change") createResponse, err := client.CreateChange(ctx, &connect.Request[sdp.CreateChangeRequest]{ Msg: &sdp.CreateChangeRequest{ - Properties: &sdp.ChangeProperties{ - Title: title, - Description: viper.GetString("description"), - TicketLink: ticketLink, - Owner: viper.GetString("owner"), - // CcEmails: viper.GetString("cc-emails"), - RawPlan: string(tfPlanOutput), - CodeChanges: codeChangesOutput, - }, + Properties: properties, }, }) if err != nil { @@ -257,16 +271,8 @@ func TerraformPlanImpl(ctx context.Context, cmd *cobra.Command, oi sdp.OvermindI _, err := client.UpdateChange(ctx, &connect.Request[sdp.UpdateChangeRequest]{ Msg: &sdp.UpdateChangeRequest{ - UUID: changeUuid[:], - Properties: &sdp.ChangeProperties{ - Title: title, - Description: viper.GetString("description"), - TicketLink: ticketLink, - Owner: viper.GetString("owner"), - // CcEmails: viper.GetString("cc-emails"), - RawPlan: string(tfPlanOutput), - CodeChanges: codeChangesOutput, - }, + UUID: changeUuid[:], + Properties: properties, }, }) if err != nil { @@ -506,16 +512,6 @@ func getTicketLinkFromPlan(planFile string) (string, error) { return fmt.Sprintf("tfplan://{SHA256}%x", h.Sum(nil)), nil } -func addTerraformBaseFlags(cmd *cobra.Command) { - cmd.PersistentFlags().Bool("reset-stored-config", false, "[deprecated: this is now autoconfigured from local terraform files] Set this to reset the sources config stored in Overmind and input fresh values.") - cmd.PersistentFlags().String("aws-config", "", "[deprecated: this is now autoconfigured from local terraform files] The chosen AWS config method, best set through the initial wizard when running the CLI. Options: 'profile_input', 'aws_profile', 'defaults', 'managed'.") - cmd.PersistentFlags().String("aws-profile", "", "[deprecated: this is now autoconfigured from local terraform files] Set this to the name of the AWS profile to use.") - cobra.CheckErr(cmd.PersistentFlags().MarkHidden("reset-stored-config")) - cobra.CheckErr(cmd.PersistentFlags().MarkHidden("aws-config")) - cobra.CheckErr(cmd.PersistentFlags().MarkHidden("aws-profile")) - cmd.PersistentFlags().Bool("only-use-managed-sources", false, "Set this to skip local autoconfiguration and only use the managed sources as configured in Overmind.") -} - func init() { terraformCmd.AddCommand(terraformPlanCmd)