diff --git a/clients/azuredevopsrepo/audit.go b/clients/azuredevopsrepo/audit.go new file mode 100644 index 000000000000..759ad98fc567 --- /dev/null +++ b/clients/azuredevopsrepo/audit.go @@ -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 +} diff --git a/clients/azuredevopsrepo/audit_test.go b/clients/azuredevopsrepo/audit_test.go new file mode 100644 index 000000000000..72c595c120d4 --- /dev/null +++ b/clients/azuredevopsrepo/audit_test.go @@ -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 +} diff --git a/clients/azuredevopsrepo/client.go b/clients/azuredevopsrepo/client.go index 35495f18e91b..a89f6cf10900 100644 --- a/clients/azuredevopsrepo/client.go +++ b/clients/azuredevopsrepo/client.go @@ -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" ) @@ -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 } @@ -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 @@ -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) { @@ -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) { @@ -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, }, diff --git a/clients/azuredevopsrepo/commits.go b/clients/azuredevopsrepo/commits.go index abda5812007f..5ba597b613a4 100644 --- a/clients/azuredevopsrepo/commits.go +++ b/clients/azuredevopsrepo/commits.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "sync" + "time" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/git" @@ -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) { @@ -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 { @@ -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 @@ -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 +} diff --git a/clients/azuredevopsrepo/work_items.go b/clients/azuredevopsrepo/work_items.go new file mode 100644 index 000000000000..0d08d98c3c52 --- /dev/null +++ b/clients/azuredevopsrepo/work_items.go @@ -0,0 +1,181 @@ +// 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/workitemtracking" + + "github.com/ossf/scorecard/v5/clients" +) + +var ( + errSystemCreatedByFieldNotMap = fmt.Errorf("error: System.CreatedBy field is not a map") + errSystemCreatedByFieldNotUniqueName = fmt.Errorf("error: System.CreatedBy field does not contain a UniqueName") + errSystemCreatedDateFieldNotString = fmt.Errorf("error: System.CreatedDate field is not a string") +) + +type ( + fnQueryWorkItems func( + ctx context.Context, + args workitemtracking.QueryByWiqlArgs, + ) (*workitemtracking.WorkItemQueryResult, error) + + fnGetWorkItems func( + ctx context.Context, + args workitemtracking.GetWorkItemsArgs, + ) (*[]workitemtracking.WorkItem, error) + + fnGetWorkItemComments func( + ctx context.Context, + args workitemtracking.GetCommentsArgs, + ) (*workitemtracking.CommentList, error) +) + +type workItemsHandler struct { + ctx context.Context + repourl *Repo + once *sync.Once + errSetup error + workItemsClient workitemtracking.Client + queryWorkItems fnQueryWorkItems + getWorkItems fnGetWorkItems + getWorkItemComments fnGetWorkItemComments + issues []clients.Issue +} + +func (w *workItemsHandler) init(ctx context.Context, repourl *Repo) { + w.ctx = ctx + w.errSetup = nil + w.once = new(sync.Once) + w.repourl = repourl + w.queryWorkItems = w.workItemsClient.QueryByWiql + w.getWorkItems = w.workItemsClient.GetWorkItems + w.getWorkItemComments = w.workItemsClient.GetComments + w.issues = nil +} + +func (w *workItemsHandler) setup() error { + w.once.Do(func() { + wiql := ` + SELECT [System.Id] + FROM WorkItems + WHERE [System.TeamProject] = @project + ORDER BY [System.Id] DESC + ` + workItems, err := w.queryWorkItems(w.ctx, workitemtracking.QueryByWiqlArgs{ + Project: &w.repourl.project, + Wiql: &workitemtracking.Wiql{ + Query: &wiql, + }, + }) + if err != nil { + w.errSetup = fmt.Errorf("error getting work items: %w", err) + return + } + + ids := make([]int, 0, len(*workItems.WorkItems)) + for _, wi := range *workItems.WorkItems { + ids = append(ids, *wi.Id) + } + + // Get details for each work item + workItemDetails, err := w.getWorkItems(w.ctx, workitemtracking.GetWorkItemsArgs{ + Ids: &ids, + }) + if err != nil { + w.errSetup = fmt.Errorf("error getting work item details: %w", err) + return + } + + w.issues = make([]clients.Issue, 0, len(*workItemDetails)) + for i := range *workItemDetails { + wi := &(*workItemDetails)[i] + + createdBy, ok := (*wi.Fields)["System.CreatedBy"].(map[string]interface{}) + if !ok { + w.errSetup = errSystemCreatedByFieldNotMap + return + } + uniqueName, ok := createdBy["uniqueName"].(string) + if !ok { + w.errSetup = errSystemCreatedByFieldNotUniqueName + return + } + createdDate, ok := (*wi.Fields)["System.CreatedDate"].(string) + if !ok { + w.errSetup = errSystemCreatedDateFieldNotString + return + } + parsedTime, err := time.Parse(time.RFC3339, createdDate) + if err != nil { + w.errSetup = fmt.Errorf("error parsing created date: %w", err) + return + } + // There is not currently an official API to get user permissions in Azure DevOps + // so we will default to RepoAssociationMember for all users. + repoAssociation := clients.RepoAssociationMember + + issue := clients.Issue{ + URI: wi.Url, + CreatedAt: &parsedTime, + Author: &clients.User{Login: uniqueName}, + AuthorAssociation: &repoAssociation, + Comments: make([]clients.IssueComment, 0), + } + + workItemComments, err := w.getWorkItemComments(w.ctx, workitemtracking.GetCommentsArgs{ + Project: &w.repourl.project, + WorkItemId: wi.Id, + }) + if err != nil { + w.errSetup = fmt.Errorf("error getting comments for work item %d: %w", *wi.Id, err) + return + } + + for i := range *workItemComments.Comments { + workItemComment := &(*workItemComments.Comments)[i] + + // There is not currently an official API to get user permissions in Azure DevOps + // so we will default to RepoAssociationMember for all users. + repoAssociation := clients.RepoAssociationMember + + comment := clients.IssueComment{ + CreatedAt: &workItemComment.CreatedDate.Time, + Author: &clients.User{Login: *workItemComment.CreatedBy.UniqueName}, + AuthorAssociation: &repoAssociation, + } + + issue.Comments = append(issue.Comments, comment) + } + + w.issues = append(w.issues, issue) + } + }) + + return w.errSetup +} + +func (w *workItemsHandler) listIssues() ([]clients.Issue, error) { + if err := w.setup(); err != nil { + return nil, fmt.Errorf("error during issuesHandler.setup: %w", err) + } + + return w.issues, nil +} diff --git a/clients/azuredevopsrepo/work_items_test.go b/clients/azuredevopsrepo/work_items_test.go new file mode 100644 index 000000000000..1ed1d3de4df4 --- /dev/null +++ b/clients/azuredevopsrepo/work_items_test.go @@ -0,0 +1,185 @@ +// 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" + "reflect" + "sync" + "testing" + "time" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/webapi" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking" + + "github.com/ossf/scorecard/v5/clients" +) + +func TestWorkItemsHandler_listIssues(t *testing.T) { + t.Parallel() + tests := []struct { + name string + wantErrStr string + mockSetup func(*workItemsHandler) + want []clients.Issue + }{ + { + name: "happy path", + mockSetup: func(w *workItemsHandler) { + workItems := &workitemtracking.WorkItemQueryResult{ + WorkItems: &[]workitemtracking.WorkItemReference{ + {Id: toPtr(1)}, + }, + } + w.queryWorkItems = func(ctx context.Context, args workitemtracking.QueryByWiqlArgs) (*workitemtracking.WorkItemQueryResult, error) { + return workItems, nil + } + + createdDate := "2024-01-01T00:00:00Z" + workItemDetails := &[]workitemtracking.WorkItem{ + { + Id: toPtr(1), + Url: toPtr("http://example.com"), + Fields: &map[string]interface{}{ + "System.CreatedDate": createdDate, + "System.CreatedBy": map[string]interface{}{ + "uniqueName": "test-user", + }, + }, + }, + } + w.getWorkItems = func(ctx context.Context, args workitemtracking.GetWorkItemsArgs) (*[]workitemtracking.WorkItem, error) { + return workItemDetails, nil + } + + commentTime := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC) + comments := &workitemtracking.CommentList{ + Comments: &[]workitemtracking.Comment{ + {CreatedDate: &azuredevops.Time{Time: commentTime}, CreatedBy: &webapi.IdentityRef{UniqueName: toPtr("test-user")}}, + }, + } + w.getWorkItemComments = func(ctx context.Context, args workitemtracking.GetCommentsArgs) (*workitemtracking.CommentList, error) { + return comments, nil + } + }, + want: []clients.Issue{ + { + URI: toPtr("http://example.com"), + CreatedAt: toPtr(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)), + Author: &clients.User{Login: "test-user"}, + AuthorAssociation: toPtr(clients.RepoAssociationMember), + Comments: []clients.IssueComment{ + { + CreatedAt: toPtr(time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)), + Author: &clients.User{Login: "test-user"}, + AuthorAssociation: toPtr(clients.RepoAssociationMember), + }, + }, + }, + }, + }, + { + name: "query work items error", + mockSetup: func(w *workItemsHandler) { + w.queryWorkItems = func(ctx context.Context, args workitemtracking.QueryByWiqlArgs) (*workitemtracking.WorkItemQueryResult, error) { + return nil, fmt.Errorf("query error") + } + }, + wantErrStr: "error during issuesHandler.setup: error getting work items: query error", + }, + { + name: "get work items error", + mockSetup: func(w *workItemsHandler) { + workItems := &workitemtracking.WorkItemQueryResult{ + WorkItems: &[]workitemtracking.WorkItemReference{ + {Id: toPtr(1)}, + }, + } + w.queryWorkItems = func(ctx context.Context, args workitemtracking.QueryByWiqlArgs) (*workitemtracking.WorkItemQueryResult, error) { + return workItems, nil + } + w.getWorkItems = func(ctx context.Context, args workitemtracking.GetWorkItemsArgs) (*[]workitemtracking.WorkItem, error) { + return nil, fmt.Errorf("get items error") + } + }, + wantErrStr: "error during issuesHandler.setup: error getting work item details: get items error", + }, + { + name: "get comments error", + mockSetup: func(w *workItemsHandler) { + workItems := &workitemtracking.WorkItemQueryResult{ + WorkItems: &[]workitemtracking.WorkItemReference{ + {Id: toPtr(1)}, + }, + } + w.queryWorkItems = func(ctx context.Context, args workitemtracking.QueryByWiqlArgs) (*workitemtracking.WorkItemQueryResult, error) { + return workItems, nil + } + createdDate := "2024-01-01T00:00:00Z" + workItemDetails := &[]workitemtracking.WorkItem{ + { + Id: toPtr(1), + Url: toPtr("http://example.com"), + Fields: &map[string]interface{}{ + "System.CreatedDate": createdDate, + "System.CreatedBy": map[string]interface{}{ + "uniqueName": "test-user", + }, + }, + }, + } + w.getWorkItems = func(ctx context.Context, args workitemtracking.GetWorkItemsArgs) (*[]workitemtracking.WorkItem, error) { + return workItemDetails, nil + } + w.getWorkItemComments = func(ctx context.Context, args workitemtracking.GetCommentsArgs) (*workitemtracking.CommentList, error) { + return nil, fmt.Errorf("comments error") + } + }, + wantErrStr: "error during issuesHandler.setup: error getting comments for work item 1: comments error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + w := &workItemsHandler{ + ctx: context.Background(), + once: new(sync.Once), + repourl: &Repo{project: "test-project"}, + } + tt.mockSetup(w) + + got, err := w.listIssues() + if tt.wantErrStr != "" { + if err == nil || err.Error() != tt.wantErrStr { + t.Errorf("listIssues() error = %v, wantErr %v", err, tt.wantErrStr) + } + return + } + if err != nil { + t.Errorf("listIssues() unexpected error: %v", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("listIssues() = %v, want %v", got, tt.want) + } + }) + } +} + +func toPtr[T any](v T) *T { + return &v +}