Skip to content

Commit 57850ee

Browse files
authored
✨ implement ListIssues and GetCreatedAt for Azure DevOps (#4419)
* ✨ implement `ListIssues` and `GetCreatedAt` for Azure DevOps Signed-off-by: Jamie Magee <jamie.magee@gmail.com> * PR comments Signed-off-by: Jamie Magee <jamie.magee@gmail.com> * Reset issues list on initialization Signed-off-by: Jamie Magee <jamie.magee@gmail.com> --------- Signed-off-by: Jamie Magee <jamie.magee@gmail.com>
1 parent cdfb58b commit 57850ee

File tree

6 files changed

+625
-12
lines changed

6 files changed

+625
-12
lines changed

clients/azuredevopsrepo/audit.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Copyright 2024 OpenSSF Scorecard Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package azuredevopsrepo
16+
17+
import (
18+
"context"
19+
"fmt"
20+
"sync"
21+
"time"
22+
23+
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/audit"
24+
)
25+
26+
type auditHandler struct {
27+
auditClient audit.Client
28+
once *sync.Once
29+
ctx context.Context
30+
errSetup error
31+
repourl *Repo
32+
createdAt time.Time
33+
queryLog fnQueryLog
34+
}
35+
36+
func (a *auditHandler) init(ctx context.Context, repourl *Repo) {
37+
a.ctx = ctx
38+
a.errSetup = nil
39+
a.once = new(sync.Once)
40+
a.repourl = repourl
41+
a.queryLog = a.auditClient.QueryLog
42+
}
43+
44+
type (
45+
fnQueryLog func(ctx context.Context, args audit.QueryLogArgs) (*audit.AuditLogQueryResult, error)
46+
)
47+
48+
func (a *auditHandler) setup() error {
49+
a.once.Do(func() {
50+
continuationToken := ""
51+
for {
52+
auditLog, err := a.queryLog(a.ctx, audit.QueryLogArgs{
53+
ContinuationToken: &continuationToken,
54+
})
55+
if err != nil {
56+
a.errSetup = fmt.Errorf("error querying audit log: %w", err)
57+
return
58+
}
59+
60+
// Check if Git.CreateRepo event exists for the repository
61+
for i := range *auditLog.DecoratedAuditLogEntries {
62+
entry := &(*auditLog.DecoratedAuditLogEntries)[i]
63+
if *entry.ActionId == "Git.CreateRepo" &&
64+
*entry.ProjectName == a.repourl.project &&
65+
(*entry.Data)["RepoName"] == a.repourl.name {
66+
a.createdAt = entry.Timestamp.Time
67+
return
68+
}
69+
}
70+
71+
if *auditLog.HasMore {
72+
continuationToken = *auditLog.ContinuationToken
73+
} else {
74+
return
75+
}
76+
}
77+
})
78+
return a.errSetup
79+
}
80+
81+
func (a *auditHandler) getRepsitoryCreatedAt() (time.Time, error) {
82+
if err := a.setup(); err != nil {
83+
return time.Time{}, fmt.Errorf("error during auditHandler.setup: %w", err)
84+
}
85+
86+
return a.createdAt, nil
87+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright 2024 OpenSSF Scorecard Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package azuredevopsrepo
16+
17+
import (
18+
"context"
19+
"errors"
20+
"sync"
21+
"testing"
22+
"time"
23+
24+
"github.com/microsoft/azure-devops-go-api/azuredevops/v7"
25+
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/audit"
26+
)
27+
28+
func Test_auditHandler_setup(t *testing.T) {
29+
t.Parallel()
30+
tests := []struct {
31+
queryLog fnQueryLog
32+
createdAt time.Time
33+
name string
34+
wantErr bool
35+
}{
36+
{
37+
name: "successful setup",
38+
queryLog: func(ctx context.Context, args audit.QueryLogArgs) (*audit.AuditLogQueryResult, error) {
39+
return &audit.AuditLogQueryResult{
40+
HasMore: new(bool),
41+
ContinuationToken: new(string),
42+
DecoratedAuditLogEntries: &[]audit.DecoratedAuditLogEntry{
43+
{
44+
ActionId: strptr("Git.CreateRepo"),
45+
ProjectName: strptr("test-project"),
46+
Data: &map[string]interface{}{"RepoName": "test-repo"},
47+
Timestamp: &azuredevops.Time{Time: time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC)},
48+
},
49+
},
50+
}, nil
51+
},
52+
wantErr: false,
53+
createdAt: time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC),
54+
},
55+
{
56+
name: "query log error",
57+
queryLog: func(ctx context.Context, args audit.QueryLogArgs) (*audit.AuditLogQueryResult, error) {
58+
return nil, errors.New("query log error")
59+
},
60+
wantErr: true,
61+
createdAt: time.Time{},
62+
},
63+
}
64+
65+
for _, tt := range tests {
66+
t.Run(tt.name, func(t *testing.T) {
67+
t.Parallel()
68+
handler := &auditHandler{
69+
once: new(sync.Once),
70+
queryLog: tt.queryLog,
71+
repourl: &Repo{
72+
project: "test-project",
73+
name: "test-repo",
74+
},
75+
}
76+
err := handler.setup()
77+
if (err != nil) != tt.wantErr {
78+
t.Fatalf("setup() error = %v, wantErr %v", err, tt.wantErr)
79+
}
80+
if !tt.wantErr && !handler.createdAt.Equal(tt.createdAt) {
81+
t.Errorf("setup() createdAt = %v, want %v", handler.createdAt, tt.createdAt)
82+
}
83+
})
84+
}
85+
}
86+
87+
func strptr(s string) *string {
88+
return &s
89+
}

