• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mindersec / minder / 14315603253

07 Apr 2025 05:28PM UTC coverage: 56.758% (-0.01%) from 56.772%
14315603253

push

github

web-flow
build(deps): bump golangci/golangci-lint-action from 6.5.2 to 7.0.0 (#5548)

* build(deps): bump golangci/golangci-lint-action from 6.5.2 to 7.0.0

Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 6.5.2 to 7.0.0.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/55c2c1448...148140484)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* Migrate to golangci-lint version 2

* Fix newly-detected golangci-lint issues

* Fix remaining lint issues from new golangci-lint

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan Anderson <evan@stacklok.com>

53 of 164 new or added lines in 78 files covered. (32.32%)

2 existing lines in 1 file now uncovered.

18301 of 32244 relevant lines covered (56.76%)

36.9 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

63.83
/internal/providers/github/common.go
1
// SPDX-FileCopyrightText: Copyright 2023 The Minder Authors
2
// SPDX-License-Identifier: Apache-2.0
3

4
// Package github provides a client for interacting with the GitHub API
5
package github
6

7
import (
8
        "bytes"
9
        "context"
10
        "errors"
11
        "fmt"
12
        "io"
13
        "net/http"
14
        "net/url"
15
        "sort"
16
        "strings"
17
        "time"
18

19
        backoffv4 "github.com/cenkalti/backoff/v4"
20
        "github.com/go-git/go-git/v5"
21
        "github.com/google/go-github/v63/github"
22
        "github.com/rs/zerolog"
23
        "golang.org/x/oauth2"
24
        "google.golang.org/protobuf/types/known/timestamppb"
25

26
        "github.com/mindersec/minder/internal/db"
27
        engerrors "github.com/mindersec/minder/internal/engine/errors"
28
        gitclient "github.com/mindersec/minder/internal/providers/git"
29
        "github.com/mindersec/minder/internal/providers/github/ghcr"
30
        "github.com/mindersec/minder/internal/providers/github/properties"
31
        "github.com/mindersec/minder/internal/providers/ratecache"
32
        minderv1 "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1"
33
        config "github.com/mindersec/minder/pkg/config/server"
34
        provifv1 "github.com/mindersec/minder/pkg/providers/v1"
35
)
36

37
//go:generate go run go.uber.org/mock/mockgen -package mock_$GOPACKAGE -destination=./mock/$GOFILE -source=./$GOFILE
38

39
const (
40
        // ExpensiveRestCallTimeout is the timeout for expensive REST calls
41
        ExpensiveRestCallTimeout = 15 * time.Second
42
        // MaxRateLimitWait is the maximum time to wait for a rate limit to reset
43
        MaxRateLimitWait = 5 * time.Minute
44
        // MaxRateLimitRetries is the maximum number of retries for rate limit errors after waiting
45
        MaxRateLimitRetries = 1
46
        // DefaultRateLimitWaitTime is the default time to wait for a rate limit to reset
47
        DefaultRateLimitWaitTime = 1 * time.Minute
48

49
        githubBranchNotFoundMsg = "Branch not found"
50
)
51

52
var (
53
        // ErrNotFound denotes if the call returned a 404
54
        ErrNotFound = errors.New("not found")
55
        // ErrBranchNotFound denotes if the branch was not found
56
        ErrBranchNotFound = errors.New("branch not found")
57
        // ErrNoPackageListingClient denotes if there is no package listing client available
58
        ErrNoPackageListingClient = errors.New("no package listing client available")
59
        // ErroNoCheckPermissions is a fixed error returned when the credentialed
60
        // identity has not been authorized to use the checks API
61
        ErroNoCheckPermissions = errors.New("missing permissions: check")
62
        // ErrBranchNameEmpty is a fixed error returned when the branch name is empty
63
        ErrBranchNameEmpty = errors.New("branch name cannot be empty")
64
)
65

66
// GitHub is the struct that contains the shared GitHub client operations
67
type GitHub struct {
68
        client               *github.Client
69
        packageListingClient *github.Client
70
        cache                ratecache.RestClientCache
71
        delegate             Delegate
72
        ghcrwrap             *ghcr.ImageLister
73
        gitConfig            config.GitConfig
74
        webhookConfig        *config.WebhookConfig
75
        propertyFetchers     properties.GhPropertyFetcherFactory
76
}
77

78
// Ensure that the GitHub client implements the GitHub interface
79
var _ provifv1.GitHub = (*GitHub)(nil)
80

81
// ClientService is an interface for GitHub operations
82
// It is used to mock GitHub operations in tests, but in order to generate
83
// mocks, the interface must be exported
84
type ClientService interface {
85
        GetInstallation(ctx context.Context, id int64, jwt string) (*github.Installation, *github.Response, error)
86
        GetUserIdFromToken(ctx context.Context, token *oauth2.Token) (*int64, error)
87
        ListUserInstallations(ctx context.Context, token *oauth2.Token) ([]*github.Installation, error)
88
        DeleteInstallation(ctx context.Context, id int64, jwt string) (*github.Response, error)
89
        GetOrgMembership(ctx context.Context, token *oauth2.Token, org string) (*github.Membership, *github.Response, error)
90
}
91

92
var _ ClientService = (*ClientServiceImplementation)(nil)
93

94
// ClientServiceImplementation is the implementation of the ClientService interface
95
type ClientServiceImplementation struct{}
96

97
// GetInstallation is a wrapper for the GitHub API to get an installation
98
func (ClientServiceImplementation) GetInstallation(
99
        ctx context.Context,
100
        installationID int64,
101
        jwt string,
102
) (*github.Installation, *github.Response, error) {
×
103
        ghClient := github.NewClient(nil).WithAuthToken(jwt)
×
104
        return ghClient.Apps.GetInstallation(ctx, installationID)
×
105
}
×
106

107
// GetUserIdFromToken is a wrapper for the GitHub API to get the user id from a token
108
func (ClientServiceImplementation) GetUserIdFromToken(ctx context.Context, token *oauth2.Token) (*int64, error) {
×
109
        ghClient := github.NewClient(nil).WithAuthToken(token.AccessToken)
×
110

×
111
        user, _, err := ghClient.Users.Get(ctx, "")
×
112
        if err != nil {
×
113
                return nil, err
×
114
        }
×
115

116
        return user.ID, nil
×
117
}
118

