diff --git a/Makefile b/Makefile index c14bdae16fe9..b8d58469a0ba 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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 ../.. diff --git a/clients/azuredevopsrepo/branches.go b/clients/azuredevopsrepo/branches.go index 784337aa3bab..900831b70cb4 100644 --- a/clients/azuredevopsrepo/branches.go +++ b/clients/azuredevopsrepo/branches.go @@ -19,57 +19,99 @@ 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 } 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, err := uuid.Parse(b.repourl.id) + if err != nil { + return nil, fmt.Errorf("error parsing repository ID %s: %w", b.repourl.id, err) + } + 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 + } + + // TODO: map Azure DevOps branch protection to Scorecard branch protection + return &clients.BranchRef{ + Name: branch.Name, + Protected: &isBranchProtected, + }, nil } diff --git a/clients/azuredevopsrepo/branches_test.go b/clients/azuredevopsrepo/branches_test.go index 2bebd3dc09ca..5e99d2584b8d 100644 --- a/clients/azuredevopsrepo/branches_test.go +++ b/clients/azuredevopsrepo/branches_test.go @@ -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, }, @@ -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", @@ -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) + } + }) + } +} diff --git a/clients/azuredevopsrepo/builds.go b/clients/azuredevopsrepo/builds.go new file mode 100644 index 000000000000..17832c68ad2a --- /dev/null +++ b/clients/azuredevopsrepo/builds.go @@ -0,0 +1,105 @@ +// 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" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/build" + + "github.com/ossf/scorecard/v5/clients" +) + +type buildsHandler struct { + ctx context.Context + 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.repourl = repourl + b.getBuildDefinitions = b.buildClient.GetDefinitions + b.getBuilds = b.buildClient.GetBuilds +} + +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 + } + + buildIds := make([]int, 0, len(buildDefinitions)) + for i := range buildDefinitions { + buildIds = append(buildIds, *buildDefinitions[i].Id) + } + + 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 + } + + workflowRuns := make([]clients.WorkflowRun, 0, len(builds.Value)) + for i := range builds.Value { + currentBuild := builds.Value[i] + workflowRuns = append(workflowRuns, clients.WorkflowRun{ + URL: *currentBuild.Url, + HeadSHA: currentBuild.SourceVersion, + }) + } + + return workflowRuns, nil +} diff --git a/clients/azuredevopsrepo/builds_e2e_test.go b/clients/azuredevopsrepo/builds_e2e_test.go new file mode 100644 index 000000000000..b49d3dfadac1 --- /dev/null +++ b/clients/azuredevopsrepo/builds_e2e_test.go @@ -0,0 +1,43 @@ +// 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" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/ossf/scorecard/v5/clients" +) + +var _ = Describe("E2E TEST: azuredevopsrepo.buildsHandler", func() { + Context("Builds - Azure DevOps", func() { + It("Should return successful builds", func() { + repo, err := MakeAzureDevOpsRepo("https://dev.azure.com/jamiemagee/jamiemagee/_git/jamiemagee") + Expect(err).Should(BeNil()) + + repoClient, err := CreateAzureDevOpsClient(context.Background(), repo) + Expect(err).Should(BeNil()) + + err = repoClient.InitRepo(repo, clients.HeadSHA, 0) + Expect(err).Should(BeNil()) + + builds, err := repoClient.ListSuccessfulWorkflowRuns("azure-pipelines.yml") + Expect(err).Should(BeNil()) + Expect(len(builds)).Should(BeNumerically(">", 0)) + }) + }) +}) diff --git a/clients/azuredevopsrepo/builds_test.go b/clients/azuredevopsrepo/builds_test.go new file mode 100644 index 000000000000..fb33461bc0b3 --- /dev/null +++ b/clients/azuredevopsrepo/builds_test.go @@ -0,0 +1,215 @@ +// 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" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/build" + + "github.com/ossf/scorecard/v5/clients" +) + +func Test_listSuccessfulBuilds(t *testing.T) { + t.Parallel() + tests := []struct { + name string + getBuildDefinitions fnListBuildDefinitions + getBuilds fnGetBuilds + want []clients.WorkflowRun + wantErr bool + }{ + { + name: "no build definitions", + getBuildDefinitions: func(ctx context.Context, args build.GetDefinitionsArgs) (*build.GetDefinitionsResponseValue, error) { + return &build.GetDefinitionsResponseValue{ + Value: []build.BuildDefinitionReference{}, + }, nil + }, + getBuilds: func(ctx context.Context, args build.GetBuildsArgs) (*build.GetBuildsResponseValue, error) { + return &build.GetBuildsResponseValue{}, nil + }, + want: []clients.WorkflowRun{}, + wantErr: false, + }, + { + name: "no builds", + getBuildDefinitions: func(ctx context.Context, args build.GetDefinitionsArgs) (*build.GetDefinitionsResponseValue, error) { + return &build.GetDefinitionsResponseValue{ + Value: []build.BuildDefinitionReference{ + {Id: toPtr(123)}, + }, + }, nil + }, + getBuilds: func(ctx context.Context, args build.GetBuildsArgs) (*build.GetBuildsResponseValue, error) { + return &build.GetBuildsResponseValue{ + Value: []build.Build{}, + }, nil + }, + want: []clients.WorkflowRun{}, + wantErr: false, + }, + { + name: "single definition and build", + getBuildDefinitions: func(ctx context.Context, args build.GetDefinitionsArgs) (*build.GetDefinitionsResponseValue, error) { + return &build.GetDefinitionsResponseValue{ + Value: []build.BuildDefinitionReference{ + {Id: toPtr(123)}, + }, + }, nil + }, + getBuilds: func(ctx context.Context, args build.GetBuildsArgs) (*build.GetBuildsResponseValue, error) { + return &build.GetBuildsResponseValue{ + Value: []build.Build{ + { + Url: toPtr("https://example.com"), + SourceVersion: toPtr("abc123"), + }, + }, + }, nil + }, + want: []clients.WorkflowRun{ + { + URL: "https://example.com", + HeadSHA: toPtr("abc123"), + }, + }, + wantErr: false, + }, + { + name: "multiple definitions and builds", + getBuildDefinitions: func(ctx context.Context, args build.GetDefinitionsArgs) (*build.GetDefinitionsResponseValue, error) { + return &build.GetDefinitionsResponseValue{ + Value: []build.BuildDefinitionReference{ + {Id: toPtr(123)}, + {Id: toPtr(456)}, + }, + }, nil + }, + getBuilds: func(ctx context.Context, args build.GetBuildsArgs) (*build.GetBuildsResponseValue, error) { + return &build.GetBuildsResponseValue{ + Value: []build.Build{ + { + Url: toPtr("https://example.com"), + SourceVersion: toPtr("abc123"), + }, + { + Url: toPtr("https://test.com"), + SourceVersion: toPtr("def456"), + }, + }, + }, nil + }, + want: []clients.WorkflowRun{ + { + URL: "https://example.com", + HeadSHA: toPtr("abc123"), + }, + { + URL: "https://test.com", + HeadSHA: toPtr("def456"), + }, + }, + wantErr: false, + }, + { + name: "multiple definitions and builds with continuation token", + getBuildDefinitions: func(ctx context.Context, args build.GetDefinitionsArgs) (*build.GetDefinitionsResponseValue, error) { + if args.ContinuationToken == nil { + return &build.GetDefinitionsResponseValue{ + Value: []build.BuildDefinitionReference{ + {Id: toPtr(123)}, + }, + ContinuationToken: "abc123", + }, nil + } + return &build.GetDefinitionsResponseValue{ + Value: []build.BuildDefinitionReference{ + {Id: toPtr(789)}, + }, + }, nil + }, + getBuilds: func(ctx context.Context, args build.GetBuildsArgs) (*build.GetBuildsResponseValue, error) { + return &build.GetBuildsResponseValue{ + Value: []build.Build{ + { + Url: toPtr("https://example.com"), + SourceVersion: toPtr("abc123"), + }, + }, + }, nil + }, + want: []clients.WorkflowRun{ + { + URL: "https://example.com", + HeadSHA: toPtr("abc123"), + }, + }, + wantErr: false, + }, + { + name: "build definitions error", + getBuildDefinitions: func(ctx context.Context, args build.GetDefinitionsArgs) (*build.GetDefinitionsResponseValue, error) { + return nil, errors.New("error") + }, + getBuilds: func(ctx context.Context, args build.GetBuildsArgs) (*build.GetBuildsResponseValue, error) { + return &build.GetBuildsResponseValue{}, nil + }, + want: nil, + wantErr: true, + }, + { + name: "builds error", + getBuildDefinitions: func(ctx context.Context, args build.GetDefinitionsArgs) (*build.GetDefinitionsResponseValue, error) { + return &build.GetDefinitionsResponseValue{ + Value: []build.BuildDefinitionReference{ + {Id: toPtr(123)}, + }, + }, nil + }, + getBuilds: func(ctx context.Context, args build.GetBuildsArgs) (*build.GetBuildsResponseValue, error) { + return nil, errors.New("error") + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + b := &buildsHandler{ + repourl: &Repo{ + project: "test", + id: "123", + }, + getBuildDefinitions: tt.getBuildDefinitions, + getBuilds: tt.getBuilds, + } + got, err := b.listSuccessfulBuilds("test.yaml") + + if (err != nil) != tt.wantErr { + t.Errorf("listSuccessfulBuilds() error = %v, wantErr %v", err, tt.wantErr) + return + } + if diff := cmp.Diff(got, tt.want); diff != "" { + t.Errorf("listSuccessfulBuilds() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/clients/azuredevopsrepo/client.go b/clients/azuredevopsrepo/client.go index 76d42f182f94..bae822d3ce64 100644 --- a/clients/azuredevopsrepo/client.go +++ b/clients/azuredevopsrepo/client.go @@ -25,9 +25,12 @@ import ( "github.com/microsoft/azure-devops-go-api/azuredevops/v7" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/audit" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/build" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/git" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/policy" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/projectanalysis" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/search" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/servicehooks" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking" "github.com/ossf/scorecard/v5/clients" @@ -40,19 +43,23 @@ var ( ) type Client struct { - azdoClient git.Client - ctx context.Context - repourl *Repo - repo *git.GitRepository - audit *auditHandler - branches *branchesHandler - commits *commitsHandler - contributors *contributorsHandler - languages *languagesHandler - search *searchHandler - workItems *workItemsHandler - zip *zipHandler - commitDepth int + azdoClient git.Client + ctx context.Context + repourl *Repo + repo *git.GitRepository + audit *auditHandler + branches *branchesHandler + builds *buildsHandler + commits *commitsHandler + contributors *contributorsHandler + languages *languagesHandler + policy *policyHandler + search *searchHandler + searchCommits *searchCommitsHandler + servicehooks *servicehooksHandler + workItems *workItemsHandler + zip *zipHandler + commitDepth int } func (c *Client) InitRepo(inputRepo clients.Repo, commitSHA string, commitDepth int) error { @@ -84,8 +91,9 @@ func (c *Client) InitRepo(inputRepo clients.Repo, commitSHA string, commitDepth host: azdoRepo.host, organization: azdoRepo.organization, project: azdoRepo.project, + projectID: repo.Project.Id.String(), name: azdoRepo.name, - id: fmt.Sprint(*repo.Id), + id: repo.Id.String(), defaultBranch: branch, commitSHA: commitSHA, } @@ -94,14 +102,22 @@ func (c *Client) InitRepo(inputRepo clients.Repo, commitSHA string, commitDepth c.branches.init(c.ctx, c.repourl) + c.builds.init(c.ctx, c.repourl) + c.commits.init(c.ctx, c.repourl, c.commitDepth) c.contributors.init(c.ctx, c.repourl) c.languages.init(c.ctx, c.repourl) + c.policy.init(c.ctx, c.repourl) + c.search.init(c.ctx, c.repourl) + c.searchCommits.init(c.ctx, c.repourl) + + c.servicehooks.init(c.ctx, c.repourl) + c.workItems.init(c.ctx, c.repourl) c.zip.init(c.ctx, c.repourl) @@ -130,7 +146,7 @@ func (c *Client) GetFileReader(filename string) (io.ReadCloser, error) { } func (c *Client) GetBranch(branch string) (*clients.BranchRef, error) { - return nil, clients.ErrUnsupportedFeature + return c.branches.getBranch(branch) } func (c *Client) GetCreatedAt() (time.Time, error) { @@ -158,6 +174,7 @@ func (c *Client) GetDefaultBranch() (*clients.BranchRef, error) { return c.branches.getDefaultBranch() } +// Org repository, AKA the /.github repository, is a GitHub-specific feature. func (c *Client) GetOrgRepoClient(context.Context) (clients.RepoClient, error) { return nil, clients.ErrUnsupportedFeature } @@ -170,6 +187,8 @@ func (c *Client) ListIssues() ([]clients.Issue, error) { return c.workItems.listIssues() } +// Azure DevOps doesn't have a license detection feature. +// Thankfully, the License check falls back to file-based detection. func (c *Client) ListLicenses() ([]clients.License, error) { return nil, clients.ErrUnsupportedFeature } @@ -183,19 +202,19 @@ func (c *Client) ListContributors() ([]clients.User, error) { } func (c *Client) ListSuccessfulWorkflowRuns(filename string) ([]clients.WorkflowRun, error) { - return nil, clients.ErrUnsupportedFeature + return c.builds.listSuccessfulBuilds(filename) } func (c *Client) ListCheckRunsForRef(ref string) ([]clients.CheckRun, error) { - return nil, clients.ErrUnsupportedFeature + return c.policy.listCheckRunsForRef(ref) } func (c *Client) ListStatuses(ref string) ([]clients.Status, error) { - return nil, clients.ErrUnsupportedFeature + return c.commits.listStatuses(ref) } func (c *Client) ListWebhooks() ([]clients.Webhook, error) { - return nil, clients.ErrUnsupportedFeature + return c.servicehooks.listWebhooks() } func (c *Client) ListProgrammingLanguages() ([]clients.Language, error) { @@ -207,7 +226,7 @@ func (c *Client) Search(request clients.SearchRequest) (clients.SearchResponse, } func (c *Client) SearchCommits(request clients.SearchCommitsOptions) ([]clients.Commit, error) { - return nil, clients.ErrUnsupportedFeature + return c.searchCommits.searchCommits(request) } func (c *Client) Close() error { @@ -224,18 +243,28 @@ func CreateAzureDevOpsClientWithToken(ctx context.Context, token string, repo cl url := "https://" + repo.Host() + "/" + strings.Split(repo.Path(), "/")[0] connection := azuredevops.NewPatConnection(url, token) - client := connection.GetClientByUrl(url) + client := azuredevops.NewClient(connection, url) auditClient, err := audit.NewClient(ctx, connection) if err != nil { return nil, fmt.Errorf("could not create azure devops audit client with error: %w", err) } + buildClient, err := build.NewClient(ctx, connection) + if err != nil { + return nil, fmt.Errorf("could not create azure devops build client with error: %w", err) + } + gitClient, err := git.NewClient(ctx, connection) if err != nil { return nil, fmt.Errorf("could not create azure devops git client with error: %w", err) } + policyClient, err := policy.NewClient(ctx, connection) + if err != nil { + return nil, fmt.Errorf("could not create azure devops policy client with error: %w", err) + } + projectAnalysisClient, err := projectanalysis.NewClient(ctx, connection) if err != nil { return nil, fmt.Errorf("could not create azure devops project analysis client with error: %w", err) @@ -246,6 +275,8 @@ func CreateAzureDevOpsClientWithToken(ctx context.Context, token string, repo cl return nil, fmt.Errorf("could not create azure devops search client with error: %w", err) } + servicehooksClient := servicehooks.NewClient(ctx, connection) + workItemsClient, err := workitemtracking.NewClient(ctx, connection) if err != nil { return nil, fmt.Errorf("could not create azure devops work item tracking client with error: %w", err) @@ -260,6 +291,9 @@ func CreateAzureDevOpsClientWithToken(ctx context.Context, token string, repo cl branches: &branchesHandler{ gitClient: gitClient, }, + builds: &buildsHandler{ + buildClient: buildClient, + }, commits: &commitsHandler{ gitClient: gitClient, }, @@ -269,9 +303,19 @@ func CreateAzureDevOpsClientWithToken(ctx context.Context, token string, repo cl languages: &languagesHandler{ projectAnalysisClient: projectAnalysisClient, }, + policy: &policyHandler{ + gitClient: gitClient, + policyClient: policyClient, + }, search: &searchHandler{ searchClient: searchClient, }, + searchCommits: &searchCommitsHandler{ + gitClient: gitClient, + }, + servicehooks: &servicehooksHandler{ + servicehooksClient: servicehooksClient, + }, workItems: &workItemsHandler{ workItemsClient: workItemsClient, }, diff --git a/clients/azuredevopsrepo/commits.go b/clients/azuredevopsrepo/commits.go index 5ba597b613a4..df741bf1a2aa 100644 --- a/clients/azuredevopsrepo/commits.go +++ b/clients/azuredevopsrepo/commits.go @@ -18,6 +18,7 @@ import ( "context" "errors" "fmt" + "regexp" "sync" "time" @@ -26,7 +27,10 @@ import ( "github.com/ossf/scorecard/v5/clients" ) -var errMultiplePullRequests = errors.New("expected 1 pull request for commit, got multiple") +var ( + errMultiplePullRequests = errors.New("expected 1 pull request for commit, got multiple") + errRefNotFound = errors.New("ref not found") +) type commitsHandler struct { gitClient git.Client @@ -40,52 +44,58 @@ type commitsHandler struct { getCommits fnGetCommits getPullRequestQuery fnGetPullRequestQuery getFirstCommit fnGetFirstCommit + getRefs fnGetRefs + getStatuses fnGetStatuses commitDepth int } -func (handler *commitsHandler) init(ctx context.Context, repourl *Repo, commitDepth int) { - handler.ctx = ctx - handler.repourl = repourl - handler.errSetup = nil - handler.once = new(sync.Once) - handler.commitDepth = commitDepth - handler.getCommits = handler.gitClient.GetCommits - handler.getPullRequestQuery = handler.gitClient.GetPullRequestQuery - handler.getFirstCommit = handler.gitClient.GetCommits +func (c *commitsHandler) init(ctx context.Context, repourl *Repo, commitDepth int) { + c.ctx = ctx + c.repourl = repourl + c.errSetup = nil + c.once = new(sync.Once) + c.commitDepth = commitDepth + c.getCommits = c.gitClient.GetCommits + c.getPullRequestQuery = c.gitClient.GetPullRequestQuery + c.getFirstCommit = c.gitClient.GetCommits + c.getRefs = c.gitClient.GetRefs + c.getStatuses = c.gitClient.GetStatuses } type ( fnGetCommits func(ctx context.Context, args git.GetCommitsArgs) (*[]git.GitCommitRef, error) fnGetPullRequestQuery func(ctx context.Context, args git.GetPullRequestQueryArgs) (*git.GitPullRequestQuery, error) fnGetFirstCommit func(ctx context.Context, args git.GetCommitsArgs) (*[]git.GitCommitRef, error) + fnGetRefs func(ctx context.Context, args git.GetRefsArgs) (*git.GetRefsResponseValue, error) + fnGetStatuses func(ctx context.Context, args git.GetStatusesArgs) (*[]git.GitStatus, error) ) -func (handler *commitsHandler) setup() error { - handler.once.Do(func() { +func (c *commitsHandler) setup() error { + c.once.Do(func() { var itemVersion git.GitVersionDescriptor - if handler.repourl.commitSHA == "HEAD" { + if c.repourl.commitSHA == headCommit { itemVersion = git.GitVersionDescriptor{ VersionType: &git.GitVersionTypeValues.Branch, - Version: &handler.repourl.defaultBranch, + Version: &c.repourl.defaultBranch, } } else { itemVersion = git.GitVersionDescriptor{ VersionType: &git.GitVersionTypeValues.Commit, - Version: &handler.repourl.commitSHA, + Version: &c.repourl.commitSHA, } } opt := git.GetCommitsArgs{ - RepositoryId: &handler.repourl.id, - Top: &handler.commitDepth, + RepositoryId: &c.repourl.id, + Top: &c.commitDepth, SearchCriteria: &git.GitQueryCommitsCriteria{ ItemVersion: &itemVersion, }, } - commits, err := handler.getCommits(handler.ctx, opt) + commits, err := c.getCommits(c.ctx, opt) if err != nil { - handler.errSetup = fmt.Errorf("request for commits failed with %w", err) + c.errSetup = fmt.Errorf("request for commits failed with %w", err) return } @@ -95,76 +105,76 @@ func (handler *commitsHandler) setup() error { } pullRequestQuery := git.GetPullRequestQueryArgs{ - RepositoryId: &handler.repourl.id, + RepositoryId: &c.repourl.id, Queries: &git.GitPullRequestQuery{ Queries: &[]git.GitPullRequestQueryInput{ { - Type: &git.GitPullRequestQueryTypeValues.Commit, + Type: &git.GitPullRequestQueryTypeValues.LastMergeCommit, Items: &commitIds, }, }, }, } - pullRequests, err := handler.getPullRequestQuery(handler.ctx, pullRequestQuery) + pullRequests, err := c.getPullRequestQuery(c.ctx, pullRequestQuery) if err != nil { - handler.errSetup = fmt.Errorf("request for pull requests failed with %w", err) + c.errSetup = fmt.Errorf("request for pull requests failed with %w", err) return } switch { case len(*commits) == 0: - handler.firstCommitCreatedAt = time.Time{} - case len(*commits) < handler.commitDepth: - handler.firstCommitCreatedAt = (*commits)[len(*commits)-1].Committer.Date.Time + c.firstCommitCreatedAt = time.Time{} + case len(*commits) < c.commitDepth: + c.firstCommitCreatedAt = (*commits)[len(*commits)-1].Committer.Date.Time default: - firstCommit, err := handler.getFirstCommit(handler.ctx, git.GetCommitsArgs{ - RepositoryId: &handler.repourl.id, + firstCommit, err := c.getFirstCommit(c.ctx, git.GetCommitsArgs{ + RepositoryId: &c.repourl.id, SearchCriteria: &git.GitQueryCommitsCriteria{ Top: &[]int{1}[0], ShowOldestCommitsFirst: &[]bool{true}[0], ItemVersion: &git.GitVersionDescriptor{ VersionType: &git.GitVersionTypeValues.Branch, - Version: &handler.repourl.defaultBranch, + Version: &c.repourl.defaultBranch, }, }, }) if err != nil { - handler.errSetup = fmt.Errorf("request for first commit failed with %w", err) + c.errSetup = fmt.Errorf("request for first commit failed with %w", err) return } - handler.firstCommitCreatedAt = (*firstCommit)[0].Committer.Date.Time + c.firstCommitCreatedAt = (*firstCommit)[0].Committer.Date.Time } - handler.commitsRaw = commits - handler.pullRequestsRaw = pullRequests + c.commitsRaw = commits + c.pullRequestsRaw = pullRequests - handler.errSetup = nil + c.errSetup = nil }) - return handler.errSetup + return c.errSetup } -func (handler *commitsHandler) listCommits() ([]clients.Commit, error) { - err := handler.setup() +func (c *commitsHandler) listCommits() ([]clients.Commit, error) { + err := c.setup() if err != nil { return nil, fmt.Errorf("error during commitsHandler.setup: %w", err) } - commits := make([]clients.Commit, len(*handler.commitsRaw)) - for i := range *handler.commitsRaw { - commit := &(*handler.commitsRaw)[i] + commits := make([]clients.Commit, len(*c.commitsRaw)) + for i := range *c.commitsRaw { + commit := &(*c.commitsRaw)[i] commits[i] = clients.Commit{ SHA: *commit.CommitId, Message: *commit.Comment, CommittedDate: commit.Committer.Date.Time, Committer: clients.User{ - Login: *commit.Committer.Name, + Login: *commit.Committer.Email, }, } } // Associate pull requests with commits - pullRequests, err := handler.listPullRequests() + pullRequests, err := c.listPullRequests() if err != nil { return nil, fmt.Errorf("error during commitsHandler.listPullRequests: %w", err) } @@ -182,14 +192,14 @@ func (handler *commitsHandler) listCommits() ([]clients.Commit, error) { return commits, nil } -func (handler *commitsHandler) listPullRequests() (map[string]clients.PullRequest, error) { - err := handler.setup() +func (c *commitsHandler) listPullRequests() (map[string]clients.PullRequest, error) { + err := c.setup() if err != nil { return nil, fmt.Errorf("error during commitsHandler.setup: %w", err) } pullRequests := make(map[string]clients.PullRequest) - for commit, azdoPullRequests := range (*handler.pullRequestsRaw.Results)[0] { + for commit, azdoPullRequests := range (*c.pullRequestsRaw.Results)[0] { if len(azdoPullRequests) == 0 { continue } @@ -205,7 +215,7 @@ func (handler *commitsHandler) listPullRequests() (map[string]clients.PullReques Author: clients.User{ Login: *azdoPullRequest.CreatedBy.DisplayName, }, - HeadSHA: *azdoPullRequest.LastMergeSourceCommit.CommitId, + HeadSHA: *azdoPullRequest.LastMergeCommit.CommitId, MergedAt: azdoPullRequest.ClosedDate.Time, } } @@ -213,10 +223,94 @@ func (handler *commitsHandler) listPullRequests() (map[string]clients.PullReques return pullRequests, nil } -func (handler *commitsHandler) getFirstCommitCreatedAt() (time.Time, error) { - if err := handler.setup(); err != nil { +func (c *commitsHandler) getFirstCommitCreatedAt() (time.Time, error) { + if err := c.setup(); err != nil { return time.Time{}, fmt.Errorf("error during commitsHandler.setup: %w", err) } - return handler.firstCommitCreatedAt, nil + return c.firstCommitCreatedAt, nil +} + +func (c *commitsHandler) listStatuses(ref string) ([]clients.Status, error) { + matched, err := regexp.MatchString("^[0-9a-fA-F]{40}$", ref) + if err != nil { + return nil, fmt.Errorf("error matching ref: %w", err) + } + if matched { + return c.statusFromCommit(ref) + } else { + return c.statusFromHead(ref) + } +} + +func (c *commitsHandler) statusFromHead(ref string) ([]clients.Status, error) { + includeStatuses := true + filter := fmt.Sprintf("heads/%s", ref) + args := git.GetRefsArgs{ + RepositoryId: &c.repourl.id, + Filter: &filter, + IncludeStatuses: &includeStatuses, + LatestStatusesOnly: &[]bool{true}[0], + } + response, err := c.getRefs(c.ctx, args) + if err != nil { + return nil, fmt.Errorf("error getting refs: %w", err) + } + + if len(response.Value) != 1 { + return nil, errRefNotFound + } + statuses := response.Value[0].Statuses + if statuses == nil { + return []clients.Status{}, nil + } + + result := make([]clients.Status, len(*statuses)) + for i, status := range *statuses { + result[i] = clients.Status{ + State: convertAzureDevOpsStatus(&status), + Context: *status.Context.Name, + URL: *status.TargetUrl, + TargetURL: *status.TargetUrl, + } + } + + return result, nil +} + +func (c *commitsHandler) statusFromCommit(ref string) ([]clients.Status, error) { + args := git.GetStatusesArgs{ + RepositoryId: &c.repourl.id, + CommitId: &ref, + LatestOnly: &[]bool{true}[0], + } + response, err := c.getStatuses(c.ctx, args) + if err != nil { + return nil, fmt.Errorf("error getting statuses: %w", err) + } + + result := make([]clients.Status, len(*response)) + for i, status := range *response { + result[i] = clients.Status{ + Context: *status.Context.Name, + State: convertAzureDevOpsStatus(&status), + URL: *status.TargetUrl, + TargetURL: *status.TargetUrl, + } + } + + return result, nil +} + +func convertAzureDevOpsStatus(s *git.GitStatus) string { + switch *s.State { + case "succeeded": + return "success" + case "failed", "error": + return "failure" + case "notApplicable", "notSet", "pending": + return "neutral" + default: + return string(*s.State) + } } diff --git a/clients/azuredevopsrepo/commits_test.go b/clients/azuredevopsrepo/commits_test.go new file mode 100644 index 000000000000..93fc1d98ec93 --- /dev/null +++ b/clients/azuredevopsrepo/commits_test.go @@ -0,0 +1,139 @@ +// 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" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/git" + + "github.com/ossf/scorecard/v5/clients" +) + +func Test_listStatuses(t *testing.T) { + t.Parallel() + tests := []struct { + name string + ref string + getRefs fnGetRefs + getStatuses fnGetStatuses + want []clients.Status + wantErr bool + }{ + { + name: "sha - no statuses", + ref: "4b825dc642cb6eb9a060e54bf8d69288fbee4904", + getStatuses: func(ctx context.Context, args git.GetStatusesArgs) (*[]git.GitStatus, error) { + return &[]git.GitStatus{}, nil + }, + want: []clients.Status{}, + wantErr: false, + }, + { + name: "sha - single status", + ref: "4b825dc642cb6eb9a060e54bf8d69288fbee4904", + getStatuses: func(ctx context.Context, args git.GetStatusesArgs) (*[]git.GitStatus, error) { + return &[]git.GitStatus{ + { + Context: &git.GitStatusContext{ + Name: toPtr("test"), + }, + State: toPtr(git.GitStatusStateValues.Succeeded), + TargetUrl: toPtr("https://example.com"), + }, + }, nil + }, + want: []clients.Status{ + { + Context: "test", + State: "success", + TargetURL: "https://example.com", + URL: "https://example.com", + }, + }, + wantErr: false, + }, + { + name: "main - no statuses", + ref: "main", + getRefs: func(ctx context.Context, args git.GetRefsArgs) (*git.GetRefsResponseValue, error) { + return &git.GetRefsResponseValue{ + Value: []git.GitRef{ + { + Statuses: &[]git.GitStatus{}, + }, + }, + }, nil + }, + want: []clients.Status{}, + wantErr: false, + }, + { + name: "main - single status", + ref: "main", + getRefs: func(ctx context.Context, args git.GetRefsArgs) (*git.GetRefsResponseValue, error) { + return &git.GetRefsResponseValue{ + Value: []git.GitRef{ + { + Statuses: &[]git.GitStatus{ + { + Context: &git.GitStatusContext{ + Name: toPtr("test"), + }, + State: toPtr(git.GitStatusStateValues.Succeeded), + TargetUrl: toPtr("https://example.com"), + }, + }, + }, + }, + }, nil + }, + want: []clients.Status{ + { + Context: "test", + State: "success", + TargetURL: "https://example.com", + URL: "https://example.com", + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := &commitsHandler{ + ctx: context.Background(), + repourl: &Repo{ + id: "id", + }, + getRefs: tt.getRefs, + getStatuses: tt.getStatuses, + } + got, err := c.listStatuses(tt.ref) + if (err != nil) != tt.wantErr { + t.Errorf("listStatuses() error = %v, wantErr %v", err, tt.wantErr) + return + } + if diff := cmp.Diff(got, tt.want); diff != "" { + t.Errorf("listStatuses() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/clients/azuredevopsrepo/const.go b/clients/azuredevopsrepo/const.go new file mode 100644 index 000000000000..13743aba0d44 --- /dev/null +++ b/clients/azuredevopsrepo/const.go @@ -0,0 +1,19 @@ +// 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 + +const ( + headCommit = "HEAD" +) diff --git a/clients/azuredevopsrepo/contributors.go b/clients/azuredevopsrepo/contributors.go index d504787cf2ec..5068b90032ac 100644 --- a/clients/azuredevopsrepo/contributors.go +++ b/clients/azuredevopsrepo/contributors.go @@ -67,14 +67,17 @@ func (c *contributorsHandler) setup() error { for i := range *commits { commit := (*commits)[i] - email := *commit.Author.Email - if _, ok := contributors[email]; ok { - user := contributors[email] + login := commit.Author.Email + if login == nil { + login = commit.Author.Name + } + if _, ok := contributors[*login]; ok { + user := contributors[*login] user.NumContributions++ - contributors[email] = user + contributors[*login] = user } else { - contributors[email] = clients.User{ - Login: email, + contributors[*login] = clients.User{ + Login: *login, NumContributions: 1, Companies: []string{c.repourl.organization}, } diff --git a/clients/azuredevopsrepo/languages_test.go b/clients/azuredevopsrepo/languages_test.go index ec618e924c29..0ec926d1c215 100644 --- a/clients/azuredevopsrepo/languages_test.go +++ b/clients/azuredevopsrepo/languages_test.go @@ -16,10 +16,10 @@ package azuredevopsrepo import ( "context" - "reflect" "sync" "testing" + "github.com/google/go-cmp/cmp" "github.com/google/uuid" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/projectanalysis" @@ -120,7 +120,7 @@ func Test_listProgrammingLanguages(t *testing.T) { t.Errorf("listProgrammingLanguages() error = %v, wantErr %v", err, tt.wantErr) return } - if !reflect.DeepEqual(got, tt.want) { + if !cmp.Equal(got, tt.want) { t.Errorf("listProgrammingLanguages() got = %v, want %v", got, tt.want) } }) diff --git a/clients/azuredevopsrepo/policy.go b/clients/azuredevopsrepo/policy.go new file mode 100644 index 000000000000..914ca8b1df02 --- /dev/null +++ b/clients/azuredevopsrepo/policy.go @@ -0,0 +1,126 @@ +// 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" + + "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" +) + +var errPullRequestNotFound = errors.New("pull request not found") + +type policyHandler struct { + ctx context.Context + repourl *Repo + gitClient git.Client + policyClient policy.Client + getPullRequestQuery fnGetPullRequestQuery + getPolicyEvaluations fnGetPolicyEvaluations +} + +type fnGetPolicyEvaluations func( + ctx context.Context, + args policy.GetPolicyEvaluationsArgs, +) (*[]policy.PolicyEvaluationRecord, error) + +func (p *policyHandler) init(ctx context.Context, repourl *Repo) { + p.ctx = ctx + p.repourl = repourl + p.getPullRequestQuery = p.gitClient.GetPullRequestQuery + p.getPolicyEvaluations = p.policyClient.GetPolicyEvaluations +} + +func (p *policyHandler) listCheckRunsForRef(ref string) ([]clients.CheckRun, error) { + // The equivalent of a check run in Azure DevOps is a policy evaluation. + // Unfortunately, Azure DevOps does not provide a way to list policy evaluations for a specific ref. + // Instead, they are associated with a pull request. + + // Get the pull request associated with the ref. + args := git.GetPullRequestQueryArgs{ + RepositoryId: &p.repourl.id, + Queries: &git.GitPullRequestQuery{ + Queries: &[]git.GitPullRequestQueryInput{ + { + Type: &git.GitPullRequestQueryTypeValues.LastMergeCommit, + Items: &[]string{ref}, + }, + }, + }, + } + queryPullRequests, err := p.getPullRequestQuery(p.ctx, args) + if err != nil { + return nil, err + } + + if len(*queryPullRequests.Results) != 1 { + return nil, errMultiplePullRequests + } + result := (*queryPullRequests.Results)[0] + pullRequests, ok := result[ref] + if !ok { + return nil, errPullRequestNotFound + } + + if len(pullRequests) != 1 { + return nil, errMultiplePullRequests + } + + pullRequest := pullRequests[0] + + artifactID := fmt.Sprintf("vstfs:///CodeReview/CodeReviewId/%s/%d", p.repourl.projectID, *pullRequest.PullRequestId) + argsPolicy := policy.GetPolicyEvaluationsArgs{ + Project: &p.repourl.project, + ArtifactId: &artifactID, + } + policyEvaluations, err := p.getPolicyEvaluations(p.ctx, argsPolicy) + if err != nil { + return nil, err + } + + const completed = "completed" + + checkRuns := make([]clients.CheckRun, len(*policyEvaluations)) + for i, evaluation := range *policyEvaluations { + checkrun := clients.CheckRun{} + + switch *evaluation.Status { + case policy.PolicyEvaluationStatusValues.Queued: + checkrun.Status = "queued" + case policy.PolicyEvaluationStatusValues.Running: + checkrun.Status = "in_progress" + case policy.PolicyEvaluationStatusValues.Approved: + checkrun.Status = completed + checkrun.Conclusion = "success" + case policy.PolicyEvaluationStatusValues.Rejected, policy.PolicyEvaluationStatusValues.Broken: + checkrun.Status = completed + checkrun.Conclusion = "failure" + case policy.PolicyEvaluationStatusValues.NotApplicable: + checkrun.Status = completed + checkrun.Conclusion = "neutral" + default: + checkrun.Status = string(*evaluation.Status) + } + + checkRuns[i] = checkrun + } + + return checkRuns, nil +} diff --git a/clients/azuredevopsrepo/policy_test.go b/clients/azuredevopsrepo/policy_test.go new file mode 100644 index 000000000000..0ba71738f00e --- /dev/null +++ b/clients/azuredevopsrepo/policy_test.go @@ -0,0 +1,92 @@ +// 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" + "testing" + + "github.com/google/go-cmp/cmp" + "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 Test_listCheckRunsForRef(t *testing.T) { + t.Parallel() + tests := []struct { + name string + ref string + getPullRequestQuery fnGetPullRequestQuery + getPolicyEvaluations fnGetPolicyEvaluations + want []clients.CheckRun + wantErr bool + }{ + { + name: "happy path", + ref: "4b825dc642cb6eb9a060e54bf8d69288fbee4904", + getPullRequestQuery: func(ctx context.Context, args git.GetPullRequestQueryArgs) (*git.GitPullRequestQuery, error) { + return &git.GitPullRequestQuery{ + Results: &[]map[string][]git.GitPullRequest{ + { + "4b825dc642cb6eb9a060e54bf8d69288fbee4904": { + { + PullRequestId: toPtr(1), + }, + }, + }, + }, + }, nil + }, + getPolicyEvaluations: func(ctx context.Context, args policy.GetPolicyEvaluationsArgs) (*[]policy.PolicyEvaluationRecord, error) { + return &[]policy.PolicyEvaluationRecord{ + { + Status: toPtr(policy.PolicyEvaluationStatusValues.Approved), + }, + }, nil + }, + want: []clients.CheckRun{ + { + Status: "completed", + Conclusion: "success", + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + p := policyHandler{ + ctx: context.Background(), + repourl: &Repo{ + id: "1", + }, + getPullRequestQuery: tt.getPullRequestQuery, + getPolicyEvaluations: tt.getPolicyEvaluations, + } + got, err := p.listCheckRunsForRef(tt.ref) + if (err != nil) != tt.wantErr { + t.Errorf("listCheckRunsForRef() error = %v, wantErr %v", err, tt.wantErr) + return + } + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("listCheckRunsForRef() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/clients/azuredevopsrepo/repo.go b/clients/azuredevopsrepo/repo.go index ef01cfd7dc3c..f3fca4ef4537 100644 --- a/clients/azuredevopsrepo/repo.go +++ b/clients/azuredevopsrepo/repo.go @@ -28,6 +28,7 @@ type Repo struct { host string organization string project string + projectID string name string id string defaultBranch string diff --git a/clients/azuredevopsrepo/search_commits.go b/clients/azuredevopsrepo/search_commits.go new file mode 100644 index 000000000000..42f017567be1 --- /dev/null +++ b/clients/azuredevopsrepo/search_commits.go @@ -0,0 +1,149 @@ +// 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" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/git" + + "github.com/ossf/scorecard/v5/clients" +) + +var errMoreThanOnePullRequest = errors.New("more than one pull request found for a commit") + +type searchCommitsHandler struct { + ctx context.Context + repourl *Repo + gitClient git.Client + getCommits fnGetCommits + getPullRequestQuery fnGetPullRequestQuery +} + +func (s *searchCommitsHandler) init(ctx context.Context, repourl *Repo) { + s.ctx = ctx + s.repourl = repourl + s.getCommits = s.gitClient.GetCommits + s.getPullRequestQuery = s.gitClient.GetPullRequestQuery +} + +func (s *searchCommitsHandler) searchCommits(searchOptions clients.SearchCommitsOptions) ([]clients.Commit, error) { + commits := make([]clients.Commit, 0) + commitsPageSize := 1000 + skip := 0 + + var itemVersion git.GitVersionDescriptor + if s.repourl.commitSHA == headCommit { + itemVersion = git.GitVersionDescriptor{ + VersionType: &git.GitVersionTypeValues.Branch, + Version: &s.repourl.defaultBranch, + } + } else { + itemVersion = git.GitVersionDescriptor{ + VersionType: &git.GitVersionTypeValues.Commit, + Version: &s.repourl.commitSHA, + } + } + + for { + args := git.GetCommitsArgs{ + RepositoryId: &s.repourl.id, + SearchCriteria: &git.GitQueryCommitsCriteria{ + ItemVersion: &itemVersion, + Author: &searchOptions.Author, + Top: &commitsPageSize, + Skip: &skip, + }, + } + response, err := s.getCommits(s.ctx, args) + if err != nil { + return nil, fmt.Errorf("failed to get commits: %w", err) + } + + if response == nil || len(*response) == 0 { + break + } + + for i := range *response { + commit := &(*response)[i] + pullRequest, err := s.getAssociatedPullRequest(commit) + if err != nil { + return nil, fmt.Errorf("failed to get associated pull request: %w", err) + } + + commits = append(commits, clients.Commit{ + SHA: *commit.CommitId, + Message: *commit.Comment, + CommittedDate: commit.Committer.Date.Time, + Committer: clients.User{ + Login: *commit.Committer.Email, + }, + AssociatedMergeRequest: pullRequest, + }) + } + + if len(*response) < commitsPageSize { + break + } + + skip += commitsPageSize + } + + return commits, nil +} + +func (s *searchCommitsHandler) getAssociatedPullRequest(commit *git.GitCommitRef) (clients.PullRequest, error) { + query, err := s.getPullRequestQuery(s.ctx, git.GetPullRequestQueryArgs{ + RepositoryId: &s.repourl.id, + Queries: &git.GitPullRequestQuery{ + Queries: &[]git.GitPullRequestQueryInput{ + { + Items: &[]string{*commit.CommitId}, + Type: &git.GitPullRequestQueryTypeValues.Commit, + }, + }, + }, + }) + if err != nil { + return clients.PullRequest{}, err + } + + if query == nil || query.Results == nil { + return clients.PullRequest{}, nil + } + + results := *query.Results + if len(results) == 0 { + return clients.PullRequest{}, nil + } + + if len(results) > 1 { + return clients.PullRequest{}, errMoreThanOnePullRequest + } + + // TODO: Azure DevOps API returns a list of pull requests for a commit. + // Scorecard currently only supports one pull request per commit. + result := results[0] + pullRequests, ok := result[*commit.CommitId] + if !ok || len(pullRequests) == 0 { + return clients.PullRequest{}, nil + } + + return clients.PullRequest{ + Number: *pullRequests[0].PullRequestId, + }, nil +} diff --git a/clients/azuredevopsrepo/search_commits_test.go b/clients/azuredevopsrepo/search_commits_test.go new file mode 100644 index 000000000000..e1deb0c0be6f --- /dev/null +++ b/clients/azuredevopsrepo/search_commits_test.go @@ -0,0 +1,107 @@ +// 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" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "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" +) + +func Test_searchCommits(t *testing.T) { + t.Parallel() + tests := []struct { + name string + commitSearchOptions clients.SearchCommitsOptions + getCommits fnGetCommits + getPullRequestQuery fnGetPullRequestQuery + want []clients.Commit + wantErr bool + }{ + { + name: "empty response", + commitSearchOptions: clients.SearchCommitsOptions{ + Author: "test", + }, + getCommits: func(ctx context.Context, args git.GetCommitsArgs) (*[]git.GitCommitRef, error) { + return &[]git.GitCommitRef{}, nil + }, + getPullRequestQuery: func(ctx context.Context, args git.GetPullRequestQueryArgs) (*git.GitPullRequestQuery, error) { + return &git.GitPullRequestQuery{}, nil + }, + want: []clients.Commit{}, + wantErr: false, + }, + { + name: "single commit", + commitSearchOptions: clients.SearchCommitsOptions{ + Author: "test", + }, + getCommits: func(ctx context.Context, args git.GetCommitsArgs) (*[]git.GitCommitRef, error) { + return &[]git.GitCommitRef{ + { + CommitId: toPtr("4b825dc642cb6eb9a060e54bf8d69288fbee4904"), + Comment: toPtr("Initial commit"), + Committer: &git.GitUserDate{ + Email: toPtr("test@example.com"), + Date: &azuredevops.Time{ + Time: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + }, + }, + }, + }, nil + }, + getPullRequestQuery: func(ctx context.Context, args git.GetPullRequestQueryArgs) (*git.GitPullRequestQuery, error) { + return &git.GitPullRequestQuery{}, nil + }, + want: []clients.Commit{ + { + SHA: "4b825dc642cb6eb9a060e54bf8d69288fbee4904", + Message: "Initial commit", + Committer: clients.User{Login: "test@example.com"}, + CommittedDate: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + AssociatedMergeRequest: clients.PullRequest{}, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + s := searchCommitsHandler{ + ctx: context.Background(), + repourl: &Repo{}, + getCommits: tt.getCommits, + getPullRequestQuery: tt.getPullRequestQuery, + } + + got, err := s.searchCommits(tt.commitSearchOptions) + if (err != nil) != tt.wantErr { + t.Errorf("searchCommits() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !cmp.Equal(got, tt.want) { + t.Errorf("searchCommits() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/clients/azuredevopsrepo/webhooks.go b/clients/azuredevopsrepo/webhooks.go new file mode 100644 index 000000000000..f495bd7823ce --- /dev/null +++ b/clients/azuredevopsrepo/webhooks.go @@ -0,0 +1,87 @@ +// 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/servicehooks" + + "github.com/ossf/scorecard/v5/clients" +) + +var webHooksConsumerID = "webHooks" + +type servicehooksHandler struct { + ctx context.Context + once *sync.Once + repourl *Repo + servicehooksClient servicehooks.Client + listSubscriptions fnListSubscriptions + errSetup error + webhooks []clients.Webhook +} + +type fnListSubscriptions func( + ctx context.Context, + args servicehooks.ListSubscriptionsArgs, +) (*[]servicehooks.Subscription, error) + +func (s *servicehooksHandler) init(ctx context.Context, repourl *Repo) { + s.ctx = ctx + s.once = new(sync.Once) + s.repourl = repourl + s.errSetup = nil + s.webhooks = nil + s.listSubscriptions = s.servicehooksClient.ListSubscriptions +} + +func (s *servicehooksHandler) setup() error { + s.once.Do(func() { + args := servicehooks.ListSubscriptionsArgs{ + ConsumerId: &webHooksConsumerID, + } + subscriptions, err := s.listSubscriptions(s.ctx, args) + if err != nil { + s.errSetup = err + return + } + + for i := range *subscriptions { + subscription := (*subscriptions)[i] + + usesAuthSecret := false + if subscription.ConsumerInputs != nil { + _, usesAuthSecret = (*subscription.ConsumerInputs)["basicAuthPassword"] + } + + s.webhooks = append(s.webhooks, clients.Webhook{ + // Azure DevOps uses uuid.UUID for ID, but Scorecard expects int64 + // ID: *subscription.Id, + Path: (*subscription.ConsumerInputs)["url"], + UsesAuthSecret: usesAuthSecret, + }) + } + }) + return s.errSetup +} + +func (s *servicehooksHandler) listWebhooks() ([]clients.Webhook, error) { + if err := s.setup(); err != nil { + return nil, err + } + return s.webhooks, nil +} diff --git a/clients/azuredevopsrepo/webhooks_test.go b/clients/azuredevopsrepo/webhooks_test.go new file mode 100644 index 000000000000..a935015eb072 --- /dev/null +++ b/clients/azuredevopsrepo/webhooks_test.go @@ -0,0 +1,101 @@ +// 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/google/go-cmp/cmp" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/servicehooks" + + "github.com/ossf/scorecard/v5/clients" +) + +func Test_listWebhooks(t *testing.T) { + t.Parallel() + tests := []struct { + name string + listSubscriptions fnListSubscriptions + want []clients.Webhook + wantErr bool + }{ + { + name: "empty response", + listSubscriptions: func(ctx context.Context, args servicehooks.ListSubscriptionsArgs) (*[]servicehooks.Subscription, error) { + return &[]servicehooks.Subscription{}, nil + }, + want: nil, + wantErr: false, + }, + { + name: "single webhook", + listSubscriptions: func(ctx context.Context, args servicehooks.ListSubscriptionsArgs) (*[]servicehooks.Subscription, error) { + return &[]servicehooks.Subscription{{ConsumerInputs: &map[string]string{"url": "https://example.com"}}}, nil + }, + want: []clients.Webhook{{Path: "https://example.com"}}, + wantErr: false, + }, + { + name: "multiple webhooks", + listSubscriptions: func(ctx context.Context, args servicehooks.ListSubscriptionsArgs) (*[]servicehooks.Subscription, error) { + return &[]servicehooks.Subscription{ + {ConsumerInputs: &map[string]string{"url": "https://example.com"}}, + {ConsumerInputs: &map[string]string{"url": "https://example2.com"}}, + }, nil + }, + want: []clients.Webhook{{Path: "https://example.com"}, {Path: "https://example2.com"}}, + wantErr: false, + }, + { + name: "with secret", + listSubscriptions: func(ctx context.Context, args servicehooks.ListSubscriptionsArgs) (*[]servicehooks.Subscription, error) { + return &[]servicehooks.Subscription{{ConsumerInputs: &map[string]string{"url": "https://example.com", "basicAuthPassword": "hunter2"}}}, nil + }, + want: []clients.Webhook{{Path: "https://example.com", UsesAuthSecret: true}}, + wantErr: false, + }, + { + name: "error", + listSubscriptions: func(ctx context.Context, args servicehooks.ListSubscriptionsArgs) (*[]servicehooks.Subscription, error) { + return nil, fmt.Errorf("error") + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + s := servicehooksHandler{ + ctx: context.Background(), + once: new(sync.Once), + repourl: &Repo{}, + listSubscriptions: tt.listSubscriptions, + } + + got, err := s.listWebhooks() + if (err != nil) != tt.wantErr { + t.Errorf("listWebhooks() error = %v, wantErr %v", err, tt.wantErr) + } + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("listWebhooks() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/clients/azuredevopsrepo/work_items_test.go b/clients/azuredevopsrepo/work_items_test.go index 1ed1d3de4df4..609c95e561dd 100644 --- a/clients/azuredevopsrepo/work_items_test.go +++ b/clients/azuredevopsrepo/work_items_test.go @@ -17,11 +17,11 @@ package azuredevopsrepo import ( "context" "fmt" - "reflect" "sync" "testing" "time" + "github.com/google/go-cmp/cmp" "github.com/microsoft/azure-devops-go-api/azuredevops/v7" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/webapi" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking" @@ -29,7 +29,7 @@ import ( "github.com/ossf/scorecard/v5/clients" ) -func TestWorkItemsHandler_listIssues(t *testing.T) { +func Test_listIssues(t *testing.T) { t.Parallel() tests := []struct { name string @@ -173,7 +173,7 @@ func TestWorkItemsHandler_listIssues(t *testing.T) { if err != nil { t.Errorf("listIssues() unexpected error: %v", err) } - if !reflect.DeepEqual(got, tt.want) { + if !cmp.Equal(got, tt.want) { t.Errorf("listIssues() = %v, want %v", got, tt.want) } }) diff --git a/clients/azuredevopsrepo/zip.go b/clients/azuredevopsrepo/zip.go index 37bc2fa173d8..1476d4e14e66 100644 --- a/clients/azuredevopsrepo/zip.go +++ b/clients/azuredevopsrepo/zip.go @@ -110,7 +110,7 @@ func (z *zipHandler) getZipfile() error { queryParams.Add("resolveLfs", "true") queryParams.Add("$format", "zip") - if z.repourl.commitSHA == "HEAD" { + if z.repourl.commitSHA == headCommit { queryParams.Add("versionDescriptor.versionType", "branch") queryParams.Add("versionDescriptor.version", z.repourl.defaultBranch) } else { @@ -118,15 +118,22 @@ func (z *zipHandler) getZipfile() error { queryParams.Add("versionDescriptor.version", z.repourl.commitSHA) } - parsedURL, err := url.Parse(baseURL + "?" + queryParams.Encode()) + parsedURL := fmt.Sprintf("%s?%s", baseURL, queryParams.Encode()) + + req, err := z.client.CreateRequestMessage( + z.ctx, + http.MethodGet, + parsedURL, + "7.1", + nil, + "", + "application/zip", + map[string]string{}, + ) if err != nil { - return fmt.Errorf("url.Parse: %w", err) + return fmt.Errorf("client.CreateRequestMessage: %w", err) } - req := &http.Request{ - Method: http.MethodGet, - URL: parsedURL, - } res, err := z.client.SendRequest(req) if err != nil { return fmt.Errorf("client.SendRequest: %w", err) diff --git a/probes/testsRunInCI/impl.go b/probes/testsRunInCI/impl.go index bad233e1f03e..7a8138f70af0 100644 --- a/probes/testsRunInCI/impl.go +++ b/probes/testsRunInCI/impl.go @@ -166,6 +166,7 @@ func isTest(s string) bool { "appveyor", "buildkite", "circleci", "e2e", "github-actions", "jenkins", "mergeable", "packit-as-a-service", "semaphoreci", "test", "travis-ci", "flutter-dashboard", "Cirrus CI", "azure-pipelines", "ci/woodpecker", + "vstfs:///build/build", } { if strings.Contains(l, pattern) { return true