diff --git a/pkg/registry/apis/provisioning/repository/github.go b/pkg/registry/apis/provisioning/repository/github.go index 8c5d3ee3cc3..68ad9383317 100644 --- a/pkg/registry/apis/provisioning/repository/github.go +++ b/pkg/registry/apis/provisioning/repository/github.go @@ -1,6 +1,7 @@ package repository import ( + "bytes" "context" "errors" "fmt" @@ -9,6 +10,7 @@ import ( "net/url" "regexp" "strings" + "text/template" "github.com/google/go-github/v66/github" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -347,7 +349,16 @@ func (r *githubRepository) Webhook(ctx context.Context, logger *slog.Logger, res } responder.Object(200, &metav1.Status{ - Message: "event processed", + Message: "push event processed", + Code: http.StatusOK, + }) + case *github.PullRequestEvent: + if err := r.onPullRequestEvent(ctx, logger, event); err != nil { + responder.Error(err) + return + } + responder.Object(200, &metav1.Status{ + Message: "pull request event processed", Code: http.StatusOK, }) case *github.PingEvent: @@ -421,12 +432,99 @@ func (r *githubRepository) onPushEvent(ctx context.Context, logger *slog.Logger, return nil } +// onPullRequestEvent is called when a pull request event is received +// If the pull request is opened, reponed or synchronize, we read the files changed. +func (r *githubRepository) onPullRequestEvent(ctx context.Context, logger *slog.Logger, event *github.PullRequestEvent) error { + action := event.GetAction() + r.logger.InfoContext(ctx, "processing pull request event", "number", event.GetNumber(), "action", action) + + if event.GetRepo() == nil { + return fmt.Errorf("missing repository in pull request event") + } + + if event.GetRepo().GetFullName() != fmt.Sprintf("%s/%s", r.config.Spec.GitHub.Owner, r.config.Spec.GitHub.Repository) { + return fmt.Errorf("repository mismatch") + } + + if action != "opened" && action != "reopened" && action != "synchronize" { + logger.InfoContext(ctx, "ignore pull request event", "action", event.GetAction()) + return nil + } + + // Get the files changed in the pull request + files, err := r.gh.ListPullRequestFiles(ctx, r.config.Spec.GitHub.Owner, r.config.Spec.GitHub.Repository, event.GetNumber()) + if err != nil { + return fmt.Errorf("list pull request files: %w", err) + } + + // TODO: implement the real handling of the files + for _, file := range files { + // reference: https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#get-a-commit + switch file.GetStatus() { + case "added": + f, err := r.Read(ctx, logger, file.GetFilename(), file.GetSHA()) + if err != nil { + r.logger.ErrorContext(ctx, "read added file", "file", file.GetFilename(), "error", err) + continue + } + r.logger.InfoContext(ctx, "added file", "file", file.GetFilename(), "resource", string(f.Data)) + case "modified": + f, err := r.Read(ctx, logger, file.GetFilename(), file.GetSHA()) + if err != nil { + r.logger.ErrorContext(ctx, "read modified file", "file", file.GetFilename(), "error", err) + continue + } + r.logger.InfoContext(ctx, "modified file", "file", file.GetFilename(), "resource", string(f.Data)) + case "removed": + r.logger.InfoContext(ctx, "removed file", "file", file.GetFilename()) + case "renamed": + r.logger.InfoContext(ctx, "renamed file", "file", file.GetFilename(), "previous", file.GetPreviousFilename()) + case "changed": + f, err := r.Read(ctx, logger, file.GetFilename(), file.GetSHA()) + if err != nil { + r.logger.ErrorContext(ctx, "read changed file", "file", file.GetFilename(), "error", err) + continue + } + r.logger.InfoContext(ctx, "changed file", "file", file.GetFilename(), "resource", string(f.Data)) + case "unchanged": + r.logger.InfoContext(ctx, "ignore unchanged file", "file", file.GetFilename()) + default: + r.logger.ErrorContext(ctx, "unhandled pull request file", "status", file.GetStatus(), "file", file.GetFilename()) + } + } + + commentTemplate := `Grafana detected that the following files have been changed in this pull request: +{{range .}} +- {{.GetFilename}} **{{.GetStatus }}** +{{end}} +` + tmpl, err := template.New("comment").Parse(commentTemplate) + if err != nil { + return fmt.Errorf("parse comment template: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, files); err != nil { + return fmt.Errorf("execute comment template: %w", err) + } + + comment := buf.String() + + if err := r.gh.CreatePullRequestComment(ctx, r.config.Spec.GitHub.Owner, r.config.Spec.GitHub.Repository, event.GetNumber(), comment); err != nil { + return fmt.Errorf("create pull request comment: %w", err) + } + + r.logger.InfoContext(ctx, "comment created", "pull_request", event.GetNumber()) + + return nil +} + func (r *githubRepository) createWebhook(ctx context.Context, logger *slog.Logger) error { cfg := pgh.WebhookConfig{ URL: r.config.Spec.GitHub.WebhookURL, Secret: r.config.Spec.GitHub.WebhookSecret, ContentType: "json", - Events: []string{"push"}, + Events: []string{"push", "pull_request"}, Active: true, } diff --git a/pkg/registry/apis/provisioning/repository/github/client.go b/pkg/registry/apis/provisioning/repository/github/client.go index e9cdcbf533b..043680ae294 100644 --- a/pkg/registry/apis/provisioning/repository/github/client.go +++ b/pkg/registry/apis/provisioning/repository/github/client.go @@ -75,6 +75,9 @@ type Client interface { CreateWebhook(ctx context.Context, owner, repository string, cfg WebhookConfig) error DeleteWebhook(ctx context.Context, owner, repository string, webhookID int64) error EditWebhook(ctx context.Context, owner, repository string, cfg WebhookConfig) error + + ListPullRequestFiles(ctx context.Context, owner, repository string, number int) ([]CommitFile, error) + CreatePullRequestComment(ctx context.Context, owner, repository string, number int, body string) error } type RepositoryContent interface { @@ -96,6 +99,13 @@ type RepositoryContent interface { GetSize() int64 } +type CommitFile interface { + GetSHA() string + GetFilename() string + GetPreviousFilename() string + GetStatus() string +} + type CreateFileOptions struct { // The message of the commit. May be empty, in which case a default value is entered. Message string diff --git a/pkg/registry/apis/provisioning/repository/github/client_mock.go b/pkg/registry/apis/provisioning/repository/github/client_mock.go index 250e1e9036f..d3aa09d684e 100644 --- a/pkg/registry/apis/provisioning/repository/github/client_mock.go +++ b/pkg/registry/apis/provisioning/repository/github/client_mock.go @@ -13,6 +13,34 @@ type MockClient struct { mock.Mock } +// BranchExists provides a mock function with given fields: ctx, owner, repository, branchName +func (_m *MockClient) BranchExists(ctx context.Context, owner string, repository string, branchName string) (bool, error) { + ret := _m.Called(ctx, owner, repository, branchName) + + if len(ret) == 0 { + panic("no return value specified for BranchExists") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, string) (bool, error)); ok { + return rf(ctx, owner, repository, branchName) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string, string) bool); ok { + r0 = rf(ctx, owner, repository, branchName) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok { + r1 = rf(ctx, owner, repository, branchName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // CreateBranch provides a mock function with given fields: ctx, owner, repository, sourceBranch, branchName func (_m *MockClient) CreateBranch(ctx context.Context, owner string, repository string, sourceBranch string, branchName string) error { ret := _m.Called(ctx, owner, repository, sourceBranch, branchName) @@ -31,26 +59,6 @@ func (_m *MockClient) CreateBranch(ctx context.Context, owner string, repository return r0 } -func (_m *MockClient) BranchExists(ctx context.Context, owner string, repository string, branchName string) (bool, error) { - ret := _m.Called(ctx, owner, repository, branchName) - if len(ret) == 0 { - panic("no return value specified for BranchExists") - } - var r0 bool - if rf, ok := ret.Get(0).(func(context.Context, string, string, string) bool); ok { - r0 = rf(ctx, owner, repository, branchName) - } else { - r0 = ret.Bool(0) - } - var r1 error - if rf, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok { - r1 = rf(ctx, owner, repository, branchName) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - // CreateFile provides a mock function with given fields: ctx, owner, repository, path, branch, message, content func (_m *MockClient) CreateFile(ctx context.Context, owner string, repository string, path string, branch string, message string, content []byte) error { ret := _m.Called(ctx, owner, repository, path, branch, message, content) @@ -69,6 +77,24 @@ func (_m *MockClient) CreateFile(ctx context.Context, owner string, repository s return r0 } +// 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 +} + // 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) error { ret := _m.Called(ctx, owner, repository, cfg) @@ -217,6 +243,36 @@ func (_m *MockClient) GetTree(ctx context.Context, owner string, repository stri return r0, r1, r2 } +// 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 +} + // 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) @@ -270,8 +326,7 @@ func (_m *MockClient) UpdateFile(ctx context.Context, owner string, repository s func NewMockClient(t interface { mock.TestingT Cleanup(func()) -}, -) *MockClient { +}) *MockClient { mock := &MockClient{} mock.Mock.Test(t) diff --git a/pkg/registry/apis/provisioning/repository/github/real.go b/pkg/registry/apis/provisioning/repository/github/real.go index 5ae940a7661..faadd3294cd 100644 --- a/pkg/registry/apis/provisioning/repository/github/real.go +++ b/pkg/registry/apis/provisioning/repository/github/real.go @@ -310,6 +310,40 @@ func (r *realImpl) EditWebhook(ctx context.Context, owner, repository string, cf return err } +func (r *realImpl) ListPullRequestFiles(ctx context.Context, owner, repository string, number int) ([]CommitFile, error) { + commitFiles, _, err := r.gh.PullRequests.ListFiles(ctx, owner, repository, number, nil) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable { + return nil, ErrServiceUnavailable + } + return nil, err + } + + ret := make([]CommitFile, 0, len(commitFiles)) + for _, f := range commitFiles { + ret = append(ret, f) + } + + return ret, nil +} + +func (r *realImpl) 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 +} + type realRepositoryContent struct { real *github.RepositoryContent }