119
// ListUserInstallations is a wrapper for the GitHub API to list user installations
120
func (ClientServiceImplementation) ListUserInstallations(
121
        ctx context.Context, token *oauth2.Token,
122
) ([]*github.Installation, error) {
×
123
        ghClient := github.NewClient(nil).WithAuthToken(token.AccessToken)
×
124

×
125
        installations, _, err := ghClient.Apps.ListUserInstallations(ctx, nil)
×
126
        return installations, err
×
127
}
×
128

129
// DeleteInstallation is a wrapper for the GitHub API to delete an installation
130
func (ClientServiceImplementation) DeleteInstallation(ctx context.Context, id int64, jwt string) (*github.Response, error) {
×
131
        ghClient := github.NewClient(nil).WithAuthToken(jwt)
×
132
        return ghClient.Apps.DeleteInstallation(ctx, id)
×
133
}
×
134

135
// GetOrgMembership is a wrapper for the GitHub API to get users' organization membership
136
func (ClientServiceImplementation) GetOrgMembership(
137
        ctx context.Context, token *oauth2.Token, org string,
138
) (*github.Membership, *github.Response, error) {
×
139
        ghClient := github.NewClient(nil).WithAuthToken(token.AccessToken)
×
140
        return ghClient.Organizations.GetOrgMembership(ctx, "", org)
×
141
}
×
142

143
// Delegate is the interface that contains operations that differ between different GitHub actors (user vs app)
144
type Delegate interface {
145
        GetCredential() provifv1.GitHubCredential
146
        ListAllRepositories(context.Context) ([]*minderv1.Repository, error)
147
        GetUserId(ctx context.Context) (int64, error)
148
        GetName(ctx context.Context) (string, error)
149
        GetLogin(ctx context.Context) (string, error)
150
        GetPrimaryEmail(ctx context.Context) (string, error)
151
        GetOwner() string
152
        IsOrg() bool
153
}
154

155
// NewGitHub creates a new GitHub client
156
func NewGitHub(
157
        client *github.Client,
158
        packageListingClient *github.Client,
159
        cache ratecache.RestClientCache,
160
        delegate Delegate,
161
        cfg *config.ProviderConfig,
162
        whcfg *config.WebhookConfig,
163
        propertyFetchers properties.GhPropertyFetcherFactory,
164
) *GitHub {
48✔
165
        var gitConfig config.GitConfig
48✔
166
        if cfg != nil {
57✔
167
                gitConfig = cfg.Git
9✔
168
        }
9✔
169
        return &GitHub{
48✔
170
                client:               client,
48✔
171
                packageListingClient: packageListingClient,
48✔
172
                cache:                cache,
48✔
173
                delegate:             delegate,
48✔
174
                ghcrwrap:             ghcr.FromGitHubClient(client, delegate.GetOwner()),
48✔
175
                gitConfig:            gitConfig,
48✔
176
                webhookConfig:        whcfg,
48✔
177
                propertyFetchers:     propertyFetchers,
48✔
178
        }
48✔
179
}
180

181
// CanImplement returns true/false depending on whether the Provider
182
// can implement the specified trait
NEW
183
func (*GitHub) CanImplement(trait minderv1.ProviderType) bool {
×
184
        return trait == minderv1.ProviderType_PROVIDER_TYPE_GITHUB ||
×
185
                trait == minderv1.ProviderType_PROVIDER_TYPE_GIT ||
×
186
                trait == minderv1.ProviderType_PROVIDER_TYPE_REST ||
×
187
                trait == minderv1.ProviderType_PROVIDER_TYPE_REPO_LISTER
×
188
}
×
189

190
// ListPackagesByRepository returns a list of all packages for a specific repository
191
func (c *GitHub) ListPackagesByRepository(
192
        ctx context.Context,
193
        owner string,
194
        artifactType string,
195
        repositoryId int64,
196
        pageNumber int,
197
        itemsPerPage int,
198
) ([]*github.Package, error) {
4✔
199
        opt := &github.PackageListOptions{
4✔
200
                PackageType: &artifactType,
4✔
201
                ListOptions: github.ListOptions{
4✔
202
                        Page:    pageNumber,
4✔
203
                        PerPage: itemsPerPage,
4✔
204
                },
4✔
205
        }
4✔
206
        // create a slice to hold the containers
4✔
207
        var allContainers []*github.Package
4✔
208

4✔
209
        if c.packageListingClient == nil {
5✔
210
                zerolog.Ctx(ctx).Error().Msg("No client available for listing packages")
1✔
211
                return allContainers, ErrNoPackageListingClient
1✔
212
        }
1✔
213

214
        type listPackagesRespWrapper struct {
3✔
215
                artifacts []*github.Package
3✔
216
                resp      *github.Response
3✔
217
        }
3✔
218
        op := func() (listPackagesRespWrapper, error) {
6✔
219
                var artifacts []*github.Package
3✔
220
                var resp *github.Response
3✔
221
                var err error
3✔
222

3✔
223
                if c.IsOrg() {
6✔
224
                        artifacts, resp, err = c.packageListingClient.Organizations.ListPackages(ctx, owner, opt)
3✔
225
                } else {
3✔
226
                        artifacts, resp, err = c.packageListingClient.Users.ListPackages(ctx, owner, opt)
×
227
                }
×
228

229
                listPackagesResp := listPackagesRespWrapper{
3✔
230
                        artifacts: artifacts,
3✔
231
                        resp:      resp,
3✔
232
                }
3✔
233

3✔
234
                if isRateLimitError(err) {
3✔
235
                        waitErr := c.waitForRateLimitReset(ctx, err)
×
236
                        if waitErr == nil {
×
237
                                return listPackagesResp, err
×
238
                        }
×
239
                        return listPackagesResp, backoffv4.Permanent(err)
×
240
                }
241

242
                return listPackagesResp, backoffv4.Permanent(err)
3✔
243
        }
244

245
        for {
6✔
246
                result, err := performWithRetry(ctx, op)
3✔
247
                if err != nil {
3✔
248
                        if result.resp != nil && result.resp.StatusCode == http.StatusNotFound {
×
249
                                return allContainers, fmt.Errorf("packages not found for repository %d: %w", repositoryId, ErrNotFound)
×
250
                        }
×
251

252
                        return allContainers, err
×
253
                }
254

255
                // now just append the ones belonging to the repository
256
                for _, artifact := range result.artifacts {
7✔
257
                        if artifact.Repository.GetID() == repositoryId {
7✔
258
                                allContainers = append(allContainers, artifact)
3✔
259
                        }
3✔
260
                }
261

262
                if result.resp.NextPage == 0 {
6✔
263
                        break
3✔
264
                }
265
                opt.Page = result.resp.NextPage
×
266
        }
267

268
        return allContainers, nil
3✔
269
}
270

