Skip to content

Commit

Permalink
✨ Initial experimental Azure DevOps client (#4377)
Browse files Browse the repository at this point in the history
* ✨ Initial Azure DevOps client

Signed-off-by: Jamie Magee <[email protected]>

* Address PR comments

Signed-off-by: Jamie Magee <[email protected]>

* Address PR comments

Signed-off-by: Jamie Magee <[email protected]>

* Fix lint

Signed-off-by: Jamie Magee <[email protected]>

* simplify IsArchived call

Signed-off-by: Jamie Magee <[email protected]>

---------

Signed-off-by: Jamie Magee <[email protected]>
  • Loading branch information
JamieMagee authored Nov 12, 2024
1 parent cf30f20 commit fee8bcf
Show file tree
Hide file tree
Showing 12 changed files with 669 additions and 15 deletions.
10 changes: 10 additions & 0 deletions checker/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ package checker
import (
"context"
"fmt"
"os"

"github.com/ossf/scorecard/v5/clients"
azdorepo "github.com/ossf/scorecard/v5/clients/azuredevopsrepo"
ghrepo "github.com/ossf/scorecard/v5/clients/githubrepo"
glrepo "github.com/ossf/scorecard/v5/clients/gitlabrepo"
"github.com/ossf/scorecard/v5/clients/localdir"
Expand Down Expand Up @@ -56,13 +58,21 @@ func GetClients(ctx context.Context, repoURI, localURI string, logger *log.Logge
retErr
}

_, experimental := os.LookupEnv("SCORECARD_EXPERIMENTAL")
var repoClient clients.RepoClient

repo, makeRepoError = glrepo.MakeGitlabRepo(repoURI)
if repo != nil && makeRepoError == nil {
repoClient, makeRepoError = glrepo.CreateGitlabClient(ctx, repo.Host())
}

if experimental && (makeRepoError != nil || repo == nil) {
repo, makeRepoError = azdorepo.MakeAzureDevOpsRepo(repoURI)
if repo != nil && makeRepoError == nil {
repoClient, makeRepoError = azdorepo.CreateAzureDevOpsClient(ctx, repo)
}
}

if makeRepoError != nil || repo == nil {
repo, makeRepoError = ghrepo.MakeGithubRepo(repoURI)
if makeRepoError != nil {
Expand Down
75 changes: 75 additions & 0 deletions clients/azuredevopsrepo/branches.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// 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 azuredevopsrepo

import (
"context"
"fmt"
"sync"

"github.com/microsoft/azure-devops-go-api/azuredevops/v7/git"

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

type branchesHandler struct {
gitClient git.Client
ctx context.Context
once *sync.Once
errSetup error
repourl *Repo
defaultBranchRef *clients.BranchRef
queryBranch fnQueryBranch
}

func (handler *branchesHandler) init(ctx context.Context, repourl *Repo) {
handler.ctx = ctx
handler.repourl = repourl
handler.errSetup = nil
handler.once = new(sync.Once)
handler.queryBranch = handler.gitClient.GetBranch
}

type (
fnQueryBranch func(ctx context.Context, args git.GetBranchArgs) (*git.GitBranchStats, error)
)

func (handler *branchesHandler) setup() error {
handler.once.Do(func() {
branch, err := handler.queryBranch(handler.ctx, git.GetBranchArgs{
RepositoryId: &handler.repourl.id,
Name: &handler.repourl.defaultBranch,
})
if err != nil {
handler.errSetup = fmt.Errorf("request for default branch failed with error %w", err)
return
}
handler.defaultBranchRef = &clients.BranchRef{
Name: branch.Name,
}

handler.errSetup = nil
})
return handler.errSetup
}

func (handler *branchesHandler) getDefaultBranch() (*clients.BranchRef, error) {
err := handler.setup()
if err != nil {
return nil, fmt.Errorf("error during branchesHandler.setup: %w", err)
}

return handler.defaultBranchRef, nil
}
76 changes: 76 additions & 0 deletions clients/azuredevopsrepo/branches_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// 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 azuredevopsrepo

import (
"context"
"fmt"
"sync"
"testing"

"github.com/microsoft/azure-devops-go-api/azuredevops/v7/git"
)

func TestGetDefaultBranch(t *testing.T) {
t.Parallel()
tests := []struct {
setupMock func() fnQueryBranch
name string
expectedName string
expectedError bool
}{
{
name: "successful branch retrieval",
setupMock: func() fnQueryBranch {
return func(ctx context.Context, args git.GetBranchArgs) (*git.GitBranchStats, error) {
return &git.GitBranchStats{Name: args.Name}, nil
}
},
expectedError: false,
expectedName: "main",
},
{
name: "error during branch retrieval",
setupMock: func() fnQueryBranch {
return func(ctx context.Context, args git.GetBranchArgs) (*git.GitBranchStats, error) {
return nil, fmt.Errorf("error")
}
},
expectedError: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
handler := &branchesHandler{
queryBranch: tt.setupMock(),
once: new(sync.Once),
repourl: &Repo{
id: "repo-id",
defaultBranch: "main",
},
}

branch, err := handler.getDefaultBranch()
if (err != nil) != tt.expectedError {
t.Errorf("expected error: %v, got: %v", tt.expectedError, err)
}
if branch != nil && *branch.Name != tt.expectedName {
t.Errorf("expected branch name: %v, got: %v", tt.expectedName, *branch.Name)
}
})
}
}
211 changes: 211 additions & 0 deletions clients/azuredevopsrepo/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
// 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 azuredevopsrepo

import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"time"

"github.com/microsoft/azure-devops-go-api/azuredevops/v7"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/git"

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

var (
_ clients.RepoClient = &Client{}
errInputRepoType = errors.New("input repo should be of type azuredevopsrepo.Repo")
errDefaultBranchNotFound = errors.New("default branch not found")
)

type Client struct {
azdoClient git.Client
ctx context.Context
repourl *Repo
repo *git.GitRepository
branches *branchesHandler
commits *commitsHandler
commitDepth int
}

func (c *Client) InitRepo(inputRepo clients.Repo, commitSHA string, commitDepth int) error {
azdoRepo, ok := inputRepo.(*Repo)
if !ok {
return fmt.Errorf("%w: %v", errInputRepoType, inputRepo)
}

repo, err := c.azdoClient.GetRepository(c.ctx, git.GetRepositoryArgs{
Project: &azdoRepo.project,
RepositoryId: &azdoRepo.name,
})
if err != nil {
return fmt.Errorf("could not get repository with error: %w", err)
}

c.repo = repo

if commitDepth <= 0 {
c.commitDepth = 30 // default
} else {
c.commitDepth = commitDepth
}

branch := strings.TrimPrefix(*repo.DefaultBranch, "refs/heads/")

c.repourl = &Repo{
scheme: azdoRepo.scheme,
host: azdoRepo.host,
organization: azdoRepo.organization,
project: azdoRepo.project,
name: azdoRepo.name,
id: fmt.Sprint(*repo.Id),
defaultBranch: branch,
commitSHA: commitSHA,
}

c.branches.init(c.ctx, c.repourl)

c.commits.init(c.ctx, c.repourl, c.commitDepth)

return nil
}

func (c *Client) URI() string {
return c.repourl.URI()
}

func (c *Client) IsArchived() (bool, error) {
return *c.repo.IsDisabled, nil
}

func (c *Client) ListFiles(predicate func(string) (bool, error)) ([]string, error) {
return []string{}, clients.ErrUnsupportedFeature
}

func (c *Client) LocalPath() (string, error) {
return "", clients.ErrUnsupportedFeature
}

func (c *Client) GetFileReader(filename string) (io.ReadCloser, error) {
return nil, clients.ErrUnsupportedFeature
}

func (c *Client) GetBranch(branch string) (*clients.BranchRef, error) {
return nil, clients.ErrUnsupportedFeature
}

func (c *Client) GetCreatedAt() (time.Time, error) {
return time.Time{}, clients.ErrUnsupportedFeature
}

func (c *Client) GetDefaultBranchName() (string, error) {
if len(c.repourl.defaultBranch) > 0 {
return c.repourl.defaultBranch, nil
}

return "", errDefaultBranchNotFound
}

func (c *Client) GetDefaultBranch() (*clients.BranchRef, error) {
return c.branches.getDefaultBranch()
}

func (c *Client) GetOrgRepoClient(context.Context) (clients.RepoClient, error) {
return nil, clients.ErrUnsupportedFeature
}

func (c *Client) ListCommits() ([]clients.Commit, error) {
return c.commits.listCommits()
}

func (c *Client) ListIssues() ([]clients.Issue, error) {
return nil, clients.ErrUnsupportedFeature
}

func (c *Client) ListLicenses() ([]clients.License, error) {
return nil, clients.ErrUnsupportedFeature
}

func (c *Client) ListReleases() ([]clients.Release, error) {
return nil, clients.ErrUnsupportedFeature
}

func (c *Client) ListContributors() ([]clients.User, error) {
return nil, clients.ErrUnsupportedFeature
}

func (c *Client) ListSuccessfulWorkflowRuns(filename string) ([]clients.WorkflowRun, error) {
return nil, clients.ErrUnsupportedFeature
}

func (c *Client) ListCheckRunsForRef(ref string) ([]clients.CheckRun, error) {
return nil, clients.ErrUnsupportedFeature
}

func (c *Client) ListStatuses(ref string) ([]clients.Status, error) {
return nil, clients.ErrUnsupportedFeature
}

func (c *Client) ListWebhooks() ([]clients.Webhook, error) {
return nil, clients.ErrUnsupportedFeature
}

func (c *Client) ListProgrammingLanguages() ([]clients.Language, error) {
return nil, clients.ErrUnsupportedFeature
}

func (c *Client) Search(request clients.SearchRequest) (clients.SearchResponse, error) {
return clients.SearchResponse{}, clients.ErrUnsupportedFeature
}

func (c *Client) SearchCommits(request clients.SearchCommitsOptions) ([]clients.Commit, error) {
return nil, clients.ErrUnsupportedFeature
}

func (c *Client) Close() error {
return nil
}

func CreateAzureDevOpsClient(ctx context.Context, repo clients.Repo) (*Client, error) {
token := os.Getenv("AZURE_DEVOPS_AUTH_TOKEN")
return CreateAzureDevOpsClientWithToken(ctx, token, repo)
}

func CreateAzureDevOpsClientWithToken(ctx context.Context, token string, repo clients.Repo) (*Client, error) {
// https://dev.azure.com/<org>
url := "https://" + repo.Host() + "/" + strings.Split(repo.Path(), "/")[0]
connection := azuredevops.NewPatConnection(url, token)

gitClient, err := git.NewClient(ctx, connection)
if err != nil {
return nil, fmt.Errorf("could not create azure devops git client with error: %w", err)
}

return &Client{
ctx: ctx,
azdoClient: gitClient,
branches: &branchesHandler{
gitClient: gitClient,
},
commits: &commitsHandler{
gitClient: gitClient,
},
}, nil
}
Loading

0 comments on commit fee8bcf

Please sign in to comment.