Skip to content

Commit

Permalink
✨ implement more of the Azure DevOps client
Browse files Browse the repository at this point in the history
Includes:
  - `GetBranch`
  - `GetSuccessfulWorkflowRuns`
  - `ListCheckRunsForRef`
  - `ListStatuses`
  - `ListWebhooks`
  - `SearchCommits`

Also, includes comments about methods which can never be implemented:
  - `GetOrgRepoClient`
    - Org repository, AKA the `<org>/.github` repository, is a GitHub-specific feature
  - `ListLicenses`
    - Azure DevOps doesn't have a license detection feature. Thankfully, the License check falls back to file-based detection.

Still need to implement:
  - `ListReleases`
    - Needs a little more investigation to line up the Azure DevOps implementation with what Scorecard expects

Signed-off-by: Jamie Magee <[email protected]>
  • Loading branch information
JamieMagee committed Dec 13, 2024
1 parent 2409124 commit 7292b2a
Show file tree
Hide file tree
Showing 22 changed files with 1,587 additions and 129 deletions.
9 changes: 7 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,11 @@ ifndef GITLAB_AUTH_TOKEN
$(error GITLAB_AUTH_TOKEN is undefined)
endif

check-env-azure-devops:
ifndef AZURE_DEVOPS_AUTH_TOKEN
$(error AZURE_DEVOPS_AUTH_TOKEN is undefined)
endif

e2e-pat: ## Runs e2e tests. Requires GITHUB_AUTH_TOKEN env var to be set to GitHub personal access token
e2e-pat: build-scorecard check-env | $(GINKGO)
# Run e2e tests. GITHUB_AUTH_TOKEN with personal access token must be exported to run this
Expand All @@ -357,8 +362,8 @@ e2e-gitlab: build-scorecard | $(GINKGO)
TEST_GITLAB_EXTERNAL=1 TOKEN_TYPE="PAT" $(GINKGO) --race -p -vv -coverprofile=e2e-coverage.out --keep-separate-coverprofiles --focus ".*GitLab" ./...

e2e-azure-devops-token: ## Runs e2e tests that require a AZURE_DEVOPS_AUTH_TOKEN
e2e-azure-devops-token: build-scorecard check-env-gitlab | $(GINKGO)
TEST_AZURE_DEVOPS_EXTERNAL=1 TOKEN_TYPE="GITLAB_PAT" $(GINKGO) --race -p -vv -coverprofile=e2e-coverage.out --keep-separate-coverprofiles --focus '.*Azure DevOps' ./...
e2e-azure-devops-token: build-scorecard check-env-azure-devops | $(GINKGO)
TEST_AZURE_DEVOPS_EXTERNAL=1 $(GINKGO) --race -p -vv -coverprofile=e2e-coverage.out --keep-separate-coverprofiles --focus "Azure DevOps" ./...

e2e-attestor: ## Runs e2e tests for scorecard-attestor
cd attestor/e2e; go test -covermode=atomic -coverprofile=e2e-coverage.out; cd ../..
Expand Down
95 changes: 67 additions & 28 deletions clients/azuredevopsrepo/branches.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,57 +19,96 @@ import (
"fmt"
"sync"

"github.com/google/uuid"
"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
gitClient git.Client
ctx context.Context
once *sync.Once
errSetup error
repourl *Repo
defaultBranchRef *clients.BranchRef
queryBranch fnQueryBranch
getPolicyConfigurations fnGetPolicyConfigurations
}

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
func (b *branchesHandler) init(ctx context.Context, repourl *Repo) {
b.ctx = ctx
b.repourl = repourl
b.errSetup = nil
b.once = new(sync.Once)
b.queryBranch = b.gitClient.GetBranch
b.getPolicyConfigurations = b.gitClient.GetPolicyConfigurations

Check warning on line 45 in clients/azuredevopsrepo/branches.go

View check run for this annotation

Codecov / codecov/patch

clients/azuredevopsrepo/branches.go#L39-L45

Added lines #L39 - L45 were not covered by tests
}