271
// getPackageVersions returns a list of all package versions for the authenticated user or org
272
func (c *GitHub) getPackageVersions(ctx context.Context, owner string, package_type string, package_name string,
273
) ([]*github.PackageVersion, error) {
4✔
274
        state := "active"
4✔
275

4✔
276
        // since the GH API sometimes returns container and sometimes CONTAINER as the type, let's just lowercase it
4✔
277
        package_type = strings.ToLower(package_type)
4✔
278

4✔
279
        opt := &github.PackageListOptions{
4✔
280
                PackageType: &package_type,
4✔
281
                State:       &state,
4✔
282
                ListOptions: github.ListOptions{
4✔
283
                        Page:    1,
4✔
284
                        PerPage: 100,
4✔
285
                },
4✔
286
        }
4✔
287

4✔
288
        // create a slice to hold the versions
4✔
289
        var allVersions []*github.PackageVersion
4✔
290

4✔
291
        // loop until we get all package versions
4✔
292
        for {
8✔
293
                var v []*github.PackageVersion
4✔
294
                var resp *github.Response
4✔
295
                var err error
4✔
296
                if c.IsOrg() {
8✔
297
                        v, resp, err = c.client.Organizations.PackageGetAllVersions(ctx, owner, package_type, package_name, opt)
4✔
298
                } else {
4✔
299
                        package_name = url.PathEscape(package_name)
×
300
                        v, resp, err = c.client.Users.PackageGetAllVersions(ctx, owner, package_type, package_name, opt)
×
301
                }
×
302
                if err != nil {
5✔
303
                        return nil, err
1✔
304
                }
1✔
305

306
                // append to the slice
307
                allVersions = append(allVersions, v...)
3✔
308

3✔
309
                // if there is no next page, break
3✔
310
                if resp.NextPage == 0 {
6✔
311
                        break
3✔
312
                }
313

314
                // update the page
315
                opt.Page = resp.NextPage
×
316
        }
317

318
        // return the slice
319
        return allVersions, nil
3✔
320
}
321

322
// GetPackageByName returns a single package for the authenticated user or for the org
323
func (c *GitHub) GetPackageByName(ctx context.Context, owner string, package_type string, package_name string,
324
) (*github.Package, error) {
2✔
325
        var pkg *github.Package
2✔
326
        var err error
2✔
327

2✔
328
        // since the GH API sometimes returns container and sometimes CONTAINER as the type, let's just lowercase it
2✔
329
        package_type = strings.ToLower(package_type)
2✔
330

2✔
331
        if c.IsOrg() {
4✔
332
                pkg, _, err = c.client.Organizations.GetPackage(ctx, owner, package_type, package_name)
2✔
333
                if err != nil {
2✔
334
                        return nil, err
×
335
                }
×
336
        } else {
×
337
                pkg, _, err = c.client.Users.GetPackage(ctx, "", package_type, package_name)
×
338
                if err != nil {
×
339
                        return nil, err
×
340
                }
×
341
        }
342
        return pkg, nil
2✔
343
}
344

345
// GetPackageVersionById returns a single package version for the specific id
346
func (c *GitHub) GetPackageVersionById(ctx context.Context, owner string, packageType string, packageName string,
347
        version int64) (*github.PackageVersion, error) {
2✔
348
        var pkgVersion *github.PackageVersion
2✔
349
        var err error
2✔
350

2✔
351
        if c.IsOrg() {
4✔
352
                pkgVersion, _, err = c.client.Organizations.PackageGetVersion(ctx, owner, packageType, packageName, version)
2✔
353
                if err != nil {
2✔
354
                        return nil, err
×
355
                }
×
356
        } else {
×
357
                packageName = url.PathEscape(packageName)
×
358
                pkgVersion, _, err = c.client.Users.PackageGetVersion(ctx, owner, packageType, packageName, version)
×
359
                if err != nil {
×
360
                        return nil, err
×
361
                }
×
362
        }
363

364
        return pkgVersion, nil
2✔
365
}
366

367
// GetPullRequest is a wrapper for the GitHub API to get a pull request
368
func (c *GitHub) GetPullRequest(
369
        ctx context.Context,
370
        owner string,
371
        repo string,
372
        number int,
373
) (*github.PullRequest, error) {
×
374
        pr, _, err := c.client.PullRequests.Get(ctx, owner, repo, number)
×
375
        if err != nil {
×
376
                return nil, err
×
377
        }
×
378
        return pr, nil
×
379
}
380

381
// ListFiles is a wrapper for the GitHub API to list files in a pull request
382
func (c *GitHub) ListFiles(
383
        ctx context.Context,
384
        owner string,
385
        repo string,
386
        prNumber int,
387
        perPage int,
388
        pageNumber int,
389
) ([]*github.CommitFile, *github.Response, error) {
3✔
390
        type listFilesRespWrapper struct {
3✔
391
                files []*github.CommitFile
3✔
392
                resp  *github.Response
3✔
393
        }
3✔
394

3✔
395
        op := func() (listFilesRespWrapper, error) {
7✔
396
                opt := &github.ListOptions{
4✔
397
                        Page:    pageNumber,
4✔
398
                        PerPage: perPage,
4✔
399
                }
4✔
400
                files, resp, err := c.client.PullRequests.ListFiles(ctx, owner, repo, prNumber, opt)
4✔
401

4✔
402
                listFileResp := listFilesRespWrapper{
4✔
403
                        files: files,
4✔
404
                        resp:  resp,
4✔
405
                }
4✔
406

4✔
407
                if isRateLimitError(err) {
5✔
408
                        waitErr := c.waitForRateLimitReset(ctx, err)
1✔
409
                        if waitErr == nil {
2✔
410
                                return listFileResp, err
1✔
411
                        }
1✔
412
                        return listFileResp, backoffv4.Permanent(err)
×
413
                }
414

415
                return listFileResp, backoffv4.Permanent(err)
3✔
416
        }
417

418
        resp, err := performWithRetry(ctx, op)
3✔
419
        return resp.files, resp.resp, err
3✔
420
}
421

