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.
This commit is contained in:
Roberto Jiménez Sánchez
2025-09-02 09:45:44 +02:00
committed by GitHub
parent fb7b69e8b5
commit 4eadc823a9
140 changed files with 223 additions and 136 deletions
@@ -0,0 +1,76 @@
// The github package exists to provide a client for the GH API, which can also be faked with a mock.
// In most cases, we want the real client, but testing should mock it, lest we get blocked from their API, or have to configure auth for simple tests.
package github
import (
"context"
"errors"
"time"
apierrors "k8s.io/apimachinery/pkg/api/errors"
)
// API errors that we need to convey after parsing real GH errors (or faking them).
var (
ErrResourceNotFound = errors.New("the resource does not exist")
//lint:ignore ST1005 this is not punctuation
ErrServiceUnavailable = apierrors.NewServiceUnavailable("github is unavailable")
ErrTooManyItems = errors.New("maximum number of items exceeded")
)
//go:generate mockery --name Client --structname MockClient --inpackage --filename mock_client.go --with-expecter
type Client interface {
// Commits
Commits(ctx context.Context, owner, repository, path, branch string) ([]Commit, error)
// Webhooks
ListWebhooks(ctx context.Context, owner, repository string) ([]WebhookConfig, error)
CreateWebhook(ctx context.Context, owner, repository string, cfg WebhookConfig) (WebhookConfig, error)
GetWebhook(ctx context.Context, owner, repository string, webhookID int64) (WebhookConfig, error)
DeleteWebhook(ctx context.Context, owner, repository string, webhookID int64) error
EditWebhook(ctx context.Context, owner, repository string, cfg WebhookConfig) error
// Pull requests
ListPullRequestFiles(ctx context.Context, owner, repository string, number int) ([]CommitFile, error)
CreatePullRequestComment(ctx context.Context, owner, repository string, number int, body string) error
}
type CommitAuthor struct {
Name string
Username string
AvatarURL string
}
type Commit struct {
Ref string
Message string
Author *CommitAuthor
Committer *CommitAuthor
CreatedAt time.Time
}
//go:generate mockery --name CommitFile --structname MockCommitFile --inpackage --filename mock_commit_file.go --with-expecter
type CommitFile interface {
GetSHA() string
GetFilename() string
GetPreviousFilename() string
GetStatus() string
}
type WebhookConfig struct {
// The ID of the webhook.
// Can be 0 on creation.
ID int64
// The events which this webhook shall contact the URL for.
Events []string
// Is the webhook enabled?
Active bool
// The URL GitHub should contact on events.
URL string
// The content type GitHub should send to the URL.
// If not specified, this is "form".
ContentType string
// The secret to use when sending events to the URL.
// If fetched from GitHub, this is empty as it contains no useful information.
Secret string
}
@@ -0,0 +1,86 @@
package github
import (
"context"
"fmt"
"github.com/grafana/grafana-app-sdk/logging"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/apps/provisioning/pkg/repository/git"
"k8s.io/apimachinery/pkg/runtime"
)
type WebhookURLBuilder interface {
WebhookURL(ctx context.Context, r *provisioning.Repository) string
}
type extra struct {
factory *Factory
decrypter repository.Decrypter
webhookBuilder WebhookURLBuilder
}
func Extra(decrypter repository.Decrypter, factory *Factory, webhookBuilder WebhookURLBuilder) repository.Extra {
return &extra{
decrypter: decrypter,
factory: factory,
webhookBuilder: webhookBuilder,
}
}
func (e *extra) Type() provisioning.RepositoryType {
return provisioning.GitHubRepositoryType
}
func (e *extra) Build(ctx context.Context, r *provisioning.Repository) (repository.Repository, error) {
logger := logging.FromContext(ctx).With("url", r.Spec.GitHub.URL, "branch", r.Spec.GitHub.Branch, "path", r.Spec.GitHub.Path)
logger.Info("Instantiating Github repository")
secure := e.decrypter(r)
cfg := r.Spec.GitHub
if cfg == nil {
return nil, fmt.Errorf("github configuration is required")
}
token, err := secure.Token(ctx)
if err != nil {
return nil, fmt.Errorf("unable to decrypt token: %w", err)
}
gitRepo, err := git.NewRepository(ctx, r, git.RepositoryConfig{
URL: cfg.URL,
Branch: cfg.Branch,
Path: cfg.Path,
Token: token,
})
if err != nil {
return nil, fmt.Errorf("error creating git repository: %w", err)
}
ghRepo, err := NewRepository(ctx, r, gitRepo, e.factory, token)
if err != nil {
return nil, fmt.Errorf("error creating github repository: %w", err)
}
if e.webhookBuilder == nil {
return ghRepo, nil
}
webhookURL := e.webhookBuilder.WebhookURL(ctx, r)
if len(webhookURL) == 0 {
logger.Debug("Skipping webhook setup as no webhooks are not configured")
return ghRepo, nil
}
webhookSecret, err := secure.WebhookSecret(ctx)
if err != nil {
return nil, fmt.Errorf("decrypt webhookSecret: %w", err)
}
return NewGithubWebhookRepository(ghRepo, webhookURL, webhookSecret), nil
}
func (e *extra) Mutate(ctx context.Context, obj runtime.Object) error {
return Mutate(ctx, obj)
}
@@ -0,0 +1,39 @@
package github
import (
"context"
"net/http"
"github.com/google/go-github/v70/github"
"golang.org/x/oauth2"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
)
// Factory creates new GitHub clients.
// It exists only for the ability to test the code easily.
type Factory struct {
// Client allows overriding the client to use in the GH client returned. It exists primarily for testing.
// FIXME: we should replace in this way. We should add some options pattern for the factory.
Client *http.Client
}
func ProvideFactory() *Factory {
return &Factory{}
}
func (r *Factory) New(ctx context.Context, ghToken common.RawSecureValue) Client {
if r.Client != nil {
return NewClient(github.NewClient(r.Client))
}
if !ghToken.IsZero() {
tokenSrc := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: string(ghToken)},
)
tokenClient := oauth2.NewClient(ctx, tokenSrc)
return NewClient(github.NewClient(tokenClient))
}
return NewClient(github.NewClient(&http.Client{}))
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,333 @@
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
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,485 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
package github
import (
context "context"
mock "github.com/stretchr/testify/mock"
)
// MockClient is an autogenerated mock type for the Client type
type MockClient struct {
mock.Mock
}
type MockClient_Expecter struct {
mock *mock.Mock
}
func (_m *MockClient) EXPECT() *MockClient_Expecter {
return &MockClient_Expecter{mock: &_m.Mock}
}
// Commits provides a mock function with given fields: ctx, owner, repository, path, branch
func (_m *MockClient) Commits(ctx context.Context, owner string, repository string, path string, branch string) ([]Commit, error) {
ret := _m.Called(ctx, owner, repository, path, branch)
if len(ret) == 0 {
panic("no return value specified for Commits")
}
var r0 []Commit
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) ([]Commit, error)); ok {
return rf(ctx, owner, repository, path, branch)
}
if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) []Commit); ok {
r0 = rf(ctx, owner, repository, path, branch)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]Commit)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, string, string, string) error); ok {
r1 = rf(ctx, owner, repository, path, branch)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockClient_Commits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Commits'
type MockClient_Commits_Call struct {
*mock.Call
}
// Commits is a helper method to define mock.On call
// - ctx context.Context
// - owner string
// - repository string
// - path string
// - branch string
func (_e *MockClient_Expecter) Commits(ctx interface{}, owner interface{}, repository interface{}, path interface{}, branch interface{}) *MockClient_Commits_Call {
return &MockClient_Commits_Call{Call: _e.mock.On("Commits", ctx, owner, repository, path, branch)}
}
func (_c *MockClient_Commits_Call) Run(run func(ctx context.Context, owner string, repository string, path string, branch string)) *MockClient_Commits_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string), args[4].(string))
})
return _c
}
func (_c *MockClient_Commits_Call) Return(_a0 []Commit, _a1 error) *MockClient_Commits_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockClient_Commits_Call) RunAndReturn(run func(context.Context, string, string, string, string) ([]Commit, error)) *MockClient_Commits_Call {
_c.Call.Return(run)
return _c
}
// CreatePullRequestComment provides a mock function with given fields: ctx, owner, repository, number, body
func (_m *MockClient) CreatePullRequestComment(ctx context.Context, owner string, repository string, number int, body string) error {
ret := _m.Called(ctx, owner, repository, number, body)
if len(ret) == 0 {
panic("no return value specified for CreatePullRequestComment")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, int, string) error); ok {
r0 = rf(ctx, owner, repository, number, body)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockClient_CreatePullRequestComment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreatePullRequestComment'
type MockClient_CreatePullRequestComment_Call struct {
*mock.Call
}
// CreatePullRequestComment is a helper method to define mock.On call
// - ctx context.Context
// - owner string
// - repository string
// - number int
// - body string
func (_e *MockClient_Expecter) CreatePullRequestComment(ctx interface{}, owner interface{}, repository interface{}, number interface{}, body interface{}) *MockClient_CreatePullRequestComment_Call {
return &MockClient_CreatePullRequestComment_Call{Call: _e.mock.On("CreatePullRequestComment", ctx, owner, repository, number, body)}
}
func (_c *MockClient_CreatePullRequestComment_Call) Run(run func(ctx context.Context, owner string, repository string, number int, body string)) *MockClient_CreatePullRequestComment_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(int), args[4].(string))
})
return _c
}
func (_c *MockClient_CreatePullRequestComment_Call) Return(_a0 error) *MockClient_CreatePullRequestComment_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockClient_CreatePullRequestComment_Call) RunAndReturn(run func(context.Context, string, string, int, string) error) *MockClient_CreatePullRequestComment_Call {
_c.Call.Return(run)
return _c
}
// CreateWebhook provides a mock function with given fields: ctx, owner, repository, cfg
func (_m *MockClient) CreateWebhook(ctx context.Context, owner string, repository string, cfg WebhookConfig) (WebhookConfig, error) {
ret := _m.Called(ctx, owner, repository, cfg)
if len(ret) == 0 {
panic("no return value specified for CreateWebhook")
}
var r0 WebhookConfig
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, WebhookConfig) (WebhookConfig, error)); ok {
return rf(ctx, owner, repository, cfg)
}
if rf, ok := ret.Get(0).(func(context.Context, string, string, WebhookConfig) WebhookConfig); ok {
r0 = rf(ctx, owner, repository, cfg)
} else {
r0 = ret.Get(0).(WebhookConfig)
}
if rf, ok := ret.Get(1).(func(context.Context, string, string, WebhookConfig) error); ok {
r1 = rf(ctx, owner, repository, cfg)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockClient_CreateWebhook_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateWebhook'
type MockClient_CreateWebhook_Call struct {
*mock.Call
}
// CreateWebhook is a helper method to define mock.On call
// - ctx context.Context
// - owner string
// - repository string
// - cfg WebhookConfig
func (_e *MockClient_Expecter) CreateWebhook(ctx interface{}, owner interface{}, repository interface{}, cfg interface{}) *MockClient_CreateWebhook_Call {
return &MockClient_CreateWebhook_Call{Call: _e.mock.On("CreateWebhook", ctx, owner, repository, cfg)}
}
func (_c *MockClient_CreateWebhook_Call) Run(run func(ctx context.Context, owner string, repository string, cfg WebhookConfig)) *MockClient_CreateWebhook_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(WebhookConfig))
})
return _c
}
func (_c *MockClient_CreateWebhook_Call) Return(_a0 WebhookConfig, _a1 error) *MockClient_CreateWebhook_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockClient_CreateWebhook_Call) RunAndReturn(run func(context.Context, string, string, WebhookConfig) (WebhookConfig, error)) *MockClient_CreateWebhook_Call {
_c.Call.Return(run)
return _c
}
// DeleteWebhook provides a mock function with given fields: ctx, owner, repository, webhookID
func (_m *MockClient) DeleteWebhook(ctx context.Context, owner string, repository string, webhookID int64) error {
ret := _m.Called(ctx, owner, repository, webhookID)
if len(ret) == 0 {
panic("no return value specified for DeleteWebhook")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, int64) error); ok {
r0 = rf(ctx, owner, repository, webhookID)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockClient_DeleteWebhook_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteWebhook'
type MockClient_DeleteWebhook_Call struct {
*mock.Call
}
// DeleteWebhook is a helper method to define mock.On call
// - ctx context.Context
// - owner string
// - repository string
// - webhookID int64
func (_e *MockClient_Expecter) DeleteWebhook(ctx interface{}, owner interface{}, repository interface{}, webhookID interface{}) *MockClient_DeleteWebhook_Call {
return &MockClient_DeleteWebhook_Call{Call: _e.mock.On("DeleteWebhook", ctx, owner, repository, webhookID)}
}
func (_c *MockClient_DeleteWebhook_Call) Run(run func(ctx context.Context, owner string, repository string, webhookID int64)) *MockClient_DeleteWebhook_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(int64))
})
return _c
}
func (_c *MockClient_DeleteWebhook_Call) Return(_a0 error) *MockClient_DeleteWebhook_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockClient_DeleteWebhook_Call) RunAndReturn(run func(context.Context, string, string, int64) error) *MockClient_DeleteWebhook_Call {
_c.Call.Return(run)
return _c
}
// EditWebhook provides a mock function with given fields: ctx, owner, repository, cfg
func (_m *MockClient) EditWebhook(ctx context.Context, owner string, repository string, cfg WebhookConfig) error {
ret := _m.Called(ctx, owner, repository, cfg)
if len(ret) == 0 {
panic("no return value specified for EditWebhook")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, WebhookConfig) error); ok {
r0 = rf(ctx, owner, repository, cfg)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockClient_EditWebhook_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EditWebhook'
type MockClient_EditWebhook_Call struct {
*mock.Call
}
// EditWebhook is a helper method to define mock.On call
// - ctx context.Context
// - owner string
// - repository string
// - cfg WebhookConfig
func (_e *MockClient_Expecter) EditWebhook(ctx interface{}, owner interface{}, repository interface{}, cfg interface{}) *MockClient_EditWebhook_Call {
return &MockClient_EditWebhook_Call{Call: _e.mock.On("EditWebhook", ctx, owner, repository, cfg)}
}
func (_c *MockClient_EditWebhook_Call) Run(run func(ctx context.Context, owner string, repository string, cfg WebhookConfig)) *MockClient_EditWebhook_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(WebhookConfig))
})
return _c
}
func (_c *MockClient_EditWebhook_Call) Return(_a0 error) *MockClient_EditWebhook_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockClient_EditWebhook_Call) RunAndReturn(run func(context.Context, string, string, WebhookConfig) error) *MockClient_EditWebhook_Call {
_c.Call.Return(run)
return _c
}
// GetWebhook provides a mock function with given fields: ctx, owner, repository, webhookID
func (_m *MockClient) GetWebhook(ctx context.Context, owner string, repository string, webhookID int64) (WebhookConfig, error) {
ret := _m.Called(ctx, owner, repository, webhookID)
if len(ret) == 0 {
panic("no return value specified for GetWebhook")
}
var r0 WebhookConfig
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, int64) (WebhookConfig, error)); ok {
return rf(ctx, owner, repository, webhookID)
}
if rf, ok := ret.Get(0).(func(context.Context, string, string, int64) WebhookConfig); ok {
r0 = rf(ctx, owner, repository, webhookID)
} else {
r0 = ret.Get(0).(WebhookConfig)
}
if rf, ok := ret.Get(1).(func(context.Context, string, string, int64) error); ok {
r1 = rf(ctx, owner, repository, webhookID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockClient_GetWebhook_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetWebhook'
type MockClient_GetWebhook_Call struct {
*mock.Call
}
// GetWebhook is a helper method to define mock.On call
// - ctx context.Context
// - owner string
// - repository string
// - webhookID int64
func (_e *MockClient_Expecter) GetWebhook(ctx interface{}, owner interface{}, repository interface{}, webhookID interface{}) *MockClient_GetWebhook_Call {
return &MockClient_GetWebhook_Call{Call: _e.mock.On("GetWebhook", ctx, owner, repository, webhookID)}
}
func (_c *MockClient_GetWebhook_Call) Run(run func(ctx context.Context, owner string, repository string, webhookID int64)) *MockClient_GetWebhook_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(int64))
})
return _c
}
func (_c *MockClient_GetWebhook_Call) Return(_a0 WebhookConfig, _a1 error) *MockClient_GetWebhook_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockClient_GetWebhook_Call) RunAndReturn(run func(context.Context, string, string, int64) (WebhookConfig, error)) *MockClient_GetWebhook_Call {
_c.Call.Return(run)
return _c
}
// ListPullRequestFiles provides a mock function with given fields: ctx, owner, repository, number
func (_m *MockClient) ListPullRequestFiles(ctx context.Context, owner string, repository string, number int) ([]CommitFile, error) {
ret := _m.Called(ctx, owner, repository, number)
if len(ret) == 0 {
panic("no return value specified for ListPullRequestFiles")
}
var r0 []CommitFile
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, int) ([]CommitFile, error)); ok {
return rf(ctx, owner, repository, number)
}
if rf, ok := ret.Get(0).(func(context.Context, string, string, int) []CommitFile); ok {
r0 = rf(ctx, owner, repository, number)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]CommitFile)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, string, int) error); ok {
r1 = rf(ctx, owner, repository, number)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockClient_ListPullRequestFiles_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListPullRequestFiles'
type MockClient_ListPullRequestFiles_Call struct {
*mock.Call
}
// ListPullRequestFiles is a helper method to define mock.On call
// - ctx context.Context
// - owner string
// - repository string
// - number int
func (_e *MockClient_Expecter) ListPullRequestFiles(ctx interface{}, owner interface{}, repository interface{}, number interface{}) *MockClient_ListPullRequestFiles_Call {
return &MockClient_ListPullRequestFiles_Call{Call: _e.mock.On("ListPullRequestFiles", ctx, owner, repository, number)}
}
func (_c *MockClient_ListPullRequestFiles_Call) Run(run func(ctx context.Context, owner string, repository string, number int)) *MockClient_ListPullRequestFiles_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(int))
})
return _c
}
func (_c *MockClient_ListPullRequestFiles_Call) Return(_a0 []CommitFile, _a1 error) *MockClient_ListPullRequestFiles_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockClient_ListPullRequestFiles_Call) RunAndReturn(run func(context.Context, string, string, int) ([]CommitFile, error)) *MockClient_ListPullRequestFiles_Call {
_c.Call.Return(run)
return _c
}
// ListWebhooks provides a mock function with given fields: ctx, owner, repository
func (_m *MockClient) ListWebhooks(ctx context.Context, owner string, repository string) ([]WebhookConfig, error) {
ret := _m.Called(ctx, owner, repository)
if len(ret) == 0 {
panic("no return value specified for ListWebhooks")
}
var r0 []WebhookConfig
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, string) ([]WebhookConfig, error)); ok {
return rf(ctx, owner, repository)
}
if rf, ok := ret.Get(0).(func(context.Context, string, string) []WebhookConfig); ok {
r0 = rf(ctx, owner, repository)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]WebhookConfig)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
r1 = rf(ctx, owner, repository)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockClient_ListWebhooks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListWebhooks'
type MockClient_ListWebhooks_Call struct {
*mock.Call
}
// ListWebhooks is a helper method to define mock.On call
// - ctx context.Context
// - owner string
// - repository string
func (_e *MockClient_Expecter) ListWebhooks(ctx interface{}, owner interface{}, repository interface{}) *MockClient_ListWebhooks_Call {
return &MockClient_ListWebhooks_Call{Call: _e.mock.On("ListWebhooks", ctx, owner, repository)}
}
func (_c *MockClient_ListWebhooks_Call) Run(run func(ctx context.Context, owner string, repository string)) *MockClient_ListWebhooks_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string))
})
return _c
}
func (_c *MockClient_ListWebhooks_Call) Return(_a0 []WebhookConfig, _a1 error) *MockClient_ListWebhooks_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockClient_ListWebhooks_Call) RunAndReturn(run func(context.Context, string, string) ([]WebhookConfig, error)) *MockClient_ListWebhooks_Call {
_c.Call.Return(run)
return _c
}
// NewMockClient creates a new instance of MockClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockClient(t interface {
mock.TestingT
Cleanup(func())
}) *MockClient {
mock := &MockClient{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -0,0 +1,212 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
package github
import mock "github.com/stretchr/testify/mock"
// MockCommitFile is an autogenerated mock type for the CommitFile type
type MockCommitFile struct {
mock.Mock
}
type MockCommitFile_Expecter struct {
mock *mock.Mock
}
func (_m *MockCommitFile) EXPECT() *MockCommitFile_Expecter {
return &MockCommitFile_Expecter{mock: &_m.Mock}
}
// GetFilename provides a mock function with no fields
func (_m *MockCommitFile) GetFilename() string {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetFilename")
}
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// MockCommitFile_GetFilename_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetFilename'
type MockCommitFile_GetFilename_Call struct {
*mock.Call
}
// GetFilename is a helper method to define mock.On call
func (_e *MockCommitFile_Expecter) GetFilename() *MockCommitFile_GetFilename_Call {
return &MockCommitFile_GetFilename_Call{Call: _e.mock.On("GetFilename")}
}
func (_c *MockCommitFile_GetFilename_Call) Run(run func()) *MockCommitFile_GetFilename_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockCommitFile_GetFilename_Call) Return(_a0 string) *MockCommitFile_GetFilename_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockCommitFile_GetFilename_Call) RunAndReturn(run func() string) *MockCommitFile_GetFilename_Call {
_c.Call.Return(run)
return _c
}
// GetPreviousFilename provides a mock function with no fields
func (_m *MockCommitFile) GetPreviousFilename() string {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetPreviousFilename")
}
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// MockCommitFile_GetPreviousFilename_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPreviousFilename'
type MockCommitFile_GetPreviousFilename_Call struct {
*mock.Call
}
// GetPreviousFilename is a helper method to define mock.On call
func (_e *MockCommitFile_Expecter) GetPreviousFilename() *MockCommitFile_GetPreviousFilename_Call {
return &MockCommitFile_GetPreviousFilename_Call{Call: _e.mock.On("GetPreviousFilename")}
}
func (_c *MockCommitFile_GetPreviousFilename_Call) Run(run func()) *MockCommitFile_GetPreviousFilename_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockCommitFile_GetPreviousFilename_Call) Return(_a0 string) *MockCommitFile_GetPreviousFilename_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockCommitFile_GetPreviousFilename_Call) RunAndReturn(run func() string) *MockCommitFile_GetPreviousFilename_Call {
_c.Call.Return(run)
return _c
}
// GetSHA provides a mock function with no fields
func (_m *MockCommitFile) GetSHA() string {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetSHA")
}
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// MockCommitFile_GetSHA_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSHA'
type MockCommitFile_GetSHA_Call struct {
*mock.Call
}
// GetSHA is a helper method to define mock.On call
func (_e *MockCommitFile_Expecter) GetSHA() *MockCommitFile_GetSHA_Call {
return &MockCommitFile_GetSHA_Call{Call: _e.mock.On("GetSHA")}
}
func (_c *MockCommitFile_GetSHA_Call) Run(run func()) *MockCommitFile_GetSHA_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockCommitFile_GetSHA_Call) Return(_a0 string) *MockCommitFile_GetSHA_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockCommitFile_GetSHA_Call) RunAndReturn(run func() string) *MockCommitFile_GetSHA_Call {
_c.Call.Return(run)
return _c
}
// GetStatus provides a mock function with no fields
func (_m *MockCommitFile) GetStatus() string {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetStatus")
}
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// MockCommitFile_GetStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetStatus'
type MockCommitFile_GetStatus_Call struct {
*mock.Call
}
// GetStatus is a helper method to define mock.On call
func (_e *MockCommitFile_Expecter) GetStatus() *MockCommitFile_GetStatus_Call {
return &MockCommitFile_GetStatus_Call{Call: _e.mock.On("GetStatus")}
}
func (_c *MockCommitFile_GetStatus_Call) Run(run func()) *MockCommitFile_GetStatus_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockCommitFile_GetStatus_Call) Return(_a0 string) *MockCommitFile_GetStatus_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockCommitFile_GetStatus_Call) RunAndReturn(run func() string) *MockCommitFile_GetStatus_Call {
_c.Call.Return(run)
return _c
}
// NewMockCommitFile creates a new instance of MockCommitFile. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockCommitFile(t interface {
mock.TestingT
Cleanup(func())
}) *MockCommitFile {
mock := &MockCommitFile{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -0,0 +1,32 @@
package github
import (
"context"
"strings"
"k8s.io/apimachinery/pkg/runtime"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
)
func Mutate(ctx context.Context, obj runtime.Object) error {
repo, ok := obj.(*provisioning.Repository)
if !ok {
return nil
}
if repo.Spec.GitHub == nil {
return nil
}
// Trim trailing ".git" and any trailing slash from the GitHub URL, if present, using the strings package.
if repo.Spec.GitHub.URL != "" {
url := repo.Spec.GitHub.URL
url = strings.TrimRight(url, "/")
url = strings.TrimSuffix(url, ".git")
url = strings.TrimRight(url, "/")
repo.Spec.GitHub.URL = url
}
return nil
}
@@ -0,0 +1,118 @@
package github
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
)
func TestMutator(t *testing.T) {
tests := []struct {
name string
obj runtime.Object
token string
expectedError string
}{
{
name: "trims trailing .git and slash from GitHub URL",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "repo1",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/org/repo.git/",
},
},
},
},
{
name: "trims only trailing slash from GitHub URL",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "repo2",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/org/repo/",
},
},
},
},
{
name: "trims only trailing .git from GitHub URL",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "repo3",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/org/repo.git",
},
},
},
},
{
name: "does not trim if no .git or slash",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "repo4",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/org/repo",
},
},
},
},
{
name: "no github spec",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: nil,
},
},
},
{
name: "empty token",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{},
},
},
},
{
name: "non-repository object",
obj: &runtime.Unknown{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Mutate(context.Background(), tt.obj)
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
} else {
assert.NoError(t, err)
}
})
}
}
@@ -0,0 +1,236 @@
package github
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"k8s.io/apimachinery/pkg/util/validation/field"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/apps/provisioning/pkg/repository/git"
"github.com/grafana/grafana/apps/provisioning/pkg/safepath"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
)
// Make sure all public functions of this struct call the (*githubRepository).logger function, to ensure the GH repo details are included.
type githubRepository struct {
git.GitRepository
config *provisioning.Repository
gh Client // assumes github.com base URL
owner string
repo string
}
// GithubRepository is an interface that combines all repository capabilities
// needed for GitHub repositories.
//go:generate mockery --name GithubRepository --structname MockGithubRepository --inpackage --filename github_repository_mock.go --with-expecter
type GithubRepository interface {
repository.Repository
repository.Versioned
repository.Writer
repository.Reader
repository.RepositoryWithURLs
repository.StageableRepository
Owner() string
Repo() string
Client() Client
}
func NewRepository(
ctx context.Context,
config *provisioning.Repository,
gitRepo git.GitRepository,
factory *Factory,
token common.RawSecureValue,
) (GithubRepository, error) {
owner, repo, err := ParseOwnerRepoGithub(config.Spec.GitHub.URL)
if err != nil {
return nil, fmt.Errorf("parse owner and repo: %w", err)
}
return &githubRepository{
config: config,
GitRepository: gitRepo,
gh: factory.New(ctx, token), // TODO, baseURL from config
owner: owner,
repo: repo,
}, nil
}
func (r *githubRepository) Owner() string {
return r.owner
}
func (r *githubRepository) Repo() string {
return r.repo
}
func (r *githubRepository) Client() Client {
return r.gh
}
// Validate implements provisioning.Repository.
func (r *githubRepository) Validate() (list field.ErrorList) {
cfg := r.Config()
gh := cfg.Spec.GitHub
if gh == nil {
list = append(list, field.Required(field.NewPath("spec", "github"), "a github config is required"))
return list
}
if gh.URL == "" {
list = append(list, field.Required(field.NewPath("spec", "github", "url"), "a github url is required"))
} else {
_, _, err := ParseOwnerRepoGithub(gh.URL)
if err != nil {
list = append(list, field.Invalid(field.NewPath("spec", "github", "url"), gh.URL, err.Error()))
} else if !strings.HasPrefix(gh.URL, "https://github.com/") {
list = append(list, field.Invalid(field.NewPath("spec", "github", "url"), gh.URL, "URL must start with https://github.com/"))
}
}
if len(list) > 0 {
return list
}
return r.GitRepository.Validate()
}
func ParseOwnerRepoGithub(giturl string) (owner string, repo string, err error) {
giturl = strings.TrimSuffix(giturl, ".git")
giturl = strings.TrimSuffix(giturl, "/")
parsed, e := url.Parse(giturl)
if e != nil {
err = e
return
}
parts := strings.Split(parsed.Path, "/")
if len(parts) < 3 {
err = fmt.Errorf("unable to parse repo+owner from url")
return
}
return parts[1], parts[2], nil
}
// Test implements provisioning.Repository.
func (r *githubRepository) Test(ctx context.Context) (*provisioning.TestResults, error) {
url := r.config.Spec.GitHub.URL
_, _, err := ParseOwnerRepoGithub(url)
if err != nil {
return repository.FromFieldError(field.Invalid(
field.NewPath("spec", "github", "url"), url, err.Error())), nil
}
return r.GitRepository.Test(ctx)
}
func (r *githubRepository) History(ctx context.Context, path, ref string) ([]provisioning.HistoryItem, error) {
if ref == "" {
ref = r.config.Spec.GitHub.Branch
}
finalPath := safepath.Join(r.config.Spec.GitHub.Path, path)
commits, err := r.gh.Commits(ctx, r.owner, r.repo, finalPath, ref)
if err != nil {
if errors.Is(err, ErrResourceNotFound) {
return nil, repository.ErrFileNotFound
}
return nil, fmt.Errorf("get commits: %w", err)
}
ret := make([]provisioning.HistoryItem, 0, len(commits))
for _, commit := range commits {
authors := make([]provisioning.Author, 0)
if commit.Author != nil {
authors = append(authors, provisioning.Author{
Name: commit.Author.Name,
Username: commit.Author.Username,
AvatarURL: commit.Author.AvatarURL,
})
}
if commit.Committer != nil && commit.Author != nil && commit.Author.Name != commit.Committer.Name {
authors = append(authors, provisioning.Author{
Name: commit.Committer.Name,
Username: commit.Committer.Username,
AvatarURL: commit.Committer.AvatarURL,
})
}
ret = append(ret, provisioning.HistoryItem{
Ref: commit.Ref,
Message: commit.Message,
Authors: authors,
CreatedAt: commit.CreatedAt.UnixMilli(),
})
}
return ret, nil
}
// ListRefs list refs from the git repository and add the ref URL to the ref item
func (r *githubRepository) ListRefs(ctx context.Context) ([]provisioning.RefItem, error) {
refs, err := r.GitRepository.ListRefs(ctx)
if err != nil {
return nil, fmt.Errorf("list refs: %w", err)
}
for i := range refs {
refs[i].RefURL = fmt.Sprintf("%s/tree/%s", r.config.Spec.GitHub.URL, refs[i].Name)
}
return refs, nil
}
// ResourceURLs implements RepositoryWithURLs.
func (r *githubRepository) ResourceURLs(ctx context.Context, file *repository.FileInfo) (*provisioning.RepositoryURLs, error) {
cfg := r.config.Spec.GitHub
if file.Path == "" || cfg == nil {
return nil, nil
}
ref := file.Ref
if ref == "" {
ref = cfg.Branch
}
urls := &provisioning.RepositoryURLs{
RepositoryURL: cfg.URL,
SourceURL: fmt.Sprintf("%s/blob/%s/%s", cfg.URL, ref, file.Path),
}
if ref != cfg.Branch {
urls.CompareURL = fmt.Sprintf("%s/compare/%s...%s", cfg.URL, cfg.Branch, ref)
// Create a new pull request
urls.NewPullRequestURL = fmt.Sprintf("%s?quick_pull=1&labels=grafana", urls.CompareURL)
}
return urls, nil
}
// RefURLs implements RepositoryWithURLs.
func (r *githubRepository) RefURLs(ctx context.Context, ref string) (*provisioning.RepositoryURLs, error) {
cfg := r.config.Spec.GitHub
if cfg == nil || ref == "" {
return nil, nil
}
urls := &provisioning.RepositoryURLs{
SourceURL: fmt.Sprintf("%s/tree/%s", cfg.URL, ref),
}
if ref != cfg.Branch {
urls.CompareURL = fmt.Sprintf("%s/compare/%s...%s", cfg.URL, cfg.Branch, ref)
urls.NewPullRequestURL = fmt.Sprintf("%s?quick_pull=1&labels=grafana", urls.CompareURL)
}
return urls, nil
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,231 @@
{
"action": "created",
"issue": {
"id": 2726065547,
"number": 12,
"state": "open",
"locked": false,
"title": "Webhook test PR",
"author_association": "MEMBER",
"user": {
"login": "ryantxu",
"id": 705951,
"node_id": "MDQ6VXNlcjcwNTk1MQ==",
"avatar_url": "https://avatars.githubusercontent.com/u/705951?v=4",
"html_url": "https://github.com/ryantxu",
"gravatar_id": "",
"type": "User",
"site_admin": false,
"url": "https://api.github.com/users/ryantxu",
"events_url": "https://api.github.com/users/ryantxu/events{/privacy}",
"following_url": "https://api.github.com/users/ryantxu/following{/other_user}",
"followers_url": "https://api.github.com/users/ryantxu/followers",
"gists_url": "https://api.github.com/users/ryantxu/gists{/gist_id}",
"organizations_url": "https://api.github.com/users/ryantxu/orgs",
"received_events_url": "https://api.github.com/users/ryantxu/received_events",
"repos_url": "https://api.github.com/users/ryantxu/repos",
"starred_url": "https://api.github.com/users/ryantxu/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/ryantxu/subscriptions"
},
"comments": 1,
"created_at": "2024-12-09T05:53:14Z",
"updated_at": "2024-12-09T06:03:21Z",
"url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/12",
"html_url": "https://github.com/grafana/git-ui-sync-demo/pull/12",
"comments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/12/comments",
"events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/12/events",
"labels_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/12/labels{/name}",
"repository_url": "https://api.github.com/repos/grafana/git-ui-sync-demo",
"pull_request": {
"url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls/12",
"html_url": "https://github.com/grafana/git-ui-sync-demo/pull/12",
"diff_url": "https://github.com/grafana/git-ui-sync-demo/pull/12.diff",
"patch_url": "https://github.com/grafana/git-ui-sync-demo/pull/12.patch"
},
"reactions": {
"total_count": 0,
"+1": 0,
"-1": 0,
"laugh": 0,
"confused": 0,
"heart": 0,
"hooray": 0,
"rocket": 0,
"eyes": 0,
"url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/12/reactions"
},
"node_id": "PR_kwDONO4cS86EfDVR",
"draft": false
},
"comment": {
"id": 2527008082,
"node_id": "IC_kwDONO4cS86WnxVS",
"body": "comment in PR",
"user": {
"login": "ryantxu",
"id": 705951,
"node_id": "MDQ6VXNlcjcwNTk1MQ==",
"avatar_url": "https://avatars.githubusercontent.com/u/705951?v=4",
"html_url": "https://github.com/ryantxu",
"gravatar_id": "",
"type": "User",
"site_admin": false,
"url": "https://api.github.com/users/ryantxu",
"events_url": "https://api.github.com/users/ryantxu/events{/privacy}",
"following_url": "https://api.github.com/users/ryantxu/following{/other_user}",
"followers_url": "https://api.github.com/users/ryantxu/followers",
"gists_url": "https://api.github.com/users/ryantxu/gists{/gist_id}",
"organizations_url": "https://api.github.com/users/ryantxu/orgs",
"received_events_url": "https://api.github.com/users/ryantxu/received_events",
"repos_url": "https://api.github.com/users/ryantxu/repos",
"starred_url": "https://api.github.com/users/ryantxu/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/ryantxu/subscriptions"
},
"reactions": {
"total_count": 0,
"+1": 0,
"-1": 0,
"laugh": 0,
"confused": 0,
"heart": 0,
"hooray": 0,
"rocket": 0,
"eyes": 0,
"url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/comments/2527008082/reactions"
},
"created_at": "2024-12-09T06:03:19Z",
"updated_at": "2024-12-09T06:03:19Z",
"author_association": "MEMBER",
"url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/comments/2527008082",
"html_url": "https://github.com/grafana/git-ui-sync-demo/pull/12#issuecomment-2527008082",
"issue_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/12"
},
"repository": {
"id": 888020043,
"node_id": "R_kgDONO4cSw",
"owner": {
"login": "grafana",
"id": 7195757,
"node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
"avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
"html_url": "https://github.com/grafana",
"gravatar_id": "",
"type": "Organization",
"site_admin": false,
"url": "https://api.github.com/users/grafana",
"events_url": "https://api.github.com/users/grafana/events{/privacy}",
"following_url": "https://api.github.com/users/grafana/following{/other_user}",
"followers_url": "https://api.github.com/users/grafana/followers",
"gists_url": "https://api.github.com/users/grafana/gists{/gist_id}",
"organizations_url": "https://api.github.com/users/grafana/orgs",
"received_events_url": "https://api.github.com/users/grafana/received_events",
"repos_url": "https://api.github.com/users/grafana/repos",
"starred_url": "https://api.github.com/users/grafana/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/grafana/subscriptions"
},
"name": "git-ui-sync-demo",
"full_name": "grafana/git-ui-sync-demo",
"description": "A repository containing Grafana dashboards to demo the Github Sync feature in Grafana.",
"default_branch": "main",
"created_at": "2024-11-13T17:13:33Z",
"pushed_at": "2024-12-09T05:58:00Z",
"updated_at": "2024-12-09T05:58:03Z",
"html_url": "https://github.com/grafana/git-ui-sync-demo",
"clone_url": "https://github.com/grafana/git-ui-sync-demo.git",
"git_url": "git://github.com/grafana/git-ui-sync-demo.git",
"ssh_url": "git@github.com:grafana/git-ui-sync-demo.git",
"svn_url": "https://github.com/grafana/git-ui-sync-demo",
"fork": false,
"forks_count": 0,
"open_issues_count": 9,
"open_issues": 9,
"stargazers_count": 0,
"watchers_count": 0,
"watchers": 0,
"size": 141,
"allow_forking": false,
"web_commit_signoff_required": false,
"archived": false,
"disabled": false,
"private": true,
"has_issues": true,
"has_wiki": true,
"has_pages": false,
"has_projects": true,
"has_downloads": true,
"has_discussions": false,
"is_template": false,
"url": "https://api.github.com/repos/grafana/git-ui-sync-demo",
"archive_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/{archive_format}{/ref}",
"assignees_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/assignees{/user}",
"blobs_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/blobs{/sha}",
"branches_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/branches{/branch}",
"collaborators_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/collaborators{/collaborator}",
"comments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/comments{/number}",
"commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/commits{/sha}",
"compare_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/compare/{base}...{head}",
"contents_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/contents/{+path}",
"contributors_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/contributors",
"deployments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/deployments",
"downloads_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/downloads",
"events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/events",
"forks_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/forks",
"git_commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/commits{/sha}",
"git_refs_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/refs{/sha}",
"git_tags_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/tags{/sha}",
"hooks_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/hooks",
"issue_comment_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/comments{/number}",
"issue_events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/events{/number}",
"issues_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues{/number}",
"keys_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/keys{/key_id}",
"labels_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/labels{/name}",
"languages_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/languages",
"merges_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/merges",
"milestones_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/milestones{/number}",
"notifications_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/notifications{?since,all,participating}",
"pulls_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls{/number}",
"releases_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/releases{/id}",
"stargazers_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/stargazers",
"statuses_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/statuses/{sha}",
"subscribers_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/subscribers",
"subscription_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/subscription",
"tags_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/tags",
"trees_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/trees{/sha}",
"teams_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/teams",
"visibility": "internal"
},
"sender": {
"login": "ryantxu",
"id": 705951,
"node_id": "MDQ6VXNlcjcwNTk1MQ==",
"avatar_url": "https://avatars.githubusercontent.com/u/705951?v=4",
"html_url": "https://github.com/ryantxu",
"gravatar_id": "",
"type": "User",
"site_admin": false,
"url": "https://api.github.com/users/ryantxu",
"events_url": "https://api.github.com/users/ryantxu/events{/privacy}",
"following_url": "https://api.github.com/users/ryantxu/following{/other_user}",
"followers_url": "https://api.github.com/users/ryantxu/followers",
"gists_url": "https://api.github.com/users/ryantxu/gists{/gist_id}",
"organizations_url": "https://api.github.com/users/ryantxu/orgs",
"received_events_url": "https://api.github.com/users/ryantxu/received_events",
"repos_url": "https://api.github.com/users/ryantxu/repos",
"starred_url": "https://api.github.com/users/ryantxu/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/ryantxu/subscriptions"
},
"organization": {
"login": "grafana",
"id": 7195757,
"node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
"avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
"description": "Grafana Labs is behind leading open source projects Grafana and Loki, and the creator of the first open \u0026 composable observability platform.",
"url": "https://api.github.com/orgs/grafana",
"events_url": "https://api.github.com/orgs/grafana/events",
"hooks_url": "https://api.github.com/orgs/grafana/hooks",
"issues_url": "https://api.github.com/orgs/grafana/issues",
"members_url": "https://api.github.com/orgs/grafana/members{/member}",
"public_members_url": "https://api.github.com/orgs/grafana/public_members{/member}",
"repos_url": "https://api.github.com/orgs/grafana/repos"
}
}
@@ -0,0 +1,143 @@
{
"zen": "Keep it logically awesome.",
"hook_id": 517704995,
"hook": {
"created_at": "2024-12-09T05:44:20Z",
"updated_at": "2024-12-09T05:44:20Z",
"url": "https://api.github.com/repos/grafana/git-ui-sync-demo/hooks/517704995",
"id": 517704995,
"type": "Repository",
"name": "web",
"test_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/hooks/517704995/test",
"ping_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/hooks/517704995/pings",
"last_response": {
"code": null,
"message": null,
"status": "unused"
},
"config": {
"content_type": "form",
"insecure_ssl": "0",
"url": "https://0b71-216-128-0-90.ngrok-free.app/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/github-example-ryan/webhook",
"secret": "********"
},
"events": [
"*"
],
"active": true
},
"repository": {
"id": 888020043,
"node_id": "R_kgDONO4cSw",
"owner": {
"login": "grafana",
"id": 7195757,
"node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
"avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
"html_url": "https://github.com/grafana",
"gravatar_id": "",
"type": "Organization",
"site_admin": false,
"url": "https://api.github.com/users/grafana",
"events_url": "https://api.github.com/users/grafana/events{/privacy}",
"following_url": "https://api.github.com/users/grafana/following{/other_user}",
"followers_url": "https://api.github.com/users/grafana/followers",
"gists_url": "https://api.github.com/users/grafana/gists{/gist_id}",
"organizations_url": "https://api.github.com/users/grafana/orgs",
"received_events_url": "https://api.github.com/users/grafana/received_events",
"repos_url": "https://api.github.com/users/grafana/repos",
"starred_url": "https://api.github.com/users/grafana/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/grafana/subscriptions"
},
"name": "git-ui-sync-demo",
"full_name": "grafana/git-ui-sync-demo",
"description": "A repository containing Grafana dashboards to demo the Github Sync feature in Grafana.",
"default_branch": "main",
"created_at": "2024-11-13T17:13:33Z",
"pushed_at": "2024-12-08T10:51:24Z",
"updated_at": "2024-11-28T12:53:26Z",
"html_url": "https://github.com/grafana/git-ui-sync-demo",
"clone_url": "https://github.com/grafana/git-ui-sync-demo.git",
"git_url": "git://github.com/grafana/git-ui-sync-demo.git",
"ssh_url": "git@github.com:grafana/git-ui-sync-demo.git",
"svn_url": "https://github.com/grafana/git-ui-sync-demo",
"fork": false,
"forks_count": 0,
"open_issues_count": 8,
"open_issues": 8,
"stargazers_count": 0,
"watchers_count": 0,
"watchers": 0,
"size": 141,
"allow_forking": false,
"web_commit_signoff_required": false,
"archived": false,
"disabled": false,
"private": true,
"has_issues": true,
"has_wiki": true,
"has_pages": false,
"has_projects": true,
"has_downloads": true,
"has_discussions": false,
"is_template": false,
"url": "https://api.github.com/repos/grafana/git-ui-sync-demo",
"archive_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/{archive_format}{/ref}",
"assignees_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/assignees{/user}",
"blobs_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/blobs{/sha}",
"branches_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/branches{/branch}",
"collaborators_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/collaborators{/collaborator}",
"comments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/comments{/number}",
"commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/commits{/sha}",
"compare_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/compare/{base}...{head}",
"contents_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/contents/{+path}",
"contributors_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/contributors",
"deployments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/deployments",
"downloads_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/downloads",
"events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/events",
"forks_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/forks",
"git_commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/commits{/sha}",
"git_refs_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/refs{/sha}",
"git_tags_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/tags{/sha}",
"hooks_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/hooks",
"issue_comment_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/comments{/number}",
"issue_events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/events{/number}",
"issues_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues{/number}",
"keys_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/keys{/key_id}",
"labels_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/labels{/name}",
"languages_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/languages",
"merges_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/merges",
"milestones_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/milestones{/number}",
"notifications_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/notifications{?since,all,participating}",
"pulls_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls{/number}",
"releases_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/releases{/id}",
"stargazers_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/stargazers",
"statuses_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/statuses/{sha}",
"subscribers_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/subscribers",
"subscription_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/subscription",
"tags_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/tags",
"trees_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/trees{/sha}",
"teams_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/teams",
"visibility": "internal"
},
"sender": {
"login": "ryantxu",
"id": 705951,
"node_id": "MDQ6VXNlcjcwNTk1MQ==",
"avatar_url": "https://avatars.githubusercontent.com/u/705951?v=4",
"html_url": "https://github.com/ryantxu",
"gravatar_id": "",
"type": "User",
"site_admin": false,
"url": "https://api.github.com/users/ryantxu",
"events_url": "https://api.github.com/users/ryantxu/events{/privacy}",
"following_url": "https://api.github.com/users/ryantxu/following{/other_user}",
"followers_url": "https://api.github.com/users/ryantxu/followers",
"gists_url": "https://api.github.com/users/ryantxu/gists{/gist_id}",
"organizations_url": "https://api.github.com/users/ryantxu/orgs",
"received_events_url": "https://api.github.com/users/ryantxu/received_events",
"repos_url": "https://api.github.com/users/ryantxu/repos",
"starred_url": "https://api.github.com/users/ryantxu/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/ryantxu/subscriptions"
}
}
@@ -0,0 +1,470 @@
{
"action": "opened",
"number": 12,
"pull_request": {
"id": 2222732625,
"number": 12,
"state": "open",
"locked": false,
"title": "Webhook test PR",
"created_at": "2024-12-09T05:53:14Z",
"updated_at": "2024-12-09T05:53:14Z",
"user": {
"login": "ryantxu",
"id": 705951,
"node_id": "MDQ6VXNlcjcwNTk1MQ==",
"avatar_url": "https://avatars.githubusercontent.com/u/705951?v=4",
"html_url": "https://github.com/ryantxu",
"gravatar_id": "",
"type": "User",
"site_admin": false,
"url": "https://api.github.com/users/ryantxu",
"events_url": "https://api.github.com/users/ryantxu/events{/privacy}",
"following_url": "https://api.github.com/users/ryantxu/following{/other_user}",
"followers_url": "https://api.github.com/users/ryantxu/followers",
"gists_url": "https://api.github.com/users/ryantxu/gists{/gist_id}",
"organizations_url": "https://api.github.com/users/ryantxu/orgs",
"received_events_url": "https://api.github.com/users/ryantxu/received_events",
"repos_url": "https://api.github.com/users/ryantxu/repos",
"starred_url": "https://api.github.com/users/ryantxu/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/ryantxu/subscriptions"
},
"draft": false,
"merged": false,
"mergeable_state": "unknown",
"comments": 0,
"commits": 1,
"additions": 1,
"deletions": 0,
"changed_files": 1,
"url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls/12",
"html_url": "https://github.com/grafana/git-ui-sync-demo/pull/12",
"issue_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/12",
"statuses_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/statuses/ab5446a53df9e5f8bdeed52250f51fad08e822bc",
"diff_url": "https://github.com/grafana/git-ui-sync-demo/pull/12.diff",
"patch_url": "https://github.com/grafana/git-ui-sync-demo/pull/12.patch",
"commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls/12/commits",
"comments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/12/comments",
"review_comments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls/12/comments",
"review_comment_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls/comments{/number}",
"review_comments": 0,
"maintainer_can_modify": false,
"author_association": "MEMBER",
"node_id": "PR_kwDONO4cS86EfDVR",
"_links": {
"self": {
"href": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls/12"
},
"html": {
"href": "https://github.com/grafana/git-ui-sync-demo/pull/12"
},
"issue": {
"href": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/12"
},
"comments": {
"href": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/12/comments"
},
"review_comments": {
"href": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls/12/comments"
},
"review_comment": {
"href": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls/comments{/number}"
},
"commits": {
"href": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls/12/commits"
},
"statuses": {
"href": "https://api.github.com/repos/grafana/git-ui-sync-demo/statuses/ab5446a53df9e5f8bdeed52250f51fad08e822bc"
}
},
"head": {
"label": "grafana:dashboard/1733653266690",
"ref": "dashboard/1733653266690",
"sha": "ab5446a53df9e5f8bdeed52250f51fad08e822bc",
"repo": {
"id": 888020043,
"node_id": "R_kgDONO4cSw",
"owner": {
"login": "grafana",
"id": 7195757,
"node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
"avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
"html_url": "https://github.com/grafana",
"gravatar_id": "",
"type": "Organization",
"site_admin": false,
"url": "https://api.github.com/users/grafana",
"events_url": "https://api.github.com/users/grafana/events{/privacy}",
"following_url": "https://api.github.com/users/grafana/following{/other_user}",
"followers_url": "https://api.github.com/users/grafana/followers",
"gists_url": "https://api.github.com/users/grafana/gists{/gist_id}",
"organizations_url": "https://api.github.com/users/grafana/orgs",
"received_events_url": "https://api.github.com/users/grafana/received_events",
"repos_url": "https://api.github.com/users/grafana/repos",
"starred_url": "https://api.github.com/users/grafana/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/grafana/subscriptions"
},
"name": "git-ui-sync-demo",
"full_name": "grafana/git-ui-sync-demo",
"description": "A repository containing Grafana dashboards to demo the Github Sync feature in Grafana.",
"default_branch": "main",
"created_at": "2024-11-13T17:13:33Z",
"pushed_at": "2024-12-08T10:51:24Z",
"updated_at": "2024-11-28T12:53:26Z",
"html_url": "https://github.com/grafana/git-ui-sync-demo",
"clone_url": "https://github.com/grafana/git-ui-sync-demo.git",
"git_url": "git://github.com/grafana/git-ui-sync-demo.git",
"ssh_url": "git@github.com:grafana/git-ui-sync-demo.git",
"svn_url": "https://github.com/grafana/git-ui-sync-demo",
"fork": false,
"forks_count": 0,
"open_issues_count": 9,
"open_issues": 9,
"stargazers_count": 0,
"watchers_count": 0,
"watchers": 0,
"size": 141,
"allow_rebase_merge": true,
"allow_update_branch": false,
"allow_squash_merge": true,
"allow_merge_commit": true,
"allow_auto_merge": false,
"allow_forking": false,
"web_commit_signoff_required": false,
"delete_branch_on_merge": false,
"use_squash_pr_title_as_default": false,
"squash_merge_commit_title": "COMMIT_OR_PR_TITLE",
"squash_merge_commit_message": "COMMIT_MESSAGES",
"merge_commit_title": "MERGE_MESSAGE",
"merge_commit_message": "PR_TITLE",
"archived": false,
"disabled": false,
"private": true,
"has_issues": true,
"has_wiki": true,
"has_pages": false,
"has_projects": true,
"has_downloads": true,
"has_discussions": false,
"is_template": false,
"url": "https://api.github.com/repos/grafana/git-ui-sync-demo",
"archive_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/{archive_format}{/ref}",
"assignees_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/assignees{/user}",
"blobs_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/blobs{/sha}",
"branches_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/branches{/branch}",
"collaborators_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/collaborators{/collaborator}",
"comments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/comments{/number}",
"commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/commits{/sha}",
"compare_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/compare/{base}...{head}",
"contents_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/contents/{+path}",
"contributors_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/contributors",
"deployments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/deployments",
"downloads_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/downloads",
"events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/events",
"forks_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/forks",
"git_commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/commits{/sha}",
"git_refs_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/refs{/sha}",
"git_tags_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/tags{/sha}",
"hooks_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/hooks",
"issue_comment_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/comments{/number}",
"issue_events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/events{/number}",
"issues_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues{/number}",
"keys_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/keys{/key_id}",
"labels_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/labels{/name}",
"languages_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/languages",
"merges_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/merges",
"milestones_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/milestones{/number}",
"notifications_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/notifications{?since,all,participating}",
"pulls_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls{/number}",
"releases_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/releases{/id}",
"stargazers_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/stargazers",
"statuses_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/statuses/{sha}",
"subscribers_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/subscribers",
"subscription_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/subscription",
"tags_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/tags",
"trees_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/trees{/sha}",
"teams_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/teams",
"visibility": "internal"
},
"user": {
"login": "grafana",
"id": 7195757,
"node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
"avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
"html_url": "https://github.com/grafana",
"gravatar_id": "",
"type": "Organization",
"site_admin": false,
"url": "https://api.github.com/users/grafana",
"events_url": "https://api.github.com/users/grafana/events{/privacy}",
"following_url": "https://api.github.com/users/grafana/following{/other_user}",
"followers_url": "https://api.github.com/users/grafana/followers",
"gists_url": "https://api.github.com/users/grafana/gists{/gist_id}",
"organizations_url": "https://api.github.com/users/grafana/orgs",
"received_events_url": "https://api.github.com/users/grafana/received_events",
"repos_url": "https://api.github.com/users/grafana/repos",
"starred_url": "https://api.github.com/users/grafana/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/grafana/subscriptions"
}
},
"base": {
"label": "grafana:main",
"ref": "main",
"sha": "6c86a0cdfd220c2fe3518cfaa4a4babf030d9a7a",
"repo": {
"id": 888020043,
"node_id": "R_kgDONO4cSw",
"owner": {
"login": "grafana",
"id": 7195757,
"node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
"avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
"html_url": "https://github.com/grafana",
"gravatar_id": "",
"type": "Organization",
"site_admin": false,
"url": "https://api.github.com/users/grafana",
"events_url": "https://api.github.com/users/grafana/events{/privacy}",
"following_url": "https://api.github.com/users/grafana/following{/other_user}",
"followers_url": "https://api.github.com/users/grafana/followers",
"gists_url": "https://api.github.com/users/grafana/gists{/gist_id}",
"organizations_url": "https://api.github.com/users/grafana/orgs",
"received_events_url": "https://api.github.com/users/grafana/received_events",
"repos_url": "https://api.github.com/users/grafana/repos",
"starred_url": "https://api.github.com/users/grafana/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/grafana/subscriptions"
},
"name": "git-ui-sync-demo",
"full_name": "grafana/git-ui-sync-demo",
"description": "A repository containing Grafana dashboards to demo the Github Sync feature in Grafana.",
"default_branch": "main",
"created_at": "2024-11-13T17:13:33Z",
"pushed_at": "2024-12-08T10:51:24Z",
"updated_at": "2024-11-28T12:53:26Z",
"html_url": "https://github.com/grafana/git-ui-sync-demo",
"clone_url": "https://github.com/grafana/git-ui-sync-demo.git",
"git_url": "git://github.com/grafana/git-ui-sync-demo.git",
"ssh_url": "git@github.com:grafana/git-ui-sync-demo.git",
"svn_url": "https://github.com/grafana/git-ui-sync-demo",
"fork": false,
"forks_count": 0,
"open_issues_count": 9,
"open_issues": 9,
"stargazers_count": 0,
"watchers_count": 0,
"watchers": 0,
"size": 141,
"allow_rebase_merge": true,
"allow_update_branch": false,
"allow_squash_merge": true,
"allow_merge_commit": true,
"allow_auto_merge": false,
"allow_forking": false,
"web_commit_signoff_required": false,
"delete_branch_on_merge": false,
"use_squash_pr_title_as_default": false,
"squash_merge_commit_title": "COMMIT_OR_PR_TITLE",
"squash_merge_commit_message": "COMMIT_MESSAGES",
"merge_commit_title": "MERGE_MESSAGE",
"merge_commit_message": "PR_TITLE",
"archived": false,
"disabled": false,
"private": true,
"has_issues": true,
"has_wiki": true,
"has_pages": false,
"has_projects": true,
"has_downloads": true,
"has_discussions": false,
"is_template": false,
"url": "https://api.github.com/repos/grafana/git-ui-sync-demo",
"archive_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/{archive_format}{/ref}",
"assignees_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/assignees{/user}",
"blobs_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/blobs{/sha}",
"branches_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/branches{/branch}",
"collaborators_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/collaborators{/collaborator}",
"comments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/comments{/number}",
"commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/commits{/sha}",
"compare_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/compare/{base}...{head}",
"contents_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/contents/{+path}",
"contributors_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/contributors",
"deployments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/deployments",
"downloads_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/downloads",
"events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/events",
"forks_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/forks",
"git_commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/commits{/sha}",
"git_refs_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/refs{/sha}",
"git_tags_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/tags{/sha}",
"hooks_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/hooks",
"issue_comment_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/comments{/number}",
"issue_events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/events{/number}",
"issues_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues{/number}",
"keys_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/keys{/key_id}",
"labels_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/labels{/name}",
"languages_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/languages",
"merges_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/merges",
"milestones_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/milestones{/number}",
"notifications_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/notifications{?since,all,participating}",
"pulls_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls{/number}",
"releases_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/releases{/id}",
"stargazers_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/stargazers",
"statuses_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/statuses/{sha}",
"subscribers_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/subscribers",
"subscription_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/subscription",
"tags_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/tags",
"trees_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/trees{/sha}",
"teams_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/teams",
"visibility": "internal"
},
"user": {
"login": "grafana",
"id": 7195757,
"node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
"avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
"html_url": "https://github.com/grafana",
"gravatar_id": "",
"type": "Organization",
"site_admin": false,
"url": "https://api.github.com/users/grafana",
"events_url": "https://api.github.com/users/grafana/events{/privacy}",
"following_url": "https://api.github.com/users/grafana/following{/other_user}",
"followers_url": "https://api.github.com/users/grafana/followers",
"gists_url": "https://api.github.com/users/grafana/gists{/gist_id}",
"organizations_url": "https://api.github.com/users/grafana/orgs",
"received_events_url": "https://api.github.com/users/grafana/received_events",
"repos_url": "https://api.github.com/users/grafana/repos",
"starred_url": "https://api.github.com/users/grafana/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/grafana/subscriptions"
}
}
},
"repository": {
"id": 888020043,
"node_id": "R_kgDONO4cSw",
"owner": {
"login": "grafana",
"id": 7195757,
"node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
"avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
"html_url": "https://github.com/grafana",
"gravatar_id": "",
"type": "Organization",
"site_admin": false,
"url": "https://api.github.com/users/grafana",
"events_url": "https://api.github.com/users/grafana/events{/privacy}",
"following_url": "https://api.github.com/users/grafana/following{/other_user}",
"followers_url": "https://api.github.com/users/grafana/followers",
"gists_url": "https://api.github.com/users/grafana/gists{/gist_id}",
"organizations_url": "https://api.github.com/users/grafana/orgs",
"received_events_url": "https://api.github.com/users/grafana/received_events",
"repos_url": "https://api.github.com/users/grafana/repos",
"starred_url": "https://api.github.com/users/grafana/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/grafana/subscriptions"
},
"name": "git-ui-sync-demo",
"full_name": "grafana/git-ui-sync-demo",
"description": "A repository containing Grafana dashboards to demo the Github Sync feature in Grafana.",
"default_branch": "main",
"created_at": "2024-11-13T17:13:33Z",
"pushed_at": "2024-12-08T10:51:24Z",
"updated_at": "2024-11-28T12:53:26Z",
"html_url": "https://github.com/grafana/git-ui-sync-demo",
"clone_url": "https://github.com/grafana/git-ui-sync-demo.git",
"git_url": "git://github.com/grafana/git-ui-sync-demo.git",
"ssh_url": "git@github.com:grafana/git-ui-sync-demo.git",
"svn_url": "https://github.com/grafana/git-ui-sync-demo",
"fork": false,
"forks_count": 0,
"open_issues_count": 9,
"open_issues": 9,
"stargazers_count": 0,
"watchers_count": 0,
"watchers": 0,
"size": 141,
"allow_forking": false,
"web_commit_signoff_required": false,
"archived": false,
"disabled": false,
"private": true,
"has_issues": true,
"has_wiki": true,
"has_pages": false,
"has_projects": true,
"has_downloads": true,
"has_discussions": false,
"is_template": false,
"url": "https://api.github.com/repos/grafana/git-ui-sync-demo",
"archive_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/{archive_format}{/ref}",
"assignees_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/assignees{/user}",
"blobs_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/blobs{/sha}",
"branches_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/branches{/branch}",
"collaborators_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/collaborators{/collaborator}",
"comments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/comments{/number}",
"commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/commits{/sha}",
"compare_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/compare/{base}...{head}",
"contents_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/contents/{+path}",
"contributors_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/contributors",
"deployments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/deployments",
"downloads_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/downloads",
"events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/events",
"forks_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/forks",
"git_commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/commits{/sha}",
"git_refs_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/refs{/sha}",
"git_tags_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/tags{/sha}",
"hooks_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/hooks",
"issue_comment_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/comments{/number}",
"issue_events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/events{/number}",
"issues_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues{/number}",
"keys_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/keys{/key_id}",
"labels_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/labels{/name}",
"languages_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/languages",
"merges_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/merges",
"milestones_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/milestones{/number}",
"notifications_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/notifications{?since,all,participating}",
"pulls_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls{/number}",
"releases_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/releases{/id}",
"stargazers_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/stargazers",
"statuses_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/statuses/{sha}",
"subscribers_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/subscribers",
"subscription_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/subscription",
"tags_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/tags",
"trees_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/trees{/sha}",
"teams_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/teams",
"visibility": "internal"
},
"sender": {
"login": "ryantxu",
"id": 705951,
"node_id": "MDQ6VXNlcjcwNTk1MQ==",
"avatar_url": "https://avatars.githubusercontent.com/u/705951?v=4",
"html_url": "https://github.com/ryantxu",
"gravatar_id": "",
"type": "User",
"site_admin": false,
"url": "https://api.github.com/users/ryantxu",
"events_url": "https://api.github.com/users/ryantxu/events{/privacy}",
"following_url": "https://api.github.com/users/ryantxu/following{/other_user}",
"followers_url": "https://api.github.com/users/ryantxu/followers",
"gists_url": "https://api.github.com/users/ryantxu/gists{/gist_id}",
"organizations_url": "https://api.github.com/users/ryantxu/orgs",
"received_events_url": "https://api.github.com/users/ryantxu/received_events",
"repos_url": "https://api.github.com/users/ryantxu/repos",
"starred_url": "https://api.github.com/users/ryantxu/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/ryantxu/subscriptions"
},
"organization": {
"login": "grafana",
"id": 7195757,
"node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
"avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
"description": "Grafana Labs is behind leading open source projects Grafana and Loki, and the creator of the first open \u0026 composable observability platform.",
"url": "https://api.github.com/orgs/grafana",
"events_url": "https://api.github.com/orgs/grafana/events",
"hooks_url": "https://api.github.com/orgs/grafana/hooks",
"issues_url": "https://api.github.com/orgs/grafana/issues",
"members_url": "https://api.github.com/orgs/grafana/members{/member}",
"public_members_url": "https://api.github.com/orgs/grafana/public_members{/member}",
"repos_url": "https://api.github.com/orgs/grafana/repos"
}
}
@@ -0,0 +1,148 @@
{
"ref": "refs/heads/not-main",
"commits": [
{
"message": "Update README.md\n\ntest message",
"author": {
"name": "Ryan McKinley",
"email": "ryantxu@gmail.com",
"username": "ryantxu"
},
"url": "https://github.com/grafana/git-ui-sync-demo/commit/72096e3adc646c5a5b8a91744f962b12bac06045",
"distinct": true,
"id": "72096e3adc646c5a5b8a91744f962b12bac06045",
"tree_id": "03ff034c54bcefae2f96041f3fb8172f2fe93df3",
"timestamp": "2024-12-09T08:58:00+03:00",
"committer": {
"name": "GitHub",
"email": "noreply@github.com",
"username": "web-flow"
},
"modified": [
"README.md"
]
}
],
"before": "6c86a0cdfd220c2fe3518cfaa4a4babf030d9a7a",
"after": "72096e3adc646c5a5b8a91744f962b12bac06045",
"created": false,
"deleted": false,
"forced": false,
"compare": "https://github.com/grafana/git-ui-sync-demo/compare/6c86a0cdfd22...72096e3adc64",
"repository": {
"id": 888020043,
"node_id": "R_kgDONO4cSw",
"name": "git-ui-sync-demo",
"full_name": "grafana/git-ui-sync-demo",
"owner": {
"login": "grafana",
"id": 7195757,
"node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
"avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
"html_url": "https://github.com/grafana",
"gravatar_id": "",
"name": "grafana",
"email": "hello@grafana.com",
"type": "Organization",
"site_admin": false,
"url": "https://api.github.com/users/grafana",
"events_url": "https://api.github.com/users/grafana/events{/privacy}",
"following_url": "https://api.github.com/users/grafana/following{/other_user}",
"followers_url": "https://api.github.com/users/grafana/followers",
"gists_url": "https://api.github.com/users/grafana/gists{/gist_id}",
"organizations_url": "https://api.github.com/users/grafana/orgs",
"received_events_url": "https://api.github.com/users/grafana/received_events",
"repos_url": "https://api.github.com/users/grafana/repos",
"starred_url": "https://api.github.com/users/grafana/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/grafana/subscriptions"
},
"private": true,
"description": "A repository containing Grafana dashboards to demo the Github Sync feature in Grafana.",
"fork": false,
"created_at": "2024-11-13T20:13:33+03:00",
"pushed_at": "2024-12-09T08:58:00+03:00",
"updated_at": "2024-11-28T12:53:26Z",
"pulls_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls{/number}",
"size": 141,
"stargazers_count": 0,
"watchers_count": 0,
"has_issues": true,
"has_downloads": true,
"has_wiki": true,
"has_pages": false,
"forks_count": 0,
"archived": false,
"disabled": false,
"open_issues_count": 9,
"default_branch": "main",
"master_branch": "main",
"organization": "grafana",
"url": "https://github.com/grafana/git-ui-sync-demo",
"archive_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/{archive_format}{/ref}",
"html_url": "https://github.com/grafana/git-ui-sync-demo",
"statuses_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/statuses/{sha}",
"git_url": "git://github.com/grafana/git-ui-sync-demo.git",
"ssh_url": "git@github.com:grafana/git-ui-sync-demo.git",
"clone_url": "https://github.com/grafana/git-ui-sync-demo.git",
"svn_url": "https://github.com/grafana/git-ui-sync-demo"
},
"head_commit": {
"message": "Update README.md\n\ntest message",
"author": {
"name": "Ryan McKinley",
"email": "ryantxu@gmail.com",
"username": "ryantxu"
},
"url": "https://github.com/grafana/git-ui-sync-demo/commit/72096e3adc646c5a5b8a91744f962b12bac06045",
"distinct": true,
"id": "72096e3adc646c5a5b8a91744f962b12bac06045",
"tree_id": "03ff034c54bcefae2f96041f3fb8172f2fe93df3",
"timestamp": "2024-12-09T08:58:00+03:00",
"committer": {
"name": "GitHub",
"email": "noreply@github.com",
"username": "web-flow"
},
"modified": [
"README.md"
]
},
"pusher": {
"name": "ryantxu",
"email": "ryantxu@gmail.com"
},
"sender": {
"login": "ryantxu",
"id": 705951,
"node_id": "MDQ6VXNlcjcwNTk1MQ==",
"avatar_url": "https://avatars.githubusercontent.com/u/705951?v=4",
"html_url": "https://github.com/ryantxu",
"gravatar_id": "",
"type": "User",
"site_admin": false,
"url": "https://api.github.com/users/ryantxu",
"events_url": "https://api.github.com/users/ryantxu/events{/privacy}",
"following_url": "https://api.github.com/users/ryantxu/following{/other_user}",
"followers_url": "https://api.github.com/users/ryantxu/followers",
"gists_url": "https://api.github.com/users/ryantxu/gists{/gist_id}",
"organizations_url": "https://api.github.com/users/ryantxu/orgs",
"received_events_url": "https://api.github.com/users/ryantxu/received_events",
"repos_url": "https://api.github.com/users/ryantxu/repos",
"starred_url": "https://api.github.com/users/ryantxu/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/ryantxu/subscriptions"
},
"organization": {
"login": "grafana",
"id": 7195757,
"node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
"avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
"description": "Grafana Labs is behind leading open source projects Grafana and Loki, and the creator of the first open \u0026 composable observability platform.",
"url": "https://api.github.com/orgs/grafana",
"events_url": "https://api.github.com/orgs/grafana/events",
"hooks_url": "https://api.github.com/orgs/grafana/hooks",
"issues_url": "https://api.github.com/orgs/grafana/issues",
"members_url": "https://api.github.com/orgs/grafana/members{/member}",
"public_members_url": "https://api.github.com/orgs/grafana/public_members{/member}",
"repos_url": "https://api.github.com/orgs/grafana/repos"
}
}
@@ -0,0 +1,234 @@
{
"ref": "refs/heads/main",
"before": "72096e3adc646c5a5b8a91744f962b12bac06045",
"after": "5c816f9812e391c62b0c5555d0b473b296d9179c",
"repository": {
"id": 888020043,
"node_id": "R_kgDONO4cSw",
"name": "git-ui-sync-demo",
"full_name": "grafana/git-ui-sync-demo",
"private": true,
"owner": {
"name": "grafana",
"email": "hello@grafana.com",
"login": "grafana",
"id": 7195757,
"node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
"avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/grafana",
"html_url": "https://github.com/grafana",
"followers_url": "https://api.github.com/users/grafana/followers",
"following_url": "https://api.github.com/users/grafana/following{/other_user}",
"gists_url": "https://api.github.com/users/grafana/gists{/gist_id}",
"starred_url": "https://api.github.com/users/grafana/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/grafana/subscriptions",
"organizations_url": "https://api.github.com/users/grafana/orgs",
"repos_url": "https://api.github.com/users/grafana/repos",
"events_url": "https://api.github.com/users/grafana/events{/privacy}",
"received_events_url": "https://api.github.com/users/grafana/received_events",
"type": "Organization",
"user_view_type": "public",
"site_admin": false
},
"html_url": "https://github.com/grafana/git-ui-sync-demo",
"description": "A repository containing Grafana dashboards to demo the Github Sync feature in Grafana.",
"fork": false,
"url": "https://github.com/grafana/git-ui-sync-demo",
"forks_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/forks",
"keys_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/keys{/key_id}",
"collaborators_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/collaborators{/collaborator}",
"teams_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/teams",
"hooks_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/hooks",
"issue_events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/events{/number}",
"events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/events",
"assignees_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/assignees{/user}",
"branches_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/branches{/branch}",
"tags_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/tags",
"blobs_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/blobs{/sha}",
"git_tags_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/tags{/sha}",
"git_refs_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/refs{/sha}",
"trees_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/trees{/sha}",
"statuses_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/statuses/{sha}",
"languages_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/languages",
"stargazers_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/stargazers",
"contributors_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/contributors",
"subscribers_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/subscribers",
"subscription_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/subscription",
"commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/commits{/sha}",
"git_commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/commits{/sha}",
"comments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/comments{/number}",
"issue_comment_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/comments{/number}",
"contents_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/contents/{+path}",
"compare_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/compare/{base}...{head}",
"merges_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/merges",
"archive_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/{archive_format}{/ref}",
"downloads_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/downloads",
"issues_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues{/number}",
"pulls_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls{/number}",
"milestones_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/milestones{/number}",
"notifications_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/notifications{?since,all,participating}",
"labels_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/labels{/name}",
"releases_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/releases{/id}",
"deployments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/deployments",
"created_at": 1731518013,
"updated_at": "2024-12-09T05:58:03Z",
"pushed_at": 1733731254,
"git_url": "git://github.com/grafana/git-ui-sync-demo.git",
"ssh_url": "git@github.com:grafana/git-ui-sync-demo.git",
"clone_url": "https://github.com/grafana/git-ui-sync-demo.git",
"svn_url": "https://github.com/grafana/git-ui-sync-demo",
"homepage": null,
"size": 142,
"stargazers_count": 0,
"watchers_count": 0,
"language": null,
"has_issues": true,
"has_projects": true,
"has_downloads": true,
"has_wiki": true,
"has_pages": false,
"has_discussions": false,
"forks_count": 0,
"mirror_url": null,
"archived": false,
"disabled": false,
"open_issues_count": 9,
"license": null,
"allow_forking": false,
"is_template": false,
"web_commit_signoff_required": false,
"topics": [
],
"visibility": "internal",
"forks": 0,
"open_issues": 9,
"watchers": 0,
"default_branch": "main",
"stargazers": 0,
"master_branch": "main",
"organization": "grafana",
"custom_properties": {
}
},
"pusher": {
"name": "ryantxu",
"email": "ryantxu@gmail.com"
},
"organization": {
"login": "grafana",
"id": 7195757,
"node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
"url": "https://api.github.com/orgs/grafana",
"repos_url": "https://api.github.com/orgs/grafana/repos",
"events_url": "https://api.github.com/orgs/grafana/events",
"hooks_url": "https://api.github.com/orgs/grafana/hooks",
"issues_url": "https://api.github.com/orgs/grafana/issues",
"members_url": "https://api.github.com/orgs/grafana/members{/member}",
"public_members_url": "https://api.github.com/orgs/grafana/public_members{/member}",
"avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
"description": "Grafana Labs is behind leading open source projects Grafana and Loki, and the creator of the first open & composable observability platform."
},
"enterprise": {
"id": 129980,
"slug": "grafana",
"name": "Grafana Labs Enterprise",
"node_id": "E_kgDOAAH7vA",
"avatar_url": "https://avatars.githubusercontent.com/b/129980?v=4",
"description": "Grafana Labs is behind leading open source projects Grafana and Loki, and the creator of the first open & composable observability platform.",
"website_url": "https://grafana.com",
"html_url": "https://github.com/enterprises/grafana",
"created_at": "2024-02-29T23:01:47Z",
"updated_at": "2024-10-21T08:45:28Z"
},
"sender": {
"login": "ryantxu",
"id": 705951,
"node_id": "MDQ6VXNlcjcwNTk1MQ==",
"avatar_url": "https://avatars.githubusercontent.com/u/705951?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/ryantxu",
"html_url": "https://github.com/ryantxu",
"followers_url": "https://api.github.com/users/ryantxu/followers",
"following_url": "https://api.github.com/users/ryantxu/following{/other_user}",
"gists_url": "https://api.github.com/users/ryantxu/gists{/gist_id}",
"starred_url": "https://api.github.com/users/ryantxu/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/ryantxu/subscriptions",
"organizations_url": "https://api.github.com/users/ryantxu/orgs",
"repos_url": "https://api.github.com/users/ryantxu/repos",
"events_url": "https://api.github.com/users/ryantxu/events{/privacy}",
"received_events_url": "https://api.github.com/users/ryantxu/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
},
"created": false,
"deleted": false,
"forced": false,
"base_ref": null,
"compare": "https://github.com/grafana/git-ui-sync-demo/compare/72096e3adc64...5c816f9812e3",
"commits": [
{
"id": "5c816f9812e391c62b0c5555d0b473b296d9179c",
"tree_id": "97c7ba756b07b6a2b7fd130e59f75deed53fe027",
"distinct": true,
"message": "nested folders",
"timestamp": "2024-12-09T11:00:48+03:00",
"url": "https://github.com/grafana/git-ui-sync-demo/commit/5c816f9812e391c62b0c5555d0b473b296d9179c",
"author": {
"name": "Ryan McKinley",
"email": "ryantxu@gmail.com",
"username": "ryantxu"
},
"committer": {
"name": "Ryan McKinley",
"email": "ryantxu@gmail.com",
"username": "ryantxu"
},
"added": [
"nested-1/README.md",
"nested-1/dash-1.json",
"nested-1/nested-2/README.md",
"nested-1/nested-2/dash-2.json"
],
"removed": [
],
"modified": [
"first-dashboard.json"
]
}
],
"head_commit": {
"id": "5c816f9812e391c62b0c5555d0b473b296d9179c",
"tree_id": "97c7ba756b07b6a2b7fd130e59f75deed53fe027",
"distinct": true,
"message": "nested folders",
"timestamp": "2024-12-09T11:00:48+03:00",
"url": "https://github.com/grafana/git-ui-sync-demo/commit/5c816f9812e391c62b0c5555d0b473b296d9179c",
"author": {
"name": "Ryan McKinley",
"email": "ryantxu@gmail.com",
"username": "ryantxu"
},
"committer": {
"name": "Ryan McKinley",
"email": "ryantxu@gmail.com",
"username": "ryantxu"
},
"added": [
"nested-1/README.md",
"nested-1/dash-1.json",
"nested-1/nested-2/README.md",
"nested-1/nested-2/dash-2.json"
],
"removed": [
],
"modified": [
"first-dashboard.json"
]
}
}
@@ -0,0 +1,148 @@
{
"ref": "refs/heads/main",
"commits": [
{
"message": "Update README.md\n\ntest message",
"author": {
"name": "Ryan McKinley",
"email": "ryantxu@gmail.com",
"username": "ryantxu"
},
"url": "https://github.com/grafana/git-ui-sync-demo/commit/72096e3adc646c5a5b8a91744f962b12bac06045",
"distinct": true,
"id": "72096e3adc646c5a5b8a91744f962b12bac06045",
"tree_id": "03ff034c54bcefae2f96041f3fb8172f2fe93df3",
"timestamp": "2024-12-09T08:58:00+03:00",
"committer": {
"name": "GitHub",
"email": "noreply@github.com",
"username": "web-flow"
},
"modified": [
"README.md"
]
}
],
"before": "6c86a0cdfd220c2fe3518cfaa4a4babf030d9a7a",
"after": "72096e3adc646c5a5b8a91744f962b12bac06045",
"created": false,
"deleted": false,
"forced": false,
"compare": "https://github.com/grafana/git-ui-sync-demo/compare/6c86a0cdfd22...72096e3adc64",
"repository": {
"id": 888020043,
"node_id": "R_kgDONO4cSw",
"name": "git-ui-sync-demo",
"full_name": "grafana/git-ui-sync-demo",
"owner": {
"login": "grafana",
"id": 7195757,
"node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
"avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
"html_url": "https://github.com/grafana",
"gravatar_id": "",
"name": "grafana",
"email": "hello@grafana.com",
"type": "Organization",
"site_admin": false,
"url": "https://api.github.com/users/grafana",
"events_url": "https://api.github.com/users/grafana/events{/privacy}",
"following_url": "https://api.github.com/users/grafana/following{/other_user}",
"followers_url": "https://api.github.com/users/grafana/followers",
"gists_url": "https://api.github.com/users/grafana/gists{/gist_id}",
"organizations_url": "https://api.github.com/users/grafana/orgs",
"received_events_url": "https://api.github.com/users/grafana/received_events",
"repos_url": "https://api.github.com/users/grafana/repos",
"starred_url": "https://api.github.com/users/grafana/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/grafana/subscriptions"
},
"private": true,
"description": "A repository containing Grafana dashboards to demo the Github Sync feature in Grafana.",
"fork": false,
"created_at": "2024-11-13T20:13:33+03:00",
"pushed_at": "2024-12-09T08:58:00+03:00",
"updated_at": "2024-11-28T12:53:26Z",
"pulls_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls{/number}",
"size": 141,
"stargazers_count": 0,
"watchers_count": 0,
"has_issues": true,
"has_downloads": true,
"has_wiki": true,
"has_pages": false,
"forks_count": 0,
"archived": false,
"disabled": false,
"open_issues_count": 9,
"default_branch": "main",
"master_branch": "main",
"organization": "grafana",
"url": "https://github.com/grafana/git-ui-sync-demo",
"archive_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/{archive_format}{/ref}",
"html_url": "https://github.com/grafana/git-ui-sync-demo",
"statuses_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/statuses/{sha}",
"git_url": "git://github.com/grafana/git-ui-sync-demo.git",
"ssh_url": "git@github.com:grafana/git-ui-sync-demo.git",
"clone_url": "https://github.com/grafana/git-ui-sync-demo.git",
"svn_url": "https://github.com/grafana/git-ui-sync-demo"
},
"head_commit": {
"message": "Update README.md\n\ntest message",
"author": {
"name": "Ryan McKinley",
"email": "ryantxu@gmail.com",
"username": "ryantxu"
},
"url": "https://github.com/grafana/git-ui-sync-demo/commit/72096e3adc646c5a5b8a91744f962b12bac06045",
"distinct": true,
"id": "72096e3adc646c5a5b8a91744f962b12bac06045",
"tree_id": "03ff034c54bcefae2f96041f3fb8172f2fe93df3",
"timestamp": "2024-12-09T08:58:00+03:00",
"committer": {
"name": "GitHub",
"email": "noreply@github.com",
"username": "web-flow"
},
"modified": [
"README.md"
]
},
"pusher": {
"name": "ryantxu",
"email": "ryantxu@gmail.com"
},
"sender": {
"login": "ryantxu",
"id": 705951,
"node_id": "MDQ6VXNlcjcwNTk1MQ==",
"avatar_url": "https://avatars.githubusercontent.com/u/705951?v=4",
"html_url": "https://github.com/ryantxu",
"gravatar_id": "",
"type": "User",
"site_admin": false,
"url": "https://api.github.com/users/ryantxu",
"events_url": "https://api.github.com/users/ryantxu/events{/privacy}",
"following_url": "https://api.github.com/users/ryantxu/following{/other_user}",
"followers_url": "https://api.github.com/users/ryantxu/followers",
"gists_url": "https://api.github.com/users/ryantxu/gists{/gist_id}",
"organizations_url": "https://api.github.com/users/ryantxu/orgs",
"received_events_url": "https://api.github.com/users/ryantxu/received_events",
"repos_url": "https://api.github.com/users/ryantxu/repos",
"starred_url": "https://api.github.com/users/ryantxu/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/ryantxu/subscriptions"
},
"organization": {
"login": "grafana",
"id": 7195757,
"node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
"avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
"description": "Grafana Labs is behind leading open source projects Grafana and Loki, and the creator of the first open \u0026 composable observability platform.",
"url": "https://api.github.com/orgs/grafana",
"events_url": "https://api.github.com/orgs/grafana/events",
"hooks_url": "https://api.github.com/orgs/grafana/hooks",
"issues_url": "https://api.github.com/orgs/grafana/issues",
"members_url": "https://api.github.com/orgs/grafana/members{/member}",
"public_members_url": "https://api.github.com/orgs/grafana/public_members{/member}",
"repos_url": "https://api.github.com/orgs/grafana/repos"
}
}
@@ -0,0 +1,368 @@
package github
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"slices"
"github.com/google/go-github/v70/github"
"github.com/google/uuid"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"github.com/grafana/grafana-app-sdk/logging"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
)
var subscribedEvents = []string{"pull_request", "push"} // same order as slices.Sort()
type WebhookRepository interface {
Webhook(ctx context.Context, req *http.Request) (*provisioning.WebhookResponse, error)
}
type GithubWebhookRepository interface {
GithubRepository
repository.Hooks
WebhookRepository
}
type githubWebhookRepository struct {
GithubRepository
config *provisioning.Repository
owner string
repo string
secret common.RawSecureValue
gh Client
webhookURL string
}
func NewGithubWebhookRepository(
basic GithubRepository,
webhookURL string,
secret common.RawSecureValue,
) GithubWebhookRepository {
return &githubWebhookRepository{
GithubRepository: basic,
config: basic.Config(),
owner: basic.Owner(),
repo: basic.Repo(),
gh: basic.Client(),
webhookURL: webhookURL,
secret: secret,
}
}
// Webhook implements Repository.
func (r *githubWebhookRepository) Webhook(ctx context.Context, req *http.Request) (*provisioning.WebhookResponse, error) {
if r.config.Status.Webhook == nil {
return nil, fmt.Errorf("unexpected webhook request")
}
if r.secret.IsZero() {
return nil, fmt.Errorf("missing webhook secret")
}
payload, err := github.ValidatePayload(req, []byte(r.secret))
if err != nil {
return nil, apierrors.NewUnauthorized("invalid signature")
}
return r.parseWebhook(github.WebHookType(req), payload)
}
// This method does not include context because it does delegate any more requests
func (r *githubWebhookRepository) parseWebhook(messageType string, payload []byte) (*provisioning.WebhookResponse, error) {
event, err := github.ParseWebHook(messageType, payload)
if err != nil {
return nil, apierrors.NewBadRequest("invalid payload")
}
switch event := event.(type) {
case *github.PushEvent:
return r.parsePushEvent(event)
case *github.PullRequestEvent:
return r.parsePullRequestEvent(event)
case *github.PingEvent:
return &provisioning.WebhookResponse{
Code: http.StatusOK,
Message: "ping received",
}, nil
default:
return &provisioning.WebhookResponse{
Code: http.StatusNotImplemented,
Message: fmt.Sprintf("unsupported messageType: %s", messageType),
}, nil
}
}
func (r *githubWebhookRepository) parsePushEvent(event *github.PushEvent) (*provisioning.WebhookResponse, error) {
if event.GetRepo() == nil {
return nil, fmt.Errorf("missing repository in push event")
}
if event.GetRepo().GetFullName() != fmt.Sprintf("%s/%s", r.owner, r.repo) {
return nil, fmt.Errorf("repository mismatch")
}
// No need to sync if not enabled
if !r.config.Spec.Sync.Enabled {
return &provisioning.WebhookResponse{Code: http.StatusOK}, nil
}
// Skip silently if the event is not for the main/master branch
// as we cannot configure the webhook to only publish events for the main branch
if event.GetRef() != fmt.Sprintf("refs/heads/%s", r.config.Spec.GitHub.Branch) {
return &provisioning.WebhookResponse{Code: http.StatusOK}, nil
}
return &provisioning.WebhookResponse{
Code: http.StatusAccepted,
Job: &provisioning.JobSpec{
Repository: r.config.GetName(),
Action: provisioning.JobActionPull,
Pull: &provisioning.SyncJobOptions{
Incremental: true,
},
},
}, nil
}
func (r *githubWebhookRepository) parsePullRequestEvent(event *github.PullRequestEvent) (*provisioning.WebhookResponse, error) {
if event.GetRepo() == nil {
return nil, fmt.Errorf("missing repository in pull request event")
}
cfg := r.config.Spec.GitHub
if cfg == nil {
return nil, fmt.Errorf("missing GitHub config")
}
if event.GetRepo().GetFullName() != fmt.Sprintf("%s/%s", r.owner, r.repo) {
return nil, fmt.Errorf("repository mismatch")
}
pr := event.GetPullRequest()
if pr == nil {
return nil, fmt.Errorf("expected PR in event")
}
if pr.GetBase().GetRef() != r.config.Spec.GitHub.Branch {
return &provisioning.WebhookResponse{
Code: http.StatusOK,
Message: fmt.Sprintf("ignoring pull request event as %s is not the configured branch", pr.GetBase().GetRef()),
}, nil
}
action := event.GetAction()
if action != "opened" && action != "reopened" && action != "synchronize" {
return &provisioning.WebhookResponse{
Code: http.StatusOK, // Nothing needed
Message: fmt.Sprintf("ignore pull request event: %s", action),
}, nil
}
// Queue an async job that will parse files
return &provisioning.WebhookResponse{
Code: http.StatusAccepted, // Nothing needed
Message: fmt.Sprintf("pull request: %s", action),
Job: &provisioning.JobSpec{
Repository: r.config.GetName(),
Action: provisioning.JobActionPullRequest,
PullRequest: &provisioning.PullRequestJobOptions{
URL: pr.GetHTMLURL(),
PR: pr.GetNumber(),
Ref: pr.GetHead().GetRef(),
Hash: pr.GetHead().GetSHA(),
},
},
}, nil
}
// CommentPullRequest adds a comment to a pull request.
func (r *githubWebhookRepository) CommentPullRequest(ctx context.Context, prNumber int, comment string) error {
ctx, _ = r.logger(ctx, "")
return r.gh.CreatePullRequestComment(ctx, r.owner, r.repo, prNumber, comment)
}
func (r *githubWebhookRepository) createWebhook(ctx context.Context) (WebhookConfig, error) {
secret, err := uuid.NewRandom()
if err != nil {
return WebhookConfig{}, fmt.Errorf("could not generate secret: %w", err)
}
cfg := WebhookConfig{
URL: r.webhookURL,
Secret: secret.String(),
ContentType: "json",
Events: subscribedEvents,
Active: true,
}
hook, err := r.gh.CreateWebhook(ctx, r.owner, r.repo, cfg)
if err != nil {
return WebhookConfig{}, err
}
// HACK: GitHub does not return the secret, so we need to update it manually
hook.Secret = cfg.Secret
logging.FromContext(ctx).Info("webhook created", "url", cfg.URL, "id", hook.ID)
return hook, nil
}
// updateWebhook checks if the webhook needs to be updated and updates it if necessary.
// if the webhook does not exist, it will create it.
func (r *githubWebhookRepository) updateWebhook(ctx context.Context) (WebhookConfig, bool, error) {
if r.config.Status.Webhook == nil || r.config.Status.Webhook.ID == 0 {
hook, err := r.createWebhook(ctx)
if err != nil {
return WebhookConfig{}, false, err
}
return hook, true, nil
}
hook, err := r.gh.GetWebhook(ctx, r.owner, r.repo, r.config.Status.Webhook.ID)
switch {
case errors.Is(err, ErrResourceNotFound):
hook, err := r.createWebhook(ctx)
if err != nil {
return WebhookConfig{}, false, err
}
return hook, true, nil
case err != nil:
return WebhookConfig{}, false, fmt.Errorf("get webhook: %w", err)
}
var mustUpdate bool
if hook.URL != r.webhookURL {
mustUpdate = true
hook.URL = r.webhookURL
}
slices.Sort(hook.Events) // consistent order for comparison
if !slices.Equal(hook.Events, subscribedEvents) {
mustUpdate = true
hook.Events = subscribedEvents
}
if !mustUpdate {
return hook, false, nil
}
// Something has changed in the webhook. Let's rotate the secret as well, so as to ensure we end up with a 100% correct webhook.
secret, err := uuid.NewRandom()
if err != nil {
return WebhookConfig{}, false, fmt.Errorf("could not generate secret: %w", err)
}
hook.Secret = secret.String()
if err := r.gh.EditWebhook(ctx, r.owner, r.repo, hook); err != nil {
return WebhookConfig{}, false, fmt.Errorf("edit webhook: %w", err)
}
return hook, true, nil
}
func (r *githubWebhookRepository) deleteWebhook(ctx context.Context) error {
logger := logging.FromContext(ctx)
if r.config.Status.Webhook == nil {
return fmt.Errorf("webhook not found")
}
id := r.config.Status.Webhook.ID
if err := r.gh.DeleteWebhook(ctx, r.owner, r.repo, id); err != nil {
return fmt.Errorf("delete webhook: %w", err)
}
logger.Info("webhook deleted", "url", r.config.Status.Webhook.URL, "id", id)
return nil
}
func (r *githubWebhookRepository) OnCreate(ctx context.Context) ([]map[string]interface{}, error) {
if len(r.webhookURL) == 0 {
return nil, nil
}
ctx, _ = r.logger(ctx, "")
hook, err := r.createWebhook(ctx)
if err != nil {
return nil, err
}
return []map[string]interface{}{
{
"op": "replace",
"path": "/status/webhook",
"value": &provisioning.WebhookStatus{
ID: hook.ID,
URL: hook.URL,
SubscribedEvents: hook.Events,
},
},
{
"op": "replace",
"path": "/secure/webhookSecret",
"value": map[string]string{
"create": hook.Secret,
},
},
}, nil
}
func (r *githubWebhookRepository) OnUpdate(ctx context.Context) ([]map[string]interface{}, error) {
if len(r.webhookURL) == 0 {
return nil, nil
}
ctx, _ = r.logger(ctx, "")
hook, changed, err := r.updateWebhook(ctx)
if err != nil || !changed {
return nil, err
}
// update the webhook and secret
return []map[string]any{{
"op": "replace",
"path": "/status/webhook",
"value": &provisioning.WebhookStatus{
ID: hook.ID,
URL: hook.URL,
SubscribedEvents: hook.Events,
},
}, {
"op": "replace",
"path": "/secure/webhookSecret",
"value": map[string]string{
"create": hook.Secret,
},
}}, nil
}
func (r *githubWebhookRepository) OnDelete(ctx context.Context) error {
if r.config.Status.Webhook == nil {
return nil
}
return r.deleteWebhook(ctx)
}
func (r *githubWebhookRepository) logger(ctx context.Context, ref string) (context.Context, logging.Logger) {
logger := logging.FromContext(ctx)
type containsGh int
var containsGhKey containsGh
if ctx.Value(containsGhKey) != nil {
return ctx, logging.FromContext(ctx)
}
if ref == "" {
ref = r.config.Spec.GitHub.Branch
}
logger = logger.With(slog.Group("github_repository", "owner", r.owner, "name", r.repo, "ref", ref))
ctx = logging.Context(ctx, logger)
// We want to ensure we don't add multiple github_repository keys. With doesn't deduplicate the keys...
ctx = context.WithValue(ctx, containsGhKey, true)
return ctx, logger
}
File diff suppressed because it is too large Load Diff