Skip to content

Commit

Permalink
✨ implement Search for Azure DevOps
Browse files Browse the repository at this point in the history
Signed-off-by: Jamie Magee <[email protected]>
  • Loading branch information
JamieMagee committed Dec 3, 2024
1 parent cdfb58b commit ad661a9
Show file tree
Hide file tree
Showing 3 changed files with 309 additions and 1 deletion.
13 changes: 12 additions & 1 deletion clients/azuredevopsrepo/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (

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

"github.com/ossf/scorecard/v5/clients"
)
Expand All @@ -42,6 +43,7 @@ type Client struct {
repo *git.GitRepository
branches *branchesHandler
commits *commitsHandler
search *searchHandler
zip *zipHandler
commitDepth int
}
Expand Down Expand Up @@ -85,6 +87,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.zip.init(c.ctx, c.repourl)

return nil
Expand Down Expand Up @@ -175,7 +179,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 @@ -203,6 +207,10 @@ 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)
}
return &Client{
ctx: ctx,
azdoClient: gitClient,
Expand All @@ -212,6 +220,9 @@ func CreateAzureDevOpsClientWithToken(ctx context.Context, token string, repo cl
commits: &commitsHandler{
gitClient: gitClient,
},
search: &searchHandler{
searchClient: searchClient,
},
zip: &zipHandler{
client: client,
},
Expand Down
101 changes: 101 additions & 0 deletions clients/azuredevopsrepo/search.go
Original file line number Diff line number Diff line change
@@ -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"
"errors"
"fmt"
"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, 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: &request.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, error) {
filters := make(map[string][]string)
if request.Query == "" {
return filters, fmt.Errorf("%w", errEmptyQuery)
}

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

if request.Path != "" {
filters["Path"] = []string{request.Path}
}
if request.Filename != "" {
filters["Filename"] = []string{request.Filename}
}

return filters, 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,
}
}
196 changes: 196 additions & 0 deletions clients/azuredevopsrepo/search_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
// 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/search"

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

func Test_buildFilters(t *testing.T) {
t.Parallel()
tests := []struct {
expectedFilters map[string][]string
repourl *Repo
request clients.SearchRequest
expectedErr error
name string
}{
{
name: "empty query",
request: clients.SearchRequest{
Query: "",
},
repourl: &Repo{
project: "project",
name: "repo",
},
expectedFilters: make(map[string][]string),
expectedErr: errEmptyQuery,
},
{
name: "query only",
request: clients.SearchRequest{
Query: "query",
},
repourl: &Repo{
project: "project",
name: "repo",
},
expectedFilters: map[string][]string{
"Project": {"project"},
"Repository": {"repo"},
},
expectedErr: nil,
},
{
name: "query and path",
request: clients.SearchRequest{
Query: "query",
Path: "path",
},
repourl: &Repo{
project: "project",
name: "repo",
},
expectedFilters: map[string][]string{
"Project": {"project"},
"Repository": {"repo"},
"Path": {"path"},
},
expectedErr: nil,
},
{
name: "query and filename",
request: clients.SearchRequest{
Query: "query",
Filename: "filename",
},
repourl: &Repo{
project: "project",
name: "repo",
},
expectedFilters: map[string][]string{
"Project": {"project"},
"Repository": {"repo"},
"Filename": {"filename"},
},
expectedErr: nil,
},
{
name: "query, path, and filename",
request: clients.SearchRequest{
Query: "query",
Path: "path",
Filename: "filename",
},
repourl: &Repo{
project: "project",
name: "repo",
},
expectedFilters: map[string][]string{
"Project": {"project"},
"Repository": {"repo"},
"Path": {"path"},
"Filename": {"filename"},
},
expectedErr: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
s := searchHandler{
repourl: tt.repourl,
}
filters, err := s.buildFilters(tt.request)
if tt.expectedErr != nil && !errors.Is(err, tt.expectedErr) {
t.Fatalf("buildFilters() error = %v, ExpectedErr %v", err, tt.expectedErr)
}
if diff := cmp.Diff(filters, tt.expectedFilters); diff != "" {
t.Errorf("buildFilters() mismatch (-want +got):\n%s", diff)
}
})
}
}

func Test_search(t *testing.T) {
t.Parallel()
tests := []struct {
name string
request clients.SearchRequest
searchCode func(ctx context.Context, args search.FetchCodeSearchResultsArgs) (*search.CodeSearchResponse, error)
wantResults []clients.SearchResult
wantHits int
wantErr bool
}{
{
name: "empty query",
request: clients.SearchRequest{
Query: "",
},
searchCode: func(ctx context.Context, args search.FetchCodeSearchResultsArgs) (*search.CodeSearchResponse, error) {
return &search.CodeSearchResponse{}, nil
},
wantErr: true,
},
{
name: "valid query",
request: clients.SearchRequest{
Query: "query",
},
searchCode: func(ctx context.Context, args search.FetchCodeSearchResultsArgs) (*search.CodeSearchResponse, error) {
return &search.CodeSearchResponse{
Count: toPtr(1),

Check failure on line 163 in clients/azuredevopsrepo/search_test.go

View workflow job for this annotation

GitHub Actions / check-linter

undefined: toPtr

Check failure on line 163 in clients/azuredevopsrepo/search_test.go

View workflow job for this annotation

GitHub Actions / unit-test

undefined: toPtr
Results: &[]search.CodeResult{{Path: strptr("path")}},

Check failure on line 164 in clients/azuredevopsrepo/search_test.go

View workflow job for this annotation

GitHub Actions / check-linter

undefined: strptr (typecheck)

Check failure on line 164 in clients/azuredevopsrepo/search_test.go

View workflow job for this annotation

GitHub Actions / unit-test

undefined: strptr
}, nil
},
wantResults: []clients.SearchResult{{Path: "path"}},
wantHits: 1,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
s := searchHandler{
searchCode: tt.searchCode,
repourl: &Repo{
project: "project",
name: "repo",
},
}

got, err := s.search(tt.request)
if (err != nil) != tt.wantErr {
t.Errorf("search() error = %v, wantErr %v", err, tt.wantErr)
return
}
if diff := cmp.Diff(got.Results, tt.wantResults); diff != "" {
t.Errorf("search() mismatch (-want +got):\n%s", diff)
}
if got.Hits != tt.wantHits {
t.Errorf("search() gotHits = %v, want %v", got.Hits, tt.wantHits)
}
})
}
}

0 comments on commit ad661a9

Please sign in to comment.