[Prototyping] Listen to pull requests and comment (#97040)
* Listen to Pull Requests and comment * Fix template
This commit is contained in:
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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user