Skip to content

Commit

Permalink
✨ implement Search for Azure DevOps (#4428)
Browse files Browse the repository at this point in the history
* ✨ implement `Search` for Azure DevOps

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

* Add E2E search test for Azure DevOps

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

* PR comments

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

* PR comments

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

---------

Signed-off-by: Jamie Magee <[email protected]>
  • Loading branch information
JamieMagee authored Dec 3, 2024
1 parent bf3432d commit 86f46b1
Show file tree
Hide file tree
Showing 6 changed files with 408 additions and 1 deletion.
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,10 @@ e2e-gitlab: ## Runs e2e tests for GitLab only. TOKEN_TYPE is not used (since the
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-attestor: ## Runs e2e tests for scorecard-attestor
cd attestor/e2e; go test -covermode=atomic -coverprofile=e2e-coverage.out; cd ../..

Expand Down
35 changes: 35 additions & 0 deletions clients/azuredevopsrepo/azure_devops_suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// 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 (
"os"
"testing"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

func TestAzureDevOpsRepoE2E(t *testing.T) {
if val, exists := os.LookupEnv("SKIP_GINKGO"); exists && val == "1" {
t.Skip()
}
if val, exists := os.LookupEnv("TEST_AZURE_DEVOPS_EXTERNAL"); !exists || val != "1" {
t.Skip()
}
t.Parallel()
RegisterFailHandler(Fail)
RunSpecs(t, "Azure DevOps Repo Suite")
}
14 changes: 13 additions & 1 deletion clients/azuredevopsrepo/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ 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/git"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/search"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking"

"github.com/ossf/scorecard/v5/clients"
Expand All @@ -45,6 +46,7 @@ type Client struct {
audit *auditHandler
branches *branchesHandler
commits *commitsHandler
search *searchHandler
workItems *workItemsHandler
zip *zipHandler
commitDepth int
Expand Down Expand Up @@ -91,6 +93,8 @@ func (c *Client) InitRepo(inputRepo clients.Repo, commitSHA string, commitDepth

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

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

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

c.zip.init(c.ctx, c.repourl)
Expand Down Expand Up @@ -192,7 +196,7 @@ func (c *Client) ListProgrammingLanguages() ([]clients.Language, error) {
}

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

func (c *Client) SearchCommits(request clients.SearchCommitsOptions) ([]clients.Commit, error) {
Expand Down Expand Up @@ -225,6 +229,11 @@ func CreateAzureDevOpsClientWithToken(ctx context.Context, token string, repo cl
return nil, fmt.Errorf("could not create azure devops git client with error: %w", err)
}

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

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)
Expand All @@ -242,6 +251,9 @@ func CreateAzureDevOpsClientWithToken(ctx context.Context, token string, repo cl
commits: &commitsHandler{
gitClient: gitClient,
},
search: &searchHandler{
searchClient: searchClient,
},
workItems: &workItemsHandler{
workItemsClient: workItemsClient,
},
Expand Down
105 changes: 105 additions & 0 deletions clients/azuredevopsrepo/search.go
Original file line number Diff line number Diff line change
@@ -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"
"errors"
"fmt"
"strings"
"sync"

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

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

var errEmptyQuery = errors.New("search query is empty")

type searchHandler struct {
searchClient search.Client
once *sync.Once
ctx context.Context
repourl *Repo
searchCode fnSearchCode
}

func (s *searchHandler) init(ctx context.Context, repourl *Repo) {
s.ctx = ctx
s.once = new(sync.Once)
s.repourl = repourl
s.searchCode = s.searchClient.FetchCodeSearchResults
}

type (
fnSearchCode func(ctx context.Context, args search.FetchCodeSearchResultsArgs) (*search.CodeSearchResponse, error)
)

func (s *searchHandler) search(request clients.SearchRequest) (clients.SearchResponse, error) {
filters, query, err := s.buildFilters(request)
if err != nil {
return clients.SearchResponse{}, fmt.Errorf("handler.buildQuery: %w", err)
}

searchResultsPageSize := 1000
args := search.FetchCodeSearchResultsArgs{
Request: &search.CodeSearchRequest{
Filters: &filters,
SearchText: &query,
Top: &searchResultsPageSize,
},
}
searchResults, err := s.searchCode(s.ctx, args)
if err != nil {
return clients.SearchResponse{}, fmt.Errorf("FetchCodeSearchResults: %w", err)
}

return searchResponseFrom(searchResults), nil
}

func (s *searchHandler) buildFilters(request clients.SearchRequest) (map[string][]string, string, error) {
filters := make(map[string][]string)
query := strings.Builder{}
if request.Query == "" {
return filters, query.String(), errEmptyQuery
}
query.WriteString(request.Query)
query.WriteString(" ")

filters["Project"] = []string{s.repourl.project}
filters["Repository"] = []string{s.repourl.name}

if request.Path != "" {
filters["Path"] = []string{request.Path}
}
if request.Filename != "" {
query.WriteString(fmt.Sprintf("file:%s", request.Filename))
}

return filters, query.String(), nil
}

func searchResponseFrom(searchResults *search.CodeSearchResponse) clients.SearchResponse {
var results []clients.SearchResult
for _, result := range *searchResults.Results {
results = append(results, clients.SearchResult{
Path: *result.Path,
})
}
return clients.SearchResponse{
Results: results,
Hits: *searchResults.Count,
}
}
48 changes: 48 additions & 0 deletions clients/azuredevopsrepo/search_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// 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.searchHandler", func() {
Context("Search - Azure DevOps", func() {
It("Should return search results", func() {
repo, err := MakeAzureDevOpsRepo("https://dev.azure.com/jamiemagee/jamiemagee/_git/scorecard")
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())

request := clients.SearchRequest{
Query: "scorecard",
Filename: "README.md",
}
results, err := repoClient.Search(request)
Expect(err).Should(BeNil())
Expect(results.Hits).Should(BeNumerically(">", 0))
Expect(len(results.Results)).Should(BeNumerically(">", 0))
})
})
})
Loading

0 comments on commit 86f46b1

Please sign in to comment.