Skip to content

Commit

Permalink
Added repo detection
Browse files Browse the repository at this point in the history
  • Loading branch information
dylanratcliffe committed Nov 27, 2024
1 parent a3bfd24 commit 85a7e25
Show file tree
Hide file tree
Showing 4 changed files with 784 additions and 38 deletions.
41 changes: 22 additions & 19 deletions cmd/changes_submit_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.")
Expand Down
252 changes: 252 additions & 0 deletions cmd/repo.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading

0 comments on commit 85a7e25

Please sign in to comment.