type (
fnQueryBranch func(ctx context.Context, args git.GetBranchArgs) (*git.GitBranchStats, error)
fnQueryBranch func(ctx context.Context, args git.GetBranchArgs) (*git.GitBranchStats, error)
fnGetPolicyConfigurations func(
ctx context.Context,
args git.GetPolicyConfigurationsArgs,
) (*git.GitPolicyConfigurationResponse, 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,
})
func (b *branchesHandler) setup() error {
b.once.Do(func() {
args := git.GetBranchArgs{
RepositoryId: &b.repourl.id,
Name: &b.repourl.defaultBranch,
}
branch, err := b.queryBranch(b.ctx, args)
if err != nil {
handler.errSetup = fmt.Errorf("request for default branch failed with error %w", err)
b.errSetup = fmt.Errorf("request for default branch failed with error %w", err)
return
}
handler.defaultBranchRef = &clients.BranchRef{
b.defaultBranchRef = &clients.BranchRef{
Name: branch.Name,
}

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

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

return handler.defaultBranchRef, nil
return b.defaultBranchRef, nil
}

func (b *branchesHandler) getBranch(branchName string) (*clients.BranchRef, error) {
branch, err := b.queryBranch(b.ctx, git.GetBranchArgs{
RepositoryId: &b.repourl.id,
Name: &branchName,
})
if err != nil {
return nil, fmt.Errorf("request for branch %s failed with error %w", branchName, err)
}

refName := fmt.Sprintf("refs/heads/%s", *branch.Name)
repositoryID := uuid.MustParse(b.repourl.id)
args := git.GetPolicyConfigurationsArgs{
RepositoryId: &repositoryID,
RefName: &refName,
}
response, err := b.getPolicyConfigurations(b.ctx, args)
if err != nil {
return nil, fmt.Errorf("request for policy configurations failed with error %w", err)
}

isBranchProtected := false
if len(*response.PolicyConfigurations) > 0 {
isBranchProtected = true
}

Check warning on line 107 in clients/azuredevopsrepo/branches.go

View check run for this annotation

Codecov / codecov/patch

clients/azuredevopsrepo/branches.go#L106-L107

Added lines #L106 - L107 were not covered by tests

// TODO: map Azure DevOps branch protection to Scorecard branch protection
return &clients.BranchRef{
Name: branch.Name,
Protected: &isBranchProtected,
}, nil
}
100 changes: 89 additions & 11 deletions clients/azuredevopsrepo/branches_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,33 +20,34 @@ import (
"sync"
"testing"

"github.com/google/go-cmp/cmp"
"github.com/google/uuid"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/git"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/policy"

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

func TestGetDefaultBranch(t *testing.T) {
func Test_getDefaultBranch(t *testing.T) {
t.Parallel()
tests := []struct {
setupMock func() fnQueryBranch
queryBranch 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
}
queryBranch: 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")
}
queryBranch: func(ctx context.Context, args git.GetBranchArgs) (*git.GitBranchStats, error) {
return nil, fmt.Errorf("error")
},
expectedError: true,
},
Expand All @@ -56,7 +57,7 @@ func TestGetDefaultBranch(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
handler := &branchesHandler{
queryBranch: tt.setupMock(),
queryBranch: tt.queryBranch,
once: new(sync.Once),
repourl: &Repo{
id: "repo-id",
Expand All @@ -74,3 +75,80 @@ func TestGetDefaultBranch(t *testing.T) {
})
}
}

func Test_getBranch(t *testing.T) {
t.Parallel()
tests := []struct {
getBranch fnQueryBranch
getPolicyConfigurations fnGetPolicyConfigurations
expected *clients.BranchRef
name string
branchName string
expectedError bool
}{
{
name: "successful branch retrieval",
branchName: "main",
getBranch: func(ctx context.Context, args git.GetBranchArgs) (*git.GitBranchStats, error) {
return &git.GitBranchStats{Name: args.Name}, nil
},
getPolicyConfigurations: func(ctx context.Context, args git.GetPolicyConfigurationsArgs) (*git.GitPolicyConfigurationResponse, error) {
return &git.GitPolicyConfigurationResponse{
PolicyConfigurations: &[]policy.PolicyConfiguration{},
}, nil
},
expected: &clients.BranchRef{
Name: toPtr("main"),
Protected: toPtr(false),
},
expectedError: false,
},
{
name: "error during branch retrieval",
branchName: "main",
getBranch: func(ctx context.Context, args git.GetBranchArgs) (*git.GitBranchStats, error) {
return nil, fmt.Errorf("error")
},
getPolicyConfigurations: func(ctx context.Context, args git.GetPolicyConfigurationsArgs) (*git.GitPolicyConfigurationResponse, error) {
return &git.GitPolicyConfigurationResponse{}, nil
},
expected: nil,
expectedError: true,
},
{
name: "error during policy configuration retrieval",
branchName: "main",
getBranch: func(ctx context.Context, args git.GetBranchArgs) (*git.GitBranchStats, error) {
return &git.GitBranchStats{Name: args.Name}, nil
},
getPolicyConfigurations: func(ctx context.Context, args git.GetPolicyConfigurationsArgs) (*git.GitPolicyConfigurationResponse, error) {
return nil, fmt.Errorf("error")
},
expected: nil,
expectedError: true,
},
}

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

branch, err := handler.getBranch(tt.branchName)
if (err != nil) != tt.expectedError {
t.Errorf("expected error: %v, got: %v", tt.expectedError, err)
}
if diff := cmp.Diff(branch, tt.expected); diff != "" {
t.Errorf("mismatch in branch ref (-want +got):\n%s", diff)
}
})
}
}
108 changes: 108 additions & 0 deletions clients/azuredevopsrepo/builds.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// 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"
"sync"

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

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

type buildsHandler struct {
ctx context.Context
once *sync.Once
repourl *Repo
buildClient build.Client
getBuildDefinitions fnListBuildDefinitions
getBuilds fnGetBuilds
}

type (
fnListBuildDefinitions func(
ctx context.Context,
args build.GetDefinitionsArgs,
) (*build.GetDefinitionsResponseValue, error)
fnGetBuilds func(
ctx context.Context,
args build.GetBuildsArgs,
) (*build.GetBuildsResponseValue, error)
)

func (b *buildsHandler) init(ctx context.Context, repourl *Repo) {
b.ctx = ctx
b.once = new(sync.Once)
b.repourl = repourl
b.getBuildDefinitions = b.buildClient.GetDefinitions
b.getBuilds = b.buildClient.GetBuilds

Check warning on line 51 in clients/azuredevopsrepo/builds.go

View check run for this annotation

Codecov / codecov/patch

clients/azuredevopsrepo/builds.go#L46-L51

Added lines #L46 - L51 were not covered by tests
}

func (b *buildsHandler) listSuccessfulBuilds(filename string) ([]clients.WorkflowRun, error) {
buildDefinitions := make([]build.BuildDefinitionReference, 0)

includeAllProperties := true
repositoryType := "TfsGit"
continuationToken := ""
for {
args := build.GetDefinitionsArgs{
Project: &b.repourl.project,
RepositoryId: &b.repourl.id,
RepositoryType: &repositoryType,
IncludeAllProperties: &includeAllProperties,
YamlFilename: &filename,
ContinuationToken: &continuationToken,
}

response, err := b.getBuildDefinitions(b.ctx, args)
if err != nil {
return nil, err
}

buildDefinitions = append(buildDefinitions, response.Value...)

if response.ContinuationToken == "" {
break
}
continuationToken = response.ContinuationToken

Check warning on line 80 in clients/azuredevopsrepo/builds.go

View check run for this annotation

Codecov / codecov/patch

clients/azuredevopsrepo/builds.go#L80

Added line #L80 was not covered by tests
}

buildIds := make([]int, 0)
for i := range buildDefinitions {
buildIds = append(buildIds, *buildDefinitions[i].Id)
}

workflowRuns := make([]clients.WorkflowRun, 0)
args := build.GetBuildsArgs{
Project: &b.repourl.project,
Definitions: &buildIds,
ResultFilter: &build.BuildResultValues.Succeeded,
}
builds, err := b.getBuilds(b.ctx, args)
if err != nil {
return nil, err
}

for i := range builds.Value {
currentBuild := builds.Value[i]
workflowRuns = append(workflowRuns, clients.WorkflowRun{
URL: *currentBuild.Url,
HeadSHA: currentBuild.SourceVersion,
})
}

return workflowRuns, nil
}
Loading

0 comments on commit 7292b2a

Please sign in to comment.