[Prototyping] Listen to pull requests and comment (#97040)

* Listen to Pull Requests and comment

* Fix template
This commit is contained in:
Roberto Jiménez Sánchez authored and GitHub committed 2024-11-27 10:38:10 +01:00
1 parent ee91485b32
commit 364637a7e2
4 files changed
+221 -24

No files matched your search

@@ -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,
}
@@ -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
@@ -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)
@@ -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
}