From 85a7e25be7d05d685be823c69d3a6374eb489e6f Mon Sep 17 00:00:00 2001 From: Dylan Ratcliffe Date: Wed, 27 Nov 2024 15:34:55 +0000 Subject: [PATCH] Added repo detection --- cmd/changes_submit_plan.go | 41 ++-- cmd/repo.go | 252 +++++++++++++++++++ cmd/repo_test.go | 491 +++++++++++++++++++++++++++++++++++++ cmd/terraform_plan.go | 38 +-- 4 files changed, 784 insertions(+), 38 deletions(-) create mode 100644 cmd/repo.go create mode 100644 cmd/repo_test.go diff --git a/cmd/changes_submit_plan.go b/cmd/changes_submit_plan.go index cb43ec91..5d1245e6 100644 --- a/cmd/changes_submit_plan.go +++ b/cmd/changes_submit_plan.go @@ -135,20 +135,30 @@ 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") + } + } + properties := &sdp.ChangeProperties{ + Title: title, + Description: viper.GetString("description"), + TicketLink: viper.GetString("ticket-link"), + Owner: viper.GetString("owner"), + RawPlan: tfPlanOutput, + CodeChanges: codeChangesOutput, + Repo: repoUrl, + } 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 +187,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 { @@ -293,6 +295,7 @@ func init() { 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("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.") // 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.") 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/terraform_plan.go b/cmd/terraform_plan.go index 84c79cba..9b4951d2 100644 --- a/cmd/terraform_plan.go +++ b/cmd/terraform_plan.go @@ -219,20 +219,28 @@ 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) + } + + properties := &sdp.ChangeProperties{ + Title: title, + Description: viper.GetString("description"), + TicketLink: ticketLink, + Owner: viper.GetString("owner"), + RawPlan: string(tfPlanOutput), + CodeChanges: codeChangesOutput, + Repo: repoUrl, + } + 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 +265,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 {