Files
grafana/apps/provisioning/pkg/repository/github/impl.go
Roberto Jiménez Sánchez 4eadc823a9 Provisioning: Move repository package to provisioning app (#110228)
* Move repository package to apps

* Move operators to grafana/grafana

* Go mod tidy

* Own package by git sync team for now

* Merged

* Do not use settings in local extra

* Remove dependency on webhook extra

* Hack to work around issue with secure contracts

* Sync Go modules

* Revert "Move operators to grafana/grafana"

This reverts commit 9f19b30a2e.
2025-09-02 09:45:44 +02:00

334 lines
8.8 KiB
Go

package github
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/google/go-github/v70/github"
)
type githubClient struct {
gh *github.Client
}
func NewClient(client *github.Client) Client {
return &githubClient{client}
}
const (
maxCommits = 1000 // Maximum number of commits to fetch
maxWebhooks = 100 // Maximum number of webhooks allowed per repository
maxPRFiles = 1000 // Maximum number of files allowed in a pull request
)
// Commits returns a list of commits for a given repository and branch.
func (r *githubClient) Commits(ctx context.Context, owner, repository, path, branch string) ([]Commit, error) {
listFn := func(ctx context.Context, opts *github.ListOptions) ([]*github.RepositoryCommit, *github.Response, error) {
return r.gh.Repositories.ListCommits(ctx, owner, repository, &github.CommitsListOptions{
Path: path,
SHA: branch,
ListOptions: *opts,
})
}
commits, err := paginatedList(
ctx,
listFn,
defaultListOptions(maxCommits),
)
if errors.Is(err, ErrTooManyItems) {
return nil, fmt.Errorf("too many commits to fetch (more than %d)", maxCommits)
}
if err != nil {
return nil, err
}
ret := make([]Commit, 0, len(commits))
for _, c := range commits {
// FIXME: This code is a mess. I am pretty sure that we have issue in
// some situations
var createdAt time.Time
var author *CommitAuthor
if c.GetCommit().GetAuthor() != nil {
author = &CommitAuthor{
Name: c.GetCommit().GetAuthor().GetName(),
Username: c.GetAuthor().GetLogin(),
AvatarURL: c.GetAuthor().GetAvatarURL(),
}
createdAt = c.GetCommit().GetAuthor().GetDate().Time
}
var committer *CommitAuthor
if c.GetCommitter() != nil {
committer = &CommitAuthor{
Name: c.GetCommit().GetCommitter().GetName(),
Username: c.GetCommitter().GetLogin(),
AvatarURL: c.GetCommitter().GetAvatarURL(),
}
}
ret = append(ret, Commit{
Ref: c.GetSHA(),
Message: c.GetCommit().GetMessage(),
Author: author,
Committer: committer,
CreatedAt: createdAt,
})
}
return ret, nil
}
func (r *githubClient) ListWebhooks(ctx context.Context, owner, repository string) ([]WebhookConfig, error) {
listFn := func(ctx context.Context, opts *github.ListOptions) ([]*github.Hook, *github.Response, error) {
return r.gh.Repositories.ListHooks(ctx, owner, repository, opts)
}
hooks, err := paginatedList(
ctx,
listFn,
defaultListOptions(maxWebhooks),
)
if errors.Is(err, ErrTooManyItems) {
return nil, fmt.Errorf("too many webhooks configured (more than %d)", maxWebhooks)
}
if err != nil {
return nil, err
}
// Pre-allocate the result slice
ret := make([]WebhookConfig, 0, len(hooks))
for _, h := range hooks {
contentType := h.GetConfig().GetContentType()
if contentType == "" {
contentType = "form"
}
ret = append(ret, WebhookConfig{
ID: h.GetID(),
Events: h.Events,
Active: h.GetActive(),
URL: h.GetConfig().GetURL(),
ContentType: contentType,
// Intentionally not setting Secret.
})
}
return ret, nil
}
func (r *githubClient) CreateWebhook(ctx context.Context, owner, repository string, cfg WebhookConfig) (WebhookConfig, error) {
if cfg.ContentType == "" {
cfg.ContentType = "form"
}
hook := &github.Hook{
URL: &cfg.URL,
Events: cfg.Events,
Active: &cfg.Active,
Config: &github.HookConfig{
ContentType: &cfg.ContentType,
Secret: &cfg.Secret,
URL: &cfg.URL,
},
}
createdHook, _, err := r.gh.Repositories.CreateHook(ctx, owner, repository, hook)
var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable {
return WebhookConfig{}, ErrServiceUnavailable
}
if err != nil {
return WebhookConfig{}, err
}
return WebhookConfig{
ID: createdHook.GetID(),
// events is not returned by GitHub.
Events: cfg.Events,
Active: createdHook.GetActive(),
URL: createdHook.GetConfig().GetURL(),
ContentType: createdHook.GetConfig().GetContentType(),
// Secret is not returned by GitHub.
Secret: cfg.Secret,
}, nil
}
func (r *githubClient) GetWebhook(ctx context.Context, owner, repository string, webhookID int64) (WebhookConfig, error) {
hook, _, err := r.gh.Repositories.GetHook(ctx, owner, repository, webhookID)
if err != nil {
var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable {
return WebhookConfig{}, ErrServiceUnavailable
}
if ghErr.Response.StatusCode == http.StatusNotFound {
return WebhookConfig{}, ErrResourceNotFound
}
return WebhookConfig{}, err
}
contentType := hook.GetConfig().GetContentType()
if contentType == "" {
// FIXME: Not sure about the value of the contentType
// we default to form in the other ones but to JSON here
contentType = "json"
}
return WebhookConfig{
ID: hook.GetID(),
Events: hook.Events,
Active: hook.GetActive(),
URL: hook.GetConfig().GetURL(),
ContentType: contentType,
// Intentionally not setting Secret.
}, nil
}
func (r *githubClient) DeleteWebhook(ctx context.Context, owner, repository string, webhookID int64) error {
_, err := r.gh.Repositories.DeleteHook(ctx, owner, repository, webhookID)
var ghErr *github.ErrorResponse
if !errors.As(err, &ghErr) {
return err
}
if ghErr.Response.StatusCode == http.StatusServiceUnavailable {
return ErrServiceUnavailable
}
if ghErr.Response.StatusCode == http.StatusNotFound {
return ErrResourceNotFound
}
return err
}
func (r *githubClient) EditWebhook(ctx context.Context, owner, repository string, cfg WebhookConfig) error {
if cfg.ContentType == "" {
cfg.ContentType = "form"
}
hook := &github.Hook{
URL: &cfg.URL,
Events: cfg.Events,
Active: &cfg.Active,
Config: &github.HookConfig{
ContentType: &cfg.ContentType,
Secret: &cfg.Secret,
URL: &cfg.URL,
},
}
_, _, err := r.gh.Repositories.EditHook(ctx, owner, repository, cfg.ID, hook)
var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable {
return ErrServiceUnavailable
}
return err
}
func (r *githubClient) ListPullRequestFiles(ctx context.Context, owner, repository string, number int) ([]CommitFile, error) {
listFn := func(ctx context.Context, opts *github.ListOptions) ([]*github.CommitFile, *github.Response, error) {
return r.gh.PullRequests.ListFiles(ctx, owner, repository, number, opts)
}
files, err := paginatedList(
ctx,
listFn,
defaultListOptions(maxPRFiles),
)
if errors.Is(err, ErrTooManyItems) {
return nil, fmt.Errorf("pull request contains too many files (more than %d)", maxPRFiles)
}
if err != nil {
return nil, err
}
// Convert to the interface type
ret := make([]CommitFile, 0, len(files))
for _, f := range files {
ret = append(ret, f)
}
return ret, nil
}
func (r *githubClient) CreatePullRequestComment(ctx context.Context, owner, repository string, number int, body string) error {
comment := &github.IssueComment{
Body: &body,
}
if _, _, err := r.gh.Issues.CreateComment(ctx, owner, repository, number, comment); err != nil {
var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable {
return ErrServiceUnavailable
}
return err
}
return nil
}
// listOptions represents pagination parameters for list operations
type listOptions struct {
github.ListOptions
MaxItems int
}
// defaultListOptions returns a ListOptions with sensible defaults
func defaultListOptions(maxItems int) listOptions {
return listOptions{
ListOptions: github.ListOptions{
Page: 1,
PerPage: 100,
},
MaxItems: maxItems,
}
}
// paginatedList is a generic function to handle GitHub API pagination
func paginatedList[T any](
ctx context.Context,
listFn func(context.Context, *github.ListOptions) ([]T, *github.Response, error),
opts listOptions,
) ([]T, error) {
var allItems []T
for {
items, resp, err := listFn(ctx, &opts.ListOptions)
if err != nil {
var ghErr *github.ErrorResponse
if !errors.As(err, &ghErr) {
return nil, err
}
if ghErr.Response.StatusCode == http.StatusServiceUnavailable {
return nil, ErrServiceUnavailable
}
if ghErr.Response.StatusCode == http.StatusNotFound {
return nil, ErrResourceNotFound
}
return nil, err
}
// Pre-allocate the slice if this is the first page
if allItems == nil {
allItems = make([]T, 0, len(items)*2) // Estimate double the first page size
}
allItems = append(allItems, items...)
// Check if we've exceeded the maximum allowed items
if len(allItems) > opts.MaxItems {
return nil, ErrTooManyItems
}
// If there are no more pages, break
if resp.NextPage == 0 {
break
}
// Set up next page
opts.Page = resp.NextPage
}
return allItems, nil
}