422
// CreateReview is a wrapper for the GitHub API to create a review
423
func (c *GitHub) CreateReview(
424
        ctx context.Context, owner, repo string, number int, reviewRequest *github.PullRequestReviewRequest,
425
) (*github.PullRequestReview, error) {
×
426
        review, _, err := c.client.PullRequests.CreateReview(ctx, owner, repo, number, reviewRequest)
×
427
        if err != nil {
×
428
                return nil, fmt.Errorf("error creating review: %w", err)
×
429
        }
×
430
        return review, nil
×
431
}
432

433
// UpdateReview is a wrapper for the GitHub API to update a review
434
func (c *GitHub) UpdateReview(
435
        ctx context.Context, owner, repo string, number int, reviewId int64, body string,
436
) (*github.PullRequestReview, error) {
×
437
        review, _, err := c.client.PullRequests.UpdateReview(ctx, owner, repo, number, reviewId, body)
×
438
        if err != nil {
×
439
                return nil, fmt.Errorf("error updating review: %w", err)
×
440
        }
×
441
        return review, nil
×
442
}
443

444
// ListIssueComments is a wrapper for the GitHub API to get all comments in a review
445
func (c *GitHub) ListIssueComments(
446
        ctx context.Context, owner, repo string, number int, opts *github.IssueListCommentsOptions,
447
) ([]*github.IssueComment, error) {
×
448
        comments, _, err := c.client.Issues.ListComments(ctx, owner, repo, number, opts)
×
449
        if err != nil {
×
450
                return nil, fmt.Errorf("error getting list of comments: %w", err)
×
451
        }
×
452
        return comments, nil
×
453
}
454

455
// ListReviews is a wrapper for the GitHub API to list reviews
456
func (c *GitHub) ListReviews(
457
        ctx context.Context,
458
        owner, repo string,
459
        number int,
460
        opt *github.ListOptions,
461
) ([]*github.PullRequestReview, error) {
×
462
        reviews, _, err := c.client.PullRequests.ListReviews(ctx, owner, repo, number, opt)
×
463
        if err != nil {
×
464
                return nil, fmt.Errorf("error listing reviews for PR %s/%s/%d: %w", owner, repo, number, err)
×
465
        }
×
466
        return reviews, nil
×
467
}
468

469
// DismissReview is a wrapper for the GitHub API to dismiss a review
470
func (c *GitHub) DismissReview(
471
        ctx context.Context,
472
        owner, repo string,
473
        prId int,
474
        reviewId int64,
475
        dismissalRequest *github.PullRequestReviewDismissalRequest,
476
) (*github.PullRequestReview, error) {
×
477
        review, _, err := c.client.PullRequests.DismissReview(ctx, owner, repo, prId, reviewId, dismissalRequest)
×
478
        if err != nil {
×
479
                return nil, fmt.Errorf("error dismissing review %d for PR %s/%s/%d: %w", reviewId, owner, repo, prId, err)
×
480
        }
×
481
        return review, nil
×
482
}
483

484
// SetCommitStatus is a wrapper for the GitHub API to set a commit status
485
func (c *GitHub) SetCommitStatus(
486
        ctx context.Context, owner, repo, ref string, status *github.RepoStatus,
487
) (*github.RepoStatus, error) {
×
488
        status, _, err := c.client.Repositories.CreateStatus(ctx, owner, repo, ref, status)
×
489
        if err != nil {
×
490
                return nil, fmt.Errorf("error creating commit status: %w", err)
×
491
        }
×
492
        return status, nil
×
493
}
494

495
// GetRepository returns a single repository for the authenticated user
496
func (c *GitHub) GetRepository(ctx context.Context, owner string, name string) (*github.Repository, error) {
×
497
        // create a slice to hold the repositories
×
498
        repo, _, err := c.client.Repositories.Get(ctx, owner, name)
×
499
        if err != nil {
×
500
                return nil, fmt.Errorf("error getting repository: %w", err)
×
501
        }
×
502

503
        return repo, nil
×
504
}
505

506
// GetBranchProtection returns the branch protection for a given branch
507
func (c *GitHub) GetBranchProtection(ctx context.Context, owner string,
508
        repo_name string, branch_name string) (*github.Protection, error) {
×
509
        var respErr *github.ErrorResponse
×
510
        if branch_name == "" {
×
511
                return nil, ErrBranchNameEmpty
×
512
        }
×
513

514
        protection, _, err := c.client.Repositories.GetBranchProtection(ctx, owner, repo_name, branch_name)
×
515
        if errors.As(err, &respErr) {
×
516
                if respErr.Message == githubBranchNotFoundMsg {
×
517
                        return nil, ErrBranchNotFound
×
518
                }
×
519

520
                return nil, fmt.Errorf("error getting branch protection: %w", err)
×
521
        } else if err != nil {
×
522
                return nil, err
×
523
        }
×
524
        return protection, nil
×
525
}
526

527
// UpdateBranchProtection updates the branch protection for a given branch
528
func (c *GitHub) UpdateBranchProtection(
529
        ctx context.Context, owner, repo, branch string, preq *github.ProtectionRequest,
530
) error {
×
531
        if branch == "" {
×
532
                return ErrBranchNameEmpty
×
533
        }
×
534
        _, _, err := c.client.Repositories.UpdateBranchProtection(ctx, owner, repo, branch, preq)
×
535
        return err
×
536
}
537

538
// GetBaseURL returns the base URL for the REST API.
539
func (c *GitHub) GetBaseURL() string {
1✔
540
        return c.client.BaseURL.String()
1✔
541
}
1✔
542

