Skip to content

Commit

Permalink
🌱 add support for parsing azure devops urls (#4178)
Browse files Browse the repository at this point in the history
Signed-off-by: Jamie Magee <jamie.magee@gmail.com>
  • Loading branch information
JamieMagee authored Aug 23, 2024
1 parent 4303b74 commit b709aa1
Show file tree
Hide file tree
Showing 2 changed files with 289 additions and 0 deletions.
116 changes: 116 additions & 0 deletions clients/azuredevops/repo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Copyright 2024 OpenSSF Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package azuredevops

import (
"fmt"
"net/url"
"strings"

"github.com/ossf/scorecard/v5/clients"
sce "github.com/ossf/scorecard/v5/errors"
)

type Repo struct {
scheme string
host string
organization string
project string
name string
metadata []string
}

// Parses input string into repoURL struct
/*
Accepted input string formats are as follows:
- "dev.azure.com/<organization:string>/<project:string>/_git/<repository:string>"
- "https://dev.azure.com/<organization:string>/<project:string>/_git/<repository:string>"
*/
func (r *Repo) parse(input string) error {
u, err := url.Parse(withDefaultScheme(input))
if err != nil {
return sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("url.Parse: %v", err))
}

const splitLen = 4
split := strings.SplitN(strings.Trim(u.Path, "/"), "/", splitLen)
if len(split) != splitLen {
return sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("Azure DevOps repo format is invalid: %s", input))
}

r.scheme, r.host, r.organization, r.project, r.name = u.Scheme, u.Host, split[0], split[1], split[3]
return nil
}

// Allow skipping scheme for ease-of-use, default to https.
func withDefaultScheme(uri string) string {
if strings.Contains(uri, "://") {
return uri
}
return "https://" + uri
}

// URI implements Repo.URI().
func (r *Repo) URI() string {
return fmt.Sprintf("%s/%s/%s/%s/%s", r.host, r.organization, r.project, "_git", r.name)
}

func (r *Repo) Host() string {
return r.host
}

// String implements Repo.String.
func (r *Repo) String() string {
return fmt.Sprintf("%s-%s_%s_%s", r.host, r.organization, r.project, r.name)
}

// IsValid checks if the repoURL is valid.
func (r *Repo) IsValid() error {
if strings.TrimSpace(r.organization) == "" ||
strings.TrimSpace(r.project) == "" ||
strings.TrimSpace(r.name) == "" {
return sce.WithMessage(sce.ErrInvalidURL, "expected full project url: "+r.URI())
}

return nil
}

func (r *Repo) AppendMetadata(metadata ...string) {
r.metadata = append(r.metadata, metadata...)
}

// Metadata implements Repo.Metadata.
func (r *Repo) Metadata() []string {
return r.metadata
}

// Path() implements RepoClient.Path.
func (r *Repo) Path() string {
return fmt.Sprintf("%s/%s/%s/%s", r.organization, r.project, "_git", r.name)
}

// MakeAzureDevOpsRepo takes input of forms in parse and returns and implementation
// of clients.Repo interface.
func MakeAzureDevOpsRepo(input string) (clients.Repo, error) {
var repo Repo
if err := repo.parse(input); err != nil {
return nil, fmt.Errorf("error during parse: %w", err)
}
if err := repo.IsValid(); err != nil {
return nil, fmt.Errorf("error in IsValid: %w", err)
}

return &repo, nil
}
173 changes: 173 additions & 0 deletions clients/azuredevops/repo_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// Copyright 2024 OpenSSF Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package azuredevops

import (
"testing"

"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
)

func TestRepo_parse(t *testing.T) {
t.Parallel()
tests := []struct {
name string
inputURL string
expected Repo
wantErr bool
flagRequired bool
}{
{
name: "valid azuredevops project with scheme",
expected: Repo{
host: "dev.azure.com",
organization: "dnceng-public",
project: "public",
name: "public",
},
inputURL: "https://dev.azure.com/dnceng-public/public/_git/public",
wantErr: false,
},
{
name: "valid azuredevops project without scheme",
expected: Repo{
host: "dev.azure.com",
organization: "dnceng-public",
project: "public",
name: "public",
},
inputURL: "dev.azure.com/dnceng-public/public/_git/public",
wantErr: false,
},
{
name: "invalid azuredevops project missing repo",
expected: Repo{},
inputURL: "https://dev.azure.com/dnceng-public/public",
wantErr: true,
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
r := Repo{}
if err := r.parse(tt.inputURL); (err != nil) != tt.wantErr {
t.Errorf("repoURL.parse() error = %v", err)
}
if tt.wantErr {
return
}
t.Log(r.URI())
if !tt.wantErr && !cmp.Equal(tt.expected, r, cmpopts.IgnoreUnexported(Repo{})) {
t.Logf("expected: %s GOT: %s", tt.expected.host, r.host)
t.Logf("expected: %s GOT: %s", tt.expected.organization, r.organization)
t.Logf("expected: %s GOT: %s", tt.expected.project, r.project)
t.Logf("expected: %s GOT: %s", tt.expected.name, r.name)
t.Errorf("Got diff: %s", cmp.Diff(tt.expected, r))
}
if !cmp.Equal(r.Host(), tt.expected.host) {
t.Errorf("%s expected host: %s got host %s", tt.inputURL, tt.expected.host, r.Host())
}
})
}
}

func TestRepo_IsValid(t *testing.T) {
t.Parallel()
tests := []struct {
name string
inputURL string
repo Repo
wantErr bool
flagRequired bool
}{
{
name: "valid azuredevops project",
repo: Repo{
host: "dev.azure.com",
organization: "dnceng-public",
project: "public",
name: "public",
},
wantErr: false,
},
{
name: "invalid azuredevops project",
repo: Repo{
host: "dev.azure.com",
organization: "dnceng-public",
project: "public",
name: "",
},
wantErr: true,
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if err := tt.repo.IsValid(); (err != nil) != tt.wantErr {
t.Errorf("repoURL.IsValid() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
})
}
}

func TestRepo_MakeAzureDevOpsRepo(t *testing.T) {
t.Parallel()
tests := []struct {
repouri string
expected bool
flagRequired bool
}{
{
repouri: "github.com/ossf/scorecard",
expected: false,
},
{
repouri: "ossf/scorecard",
expected: false,
},
{
repouri: "https://github.com/ossf/scorecard",
expected: false,
},
{
repouri: "https://dev.azure.com/dnceng-public/public/_git/public",
expected: true,
},
{
repouri: "dev.azure.com/dnceng-public/public/_git/public",
expected: true,
},
}

for _, tt := range tests {
g, err := MakeAzureDevOpsRepo(tt.repouri)
if (g != nil) != (err == nil) {
t.Errorf("got azuredevopsrepo: %s with err %s", g, err)
}
isAzureDevOps := g != nil && err == nil
if isAzureDevOps != tt.expected {
t.Errorf("got %s isazuredevops: %t expected %t", tt.repouri, isAzureDevOps, tt.expected)
}
}
}

0 comments on commit b709aa1

Please sign in to comment.