clients/azuredevopsrepo/client.go

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ import (
2424
"time"
2525

2626
"github.com/microsoft/azure-devops-go-api/azuredevops/v7"
27+
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/audit"
2728
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/git"
29+
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking"
2830

2931
"github.com/ossf/scorecard/v5/clients"
3032
)
@@ -40,8 +42,10 @@ type Client struct {
4042
ctx context.Context
4143
repourl *Repo
4244
repo *git.GitRepository
45+
audit *auditHandler
4346
branches *branchesHandler
4447
commits *commitsHandler
48+
workItems *workItemsHandler
4549
zip *zipHandler
4650
commitDepth int
4751
}
@@ -81,10 +85,14 @@ func (c *Client) InitRepo(inputRepo clients.Repo, commitSHA string, commitDepth
8185
commitSHA: commitSHA,
8286
}
8387

88+
c.audit.init(c.ctx, c.repourl)
89+
8490
c.branches.init(c.ctx, c.repourl)
8591

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

94+
c.workItems.init(c.ctx, c.repourl)
95+
8896
c.zip.init(c.ctx, c.repourl)
8997

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

117125
func (c *Client) GetCreatedAt() (time.Time, error) {
118-
return time.Time{}, clients.ErrUnsupportedFeature
126+
createdAt, err := c.audit.getRepsitoryCreatedAt()
127+
if err != nil {
128+
return time.Time{}, err
129+
}
130+
131+
// The audit log may not be enabled on the repository
132+
if createdAt.IsZero() {
133+
return c.commits.getFirstCommitCreatedAt()
134+
}
135+
return createdAt, nil
119136
}
120137

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

141158
func (c *Client) ListIssues() ([]clients.Issue, error) {
142-
return nil, clients.ErrUnsupportedFeature
159+
return c.workItems.listIssues()
143160
}
144161

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

199216
client := connection.GetClientByUrl(url)
200217

218+
auditClient, err := audit.NewClient(ctx, connection)
219+
if err != nil {
220+
return nil, fmt.Errorf("could not create azure devops audit client with error: %w", err)
221+
}
222+
201223
gitClient, err := git.NewClient(ctx, connection)
202224
if err != nil {
203225
return nil, fmt.Errorf("could not create azure devops git client with error: %w", err)
204226
}
205227