543
// NewRequest creates an API request. A relative URL can be provided in urlStr,
544
// which will be resolved to the BaseURL of the Client. Relative URLS should
545
// always be specified without a preceding slash. If specified, the value
546
// pointed to by body is JSON encoded and included as the request body.
547
func (c *GitHub) NewRequest(method, requestUrl string, body any) (*http.Request, error) {
7✔
548
        return c.client.NewRequest(method, requestUrl, body)
7✔
549
}
7✔
550

551
// Do sends an API request and returns the API response.
552
func (c *GitHub) Do(ctx context.Context, req *http.Request) (*http.Response, error) {
7✔
553
        var buf bytes.Buffer
7✔
554

7✔
555
        // The GitHub client closes the response body, so we need to capture it
7✔
556
        // in a buffer so that we can return it to the caller
7✔
557
        resp, err := c.client.Do(ctx, req, &buf)
7✔
558
        if err != nil && resp == nil {
7✔
559
                return nil, err
×
560
        }
×
561

562
        if resp.Response != nil {
14✔
563
                resp.Body = io.NopCloser(&buf)
7✔
564
        }
7✔
565
        return resp.Response, err
7✔
566
}
567

568
// GetCredential returns the credential used to authenticate with the GitHub API
569
func (c *GitHub) GetCredential() provifv1.GitHubCredential {
×
570
        return c.delegate.GetCredential()
×
571
}
×
572

573
// IsOrg returns true if the owner is an organization
574
func (c *GitHub) IsOrg() bool {
11✔
575
        return c.delegate.IsOrg()
11✔
576
}
11✔
577

578
// ListHooks lists all Hooks for the specified repository.
579
func (c *GitHub) ListHooks(ctx context.Context, owner, repo string) ([]*github.Hook, error) {
×
580
        list, resp, err := c.client.Repositories.ListHooks(ctx, owner, repo, nil)
×
581
        if err != nil && resp.StatusCode == http.StatusNotFound {
×
582
                // return empty list so that the caller can ignore the error and iterate over the empty list
×
583
                return []*github.Hook{}, fmt.Errorf("hooks not found for repository %s/%s: %w", owner, repo, ErrNotFound)
×
584
        }
×
585
        return list, err
×
586
}
587

588
// DeleteHook deletes a specified Hook.
589
func (c *GitHub) DeleteHook(ctx context.Context, owner, repo string, id int64) error {
×
590
        resp, err := c.client.Repositories.DeleteHook(ctx, owner, repo, id)
×
591
        if resp != nil && resp.StatusCode == http.StatusNotFound {
×
592
                // If the hook is not found, we can ignore the
×
593
                // error, user might have deleted it manually.
×
594
                return nil
×
595
        }
×
596
        if resp != nil && resp.StatusCode == http.StatusForbidden {
×
597
                // We ignore deleting webhooks that we're not
×
598
                // allowed to touch. This is usually the case
×
599
                // with repository transfer.
×
600
                return nil
×
601
        }
×
602
        return err
×
603
}
604

605
// CreateHook creates a new Hook.
606
func (c *GitHub) CreateHook(ctx context.Context, owner, repo string, hook *github.Hook) (*github.Hook, error) {
4✔
607
        h, _, err := c.client.Repositories.CreateHook(ctx, owner, repo, hook)
4✔
608
        return h, err
4✔
609
}
4✔
610

611
// EditHook edits an existing Hook.
612
func (c *GitHub) EditHook(ctx context.Context, owner, repo string, id int64, hook *github.Hook) (*github.Hook, error) {
×
613
        h, _, err := c.client.Repositories.EditHook(ctx, owner, repo, id, hook)
×
614
        return h, err
×
615
}
×
616

617
// CreateSecurityAdvisory creates a new security advisory
618
func (c *GitHub) CreateSecurityAdvisory(ctx context.Context, owner, repo, severity, summary, description string,
619
        v []*github.AdvisoryVulnerability) (string, error) {
3✔
620
        u := fmt.Sprintf("repos/%v/%v/security-advisories", owner, repo)
3✔
621

3✔
622
        payload := &struct {
3✔
623
                Summary         string                          `json:"summary"`
3✔
624
                Description     string                          `json:"description"`
3✔
625
                Severity        string                          `json:"severity"`
3✔
626
                Vulnerabilities []*github.AdvisoryVulnerability `json:"vulnerabilities"`
3✔
627
        }{
3✔
628
                Summary:         summary,
3✔
629
                Description:     description,
3✔
630
                Severity:        severity,
3✔
631
                Vulnerabilities: v,
3✔
632
        }
3✔
633
        req, err := c.client.NewRequest("POST", u, payload)
3✔
634
        if err != nil {
3✔
635
                return "", err
×
636
        }
×
637

638
        res := &struct {
3✔
639
                ID string `json:"ghsa_id"`
3✔
640
        }{}
3✔
641

3✔
642
        resp, err := c.client.Do(ctx, req, res)
3✔
643
        if err != nil {
5✔
644
                return "", err
2✔
645
        }
2✔
646

647
        if resp.StatusCode != http.StatusCreated {
1✔
648
                return "", fmt.Errorf("error creating security advisory: %v", resp.Status)
×
649
        }
×
650
        return res.ID, nil
1✔
651
}
652

653
// CloseSecurityAdvisory closes a security advisory
654
func (c *GitHub) CloseSecurityAdvisory(ctx context.Context, owner, repo, id string) error {
4✔
655
        u := fmt.Sprintf("repos/%v/%v/security-advisories/%v", owner, repo, id)
4✔
656

4✔
657
        payload := &struct {
4✔
658
                State string `json:"state"`
4✔
659
        }{
4✔
660
                State: "closed",
4✔
661
        }
4✔
662

4✔
663
        req, err := c.client.NewRequest("PATCH", u, payload)
4✔
664
        if err != nil {
4✔
665
                return err
×
666
        }
×
667

668
        resp, err := c.client.Do(ctx, req, nil)
4✔
669
        if err != nil {
7✔
670
                return err
3✔
671
        }
3✔
672
        // Translate the HTTP status code to an error, nil if between 200 and 299
673
        return engerrors.HTTPErrorCodeToErr(resp.StatusCode)
1✔
674
}
675

