Skip to content

remove azdo git remote constraint for dev.azure.com only #4104

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 51 additions & 41 deletions cli/azd/pkg/pipeline/azdo_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"errors"
"fmt"
"log"
"regexp"
"strings"

"github.com/azure/azure-dev/cli/azd/pkg/azdo"
Expand Down Expand Up @@ -418,56 +417,68 @@ 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")

// ErrSSHNotSupported the error used when ssh git remote is detected
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.
Expand Down Expand Up @@ -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)
Expand Down
134 changes: 119 additions & 15 deletions cli/azd/pkg/pipeline/azdo_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
})
}
Loading