228+
workItemsClient, err := workitemtracking.NewClient(ctx, connection)
229+
if err != nil {
230+
return nil, fmt.Errorf("could not create azure devops work item tracking client with error: %w", err)
231+
}
232+
206233
return &Client{
207234
ctx: ctx,
208235
azdoClient: gitClient,
236+
audit: &auditHandler{
237+
auditClient: auditClient,
238+
},
209239
branches: &branchesHandler{
210240
gitClient: gitClient,
211241
},
212242
commits: &commitsHandler{
213243
gitClient: gitClient,
214244
},
245+
workItems: &workItemsHandler{
246+
workItemsClient: workItemsClient,
247+
},
215248
zip: &zipHandler{
216249
client: client,
217250
},

clients/azuredevopsrepo/commits.go

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"errors"
2020
"fmt"
2121
"sync"
22+
"time"
2223

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

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

3031
type commitsHandler struct {
31-
gitClient git.Client
32-
ctx context.Context
33-
errSetup error
34-
once *sync.Once
35-
repourl *Repo
36-
commitsRaw *[]git.GitCommitRef
37-
pullRequestsRaw *git.GitPullRequestQuery
38-
getCommits fnGetCommits
39-
getPullRequestQuery fnGetPullRequestQuery
40-
commitDepth int
32+
gitClient git.Client
33+
ctx context.Context
34+
errSetup error
35+
once *sync.Once
36+
repourl *Repo
37+
commitsRaw *[]git.GitCommitRef
38+
pullRequestsRaw *git.GitPullRequestQuery
39+
firstCommitCreatedAt time.Time
40+
getCommits fnGetCommits
41+
getPullRequestQuery fnGetPullRequestQuery
42+
getFirstCommit fnGetFirstCommit
43+
commitDepth int
4144
}
4245

4346
func (handler *commitsHandler) init(ctx context.Context, repourl *Repo, commitDepth int) {
@@ -48,11 +51,13 @@ func (handler *commitsHandler) init(ctx context.Context, repourl *Repo, commitDe
4851
handler.commitDepth = commitDepth
4952
handler.getCommits = handler.gitClient.GetCommits
5053
handler.getPullRequestQuery = handler.gitClient.GetPullRequestQuery
54+
handler.getFirstCommit = handler.gitClient.GetCommits
5155
}
5256

5357
type (
5458
fnGetCommits func(ctx context.Context, args git.GetCommitsArgs) (*[]git.GitCommitRef, error)
5559
fnGetPullRequestQuery func(ctx context.Context, args git.GetPullRequestQueryArgs) (*git.GitPullRequestQuery, error)
60+
fnGetFirstCommit func(ctx context.Context, args git.GetCommitsArgs) (*[]git.GitCommitRef, error)
5661
)
5762

5863
func (handler *commitsHandler) setup() error {
@@ -106,6 +111,31 @@ func (handler *commitsHandler) setup() error {
106111
return
107112
}
108113

114+
switch {
115+
case len(*commits) == 0:
116+
handler.firstCommitCreatedAt = time.Time{}
117+
case len(*commits) < handler.commitDepth:
118+
handler.firstCommitCreatedAt = (*commits)[len(*commits)-1].Committer.Date.Time
119+
default:
120+
firstCommit, err := handler.getFirstCommit(handler.ctx, git.GetCommitsArgs{
121+
RepositoryId: &handler.repourl.id,
122+
SearchCriteria: &git.GitQueryCommitsCriteria{
123+
Top: &[]int{1}[0],
124+
ShowOldestCommitsFirst: &[]bool{true}[0],
125+
ItemVersion: &git.GitVersionDescriptor{
126+
VersionType: &git.GitVersionTypeValues.Branch,
127+
Version: &handler.repourl.defaultBranch,
128+
},
129+
},
130+
})
131+
if err != nil {
132+
handler.errSetup = fmt.Errorf("request for first commit failed with %w", err)
133+
return
134+
}
135+
136+
handler.firstCommitCreatedAt = (*firstCommit)[0].Committer.Date.Time
137+
}
138+
109139
handler.commitsRaw = commits
110140
handler.pullRequestsRaw = pullRequests
111141

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

183213
return pullRequests, nil
184214
}
215+
216+
func (handler *commitsHandler) getFirstCommitCreatedAt() (time.Time, error) {
217+
if err := handler.setup(); err != nil {
218+
return time.Time{}, fmt.Errorf("error during commitsHandler.setup: %w", err)
219+
}
220+
221+
return handler.firstCommitCreatedAt, nil
222+
}

0 commit comments

Comments
 (0)