676
// CreatePullRequest creates a pull request in a repository.
677
func (c *GitHub) CreatePullRequest(
678
        ctx context.Context,
679
        owner, repo, title, body, head, base string,
680
) (*github.PullRequest, error) {
×
681
        pr, _, err := c.client.PullRequests.Create(ctx, owner, repo, &github.NewPullRequest{
×
682
                Title:               github.String(title),
×
683
                Body:                github.String(body),
×
684
                Head:                github.String(head),
×
685
                Base:                github.String(base),
×
686
                MaintainerCanModify: github.Bool(true),
×
687
        })
×
688
        if err != nil {
×
689
                return nil, err
×
690
        }
×
691

692
        return pr, nil
×
693
}
694

695
// ClosePullRequest closes a pull request in a repository.
696
func (c *GitHub) ClosePullRequest(ctx context.Context, owner, repo string, number int) (*github.PullRequest, error) {
×
697
        pr, _, err := c.client.PullRequests.Edit(ctx, owner, repo, number, &github.PullRequest{
×
698
                State: github.String("closed"),
×
699
        })
×
700
        if err != nil {
×
701
                return nil, err
×
702
        }
×
703
        return pr, nil
×
704
}
705

706
// ListPullRequests lists all pull requests in a repository.
707
func (c *GitHub) ListPullRequests(
708
        ctx context.Context,
709
        owner, repo string,
710
        opt *github.PullRequestListOptions,
711
) ([]*github.PullRequest, error) {
×
712
        prs, _, err := c.client.PullRequests.List(ctx, owner, repo, opt)
×
713
        if err != nil {
×
714
                return nil, err
×
715
        }
×
716

717
        return prs, nil
×
718
}
719

720
// CreateIssueComment creates a comment on a pull request or an issue
721
func (c *GitHub) CreateIssueComment(
722
        ctx context.Context, owner, repo string, number int, comment string,
723
) (*github.IssueComment, error) {
14✔
724
        var issueComment *github.IssueComment
14✔
725

14✔
726
        op := func() (any, error) {
30✔
727
                var err error
16✔
728

16✔
729
                issueComment, _, err = c.client.Issues.CreateComment(ctx, owner, repo, number, &github.IssueComment{
16✔
730
                        Body: &comment,
16✔
731
                })
16✔
732

16✔
733
                if isRateLimitError(err) {
30✔
734
                        waitWrr := c.waitForRateLimitReset(ctx, err)
14✔
735
                        if waitWrr == nil {
16✔
736
                                return nil, err
2✔
737
                        }
2✔
738
                        return nil, backoffv4.Permanent(err)
12✔
739
                }
740

741
                return nil, backoffv4.Permanent(err)
2✔
742
        }
743
        _, retryErr := performWithRetry(ctx, op)
14✔
744
        return issueComment, retryErr
14✔
745
}
746

747
// UpdateIssueComment updates a comment on a pull request or an issue
748
func (c *GitHub) UpdateIssueComment(ctx context.Context, owner, repo string, number int64, comment string) error {
×
749
        _, _, err := c.client.Issues.EditComment(ctx, owner, repo, number, &github.IssueComment{
×
750
                Body: &comment,
×
751
        })
×
752
        return err
×
753
}
×
754

755
// Clone clones a GitHub repository
756
func (c *GitHub) Clone(ctx context.Context, cloneUrl string, branch string) (*git.Repository, error) {
×
757
        delegator := gitclient.NewGit(c.delegate.GetCredential(), gitclient.WithConfig(c.gitConfig))
×
758
        return delegator.Clone(ctx, cloneUrl, branch)
×
759
}
×
760

761
// AddAuthToPushOptions adds authorization to the push options
762
func (c *GitHub) AddAuthToPushOptions(ctx context.Context, pushOptions *git.PushOptions) error {
×
763
        login, err := c.delegate.GetLogin(ctx)
×
764
        if err != nil {
×
765
                return fmt.Errorf("cannot get login: %w", err)
×
766
        }
×
767
        c.delegate.GetCredential().AddToPushOptions(pushOptions, login)
×
768
        return nil
×
769
}
770

771
// ListAllRepositories lists all repositories the credential has access to
772
func (c *GitHub) ListAllRepositories(ctx context.Context) ([]*minderv1.Repository, error) {
3✔
773
        return c.delegate.ListAllRepositories(ctx)
3✔
774
}
3✔
775

776
// GetUserId returns the user id for the acting user
777
func (c *GitHub) GetUserId(ctx context.Context) (int64, error) {
1✔
778
        return c.delegate.GetUserId(ctx)
1✔
779
}
1✔
780

781
// GetName returns the username for the acting user
782
func (c *GitHub) GetName(ctx context.Context) (string, error) {
1✔
783
        return c.delegate.GetName(ctx)
1✔
784
}
1✔
785

786
// GetLogin returns the login for the acting user
787
func (c *GitHub) GetLogin(ctx context.Context) (string, error) {
1✔
788
        return c.delegate.GetLogin(ctx)
1✔
789
}
1✔
790

791
// GetPrimaryEmail returns the primary email for the acting user
792
func (c *GitHub) GetPrimaryEmail(ctx context.Context) (string, error) {
1✔
793
        return c.delegate.GetPrimaryEmail(ctx)
1✔
794
}
1✔
795

796
// ListImages lists all containers in the GitHub Container Registry
797
func (c *GitHub) ListImages(ctx context.Context) ([]string, error) {
×
798
        return c.ghcrwrap.ListImages(ctx)
×
799
}
×
800

801
// GetNamespaceURL returns the URL for the repository
802
func (c *GitHub) GetNamespaceURL() string {
×
803
        return c.ghcrwrap.GetNamespaceURL()
×
804
}
×
805

