From b0dfb70c7e5d4a8beafcc304d56cd2251d9a37b6 Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Tue, 10 Dec 2024 15:53:07 -0800 Subject: [PATCH] :sparkles: implement `ListContributors` for Azure DevOps (#4437) * :sparkles: implement `ListContributors` for Azure DevOps Signed-off-by: Jamie Magee * Fix tests Signed-off-by: Jamie Magee --------- Signed-off-by: Jamie Magee --- clients/azuredevopsrepo/client.go | 32 +++-- clients/azuredevopsrepo/contributors.go | 100 ++++++++++++++ clients/azuredevopsrepo/contributors_test.go | 130 +++++++++++++++++++ go.mod | 2 +- 4 files changed, 250 insertions(+), 14 deletions(-) create mode 100644 clients/azuredevopsrepo/contributors.go create mode 100644 clients/azuredevopsrepo/contributors_test.go diff --git a/clients/azuredevopsrepo/client.go b/clients/azuredevopsrepo/client.go index 5c57f782452..76d42f182f9 100644 --- a/clients/azuredevopsrepo/client.go +++ b/clients/azuredevopsrepo/client.go @@ -40,18 +40,19 @@ var ( ) type Client struct { - azdoClient git.Client - ctx context.Context - repourl *Repo - repo *git.GitRepository - audit *auditHandler - branches *branchesHandler - commits *commitsHandler - 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 + commits *commitsHandler + contributors *contributorsHandler + languages *languagesHandler + search *searchHandler + workItems *workItemsHandler + zip *zipHandler + commitDepth int } func (c *Client) InitRepo(inputRepo clients.Repo, commitSHA string, commitDepth int) error { @@ -95,6 +96,8 @@ func (c *Client) InitRepo(inputRepo clients.Repo, commitSHA string, commitDepth c.commits.init(c.ctx, c.repourl, c.commitDepth) + c.contributors.init(c.ctx, c.repourl) + c.languages.init(c.ctx, c.repourl) c.search.init(c.ctx, c.repourl) @@ -176,7 +179,7 @@ func (c *Client) ListReleases() ([]clients.Release, error) { } func (c *Client) ListContributors() ([]clients.User, error) { - return nil, clients.ErrUnsupportedFeature + return c.contributors.listContributors() } func (c *Client) ListSuccessfulWorkflowRuns(filename string) ([]clients.WorkflowRun, error) { @@ -260,6 +263,9 @@ func CreateAzureDevOpsClientWithToken(ctx context.Context, token string, repo cl commits: &commitsHandler{ gitClient: gitClient, }, + contributors: &contributorsHandler{ + gitClient: gitClient, + }, languages: &languagesHandler{ projectAnalysisClient: projectAnalysisClient, }, diff --git a/clients/azuredevopsrepo/contributors.go b/clients/azuredevopsrepo/contributors.go new file mode 100644 index 00000000000..d504787cf2e --- /dev/null +++ b/clients/azuredevopsrepo/contributors.go @@ -0,0 +1,100 @@ +// 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/git" + + "github.com/ossf/scorecard/v5/clients" +) + +type contributorsHandler struct { + ctx context.Context + once *sync.Once + repourl *Repo + gitClient git.Client + errSetup error + getCommits fnGetCommits + contributors []clients.User +} + +func (c *contributorsHandler) init(ctx context.Context, repourl *Repo) { + c.ctx = ctx + c.once = new(sync.Once) + c.repourl = repourl + c.errSetup = nil + c.getCommits = c.gitClient.GetCommits + c.contributors = nil +} + +func (c *contributorsHandler) setup() error { + c.once.Do(func() { + contributors := make(map[string]clients.User) + commitsPageSize := 1000 + skip := 0 + for { + args := git.GetCommitsArgs{ + RepositoryId: &c.repourl.id, + SearchCriteria: &git.GitQueryCommitsCriteria{ + Top: &commitsPageSize, + Skip: &skip, + }, + } + commits, err := c.getCommits(c.ctx, args) + if err != nil { + c.errSetup = err + return + } + + if commits == nil || len(*commits) == 0 { + break + } + + for i := range *commits { + commit := (*commits)[i] + email := *commit.Author.Email + if _, ok := contributors[email]; ok { + user := contributors[email] + user.NumContributions++ + contributors[email] = user + } else { + contributors[email] = clients.User{ + Login: email, + NumContributions: 1, + Companies: []string{c.repourl.organization}, + } + } + } + + skip += commitsPageSize + } + + for _, contributor := range contributors { + c.contributors = append(c.contributors, contributor) + } + }) + return c.errSetup +} + +func (c *contributorsHandler) listContributors() ([]clients.User, error) { + if err := c.setup(); err != nil { + return nil, err + } + + return c.contributors, nil +} diff --git a/clients/azuredevopsrepo/contributors_test.go b/clients/azuredevopsrepo/contributors_test.go new file mode 100644 index 00000000000..1f71c504648 --- /dev/null +++ b/clients/azuredevopsrepo/contributors_test.go @@ -0,0 +1,130 @@ +// 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" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/git" + + "github.com/ossf/scorecard/v5/clients" +) + +func Test_listContributors(t *testing.T) { + t.Parallel() + tests := []struct { + name string + getCommits fnGetCommits + wantContribs []clients.User + wantErr bool + }{ + { + name: "no commits", + getCommits: func(ctx context.Context, args git.GetCommitsArgs) (*[]git.GitCommitRef, error) { + return &[]git.GitCommitRef{}, nil + }, + wantContribs: nil, + wantErr: false, + }, + { + name: "single contributor", + getCommits: func(ctx context.Context, args git.GetCommitsArgs) (*[]git.GitCommitRef, error) { + if *args.SearchCriteria.Skip == 0 { + return &[]git.GitCommitRef{ + { + Author: &git.GitUserDate{ + Email: toPtr("test@example.com"), + }, + }, + }, nil + } else { + return &[]git.GitCommitRef{}, nil + } + }, + wantContribs: []clients.User{ + { + Login: "test@example.com", + Companies: []string{"testOrg"}, + NumContributions: 1, + }, + }, + wantErr: false, + }, + { + name: "multiple contributors", + getCommits: func(ctx context.Context, args git.GetCommitsArgs) (*[]git.GitCommitRef, error) { + if *args.SearchCriteria.Skip == 0 { + return &[]git.GitCommitRef{ + { + Author: &git.GitUserDate{ + Email: toPtr("test@example.com"), + }, + }, + { + Author: &git.GitUserDate{ + Email: toPtr("test2@example.com"), + }, + }, + { + Author: &git.GitUserDate{ + Email: toPtr("test2@example.com"), + }, + }, + }, nil + } else { + return &[]git.GitCommitRef{}, nil + } + }, + wantContribs: []clients.User{ + { + Login: "test@example.com", + Companies: []string{"testOrg"}, + NumContributions: 1, + }, + { + Login: "test2@example.com", + Companies: []string{"testOrg"}, + NumContributions: 2, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := contributorsHandler{ + ctx: context.Background(), + once: new(sync.Once), + repourl: &Repo{ + organization: "testOrg", + }, + getCommits: tt.getCommits, + } + err := c.setup() + if (err != nil) != tt.wantErr { + t.Errorf("contributorsHandler.setup() error = %v, wantErr %v", err, tt.wantErr) + return + } + if diff := cmp.Diff(tt.wantContribs, c.contributors, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" { + t.Errorf("contributorsHandler.setup() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/go.mod b/go.mod index 913e7b960d7..297f6aac93a 100644 --- a/go.mod +++ b/go.mod @@ -157,7 +157,7 @@ require ( github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/google/uuid v1.6.0 github.com/google/wire v0.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect github.com/googleapis/gax-go/v2 v2.13.0 // indirect