diff --git a/cli/azd/pkg/pipeline/azdo_provider.go b/cli/azd/pkg/pipeline/azdo_provider.go index 612fe6f937c..1739b9f0a12 100644 --- a/cli/azd/pkg/pipeline/azdo_provider.go +++ b/cli/azd/pkg/pipeline/azdo_provider.go @@ -8,7 +8,6 @@ import ( "errors" "fmt" "log" - "regexp" "strings" "github.com/azure/azure-dev/cli/azd/pkg/azdo" @@ -418,12 +417,6 @@ func (p *AzdoScmProvider) promptForAzdoRepository(ctx context.Context, console i return remoteUrl, nil } -// defines the structure of an ssl git remote -var azdoRemoteGitUrlRegex = regexp.MustCompile(`^git@ssh.dev.azure\.com:(.*?)(?:\.git)?$`) - -// defines the structure of an HTTPS git remote -var azdoRemoteHttpsUrlRegex = regexp.MustCompile(`^https://[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*:*.+@dev.azure\.com/(.*?)$`) - // ErrRemoteHostIsNotAzDo the error used when a non Azure DevOps remote is found var ErrRemoteHostIsNotAzDo = errors.New("existing remote is not an Azure DevOps host") @@ -431,43 +424,61 @@ var ErrRemoteHostIsNotAzDo = errors.New("existing remote is not an Azure DevOps var ErrSSHNotSupported = errors.New("ssh git remote is not supported. " + "Use HTTPS git remote to connect the remote repository") -// helper function to determine if the provided remoteUrl is an azure devops repo. -// currently supports AzDo PaaS -func isAzDoRemote(remoteUrl string) error { - if azdoRemoteGitUrlRegex.MatchString(remoteUrl) { - return ErrSSHNotSupported - } - slug := "" - for _, r := range []*regexp.Regexp{azdoRemoteGitUrlRegex, azdoRemoteHttpsUrlRegex} { - captures := r.FindStringSubmatch(remoteUrl) - if captures != nil { - slug = captures[1] +type azdoRemote struct { + Project string + RepositoryName string +} + +// parseAzDoRemote extracts the organization, project and repository name from an Azure DevOps remote url +// the url can be in the form of: +// - https://dev.azure.com/[org|user]/[project]/_git/[repo] +// - https://[user]@dev.azure.com/[org|user]/[project]/_git/[repo] +// - https://[org].visualstudio.com/[project]/_git/[repo] +// - git@ssh.dev.azure.com:v[1-3]/[user|org]/[project]/[repo] +// - git@vs-ssh.visualstudio.com:v[1-3]/[user|org]/[project]/[repo] +// - git@ssh.visualstudio.com:v[1-3]/[user|org]/[project]/[repo] +func parseAzDoRemote(remoteUrl string) (*azdoRemote, error) { + // Initialize the azdoRemote struct + azdoRemote := &azdoRemote{} + + if !strings.Contains(remoteUrl, "visualstudio.com") && !strings.Contains(remoteUrl, "dev.azure.com") { + return nil, fmt.Errorf("%w: %s", ErrRemoteHostIsNotAzDo, remoteUrl) + } + + if strings.Contains(remoteUrl, "/_git/") { + // applies to http or https + parts := strings.Split(remoteUrl, "/_git/") + projectNameStart := strings.LastIndex(parts[0], "/") + projectPartLen := len(parts[0]) + + if len(parts) != 2 || // remoteUrl must have exactly one "/_git/" substring + !strings.Contains(parts[0], "/") || // part 0 (the project) must have more than one "/" + projectPartLen <= 1 || // part 0 must be greater than 1 character + projectNameStart == projectPartLen-1 { // part 0 must not end with "/" + return nil, fmt.Errorf("%w: %s", ErrRemoteHostIsNotAzDo, remoteUrl) } + + azdoRemote.Project = parts[0][projectNameStart+1:] + azdoRemote.RepositoryName = parts[1] + return azdoRemote, nil } - if slug == "" { - return ErrRemoteHostIsNotAzDo - } - return nil -} -func parseAzDoRemote(remoteUrl string) (string, error) { - for _, r := range []*regexp.Regexp{azdoRemoteGitUrlRegex, azdoRemoteHttpsUrlRegex} { - captures := r.FindStringSubmatch(remoteUrl) - if captures != nil { - return captures[1], nil - } + if strings.Contains(remoteUrl, "git@") { + // applies to git@ -> project and repo always in the last two parts + parts := strings.Split(remoteUrl, "/") + partsLen := len(parts) + azdoRemote.Project = parts[partsLen-2] + azdoRemote.RepositoryName = parts[partsLen-1] + return azdoRemote, nil } - return "", nil + + // If the remoteUrl does not match any of the supported formats, return an error + return nil, fmt.Errorf("%w: %s", ErrRemoteHostIsNotAzDo, remoteUrl) } // gitRepoDetails extracts the information from an Azure DevOps remote url into general scm concepts // like owner, name and path func (p *AzdoScmProvider) gitRepoDetails(ctx context.Context, remoteUrl string) (*gitRepositoryDetails, error) { - err := isAzDoRemote(remoteUrl) - if err != nil { - return nil, err - } - repoDetails := p.getRepoDetails() // Try getting values from the env. // This is a quick shortcut to avoid parsing the remote in detail. @@ -496,17 +507,16 @@ func (p *AzdoScmProvider) gitRepoDetails(ctx context.Context, remoteUrl string) } if repoDetails.projectId == "" || repoDetails.repoId == "" { - // Removing environment or creating a new one would remove any memory fro project + // Removing environment or creating a new one would remove any memory from project // and repo. In that case, it needs to be calculated from the remote url - azdoSlug, err := parseAzDoRemote(remoteUrl) + azdoRemote, err := parseAzDoRemote(remoteUrl) if err != nil { return nil, fmt.Errorf("parsing Azure DevOps remote url: %s: %w", remoteUrl, err) } - // azdoSlug => Org/Project/_git/repoName - parts := strings.Split(azdoSlug, "_git/") - repoDetails.projectName = strings.Split(parts[0], "/")[1] + + repoDetails.projectName = azdoRemote.Project p.env.DotenvSet(azdo.AzDoEnvironmentProjectName, repoDetails.projectName) - repoDetails.repoName = parts[1] + repoDetails.repoName = azdoRemote.RepositoryName p.env.DotenvSet(azdo.AzDoEnvironmentRepoName, repoDetails.repoName) connection, err := p.getAzdoConnection(ctx) diff --git a/cli/azd/pkg/pipeline/azdo_provider_test.go b/cli/azd/pkg/pipeline/azdo_provider_test.go index 22247712ebd..deced61800d 100644 --- a/cli/azd/pkg/pipeline/azdo_provider_test.go +++ b/cli/azd/pkg/pipeline/azdo_provider_test.go @@ -38,22 +38,11 @@ func Test_azdo_provider_getRepoDetails(t *testing.T) { require.EqualValues(t, false, details.pushStatus) }) - t.Run("ssh not supported", func(t *testing.T) { - // arrange - provider := getAzdoScmProviderTestHarness(mockinput.NewMockConsole()) - ctx := context.Background() - - // act - details, e := provider.gitRepoDetails(ctx, "git@ssh.dev.azure.com:v3/fake_org/repo1/repo1") - - // assert - require.Error(t, e, ErrSSHNotSupported) - require.EqualValues(t, (*gitRepositoryDetails)(nil), details) - }) - t.Run("non azure devops https remote", func(t *testing.T) { //arrange - provider := &AzdoScmProvider{} + provider := &AzdoScmProvider{ + env: environment.New("test"), + } ctx := context.Background() //act @@ -66,7 +55,9 @@ func Test_azdo_provider_getRepoDetails(t *testing.T) { t.Run("non azure devops git remote", func(t *testing.T) { //arrange - provider := &AzdoScmProvider{} + provider := &AzdoScmProvider{ + env: environment.New("test"), + } ctx := context.Background() //act @@ -221,3 +212,116 @@ func getAzdoCiProviderTestHarness(console input.Console) *AzdoCiProvider { console: console, } } + +func Test_parseAzDoRemote(t *testing.T) { + + // the url can be in the form of: + // - https://dev.azure.com/[org|user]/[project]/_git/[repo] + t.Run("valid HTTPS remote", func(t *testing.T) { + remoteUrl := "https://dev.azure.com/org/project/_git/repo" + expected := &azdoRemote{ + Project: "project", + RepositoryName: "repo", + } + + result, err := parseAzDoRemote(remoteUrl) + + require.NoError(t, err) + require.Equal(t, expected, result) + }) + + // the url can be in the form of: + // - https://[user]@dev.azure.com/[org|user]/[project]/_git/[repo] + t.Run("valid user HTTPS remote", func(t *testing.T) { + remoteUrl := "https://user@visualstudio.com/org/project/_git/repo" + expected := &azdoRemote{ + Project: "project", + RepositoryName: "repo", + } + + result, err := parseAzDoRemote(remoteUrl) + + require.NoError(t, err) + require.Equal(t, expected, result) + }) + + // the url can be in the form of: + // - https://[org].visualstudio.com/[project]/_git/[repo] + t.Run("valid legacy HTTPS remote", func(t *testing.T) { + remoteUrl := "https://visualstudio.com/org/project/_git/repo" + expected := &azdoRemote{ + Project: "project", + RepositoryName: "repo", + } + + result, err := parseAzDoRemote(remoteUrl) + + require.NoError(t, err) + require.Equal(t, expected, result) + }) + + t.Run("valid legacy HTTPS remote with org", func(t *testing.T) { + remoteUrl := "https://org.visualstudio.com/org/project/_git/repo" + expected := &azdoRemote{ + Project: "project", + RepositoryName: "repo", + } + + result, err := parseAzDoRemote(remoteUrl) + + require.NoError(t, err) + require.Equal(t, expected, result) + }) + + // the url can be in the form of: + // - git@ssh.dev.azure.com:v[1-3]/[user|org]/[project]/[repo] + // - git@vs-ssh.visualstudio.com:v[1-3]/[user|org]/[project]/[repo] + // - git@ssh.visualstudio.com:v[1-3]/[user|org]/[project]/[repo] + t.Run("valid SSH remote", func(t *testing.T) { + remoteUrl := "git@ssh.dev.azure.com:v3/org/project/repo" + expected := &azdoRemote{ + Project: "project", + RepositoryName: "repo", + } + + result, err := parseAzDoRemote(remoteUrl) + + require.NoError(t, err) + require.Equal(t, expected, result) + }) + + t.Run("valid legacy SSH remote", func(t *testing.T) { + remoteUrl := "git@vs-ssh.visualstudio.com:v3/org/project/repo" + expected := &azdoRemote{ + Project: "project", + RepositoryName: "repo", + } + + result, err := parseAzDoRemote(remoteUrl) + + require.NoError(t, err) + require.Equal(t, expected, result) + }) + + t.Run("valid legacy SSH remote", func(t *testing.T) { + remoteUrl := "git@ssh.visualstudio.com:v3/org/project/repo" + expected := &azdoRemote{ + Project: "project", + RepositoryName: "repo", + } + + result, err := parseAzDoRemote(remoteUrl) + + require.NoError(t, err) + require.Equal(t, expected, result) + }) + + t.Run("invalid remote", func(t *testing.T) { + remoteUrl := "https://github.com/user/repo" + + result, err := parseAzDoRemote(remoteUrl) + + require.Error(t, err) + require.Nil(t, result) + }) +}