806
// GetArtifactVersions returns a list of all versions for a specific artifact
807
func (gv *GitHub) GetArtifactVersions(
808
        ctx context.Context, artifact *minderv1.Artifact,
809
        filter provifv1.GetArtifactVersionsFilter,
810
) ([]*minderv1.ArtifactVersion, error) {
2✔
811
        // We don't need to URL-encode the artifact name
2✔
812
        // since this already happens in go-github
2✔
813
        upstreamVersions, err := gv.getPackageVersions(
2✔
814
                ctx, artifact.GetOwner(), artifact.GetTypeLower(), artifact.GetName(),
2✔
815
        )
2✔
816
        if err != nil {
3✔
817
                return nil, fmt.Errorf("error retrieving artifact versions: %w", err)
1✔
818
        }
1✔
819

820
        out := make([]*minderv1.ArtifactVersion, 0, len(upstreamVersions))
1✔
821
        for _, uv := range upstreamVersions {
2✔
822
                tags := uv.Metadata.Container.Tags
1✔
823

1✔
824
                if err := filter.IsSkippable(uv.CreatedAt.Time, tags); err != nil {
1✔
825
                        zerolog.Ctx(ctx).Debug().Str("name", artifact.GetName()).Strs("tags", tags).
×
826
                                Str("reason", err.Error()).Msg("skipping artifact version")
×
827
                        continue
×
828
                }
829

830
                sort.Strings(tags)
1✔
831

1✔
832
                // only the tags and creation time is relevant to us.
1✔
833
                out = append(out, &minderv1.ArtifactVersion{
1✔
834
                        Tags: tags,
1✔
835
                        // NOTE: GitHub's name is actually a SHA. This is misleading...
1✔
836
                        // but it is what it is. We'll use it as the SHA for now.
1✔
837
                        Sha:       *uv.Name,
1✔
838
                        CreatedAt: timestamppb.New(uv.CreatedAt.Time),
1✔
839
                })
1✔
840
        }
841

842
        return out, nil
1✔
843
}
844

845
// setAsRateLimited adds the GitHub to the cache as rate limited.
846
// An optimistic concurrency control mechanism is used to ensure that every request doesn't need
847
// synchronization. GitHub only adds itself to the cache if it's not already there. It doesn't
848
// remove itself from the cache when the rate limit is reset. This approach leverages the high
849
// likelihood of the client or token being rate-limited again. By keeping the client in the cache,
850
// we can reuse client's rateLimits map, which holds rate limits for different endpoints.
851
// This reuse of cached rate limits helps avoid unnecessary GitHub API requests when the client
852
// is rate-limited. Every cache entry has an expiration time, so the cache will eventually evict
853
// the rate-limited client.
854
func (c *GitHub) setAsRateLimited() {
18✔
855
        if c.cache != nil {
36✔
856
                c.cache.Set(c.delegate.GetOwner(), c.delegate.GetCredential().GetCacheKey(), db.ProviderTypeGithub, c)
18✔
857
        }
18✔
858
}
859

860
// waitForRateLimitReset waits for token wait limit to reset. Returns error if wait time is more
861
// than MaxRateLimitWait or requests' context is cancelled.
862
func (c *GitHub) waitForRateLimitReset(ctx context.Context, err error) error {
19✔
863
        var rateLimitError *github.RateLimitError
19✔
864
        isRateLimitErr := errors.As(err, &rateLimitError)
19✔
865

19✔
866
        if isRateLimitErr {
36✔
867
                return c.processPrimaryRateLimitErr(ctx, rateLimitError)
17✔
868
        }
17✔
869

870
        var abuseRateLimitError *github.AbuseRateLimitError
2✔
871
        isAbuseRateLimitErr := errors.As(err, &abuseRateLimitError)
2✔
872

2✔
873
        if isAbuseRateLimitErr {
3✔
874
                return c.processAbuseRateLimitErr(ctx, abuseRateLimitError)
1✔
875
        }
1✔
876

877
        return nil
1✔
878
}
879

880
func (c *GitHub) processPrimaryRateLimitErr(ctx context.Context, err *github.RateLimitError) error {
17✔
881
        logger := zerolog.Ctx(ctx)
17✔
882
        rate := err.Rate
17✔
883
        if rate.Remaining == 0 {
34✔
884
                c.setAsRateLimited()
17✔
885

17✔
886
                waitTime := DefaultRateLimitWaitTime
17✔
887
                resetTime := rate.Reset.Time
17✔
888
                if !resetTime.IsZero() {
34✔
889
                        waitTime = time.Until(resetTime)
17✔
890
                }
17✔
891

892
                logRateLimitError(logger, "RateLimitError", waitTime, c.delegate.GetOwner(), err.Response)
17✔
893

17✔
894
                if waitTime > MaxRateLimitWait {
30✔
895
                        logger.Debug().Msgf("rate limit reset time: %v exceeds maximum wait time: %v", waitTime, MaxRateLimitWait)
13✔
896
                        return err
13✔
897
                }
13✔
898

899
                // Wait for the rate limit to reset
900
                select {
4✔
901
                case <-time.After(waitTime):
4✔
902
                        return nil
4✔
903
                case <-ctx.Done():
×
904
                        logger.Debug().Err(ctx.Err()).Msg("context done while waiting for rate limit to reset")
×
905
                        return err
×
906
                }
907
        }
908

909
        return nil
×
910
}
911

912
func (c *GitHub) processAbuseRateLimitErr(ctx context.Context, err *github.AbuseRateLimitError) error {
1✔
913
        logger := zerolog.Ctx(ctx)
1✔
914
        c.setAsRateLimited()
1✔
915

1✔
916
        retryAfter := err.RetryAfter
1✔
917
        waitTime := DefaultRateLimitWaitTime
1✔
918
        if retryAfter != nil && *retryAfter > 0 {
2✔
919
                waitTime = *retryAfter
1✔
920
        }
1✔
921

922
        logRateLimitError(logger, "AbuseRateLimitError", waitTime, c.delegate.GetOwner(), err.Response)
1✔
923

1✔
924
        if waitTime > MaxRateLimitWait {
1✔
925
                logger.Debug().Msgf("abuse rate limit wait time: %v exceeds maximum wait time: %v", waitTime, MaxRateLimitWait)
×
926
                return err
×
927
        }
×
928

929
        // Wait for the rate limit to reset
930
        select {
1✔
931
        case <-time.After(waitTime):
1✔
932
                return nil
1✔
933
        case <-ctx.Done():
×
934
                logger.Debug().Err(ctx.Err()).Msg("context done while waiting for rate limit to reset")
×
935
                return err
×
936
        }
937
}
938

