Skip to content

Commit

Permalink
✨ implement ListIssues and GetCreatedAt for Azure DevOps (#4419)
Browse files Browse the repository at this point in the history
* ✨ implement `ListIssues` and `GetCreatedAt` for Azure DevOps

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

* PR comments

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

* Reset issues list on initialization

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

---------

Signed-off-by: Jamie Magee <[email protected]>
  • Loading branch information
JamieMagee authored Dec 3, 2024
1 parent cdfb58b commit 57850ee
Show file tree
Hide file tree
Showing 6 changed files with 625 additions and 12 deletions.
87 changes: 87 additions & 0 deletions clients/azuredevopsrepo/audit.go
Original file line number Diff line number Diff line change
@@ -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"
"fmt"
"sync"
"time"

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

type auditHandler struct {
auditClient audit.Client
once *sync.Once
ctx context.Context
errSetup error
repourl *Repo
createdAt time.Time
queryLog fnQueryLog
}

func (a *auditHandler) init(ctx context.Context, repourl *Repo) {
a.ctx = ctx
a.errSetup = nil
a.once = new(sync.Once)
a.repourl = repourl
a.queryLog = a.auditClient.QueryLog
}

type (
fnQueryLog func(ctx context.Context, args audit.QueryLogArgs) (*audit.AuditLogQueryResult, error)
)

func (a *auditHandler) setup() error {
a.once.Do(func() {
continuationToken := ""
for {
auditLog, err := a.queryLog(a.ctx, audit.QueryLogArgs{
ContinuationToken: &continuationToken,
})
if err != nil {
a.errSetup = fmt.Errorf("error querying audit log: %w", err)
return
}

// Check if Git.CreateRepo event exists for the repository
for i := range *auditLog.DecoratedAuditLogEntries {
entry := &(*auditLog.DecoratedAuditLogEntries)[i]
if *entry.ActionId == "Git.CreateRepo" &&
*entry.ProjectName == a.repourl.project &&
(*entry.Data)["RepoName"] == a.repourl.name {
a.createdAt = entry.Timestamp.Time
return
}
}

if *auditLog.HasMore {
continuationToken = *auditLog.ContinuationToken
} else {
return
}
}
})
return a.errSetup
}

func (a *auditHandler) getRepsitoryCreatedAt() (time.Time, error) {
if err := a.setup(); err != nil {
return time.Time{}, fmt.Errorf("error during auditHandler.setup: %w", err)
}

return a.createdAt, nil
}
89 changes: 89 additions & 0 deletions clients/azuredevopsrepo/audit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// 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"
"sync"
"testing"
"time"

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

func Test_auditHandler_setup(t *testing.T) {
t.Parallel()
tests := []struct {
queryLog fnQueryLog
createdAt time.Time
name string
wantErr bool
}{
{
name: "successful setup",
queryLog: func(ctx context.Context, args audit.QueryLogArgs) (*audit.AuditLogQueryResult, error) {
return &audit.AuditLogQueryResult{
HasMore: new(bool),
ContinuationToken: new(string),
DecoratedAuditLogEntries: &[]audit.DecoratedAuditLogEntry{
{
ActionId: strptr("Git.CreateRepo"),
ProjectName: strptr("test-project"),
Data: &map[string]interface{}{"RepoName": "test-repo"},
Timestamp: &azuredevops.Time{Time: time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC)},
},
},
}, nil
},
wantErr: false,
createdAt: time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC),
},
{
name: "query log error",
queryLog: func(ctx context.Context, args audit.QueryLogArgs) (*audit.AuditLogQueryResult, error) {
return nil, errors.New("query log error")
},
wantErr: true,
createdAt: time.Time{},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
handler := &auditHandler{
once: new(sync.Once),
queryLog: tt.queryLog,
repourl: &Repo{
project: "test-project",
name: "test-repo",
},
}
err := handler.setup()
if (err != nil) != tt.wantErr {
t.Fatalf("setup() error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr && !handler.createdAt.Equal(tt.createdAt) {
t.Errorf("setup() createdAt = %v, want %v", handler.createdAt, tt.createdAt)
}
})
}
}

func strptr(s string) *string {
return &s
}
37 changes: 35 additions & 2 deletions clients/azuredevopsrepo/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ import (
"time"

"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/workitemtracking"

"github.com/ossf/scorecard/v5/clients"
)
Expand All @@ -40,8 +42,10 @@ type Client struct {
ctx context.Context
repourl *Repo
repo *git.GitRepository
audit *auditHandler
branches *branchesHandler
commits *commitsHandler
workItems *workItemsHandler
zip *zipHandler
commitDepth int
}
Expand Down Expand Up @@ -81,10 +85,14 @@ func (c *Client) InitRepo(inputRepo clients.Repo, commitSHA string, commitDepth
commitSHA: commitSHA,
}

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

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

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

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

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

return nil
Expand Down Expand Up @@ -115,7 +123,16 @@ func (c *Client) GetBranch(branch string) (*clients.BranchRef, error) {
}

func (c *Client) GetCreatedAt() (time.Time, error) {
return time.Time{}, clients.ErrUnsupportedFeature
createdAt, err := c.audit.getRepsitoryCreatedAt()
if err != nil {
return time.Time{}, err
}

// The audit log may not be enabled on the repository
if createdAt.IsZero() {
return c.commits.getFirstCommitCreatedAt()
}
return createdAt, nil
}

func (c *Client) GetDefaultBranchName() (string, error) {
Expand All @@ -139,7 +156,7 @@ func (c *Client) ListCommits() ([]clients.Commit, error) {
}

func (c *Client) ListIssues() ([]clients.Issue, error) {
return nil, clients.ErrUnsupportedFeature
return c.workItems.listIssues()
}

func (c *Client) ListLicenses() ([]clients.License, error) {
Expand Down Expand Up @@ -198,20 +215,36 @@ func CreateAzureDevOpsClientWithToken(ctx context.Context, token string, repo cl

client := connection.GetClientByUrl(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)
}

gitClient, err := git.NewClient(ctx, connection)
if err != nil {
return nil, fmt.Errorf("could not create azure devops git 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)
}

return &Client{
ctx: ctx,
azdoClient: gitClient,
audit: &auditHandler{
auditClient: auditClient,
},
branches: &branchesHandler{
gitClient: gitClient,
},
commits: &commitsHandler{
gitClient: gitClient,
},
workItems: &workItemsHandler{
workItemsClient: workItemsClient,
},
zip: &zipHandler{
client: client,
},
Expand Down
58 changes: 48 additions & 10 deletions clients/azuredevopsrepo/commits.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"errors"
"fmt"
"sync"
"time"

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

Expand All @@ -28,16 +29,18 @@ import (
var errMultiplePullRequests = errors.New("expected 1 pull request for commit, got multiple")

type commitsHandler struct {
gitClient git.Client
ctx context.Context
errSetup error
once *sync.Once
repourl *Repo
commitsRaw *[]git.GitCommitRef
pullRequestsRaw *git.GitPullRequestQuery
getCommits fnGetCommits
getPullRequestQuery fnGetPullRequestQuery
commitDepth int
gitClient git.Client
ctx context.Context
errSetup error
once *sync.Once
repourl *Repo
commitsRaw *[]git.GitCommitRef
pullRequestsRaw *git.GitPullRequestQuery
firstCommitCreatedAt time.Time
getCommits fnGetCommits
getPullRequestQuery fnGetPullRequestQuery
getFirstCommit fnGetFirstCommit
commitDepth int
}

func (handler *commitsHandler) init(ctx context.Context, repourl *Repo, commitDepth int) {
Expand All @@ -48,11 +51,13 @@ func (handler *commitsHandler) init(ctx context.Context, repourl *Repo, commitDe
handler.commitDepth = commitDepth
handler.getCommits = handler.gitClient.GetCommits
handler.getPullRequestQuery = handler.gitClient.GetPullRequestQuery
handler.getFirstCommit = handler.gitClient.GetCommits
}

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)
)

func (handler *commitsHandler) setup() error {
Expand Down Expand Up @@ -106,6 +111,31 @@ func (handler *commitsHandler) setup() error {
return
}

switch {
case len(*commits) == 0:
handler.firstCommitCreatedAt = time.Time{}
case len(*commits) < handler.commitDepth:
handler.firstCommitCreatedAt = (*commits)[len(*commits)-1].Committer.Date.Time
default:
firstCommit, err := handler.getFirstCommit(handler.ctx, git.GetCommitsArgs{
RepositoryId: &handler.repourl.id,
SearchCriteria: &git.GitQueryCommitsCriteria{
Top: &[]int{1}[0],
ShowOldestCommitsFirst: &[]bool{true}[0],
ItemVersion: &git.GitVersionDescriptor{
VersionType: &git.GitVersionTypeValues.Branch,
Version: &handler.repourl.defaultBranch,
},
},
})
if err != nil {
handler.errSetup = fmt.Errorf("request for first commit failed with %w", err)
return
}

handler.firstCommitCreatedAt = (*firstCommit)[0].Committer.Date.Time
}

handler.commitsRaw = commits
handler.pullRequestsRaw = pullRequests

Expand Down Expand Up @@ -182,3 +212,11 @@ func (handler *commitsHandler) listPullRequests() (map[string]clients.PullReques

return pullRequests, nil
}

func (handler *commitsHandler) getFirstCommitCreatedAt() (time.Time, error) {
if err := handler.setup(); err != nil {
return time.Time{}, fmt.Errorf("error during commitsHandler.setup: %w", err)
}

return handler.firstCommitCreatedAt, nil
}
Loading

0 comments on commit 57850ee

Please sign in to comment.