939
func logRateLimitError(logger *zerolog.Logger, errType string, waitTime time.Duration, owner string, resp *http.Response) {
18✔
940
        var method, path string
18✔
941
        if resp != nil && resp.Request != nil {
36✔
942
                method = resp.Request.Method
18✔
943
                path = resp.Request.URL.Path
18✔
944
        }
18✔
945

946
        event := logger.Debug().
18✔
947
                Str("owner", owner).
18✔
948
                Str("wait_time", waitTime.String()).
18✔
949
                Str("error_type", errType)
18✔
950

18✔
951
        if method != "" {
36✔
952
                event = event.Str("method", method)
18✔
953
        }
18✔
954

955
        if path != "" {
36✔
956
                event = event.Str("path", path)
18✔
957
        }
18✔
958

959
        event.Msg("rate limit exceeded")
18✔
960
}
961

962
func performWithRetry[T any](ctx context.Context, op backoffv4.OperationWithData[T]) (T, error) {
23✔
963
        exponentialBackOff := backoffv4.NewExponentialBackOff()
23✔
964
        maxRetriesBackoff := backoffv4.WithMaxRetries(exponentialBackOff, MaxRateLimitRetries)
23✔
965
        return backoffv4.RetryWithData(op, backoffv4.WithContext(maxRetriesBackoff, ctx))
23✔
966
}
23✔
967

968
func isRateLimitError(err error) bool {
23✔
969
        var rateLimitError *github.RateLimitError
23✔
970
        isRateLimitErr := errors.As(err, &rateLimitError)
23✔
971

23✔
972
        var abuseRateLimitError *github.AbuseRateLimitError
23✔
973
        isAbuseRateLimitErr := errors.As(err, &abuseRateLimitError)
23✔
974

23✔
975
        return isRateLimitErr || isAbuseRateLimitErr
23✔
976
}
23✔
977

978
// IsMinderHook checks if a GitHub hook is a Minder hook
979
func IsMinderHook(hook *github.Hook, hostURL string) (bool, error) {
4✔
980
        configURL := hook.GetConfig().GetURL()
4✔
981
        if configURL == "" {
5✔
982
                return false, fmt.Errorf("unexpected hook config structure: %v", hook.Config)
1✔
983
        }
1✔
984
        parsedURL, err := url.Parse(configURL)
3✔
985
        if err != nil {
4✔
986
                return false, err
1✔
987
        }
1✔
988
        if parsedURL.Host == hostURL {
3✔
989
                return true, nil
1✔
990
        }
1✔
991

992
        return false, nil
1✔
993
}
994

995
// CanHandleOwner checks if the GitHub provider has the right credentials to handle the owner
996
func CanHandleOwner(_ context.Context, prov db.Provider, owner string) bool {
4✔
997
        // TODO: this is fragile and does not handle organization renames, in the future we can make sure the credential
4✔
998
        // has admin permissions on the owner
4✔
999
        if prov.Name == fmt.Sprintf("%s-%s", db.ProviderClassGithubApp, owner) {
5✔
1000
                return true
1✔
1001
        }
1✔
1002
        if prov.Class == db.ProviderClassGithub {
4✔
1003
                return true
1✔
1004
        }
1✔
1005
        return false
2✔
1006
}
1007

1008
// NewFallbackTokenClient creates a new GitHub client that uses the GitHub App's fallback token
1009
func NewFallbackTokenClient(appConfig config.ProviderConfig) *github.Client {
3✔
1010
        if appConfig.GitHubApp == nil {
4✔
1011
                return nil
1✔
1012
        }
1✔
1013
        fallbackToken, err := appConfig.GitHubApp.GetFallbackToken()
2✔
1014
        if err != nil || fallbackToken == "" {
3✔
1015
                return nil
1✔
1016
        }
1✔
1017
        var packageListingClient *github.Client
1✔
1018

1✔
1019
        fallbackTokenSource := oauth2.StaticTokenSource(
1✔
1020
                &oauth2.Token{AccessToken: fallbackToken},
1✔
1021
        )
1✔
1022
        fallbackTokenTC := &http.Client{
1✔
1023
                Transport: &oauth2.Transport{
1✔
1024
                        Base:   http.DefaultClient.Transport,
1✔
1025
                        Source: fallbackTokenSource,
1✔
1026
                },
1✔
1027
        }
1✔
1028

1✔
1029
        packageListingClient = github.NewClient(fallbackTokenTC)
1✔
1030
        return packageListingClient
1✔
1031
}
1032

1033
// StartCheckRun calls the GitHub API to initialize a new check using the
1034
// supplied options.
1035
func (c *GitHub) StartCheckRun(
1036
        ctx context.Context, owner, repo string, opts *github.CreateCheckRunOptions,
1037
) (*github.CheckRun, error) {
2✔
1038
        if opts.StartedAt == nil {
3✔
1039
                opts.StartedAt = &github.Timestamp{Time: time.Now()}
1✔
1040
        }
1✔
1041

1042
        run, resp, err := c.client.Checks.CreateCheckRun(ctx, owner, repo, *opts)
2✔
1043
        if err != nil {
3✔
1044
                // If error is 403 then it means we are missing permissions
1✔
1045
                if resp.StatusCode == 403 {
2✔
1046
                        return nil, fmt.Errorf("missing permissions: check")
1✔
1047
                }
1✔
1048
                return nil, ErroNoCheckPermissions
×
1049
        }
1050
        return run, nil
1✔
1051
}
1052

1053
// UpdateCheckRun updates an existing check run in GitHub. The check run is referenced
1054
// using its run ID. This function returns the updated CheckRun srtuct.
1055
func (c *GitHub) UpdateCheckRun(
1056
        ctx context.Context, owner, repo string, checkRunID int64, opts *github.UpdateCheckRunOptions,
1057
) (*github.CheckRun, error) {
2✔
1058
        run, resp, err := c.client.Checks.UpdateCheckRun(ctx, owner, repo, checkRunID, *opts)
2✔
1059
        if err != nil {
3✔
1060
                // If error is 403 then it means we are missing permissions
1✔
1061
                if resp.StatusCode == 403 {
2✔
1062
                        return nil, ErroNoCheckPermissions
1✔
1063
                }
1✔
1064
                return nil, fmt.Errorf("updating check: %w", err)
×
1065
        }
1066
        return run, nil
1✔
1067
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc