Provisioning: introduce concept of provisioning extras (#104981)
* Spike: Extras * Attempt to wire it up * Hack * Fix issue with jobs * Wire more things up * Fix more wiring stuff * Remove webhook secret key from main registration * Move secret encryption also outside register * Add TODOs in code * Add more explanations * Move connectors to different package * Move pull request job into webhooks * Separate registration * Remove duplicate files * Fix missing function * Extract webhook repository logic out of the core github repository * Use status patcher in webhook connector * Fix change in go mod * Change hooks signature * Remove TODOs * Remove Webhook methos from go-git * Remove leftover * Fix mistake in OpenAPI spec * Fix some tests * Fix some issues * Fix linting
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
// Code generated by mockery v2.52.4. DO NOT EDIT.
|
||||
|
||||
package pullrequest
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
grpc "google.golang.org/grpc"
|
||||
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
|
||||
resource "github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
)
|
||||
|
||||
// MockBlobStoreClient is an autogenerated mock type for the BlobStoreClient type
|
||||
type MockBlobStoreClient struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type MockBlobStoreClient_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *MockBlobStoreClient) EXPECT() *MockBlobStoreClient_Expecter {
|
||||
return &MockBlobStoreClient_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// PutBlob provides a mock function with given fields: ctx, in, opts
|
||||
func (_m *MockBlobStoreClient) PutBlob(ctx context.Context, in *resource.PutBlobRequest, opts ...grpc.CallOption) (*resource.PutBlobResponse, error) {
|
||||
_va := make([]interface{}, len(opts))
|
||||
for _i := range opts {
|
||||
_va[_i] = opts[_i]
|
||||
}
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, ctx, in)
|
||||
_ca = append(_ca, _va...)
|
||||
ret := _m.Called(_ca...)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for PutBlob")
|
||||
}
|
||||
|
||||
var r0 *resource.PutBlobResponse
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *resource.PutBlobRequest, ...grpc.CallOption) (*resource.PutBlobResponse, error)); ok {
|
||||
return rf(ctx, in, opts...)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *resource.PutBlobRequest, ...grpc.CallOption) *resource.PutBlobResponse); ok {
|
||||
r0 = rf(ctx, in, opts...)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*resource.PutBlobResponse)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *resource.PutBlobRequest, ...grpc.CallOption) error); ok {
|
||||
r1 = rf(ctx, in, opts...)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockBlobStoreClient_PutBlob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutBlob'
|
||||
type MockBlobStoreClient_PutBlob_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// PutBlob is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - in *resource.PutBlobRequest
|
||||
// - opts ...grpc.CallOption
|
||||
func (_e *MockBlobStoreClient_Expecter) PutBlob(ctx interface{}, in interface{}, opts ...interface{}) *MockBlobStoreClient_PutBlob_Call {
|
||||
return &MockBlobStoreClient_PutBlob_Call{Call: _e.mock.On("PutBlob",
|
||||
append([]interface{}{ctx, in}, opts...)...)}
|
||||
}
|
||||
|
||||
func (_c *MockBlobStoreClient_PutBlob_Call) Run(run func(ctx context.Context, in *resource.PutBlobRequest, opts ...grpc.CallOption)) *MockBlobStoreClient_PutBlob_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
variadicArgs := make([]grpc.CallOption, len(args)-2)
|
||||
for i, a := range args[2:] {
|
||||
if a != nil {
|
||||
variadicArgs[i] = a.(grpc.CallOption)
|
||||
}
|
||||
}
|
||||
run(args[0].(context.Context), args[1].(*resource.PutBlobRequest), variadicArgs...)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockBlobStoreClient_PutBlob_Call) Return(_a0 *resource.PutBlobResponse, _a1 error) *MockBlobStoreClient_PutBlob_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockBlobStoreClient_PutBlob_Call) RunAndReturn(run func(context.Context, *resource.PutBlobRequest, ...grpc.CallOption) (*resource.PutBlobResponse, error)) *MockBlobStoreClient_PutBlob_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// NewMockBlobStoreClient creates a new instance of MockBlobStoreClient. 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 NewMockBlobStoreClient(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockBlobStoreClient {
|
||||
mock := &MockBlobStoreClient{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package pullrequest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
|
||||
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
||||
"github.com/grafana/grafana/pkg/infra/slugify"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
)
|
||||
|
||||
type changeInfo struct {
|
||||
GrafanaBaseURL string
|
||||
|
||||
// Files we tried to read
|
||||
Changes []fileChangeInfo
|
||||
|
||||
// More files changed than we processed
|
||||
SkippedFiles int
|
||||
|
||||
// Requested image render, but it is not available
|
||||
MissingImageRenderer bool
|
||||
}
|
||||
|
||||
type fileChangeInfo struct {
|
||||
Change repository.VersionedFileChange
|
||||
Error string
|
||||
|
||||
// The parsed value
|
||||
Parsed *resources.ParsedResource
|
||||
|
||||
// The title from inside the resource (or name if not found)
|
||||
Title string
|
||||
|
||||
// The URL where this will appear (target)
|
||||
GrafanaURL string
|
||||
GrafanaScreenshotURL string
|
||||
|
||||
// URL where we can see a preview of this particular change
|
||||
PreviewURL string
|
||||
PreviewScreenshotURL string
|
||||
}
|
||||
|
||||
type evaluator struct {
|
||||
render ScreenshotRenderer
|
||||
parsers resources.ParserFactory
|
||||
urlProvider func(namespace string) string
|
||||
}
|
||||
|
||||
func NewEvaluator(render ScreenshotRenderer, parsers resources.ParserFactory, urlProvider func(namespace string) string) Evaluator {
|
||||
return &evaluator{
|
||||
render: render,
|
||||
parsers: parsers,
|
||||
urlProvider: urlProvider,
|
||||
}
|
||||
}
|
||||
|
||||
// This will process the list of versioned file changes into changeInfo
|
||||
func (e *evaluator) Evaluate(ctx context.Context, repo repository.Reader, opts provisioning.PullRequestJobOptions, changes []repository.VersionedFileChange, progress jobs.JobProgressRecorder) (changeInfo, error) {
|
||||
cfg := repo.Config()
|
||||
parser, err := e.parsers.GetParser(ctx, repo)
|
||||
if err != nil {
|
||||
return changeInfo{}, fmt.Errorf("failed to get parser for %s: %w", cfg.Name, err)
|
||||
}
|
||||
|
||||
rendererAvailable := e.render.IsAvailable(ctx)
|
||||
shouldRender := rendererAvailable && len(changes) == 1 && cfg.Spec.GitHub.GenerateDashboardPreviews
|
||||
info := changeInfo{
|
||||
GrafanaBaseURL: e.urlProvider(cfg.Namespace),
|
||||
MissingImageRenderer: !rendererAvailable,
|
||||
}
|
||||
|
||||
logger := logging.FromContext(ctx)
|
||||
|
||||
for i, change := range changes {
|
||||
// process maximum 10 files
|
||||
if i >= 10 {
|
||||
info.SkippedFiles = len(changes) - i
|
||||
logger.Info("skipping remaining files", "count", info.SkippedFiles)
|
||||
break
|
||||
}
|
||||
|
||||
progress.SetMessage(ctx, fmt.Sprintf("process %s", change.Path))
|
||||
logger.With("action", change.Action).With("path", change.Path)
|
||||
info.Changes = append(info.Changes, e.evaluateFile(ctx, repo, info.GrafanaBaseURL, change, opts, parser, shouldRender))
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
var dashboardKind = dashboard.DashboardResourceInfo.GroupVersionKind().Kind
|
||||
|
||||
func (e *evaluator) evaluateFile(ctx context.Context, repo repository.Reader, baseURL string, change repository.VersionedFileChange, opts provisioning.PullRequestJobOptions, parser resources.Parser, shouldRender bool) fileChangeInfo {
|
||||
if change.Action == repository.FileActionDeleted {
|
||||
// TODO: read the old and verify
|
||||
return fileChangeInfo{Change: change, Error: "delete feedback not yet implemented"}
|
||||
}
|
||||
|
||||
info := fileChangeInfo{Change: change}
|
||||
fileInfo, err := repo.Read(ctx, change.Path, change.Ref)
|
||||
if err != nil {
|
||||
logger.Info("unable to read file", "err", err)
|
||||
info.Error = err.Error()
|
||||
return info
|
||||
}
|
||||
|
||||
// Read the file as a resource
|
||||
info.Parsed, err = parser.Parse(ctx, fileInfo)
|
||||
if err != nil {
|
||||
info.Error = err.Error()
|
||||
return info
|
||||
}
|
||||
|
||||
// Find a name within the file
|
||||
obj := info.Parsed.Obj
|
||||
info.Title = info.Parsed.Meta.FindTitle(obj.GetName())
|
||||
|
||||
// Check what happens when we apply changes
|
||||
// NOTE: this will also invoke any server side validation
|
||||
err = info.Parsed.DryRun(ctx)
|
||||
if err != nil {
|
||||
info.Error = err.Error()
|
||||
return info
|
||||
}
|
||||
|
||||
// Dashboards get special handling
|
||||
if info.Parsed.GVK.Kind == dashboardKind {
|
||||
// FIXME: extract the logic out of a dashboard URL builder/injector or similar
|
||||
// for testability and decoupling
|
||||
if info.Parsed.Existing != nil {
|
||||
info.GrafanaURL = fmt.Sprintf("%sd/%s/%s", baseURL, obj.GetName(),
|
||||
slugify.Slugify(info.Title))
|
||||
}
|
||||
|
||||
// Load this file directly
|
||||
info.PreviewURL = baseURL + path.Join("admin/provisioning",
|
||||
info.Parsed.Repo.Name, "dashboard/preview", info.Parsed.Info.Path)
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("ref", info.Parsed.Info.Ref)
|
||||
if opts.URL != "" {
|
||||
query.Set("pull_request_url", url.QueryEscape(opts.URL))
|
||||
}
|
||||
info.PreviewURL += "?" + query.Encode()
|
||||
if shouldRender {
|
||||
if info.GrafanaURL != "" {
|
||||
info.GrafanaScreenshotURL, err = renderScreenshotFromGrafanaURL(ctx, baseURL, e.render, info.Parsed.Repo, info.GrafanaURL)
|
||||
if err != nil {
|
||||
info.Error = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
if info.PreviewURL != "" {
|
||||
info.PreviewScreenshotURL, err = renderScreenshotFromGrafanaURL(ctx, baseURL, e.render, info.Parsed.Repo, info.PreviewURL)
|
||||
if err != nil {
|
||||
info.Error = err.Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
func renderScreenshotFromGrafanaURL(ctx context.Context,
|
||||
baseURL string,
|
||||
renderer ScreenshotRenderer,
|
||||
repo provisioning.ResourceRepositoryInfo,
|
||||
grafanaURL string,
|
||||
) (string, error) {
|
||||
parsed, err := url.Parse(grafanaURL)
|
||||
if err != nil {
|
||||
logging.FromContext(ctx).Warn("invalid", "url", grafanaURL, "err", err)
|
||||
return "", err
|
||||
}
|
||||
snap, err := renderer.RenderScreenshot(ctx, repo, strings.TrimPrefix(parsed.Path, "/"), parsed.Query())
|
||||
if err != nil {
|
||||
logging.FromContext(ctx).Warn("render failed", "url", grafanaURL, "err", err)
|
||||
return "", fmt.Errorf("error rendering screenshot: %w", err)
|
||||
}
|
||||
if strings.Contains(snap, "://") {
|
||||
return snap, nil // it is a full URL already (can happen when the blob storage returns CDN urls)
|
||||
}
|
||||
base, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
logger.Warn("invalid base", "url", baseURL, "err", err)
|
||||
return "", err
|
||||
}
|
||||
return base.JoinPath(snap).String(), nil
|
||||
}
|
||||
@@ -0,0 +1,914 @@
|
||||
package pullrequest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
)
|
||||
|
||||
func TestCalculateChanges(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupMocks func(parser *resources.MockParser, reader *repository.MockReader, progress *jobs.MockJobProgressRecorder, renderer *MockScreenshotRenderer, parserFactory *resources.MockParserFactory)
|
||||
changes []repository.VersionedFileChange
|
||||
expectedInfo changeInfo
|
||||
expectedError string
|
||||
grafanaBaseURL string
|
||||
}{
|
||||
{
|
||||
name: "with screenshot",
|
||||
setupMocks: func(parser *resources.MockParser, reader *repository.MockReader, progress *jobs.MockJobProgressRecorder, renderer *MockScreenshotRenderer, parserFactory *resources.MockParserFactory) {
|
||||
finfo := &repository.FileInfo{
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
Data: []byte("xxxx"),
|
||||
}
|
||||
obj := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": resources.DashboardResource.GroupVersion().String(),
|
||||
"kind": dashboardKind,
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "the-uid",
|
||||
},
|
||||
"spec": map[string]interface{}{
|
||||
"title": "hello world",
|
||||
},
|
||||
},
|
||||
}
|
||||
meta, _ := utils.MetaAccessor(obj)
|
||||
|
||||
progress.On("SetMessage", mock.Anything, "process path/to/file.json").Return()
|
||||
reader.On("Read", mock.Anything, "path/to/file.json", "ref").Return(finfo, nil)
|
||||
reader.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "x",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
GenerateDashboardPreviews: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
parser.On("Parse", mock.Anything, finfo).Return(&resources.ParsedResource{
|
||||
Info: finfo,
|
||||
Repo: provisioning.ResourceRepositoryInfo{
|
||||
Namespace: "x",
|
||||
Name: "y",
|
||||
},
|
||||
GVK: schema.GroupVersionKind{
|
||||
Kind: dashboardKind,
|
||||
},
|
||||
Obj: obj,
|
||||
Existing: obj,
|
||||
Meta: meta,
|
||||
DryRunResponse: obj,
|
||||
}, nil)
|
||||
renderer.On("IsAvailable", mock.Anything, mock.Anything).Return(true)
|
||||
renderer.On("RenderScreenshot", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||
Return(getDummyRenderedURL("x"), nil)
|
||||
parserFactory.On("GetParser", mock.Anything, mock.Anything).Return(parser, nil)
|
||||
},
|
||||
changes: []repository.VersionedFileChange{{
|
||||
Action: repository.FileActionCreated,
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
}},
|
||||
expectedInfo: changeInfo{
|
||||
Changes: []fileChangeInfo{{
|
||||
Change: repository.VersionedFileChange{
|
||||
Action: repository.FileActionCreated,
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
},
|
||||
GrafanaURL: "http://host/d/the-uid/hello-world",
|
||||
PreviewURL: "http://host/admin/provisioning/y/dashboard/preview/path/to/file.json?pull_request_url=http%253A%252F%252Fgithub.com%252Fpr%252F&ref=ref",
|
||||
GrafanaScreenshotURL: "https://cdn2.thecatapi.com/images/9e2.jpg",
|
||||
PreviewScreenshotURL: "https://cdn2.thecatapi.com/images/9e2.jpg",
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "without screenshot",
|
||||
setupMocks: func(parser *resources.MockParser, reader *repository.MockReader, progress *jobs.MockJobProgressRecorder, renderer *MockScreenshotRenderer, parserFactory *resources.MockParserFactory) {
|
||||
finfo := &repository.FileInfo{
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
Data: []byte("xxxx"),
|
||||
}
|
||||
obj := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": resources.DashboardResource.GroupVersion().String(),
|
||||
"kind": dashboardKind,
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "the-uid",
|
||||
},
|
||||
"spec": map[string]interface{}{
|
||||
"title": "hello world",
|
||||
},
|
||||
},
|
||||
}
|
||||
meta, _ := utils.MetaAccessor(obj)
|
||||
|
||||
progress.On("SetMessage", mock.Anything, "process path/to/file.json").Return()
|
||||
reader.On("Read", mock.Anything, "path/to/file.json", "ref").Return(finfo, nil)
|
||||
reader.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "x",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
GenerateDashboardPreviews: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
parser.On("Parse", mock.Anything, finfo).Return(&resources.ParsedResource{
|
||||
Info: finfo,
|
||||
Repo: provisioning.ResourceRepositoryInfo{
|
||||
Namespace: "x",
|
||||
Name: "y",
|
||||
},
|
||||
GVK: schema.GroupVersionKind{
|
||||
Kind: dashboardKind,
|
||||
},
|
||||
Obj: obj,
|
||||
Existing: obj,
|
||||
Meta: meta,
|
||||
DryRunResponse: obj,
|
||||
}, nil)
|
||||
renderer.On("IsAvailable", mock.Anything, mock.Anything).Return(false)
|
||||
parserFactory.On("GetParser", mock.Anything, mock.Anything).Return(parser, nil)
|
||||
},
|
||||
changes: []repository.VersionedFileChange{{
|
||||
Action: repository.FileActionCreated,
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
}},
|
||||
expectedInfo: changeInfo{
|
||||
Changes: []fileChangeInfo{{
|
||||
Change: repository.VersionedFileChange{
|
||||
Action: repository.FileActionCreated,
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
},
|
||||
GrafanaURL: "http://host/d/the-uid/hello-world",
|
||||
PreviewURL: "http://host/admin/provisioning/y/dashboard/preview/path/to/file.json?pull_request_url=http%253A%252F%252Fgithub.com%252Fpr%252F&ref=ref",
|
||||
GrafanaScreenshotURL: "",
|
||||
PreviewScreenshotURL: "",
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "process first 10 files",
|
||||
setupMocks: func(parser *resources.MockParser, reader *repository.MockReader, progress *jobs.MockJobProgressRecorder, renderer *MockScreenshotRenderer, parserFactory *resources.MockParserFactory) {
|
||||
finfo := &repository.FileInfo{
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
Data: []byte("xxxx"),
|
||||
}
|
||||
obj := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": resources.DashboardResource.GroupVersion().String(),
|
||||
"kind": dashboardKind,
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "the-uid",
|
||||
},
|
||||
"spec": map[string]interface{}{
|
||||
"title": "hello world",
|
||||
},
|
||||
},
|
||||
}
|
||||
meta, _ := utils.MetaAccessor(obj)
|
||||
|
||||
progress.On("SetMessage", mock.Anything, mock.Anything).Return()
|
||||
reader.On("Read", mock.Anything, "path/to/file.json", "ref").Return(finfo, nil)
|
||||
reader.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "x",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
GenerateDashboardPreviews: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
parser.On("Parse", mock.Anything, finfo).Return(&resources.ParsedResource{
|
||||
Info: finfo,
|
||||
Repo: provisioning.ResourceRepositoryInfo{
|
||||
Namespace: "x",
|
||||
Name: "y",
|
||||
},
|
||||
GVK: schema.GroupVersionKind{
|
||||
Kind: dashboardKind,
|
||||
},
|
||||
Obj: obj,
|
||||
Existing: obj,
|
||||
Meta: meta,
|
||||
DryRunResponse: obj,
|
||||
}, nil)
|
||||
renderer.On("IsAvailable", mock.Anything, mock.Anything).Return(true)
|
||||
parserFactory.On("GetParser", mock.Anything, mock.Anything).Return(parser, nil)
|
||||
},
|
||||
changes: func() []repository.VersionedFileChange {
|
||||
changes := []repository.VersionedFileChange{}
|
||||
for range 15 {
|
||||
changes = append(changes, repository.VersionedFileChange{
|
||||
Action: repository.FileActionCreated,
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
})
|
||||
}
|
||||
return changes
|
||||
}(),
|
||||
expectedInfo: changeInfo{
|
||||
SkippedFiles: 5,
|
||||
Changes: func() []fileChangeInfo {
|
||||
changes := []fileChangeInfo{}
|
||||
for range 10 {
|
||||
changes = append(changes, fileChangeInfo{
|
||||
Change: repository.VersionedFileChange{
|
||||
Action: repository.FileActionCreated,
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
},
|
||||
GrafanaURL: "http://host/d/the-uid/hello-world",
|
||||
PreviewURL: "http://host/admin/provisioning/y/dashboard/preview/path/to/file.json?pull_request_url=http%253A%252F%252Fgithub.com%252Fpr%252F&ref=ref",
|
||||
})
|
||||
}
|
||||
return changes
|
||||
}(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "parser factory error",
|
||||
setupMocks: func(parser *resources.MockParser, reader *repository.MockReader, progress *jobs.MockJobProgressRecorder, renderer *MockScreenshotRenderer, parserFactory *resources.MockParserFactory) {
|
||||
reader.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "x",
|
||||
},
|
||||
})
|
||||
parserFactory.On("GetParser", mock.Anything, mock.Anything).Return(nil, fmt.Errorf("parser factory error"))
|
||||
},
|
||||
changes: []repository.VersionedFileChange{{
|
||||
Action: repository.FileActionCreated,
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
}},
|
||||
expectedError: "failed to get parser for test-repo: parser factory error",
|
||||
},
|
||||
{
|
||||
name: "file read error",
|
||||
setupMocks: func(parser *resources.MockParser, reader *repository.MockReader, progress *jobs.MockJobProgressRecorder, renderer *MockScreenshotRenderer, parserFactory *resources.MockParserFactory) {
|
||||
reader.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "x",
|
||||
},
|
||||
})
|
||||
renderer.On("IsAvailable", mock.Anything, mock.Anything).Return(false)
|
||||
parserFactory.On("GetParser", mock.Anything, mock.Anything).Return(parser, nil)
|
||||
progress.On("SetMessage", mock.Anything, "process path/to/file.json").Return()
|
||||
reader.On("Read", mock.Anything, "path/to/file.json", "ref").Return(nil, fmt.Errorf("read error"))
|
||||
},
|
||||
changes: []repository.VersionedFileChange{{
|
||||
Action: repository.FileActionCreated,
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
}},
|
||||
expectedInfo: changeInfo{
|
||||
Changes: []fileChangeInfo{{
|
||||
Change: repository.VersionedFileChange{
|
||||
Action: repository.FileActionCreated,
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
},
|
||||
Error: "read error",
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "parse error",
|
||||
setupMocks: func(parser *resources.MockParser, reader *repository.MockReader, progress *jobs.MockJobProgressRecorder, renderer *MockScreenshotRenderer, parserFactory *resources.MockParserFactory) {
|
||||
reader.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "x",
|
||||
},
|
||||
})
|
||||
parserFactory.On("GetParser", mock.Anything, mock.Anything).Return(parser, nil)
|
||||
renderer.On("IsAvailable", mock.Anything, mock.Anything).Return(false)
|
||||
progress.On("SetMessage", mock.Anything, "process path/to/file.json").Return()
|
||||
|
||||
finfo := &repository.FileInfo{
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
Data: []byte("invalid json"),
|
||||
}
|
||||
reader.On("Read", mock.Anything, "path/to/file.json", "ref").Return(finfo, nil)
|
||||
parser.On("Parse", mock.Anything, finfo).Return(nil, fmt.Errorf("parse error"))
|
||||
},
|
||||
changes: []repository.VersionedFileChange{{
|
||||
Action: repository.FileActionCreated,
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
}},
|
||||
expectedInfo: changeInfo{
|
||||
Changes: []fileChangeInfo{{
|
||||
Change: repository.VersionedFileChange{
|
||||
Action: repository.FileActionCreated,
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
},
|
||||
Error: "parse error",
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dry run error",
|
||||
setupMocks: func(parser *resources.MockParser, reader *repository.MockReader, progress *jobs.MockJobProgressRecorder, renderer *MockScreenshotRenderer, parserFactory *resources.MockParserFactory) {
|
||||
reader.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "x",
|
||||
},
|
||||
})
|
||||
parserFactory.On("GetParser", mock.Anything, mock.Anything).Return(parser, nil)
|
||||
progress.On("SetMessage", mock.Anything, "process path/to/file.json").Return()
|
||||
renderer.On("IsAvailable", mock.Anything, mock.Anything).Return(false)
|
||||
|
||||
finfo := &repository.FileInfo{
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
Data: []byte("xxxx"),
|
||||
}
|
||||
obj := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": resources.DashboardResource.GroupVersion().String(),
|
||||
"kind": dashboardKind,
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "the-uid",
|
||||
},
|
||||
"spec": map[string]interface{}{
|
||||
"title": "hello world",
|
||||
},
|
||||
},
|
||||
}
|
||||
meta, _ := utils.MetaAccessor(obj)
|
||||
|
||||
reader.On("Read", mock.Anything, "path/to/file.json", "ref").Return(finfo, nil)
|
||||
parsed := &resources.ParsedResource{
|
||||
Info: finfo,
|
||||
Repo: provisioning.ResourceRepositoryInfo{
|
||||
Namespace: "x",
|
||||
Name: "y",
|
||||
},
|
||||
GVK: schema.GroupVersionKind{
|
||||
Kind: dashboardKind,
|
||||
},
|
||||
Obj: obj,
|
||||
Existing: obj,
|
||||
Meta: meta,
|
||||
}
|
||||
parser.On("Parse", mock.Anything, finfo).Return(parsed, nil)
|
||||
parsed.DryRunResponse = nil // This will cause a dry run error
|
||||
},
|
||||
changes: []repository.VersionedFileChange{{
|
||||
Action: repository.FileActionCreated,
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
}},
|
||||
expectedInfo: changeInfo{
|
||||
Changes: []fileChangeInfo{{
|
||||
Change: repository.VersionedFileChange{
|
||||
Action: repository.FileActionCreated,
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
},
|
||||
Error: "no client configured",
|
||||
Title: "hello world",
|
||||
Parsed: &resources.ParsedResource{
|
||||
Info: &repository.FileInfo{
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
Data: []byte("xxxx"),
|
||||
},
|
||||
GVK: schema.GroupVersionKind{
|
||||
Kind: dashboardKind,
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "screenshot render error",
|
||||
setupMocks: func(parser *resources.MockParser, reader *repository.MockReader, progress *jobs.MockJobProgressRecorder, renderer *MockScreenshotRenderer, parserFactory *resources.MockParserFactory) {
|
||||
finfo := &repository.FileInfo{
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
Data: []byte("xxxx"),
|
||||
}
|
||||
obj := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": resources.DashboardResource.GroupVersion().String(),
|
||||
"kind": dashboardKind,
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "the-uid",
|
||||
},
|
||||
"spec": map[string]interface{}{
|
||||
"title": "hello world",
|
||||
},
|
||||
},
|
||||
}
|
||||
meta, _ := utils.MetaAccessor(obj)
|
||||
|
||||
renderer.On("IsAvailable", mock.Anything, mock.Anything).Return(true)
|
||||
progress.On("SetMessage", mock.Anything, "process path/to/file.json").Return()
|
||||
reader.On("Read", mock.Anything, "path/to/file.json", "ref").Return(finfo, nil)
|
||||
reader.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "x",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
GenerateDashboardPreviews: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
parser.On("Parse", mock.Anything, finfo).Return(&resources.ParsedResource{
|
||||
Info: finfo,
|
||||
Repo: provisioning.ResourceRepositoryInfo{
|
||||
Namespace: "x",
|
||||
Name: "y",
|
||||
},
|
||||
GVK: schema.GroupVersionKind{
|
||||
Kind: dashboardKind,
|
||||
},
|
||||
Obj: obj,
|
||||
Existing: obj,
|
||||
Meta: meta,
|
||||
DryRunResponse: obj,
|
||||
}, nil)
|
||||
renderer.On("IsAvailable", mock.Anything, mock.Anything).Return(true)
|
||||
renderer.On("RenderScreenshot", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||
Return("", fmt.Errorf("render error"))
|
||||
parserFactory.On("GetParser", mock.Anything, mock.Anything).Return(parser, nil)
|
||||
},
|
||||
changes: []repository.VersionedFileChange{{
|
||||
Action: repository.FileActionCreated,
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
}},
|
||||
expectedInfo: changeInfo{
|
||||
MissingImageRenderer: true,
|
||||
Changes: []fileChangeInfo{{
|
||||
Change: repository.VersionedFileChange{
|
||||
Action: repository.FileActionCreated,
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
},
|
||||
Error: "error rendering screenshot: render error",
|
||||
GrafanaURL: "http://host/d/the-uid/hello-world",
|
||||
PreviewURL: "http://host/admin/provisioning/y/dashboard/preview/path/to/file.json?pull_request_url=http%253A%252F%252Fgithub.com%252Fpr%252F&ref=ref",
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non-dashboard resource",
|
||||
setupMocks: func(parser *resources.MockParser, reader *repository.MockReader, progress *jobs.MockJobProgressRecorder, renderer *MockScreenshotRenderer, parserFactory *resources.MockParserFactory) {
|
||||
finfo := &repository.FileInfo{
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
Data: []byte("xxxx"),
|
||||
}
|
||||
obj := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "test/v1",
|
||||
"kind": "TestResource",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "test-resource",
|
||||
},
|
||||
"spec": map[string]interface{}{
|
||||
"title": "Test Resource",
|
||||
},
|
||||
},
|
||||
}
|
||||
meta, _ := utils.MetaAccessor(obj)
|
||||
renderer.On("IsAvailable", mock.Anything, mock.Anything).Return(false)
|
||||
progress.On("SetMessage", mock.Anything, "process path/to/file.json").Return()
|
||||
reader.On("Read", mock.Anything, "path/to/file.json", "ref").Return(finfo, nil)
|
||||
reader.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "x",
|
||||
},
|
||||
})
|
||||
parser.On("Parse", mock.Anything, finfo).Return(&resources.ParsedResource{
|
||||
Info: finfo,
|
||||
Repo: provisioning.ResourceRepositoryInfo{
|
||||
Namespace: "x",
|
||||
Name: "y",
|
||||
},
|
||||
GVK: schema.GroupVersionKind{
|
||||
Kind: "TestResource",
|
||||
},
|
||||
Obj: obj,
|
||||
Existing: obj,
|
||||
Meta: meta,
|
||||
DryRunResponse: obj,
|
||||
}, nil)
|
||||
parserFactory.On("GetParser", mock.Anything, mock.Anything).Return(parser, nil)
|
||||
},
|
||||
changes: []repository.VersionedFileChange{{
|
||||
Action: repository.FileActionCreated,
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
}},
|
||||
expectedInfo: changeInfo{
|
||||
Changes: []fileChangeInfo{{
|
||||
Change: repository.VersionedFileChange{
|
||||
Action: repository.FileActionCreated,
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
},
|
||||
Title: "Test Resource",
|
||||
Parsed: &resources.ParsedResource{
|
||||
Info: &repository.FileInfo{
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
Data: []byte("xxxx"),
|
||||
},
|
||||
GVK: schema.GroupVersionKind{
|
||||
Kind: "TestResource",
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deleted file",
|
||||
setupMocks: func(parser *resources.MockParser, reader *repository.MockReader, progress *jobs.MockJobProgressRecorder, renderer *MockScreenshotRenderer, parserFactory *resources.MockParserFactory) {
|
||||
reader.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "x",
|
||||
},
|
||||
})
|
||||
renderer.On("IsAvailable", mock.Anything, mock.Anything).Return(false)
|
||||
parserFactory.On("GetParser", mock.Anything, mock.Anything).Return(parser, nil)
|
||||
progress.On("SetMessage", mock.Anything, "process path/to/file.json").Return()
|
||||
},
|
||||
changes: []repository.VersionedFileChange{{
|
||||
Action: repository.FileActionDeleted,
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
}},
|
||||
expectedInfo: changeInfo{
|
||||
Changes: []fileChangeInfo{{
|
||||
Change: repository.VersionedFileChange{
|
||||
Action: repository.FileActionDeleted,
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
},
|
||||
Error: "delete feedback not yet implemented",
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid grafana url",
|
||||
setupMocks: func(parser *resources.MockParser, reader *repository.MockReader, progress *jobs.MockJobProgressRecorder, renderer *MockScreenshotRenderer, parserFactory *resources.MockParserFactory) {
|
||||
finfo := &repository.FileInfo{
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
Data: []byte("xxxx"),
|
||||
}
|
||||
obj := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": resources.DashboardResource.GroupVersion().String(),
|
||||
"kind": dashboardKind,
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "the:uid", // Invalid character in UID
|
||||
},
|
||||
"spec": map[string]interface{}{
|
||||
"title": "hello world",
|
||||
},
|
||||
},
|
||||
}
|
||||
meta, _ := utils.MetaAccessor(obj)
|
||||
|
||||
renderer.On("IsAvailable", mock.Anything, mock.Anything).Return(true)
|
||||
renderer.On("RenderScreenshot", mock.Anything, mock.MatchedBy(func(repo provisioning.ResourceRepositoryInfo) bool {
|
||||
return repo.Namespace == "x" && repo.Name == "y"
|
||||
}), "d/the:uid/hello-world", mock.Anything).Return("", fmt.Errorf("invalid URL"))
|
||||
renderer.On("RenderScreenshot", mock.Anything, mock.MatchedBy(func(repo provisioning.ResourceRepositoryInfo) bool {
|
||||
return repo.Namespace == "x" && repo.Name == "y"
|
||||
}), "admin/provisioning/y/dashboard/preview/path/to/file.json", mock.Anything).Return("", fmt.Errorf("invalid preview URL"))
|
||||
|
||||
progress.On("SetMessage", mock.Anything, "process path/to/file.json").Return()
|
||||
reader.On("Read", mock.Anything, "path/to/file.json", "ref").Return(finfo, nil)
|
||||
reader.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "x",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
GenerateDashboardPreviews: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
parser.On("Parse", mock.Anything, finfo).Return(&resources.ParsedResource{
|
||||
Info: finfo,
|
||||
Repo: provisioning.ResourceRepositoryInfo{
|
||||
Namespace: "x",
|
||||
Name: "y",
|
||||
},
|
||||
GVK: schema.GroupVersionKind{
|
||||
Kind: dashboardKind,
|
||||
},
|
||||
Obj: obj,
|
||||
Existing: obj,
|
||||
Meta: meta,
|
||||
DryRunResponse: obj,
|
||||
}, nil)
|
||||
parserFactory.On("GetParser", mock.Anything, mock.Anything).Return(parser, nil)
|
||||
},
|
||||
changes: []repository.VersionedFileChange{{
|
||||
Action: repository.FileActionCreated,
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
}},
|
||||
expectedInfo: changeInfo{
|
||||
MissingImageRenderer: true,
|
||||
Changes: []fileChangeInfo{{
|
||||
Change: repository.VersionedFileChange{
|
||||
Action: repository.FileActionCreated,
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
},
|
||||
Error: "error rendering screenshot: invalid preview URL",
|
||||
GrafanaURL: "http://host/d/the:uid/hello-world", // Invalid URL
|
||||
PreviewURL: "http://host/admin/provisioning/y/dashboard/preview/path/to/file.json?pull_request_url=http%253A%252F%252Fgithub.com%252Fpr%252F&ref=ref",
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "malformed grafana url",
|
||||
grafanaBaseURL: "ht tp://bad url/",
|
||||
setupMocks: func(parser *resources.MockParser, reader *repository.MockReader, progress *jobs.MockJobProgressRecorder, renderer *MockScreenshotRenderer, parserFactory *resources.MockParserFactory) {
|
||||
finfo := &repository.FileInfo{
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
Data: []byte("xxxx"),
|
||||
}
|
||||
obj := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": resources.DashboardResource.GroupVersion().String(),
|
||||
"kind": dashboardKind,
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "the-uid",
|
||||
},
|
||||
"spec": map[string]interface{}{
|
||||
"title": "hello world",
|
||||
},
|
||||
},
|
||||
}
|
||||
meta, _ := utils.MetaAccessor(obj)
|
||||
|
||||
renderer.On("IsAvailable", mock.Anything, mock.Anything).Return(false)
|
||||
progress.On("SetMessage", mock.Anything, "process path/to/file.json").Return()
|
||||
reader.On("Read", mock.Anything, "path/to/file.json", "ref").Return(finfo, nil)
|
||||
reader.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "x",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
GenerateDashboardPreviews: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
parsed := &resources.ParsedResource{
|
||||
Info: finfo,
|
||||
Repo: provisioning.ResourceRepositoryInfo{
|
||||
Namespace: "x",
|
||||
Name: "y",
|
||||
},
|
||||
GVK: schema.GroupVersionKind{
|
||||
Kind: dashboardKind,
|
||||
},
|
||||
Obj: obj,
|
||||
Existing: obj,
|
||||
Meta: meta,
|
||||
DryRunResponse: obj,
|
||||
}
|
||||
parser.On("Parse", mock.Anything, finfo).Return(parsed, nil)
|
||||
parserFactory.On("GetParser", mock.Anything, mock.Anything).Return(parser, nil)
|
||||
},
|
||||
changes: []repository.VersionedFileChange{{
|
||||
Action: repository.FileActionCreated,
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
}},
|
||||
expectedInfo: changeInfo{
|
||||
MissingImageRenderer: true,
|
||||
Changes: []fileChangeInfo{{
|
||||
Change: repository.VersionedFileChange{
|
||||
Action: repository.FileActionCreated,
|
||||
Path: "path/to/file.json",
|
||||
Ref: "ref",
|
||||
},
|
||||
GrafanaURL: "ht tp://bad url/d/the-uid/hello-world", // Malformed URL
|
||||
PreviewURL: "ht tp://bad url/admin/provisioning/y/dashboard/preview/path/to/file.json?pull_request_url=http%253A%252F%252Fgithub.com%252Fpr%252F&ref=ref",
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parser := resources.NewMockParser(t)
|
||||
reader := repository.NewMockReader(t)
|
||||
progress := jobs.NewMockJobProgressRecorder(t)
|
||||
renderer := NewMockScreenshotRenderer(t)
|
||||
parserFactory := resources.NewMockParserFactory(t)
|
||||
|
||||
tt.setupMocks(parser, reader, progress, renderer, parserFactory)
|
||||
|
||||
evaluator := NewEvaluator(renderer, parserFactory, func(_ string) string {
|
||||
if tt.grafanaBaseURL != "" {
|
||||
return tt.grafanaBaseURL
|
||||
}
|
||||
|
||||
return "http://host/"
|
||||
})
|
||||
|
||||
pullRequest := provisioning.PullRequestJobOptions{
|
||||
Ref: "ref",
|
||||
PR: 123,
|
||||
URL: "http://github.com/pr/",
|
||||
}
|
||||
|
||||
info, err := evaluator.Evaluate(context.Background(), reader, pullRequest, tt.changes, progress)
|
||||
if tt.expectedError != "" {
|
||||
require.EqualError(t, err, tt.expectedError)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, len(tt.expectedInfo.Changes), len(info.Changes))
|
||||
require.Equal(t, tt.expectedInfo.SkippedFiles, info.SkippedFiles)
|
||||
|
||||
// compare change URLs
|
||||
for i, change := range info.Changes {
|
||||
require.Equal(t, tt.expectedInfo.Changes[i].GrafanaURL, change.GrafanaURL)
|
||||
require.Equal(t, tt.expectedInfo.Changes[i].PreviewURL, change.PreviewURL)
|
||||
require.Equal(t, tt.expectedInfo.Changes[i].GrafanaScreenshotURL, change.GrafanaScreenshotURL)
|
||||
require.Equal(t, tt.expectedInfo.Changes[i].PreviewScreenshotURL, change.PreviewScreenshotURL)
|
||||
require.Equal(t, tt.expectedInfo.Changes[i].Error, change.Error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDummyImageURL(t *testing.T) {
|
||||
urls := []string{}
|
||||
for i := range 10 {
|
||||
urls = append(urls, getDummyRenderedURL(fmt.Sprintf("http://%d", i)))
|
||||
}
|
||||
require.Equal(t, []string{
|
||||
"https://cdn2.thecatapi.com/images/9e2.jpg",
|
||||
"https://cdn2.thecatapi.com/images/bhs.jpg",
|
||||
"https://cdn2.thecatapi.com/images/d54.jpg",
|
||||
"https://cdn2.thecatapi.com/images/99c.jpg",
|
||||
"https://cdn2.thecatapi.com/images/9e2.jpg",
|
||||
"https://cdn2.thecatapi.com/images/bhs.jpg",
|
||||
"https://cdn2.thecatapi.com/images/d54.jpg",
|
||||
"https://cdn2.thecatapi.com/images/99c.jpg",
|
||||
"https://cdn2.thecatapi.com/images/9e2.jpg",
|
||||
"https://cdn2.thecatapi.com/images/bhs.jpg",
|
||||
}, urls)
|
||||
}
|
||||
|
||||
// Returns a random (but stable) image for a string
|
||||
func getDummyRenderedURL(url string) string {
|
||||
dummy := []string{
|
||||
"https://cdn2.thecatapi.com/images/9e2.jpg",
|
||||
"https://cdn2.thecatapi.com/images/bhs.jpg",
|
||||
"https://cdn2.thecatapi.com/images/d54.jpg",
|
||||
"https://cdn2.thecatapi.com/images/99c.jpg",
|
||||
}
|
||||
|
||||
idx := 0
|
||||
hash := sha256.New()
|
||||
bytes := hash.Sum([]byte(url))
|
||||
if len(bytes) > 8 {
|
||||
v := binary.BigEndian.Uint64(bytes[0:8])
|
||||
idx = int(v) % len(dummy)
|
||||
}
|
||||
return dummy[idx]
|
||||
}
|
||||
|
||||
// FIXME: test these cases from the public interface once the component is refactored
|
||||
func TestRenderScreenshotFromGrafanaURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
grafanaURL string
|
||||
setupMock func(renderer *MockScreenshotRenderer)
|
||||
wantSnap string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "invalid grafana url",
|
||||
baseURL: "http://host/",
|
||||
grafanaURL: "ht tp://host/d/uid/dashboard",
|
||||
setupMock: func(renderer *MockScreenshotRenderer) {},
|
||||
wantErr: `parse "ht tp://host/d/uid/dashboard": first path segment in URL cannot contain colon`,
|
||||
},
|
||||
{
|
||||
name: "invalid base url",
|
||||
baseURL: "ht tp://bad host/",
|
||||
grafanaURL: "http://host/d/uid/dashboard",
|
||||
setupMock: func(renderer *MockScreenshotRenderer) {
|
||||
renderer.On("RenderScreenshot", mock.Anything, mock.MatchedBy(func(repo provisioning.ResourceRepositoryInfo) bool {
|
||||
return repo.Namespace == "test" && repo.Name == "repo"
|
||||
}), "d/uid/dashboard", mock.Anything).Return("screenshot.png", nil)
|
||||
},
|
||||
wantErr: `parse "ht tp://bad host/": first path segment in URL cannot contain colon`,
|
||||
},
|
||||
{
|
||||
name: "render error",
|
||||
baseURL: "http://host/",
|
||||
grafanaURL: "http://host/d/uid/dashboard",
|
||||
setupMock: func(renderer *MockScreenshotRenderer) {
|
||||
renderer.On("RenderScreenshot", mock.Anything, mock.MatchedBy(func(repo provisioning.ResourceRepositoryInfo) bool {
|
||||
return repo.Namespace == "test" && repo.Name == "repo"
|
||||
}), "d/uid/dashboard", mock.Anything).Return("", fmt.Errorf("render failed"))
|
||||
},
|
||||
wantErr: "error rendering screenshot: render failed",
|
||||
},
|
||||
{
|
||||
name: "cdn url returned",
|
||||
baseURL: "http://host/",
|
||||
grafanaURL: "http://host/d/uid/dashboard",
|
||||
setupMock: func(renderer *MockScreenshotRenderer) {
|
||||
renderer.On("RenderScreenshot", mock.Anything, mock.MatchedBy(func(repo provisioning.ResourceRepositoryInfo) bool {
|
||||
return repo.Namespace == "test" && repo.Name == "repo"
|
||||
}), "d/uid/dashboard", mock.Anything).Return("https://cdn.example.com/screenshot.png", nil)
|
||||
},
|
||||
wantSnap: "https://cdn.example.com/screenshot.png",
|
||||
},
|
||||
{
|
||||
name: "successful render with relative path",
|
||||
baseURL: "http://host/",
|
||||
grafanaURL: "http://host/d/uid/dashboard",
|
||||
setupMock: func(renderer *MockScreenshotRenderer) {
|
||||
renderer.On("RenderScreenshot", mock.Anything, mock.MatchedBy(func(repo provisioning.ResourceRepositoryInfo) bool {
|
||||
return repo.Namespace == "test" && repo.Name == "repo"
|
||||
}), "d/uid/dashboard", mock.Anything).Return("screenshots/123.png", nil)
|
||||
},
|
||||
wantSnap: "http://host/screenshots/123.png",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
renderer := NewMockScreenshotRenderer(t)
|
||||
tt.setupMock(renderer)
|
||||
|
||||
repo := provisioning.ResourceRepositoryInfo{
|
||||
Namespace: "test",
|
||||
Name: "repo",
|
||||
}
|
||||
|
||||
got, err := renderScreenshotFromGrafanaURL(context.Background(), tt.baseURL, renderer, repo, tt.grafanaURL)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantSnap, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package pullrequest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type commenter struct {
|
||||
templateDashboard *template.Template
|
||||
templateTable *template.Template
|
||||
templateRenderInfo *template.Template
|
||||
}
|
||||
|
||||
func NewCommenter() Commenter {
|
||||
return &commenter{
|
||||
templateDashboard: template.Must(template.New("dashboard").Parse(commentTemplateSingleDashboard)),
|
||||
templateTable: template.Must(template.New("table").Parse(commentTemplateTable)),
|
||||
templateRenderInfo: template.Must(template.New("setup").Parse(commentTemplateMissingImageRenderer)),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *commenter) Comment(ctx context.Context, prRepo PullRequestRepo, pr int, info changeInfo) error {
|
||||
comment, err := c.generateComment(ctx, info)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to generate comment text: %w", err)
|
||||
}
|
||||
|
||||
if err := prRepo.CommentPullRequest(ctx, pr, comment); err != nil {
|
||||
return fmt.Errorf("comment pull request: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *commenter) generateComment(_ context.Context, info changeInfo) (string, error) {
|
||||
// TODO: should we comment even if there are no changes?
|
||||
if len(info.Changes) == 0 {
|
||||
return "Grafana didn't find any changes in this pull request.", nil
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if len(info.Changes) == 1 && info.Changes[0].Parsed.GVK.Kind == dashboardKind {
|
||||
if err := c.templateDashboard.Execute(&buf, info.Changes[0]); err != nil {
|
||||
return "", fmt.Errorf("unable to execute template: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := c.templateTable.Execute(&buf, info); err != nil {
|
||||
return "", fmt.Errorf("unable to execute template: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if info.MissingImageRenderer {
|
||||
if err := c.templateRenderInfo.Execute(&buf, info); err != nil {
|
||||
return "", fmt.Errorf("unable to execute template: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(buf.String()), nil
|
||||
}
|
||||
|
||||
const commentTemplateSingleDashboard = `Hey there! 🎉
|
||||
Grafana spotted some changes to your dashboard.
|
||||
|
||||
{{- if and .GrafanaScreenshotURL .PreviewScreenshotURL}}
|
||||
### Side by Side Comparison of {{.Parsed.Info.Path}}
|
||||
| Before | After |
|
||||
|----------|---------|
|
||||
|  |  |
|
||||
{{- else if .GrafanaScreenshotURL}}
|
||||
### Original of {{.Title}}
|
||||

|
||||
{{- else if .PreviewScreenshotURL}}
|
||||
### Preview of {{.Parsed.Info.Path}}
|
||||

|
||||
{{ end}}
|
||||
|
||||
{{ if and .GrafanaURL .PreviewURL}}
|
||||
See the [original]({{.GrafanaURL}}) and [preview]({{.PreviewURL}}) of {{.Parsed.Info.Path}}.
|
||||
{{- else if .GrafanaURL}}
|
||||
See the [original]({{.GrafanaURL}}) of {{.Title}}.
|
||||
{{- else if .PreviewURL}}
|
||||
See the [preview]({{.PreviewURL}}) of {{.Parsed.Info.Path}}.
|
||||
{{- end}}
|
||||
`
|
||||
|
||||
const commentTemplateTable = `Hey there! 🎉
|
||||
Grafana spotted some changes.
|
||||
|
||||
| Action | Kind | Resource | Preview |
|
||||
|--------|------|----------|---------|
|
||||
{{- range .Changes}}
|
||||
| {{.Parsed.Action}} | {{.Kind}} | {{.ExistingLink}} | {{ if .PreviewURL}}[preview]({{.PreviewURL}}){{ end }} |
|
||||
{{- end}}
|
||||
|
||||
{{ if .SkippedFiles }}
|
||||
and {{ .SkippedFiles }} more files.
|
||||
{{ end}}
|
||||
`
|
||||
|
||||
// TODO: this should expand and show links to setup docs
|
||||
const commentTemplateMissingImageRenderer = `
|
||||
NOTE: The image renderer is not configured
|
||||
`
|
||||
|
||||
// TODO: does this have some value?
|
||||
func (f *fileChangeInfo) Kind() string {
|
||||
if f.Parsed == nil {
|
||||
return filepath.Ext(f.Change.Path)
|
||||
}
|
||||
v := f.Parsed.GVK.Kind
|
||||
if v == "" {
|
||||
return filepath.Ext(f.Parsed.Info.Path)
|
||||
}
|
||||
return f.Parsed.GVK.Kind
|
||||
}
|
||||
|
||||
// TODO: does this have some value?
|
||||
func (f *fileChangeInfo) ExistingLink() string {
|
||||
if f.GrafanaURL != "" {
|
||||
return fmt.Sprintf("[%s](%s)", f.Title, f.GrafanaURL)
|
||||
}
|
||||
return f.Title
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package pullrequest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
)
|
||||
|
||||
func TestCommenter_Comment_FailedToComment(t *testing.T) {
|
||||
repo := NewMockPullRequestRepo(t)
|
||||
repo.On("CommentPullRequest", context.Background(), 1, mock.Anything).Return(errors.New("failed"))
|
||||
|
||||
commenter := NewCommenter()
|
||||
err := commenter.Comment(context.Background(), repo, 1, changeInfo{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGenerateComment(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
Name string
|
||||
Input changeInfo
|
||||
}{
|
||||
{"no changes", changeInfo{}},
|
||||
{"new dashboard", changeInfo{
|
||||
GrafanaBaseURL: "http://host/",
|
||||
Changes: []fileChangeInfo{
|
||||
{
|
||||
Parsed: &resources.ParsedResource{
|
||||
Info: &repository.FileInfo{
|
||||
Path: "file.json",
|
||||
},
|
||||
GVK: schema.GroupVersionKind{Kind: "Dashboard"},
|
||||
Action: v0alpha1.ResourceActionCreate,
|
||||
},
|
||||
Title: "New Dashboard",
|
||||
PreviewURL: "http://grafana/admin/preview",
|
||||
PreviewScreenshotURL: getDummyRenderedURL("http://grafana/admin/preview"),
|
||||
},
|
||||
},
|
||||
}},
|
||||
{"update dashboard", changeInfo{
|
||||
GrafanaBaseURL: "http://host/",
|
||||
Changes: []fileChangeInfo{
|
||||
{
|
||||
Parsed: &resources.ParsedResource{
|
||||
Info: &repository.FileInfo{
|
||||
Path: "file.json",
|
||||
},
|
||||
Action: v0alpha1.ResourceActionUpdate,
|
||||
GVK: schema.GroupVersionKind{Kind: "Dashboard"},
|
||||
},
|
||||
Title: "Existing Dashboard",
|
||||
GrafanaURL: "http://grafana/d/uid",
|
||||
PreviewURL: "http://grafana/admin/preview",
|
||||
|
||||
GrafanaScreenshotURL: getDummyRenderedURL("http://grafana/d/uid"),
|
||||
PreviewScreenshotURL: getDummyRenderedURL("http://grafana/admin/preview"),
|
||||
},
|
||||
},
|
||||
}},
|
||||
{"update dashboard missing renderer", changeInfo{
|
||||
GrafanaBaseURL: "http://host/",
|
||||
Changes: []fileChangeInfo{
|
||||
{
|
||||
Parsed: &resources.ParsedResource{
|
||||
Info: &repository.FileInfo{
|
||||
Path: "file.json",
|
||||
},
|
||||
Action: v0alpha1.ResourceActionUpdate,
|
||||
GVK: schema.GroupVersionKind{Kind: "Dashboard"},
|
||||
},
|
||||
Title: "Existing Dashboard",
|
||||
GrafanaURL: "http://grafana/d/uid",
|
||||
PreviewURL: "http://grafana/admin/preview",
|
||||
},
|
||||
},
|
||||
MissingImageRenderer: true,
|
||||
}},
|
||||
{"multiple files", changeInfo{
|
||||
GrafanaBaseURL: "http://host/",
|
||||
SkippedFiles: 5,
|
||||
Changes: []fileChangeInfo{
|
||||
{
|
||||
Parsed: &resources.ParsedResource{
|
||||
Info: &repository.FileInfo{
|
||||
Path: "aaa.json",
|
||||
},
|
||||
Action: v0alpha1.ResourceActionCreate,
|
||||
GVK: schema.GroupVersionKind{Kind: "Dashboard"},
|
||||
},
|
||||
Title: "Dash A",
|
||||
PreviewURL: "http://grafana/admin/preview",
|
||||
},
|
||||
{
|
||||
Parsed: &resources.ParsedResource{
|
||||
Info: &repository.FileInfo{
|
||||
Path: "bbb.json",
|
||||
},
|
||||
Action: v0alpha1.ResourceActionUpdate,
|
||||
GVK: schema.GroupVersionKind{Kind: "Dashboard"},
|
||||
},
|
||||
Title: "Dash B",
|
||||
GrafanaURL: "http://grafana/d/bbb",
|
||||
PreviewURL: "http://grafana/admin/preview",
|
||||
},
|
||||
{
|
||||
Parsed: &resources.ParsedResource{
|
||||
Info: &repository.FileInfo{
|
||||
Path: "bbb.json",
|
||||
},
|
||||
Action: v0alpha1.ResourceActionCreate,
|
||||
GVK: schema.GroupVersionKind{Kind: "Playlist"},
|
||||
},
|
||||
Title: "My Playlist",
|
||||
},
|
||||
},
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
repo := NewMockPullRequestRepo(t)
|
||||
|
||||
// expectation on the comment
|
||||
fpath := filepath.Join("testdata", strings.ReplaceAll(tc.Name, " ", "-")+".md")
|
||||
// We can ignore the gosec G304 because this is only for tests
|
||||
// nolint:gosec
|
||||
expect, err := os.ReadFile(fpath)
|
||||
require.NoError(t, err)
|
||||
repo.On("CommentPullRequest", context.Background(), 1, string(expect)).Return(nil)
|
||||
|
||||
commenter := NewCommenter()
|
||||
err = commenter.Comment(context.Background(), repo, 1, tc.Input)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Code generated by mockery v2.52.4. DO NOT EDIT.
|
||||
|
||||
package pullrequest
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockCommenter is an autogenerated mock type for the Commenter type
|
||||
type MockCommenter struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type MockCommenter_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *MockCommenter) EXPECT() *MockCommenter_Expecter {
|
||||
return &MockCommenter_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Comment provides a mock function with given fields: ctx, repo, pr, changeInfo3
|
||||
func (_m *MockCommenter) Comment(ctx context.Context, repo PullRequestRepo, pr int, changeInfo3 changeInfo) error {
|
||||
ret := _m.Called(ctx, repo, pr, changeInfo3)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Comment")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, PullRequestRepo, int, changeInfo) error); ok {
|
||||
r0 = rf(ctx, repo, pr, changeInfo3)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockCommenter_Comment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Comment'
|
||||
type MockCommenter_Comment_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Comment is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - repo PullRequestRepo
|
||||
// - pr int
|
||||
// - changeInfo3 changeInfo
|
||||
func (_e *MockCommenter_Expecter) Comment(ctx interface{}, repo interface{}, pr interface{}, changeInfo3 interface{}) *MockCommenter_Comment_Call {
|
||||
return &MockCommenter_Comment_Call{Call: _e.mock.On("Comment", ctx, repo, pr, changeInfo3)}
|
||||
}
|
||||
|
||||
func (_c *MockCommenter_Comment_Call) Run(run func(ctx context.Context, repo PullRequestRepo, pr int, changeInfo3 changeInfo)) *MockCommenter_Comment_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(PullRequestRepo), args[2].(int), args[3].(changeInfo))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockCommenter_Comment_Call) Return(_a0 error) *MockCommenter_Comment_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockCommenter_Comment_Call) RunAndReturn(run func(context.Context, PullRequestRepo, int, changeInfo) error) *MockCommenter_Comment_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// NewMockCommenter creates a new instance of MockCommenter. 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 NewMockCommenter(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockCommenter {
|
||||
mock := &MockCommenter{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Code generated by mockery v2.52.4. DO NOT EDIT.
|
||||
|
||||
package pullrequest
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
jobs "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
|
||||
repository "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
|
||||
|
||||
v0alpha1 "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||
)
|
||||
|
||||
// MockEvaluator is an autogenerated mock type for the Evaluator type
|
||||
type MockEvaluator struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type MockEvaluator_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *MockEvaluator) EXPECT() *MockEvaluator_Expecter {
|
||||
return &MockEvaluator_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Evaluate provides a mock function with given fields: ctx, repo, opts, changes, progress
|
||||
func (_m *MockEvaluator) Evaluate(ctx context.Context, repo repository.Reader, opts v0alpha1.PullRequestJobOptions, changes []repository.VersionedFileChange, progress jobs.JobProgressRecorder) (changeInfo, error) {
|
||||
ret := _m.Called(ctx, repo, opts, changes, progress)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Evaluate")
|
||||
}
|
||||
|
||||
var r0 changeInfo
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, repository.Reader, v0alpha1.PullRequestJobOptions, []repository.VersionedFileChange, jobs.JobProgressRecorder) (changeInfo, error)); ok {
|
||||
return rf(ctx, repo, opts, changes, progress)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, repository.Reader, v0alpha1.PullRequestJobOptions, []repository.VersionedFileChange, jobs.JobProgressRecorder) changeInfo); ok {
|
||||
r0 = rf(ctx, repo, opts, changes, progress)
|
||||
} else {
|
||||
r0 = ret.Get(0).(changeInfo)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, repository.Reader, v0alpha1.PullRequestJobOptions, []repository.VersionedFileChange, jobs.JobProgressRecorder) error); ok {
|
||||
r1 = rf(ctx, repo, opts, changes, progress)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockEvaluator_Evaluate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Evaluate'
|
||||
type MockEvaluator_Evaluate_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Evaluate is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - repo repository.Reader
|
||||
// - opts v0alpha1.PullRequestJobOptions
|
||||
// - changes []repository.VersionedFileChange
|
||||
// - progress jobs.JobProgressRecorder
|
||||
func (_e *MockEvaluator_Expecter) Evaluate(ctx interface{}, repo interface{}, opts interface{}, changes interface{}, progress interface{}) *MockEvaluator_Evaluate_Call {
|
||||
return &MockEvaluator_Evaluate_Call{Call: _e.mock.On("Evaluate", ctx, repo, opts, changes, progress)}
|
||||
}
|
||||
|
||||
func (_c *MockEvaluator_Evaluate_Call) Run(run func(ctx context.Context, repo repository.Reader, opts v0alpha1.PullRequestJobOptions, changes []repository.VersionedFileChange, progress jobs.JobProgressRecorder)) *MockEvaluator_Evaluate_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(repository.Reader), args[2].(v0alpha1.PullRequestJobOptions), args[3].([]repository.VersionedFileChange), args[4].(jobs.JobProgressRecorder))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockEvaluator_Evaluate_Call) Return(_a0 changeInfo, _a1 error) *MockEvaluator_Evaluate_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockEvaluator_Evaluate_Call) RunAndReturn(run func(context.Context, repository.Reader, v0alpha1.PullRequestJobOptions, []repository.VersionedFileChange, jobs.JobProgressRecorder) (changeInfo, error)) *MockEvaluator_Evaluate_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// NewMockEvaluator creates a new instance of MockEvaluator. 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 NewMockEvaluator(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockEvaluator {
|
||||
mock := &MockEvaluator{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// Code generated by mockery v2.52.4. DO NOT EDIT.
|
||||
|
||||
package pullrequest
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
repository "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
|
||||
v0alpha1 "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||
)
|
||||
|
||||
// MockPullRequestRepo is an autogenerated mock type for the PullRequestRepo type
|
||||
type MockPullRequestRepo struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type MockPullRequestRepo_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *MockPullRequestRepo) EXPECT() *MockPullRequestRepo_Expecter {
|
||||
return &MockPullRequestRepo_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// CommentPullRequest provides a mock function with given fields: ctx, pr, comment
|
||||
func (_m *MockPullRequestRepo) CommentPullRequest(ctx context.Context, pr int, comment string) error {
|
||||
ret := _m.Called(ctx, pr, comment)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for CommentPullRequest")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int, string) error); ok {
|
||||
r0 = rf(ctx, pr, comment)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockPullRequestRepo_CommentPullRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CommentPullRequest'
|
||||
type MockPullRequestRepo_CommentPullRequest_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// CommentPullRequest is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - pr int
|
||||
// - comment string
|
||||
func (_e *MockPullRequestRepo_Expecter) CommentPullRequest(ctx interface{}, pr interface{}, comment interface{}) *MockPullRequestRepo_CommentPullRequest_Call {
|
||||
return &MockPullRequestRepo_CommentPullRequest_Call{Call: _e.mock.On("CommentPullRequest", ctx, pr, comment)}
|
||||
}
|
||||
|
||||
func (_c *MockPullRequestRepo_CommentPullRequest_Call) Run(run func(ctx context.Context, pr int, comment string)) *MockPullRequestRepo_CommentPullRequest_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(int), args[2].(string))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockPullRequestRepo_CommentPullRequest_Call) Return(_a0 error) *MockPullRequestRepo_CommentPullRequest_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockPullRequestRepo_CommentPullRequest_Call) RunAndReturn(run func(context.Context, int, string) error) *MockPullRequestRepo_CommentPullRequest_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// CompareFiles provides a mock function with given fields: ctx, base, ref
|
||||
func (_m *MockPullRequestRepo) CompareFiles(ctx context.Context, base string, ref string) ([]repository.VersionedFileChange, error) {
|
||||
ret := _m.Called(ctx, base, ref)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for CompareFiles")
|
||||
}
|
||||
|
||||
var r0 []repository.VersionedFileChange
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string, string) ([]repository.VersionedFileChange, error)); ok {
|
||||
return rf(ctx, base, ref)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string, string) []repository.VersionedFileChange); ok {
|
||||
r0 = rf(ctx, base, ref)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]repository.VersionedFileChange)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
|
||||
r1 = rf(ctx, base, ref)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockPullRequestRepo_CompareFiles_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CompareFiles'
|
||||
type MockPullRequestRepo_CompareFiles_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// CompareFiles is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - base string
|
||||
// - ref string
|
||||
func (_e *MockPullRequestRepo_Expecter) CompareFiles(ctx interface{}, base interface{}, ref interface{}) *MockPullRequestRepo_CompareFiles_Call {
|
||||
return &MockPullRequestRepo_CompareFiles_Call{Call: _e.mock.On("CompareFiles", ctx, base, ref)}
|
||||
}
|
||||
|
||||
func (_c *MockPullRequestRepo_CompareFiles_Call) Run(run func(ctx context.Context, base string, ref string)) *MockPullRequestRepo_CompareFiles_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(string), args[2].(string))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockPullRequestRepo_CompareFiles_Call) Return(_a0 []repository.VersionedFileChange, _a1 error) *MockPullRequestRepo_CompareFiles_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockPullRequestRepo_CompareFiles_Call) RunAndReturn(run func(context.Context, string, string) ([]repository.VersionedFileChange, error)) *MockPullRequestRepo_CompareFiles_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Config provides a mock function with no fields
|
||||
func (_m *MockPullRequestRepo) Config() *v0alpha1.Repository {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Config")
|
||||
}
|
||||
|
||||
var r0 *v0alpha1.Repository
|
||||
if rf, ok := ret.Get(0).(func() *v0alpha1.Repository); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*v0alpha1.Repository)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockPullRequestRepo_Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Config'
|
||||
type MockPullRequestRepo_Config_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Config is a helper method to define mock.On call
|
||||
func (_e *MockPullRequestRepo_Expecter) Config() *MockPullRequestRepo_Config_Call {
|
||||
return &MockPullRequestRepo_Config_Call{Call: _e.mock.On("Config")}
|
||||
}
|
||||
|
||||
func (_c *MockPullRequestRepo_Config_Call) Run(run func()) *MockPullRequestRepo_Config_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run()
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockPullRequestRepo_Config_Call) Return(_a0 *v0alpha1.Repository) *MockPullRequestRepo_Config_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockPullRequestRepo_Config_Call) RunAndReturn(run func() *v0alpha1.Repository) *MockPullRequestRepo_Config_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Read provides a mock function with given fields: ctx, path, ref
|
||||
func (_m *MockPullRequestRepo) Read(ctx context.Context, path string, ref string) (*repository.FileInfo, error) {
|
||||
ret := _m.Called(ctx, path, ref)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Read")
|
||||
}
|
||||
|
||||
var r0 *repository.FileInfo
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string, string) (*repository.FileInfo, error)); ok {
|
||||
return rf(ctx, path, ref)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string, string) *repository.FileInfo); ok {
|
||||
r0 = rf(ctx, path, ref)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*repository.FileInfo)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
|
||||
r1 = rf(ctx, path, ref)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockPullRequestRepo_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read'
|
||||
type MockPullRequestRepo_Read_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Read is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - path string
|
||||
// - ref string
|
||||
func (_e *MockPullRequestRepo_Expecter) Read(ctx interface{}, path interface{}, ref interface{}) *MockPullRequestRepo_Read_Call {
|
||||
return &MockPullRequestRepo_Read_Call{Call: _e.mock.On("Read", ctx, path, ref)}
|
||||
}
|
||||
|
||||
func (_c *MockPullRequestRepo_Read_Call) Run(run func(ctx context.Context, path string, ref string)) *MockPullRequestRepo_Read_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(string), args[2].(string))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockPullRequestRepo_Read_Call) Return(_a0 *repository.FileInfo, _a1 error) *MockPullRequestRepo_Read_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockPullRequestRepo_Read_Call) RunAndReturn(run func(context.Context, string, string) (*repository.FileInfo, error)) *MockPullRequestRepo_Read_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// NewMockPullRequestRepo creates a new instance of MockPullRequestRepo. 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 NewMockPullRequestRepo(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockPullRequestRepo {
|
||||
mock := &MockPullRequestRepo{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package pullrequest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/rendering"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
//go:generate mockery --name BlobStoreClient --structname MockBlobStoreClient --inpackage --filename blobstore_client_mock.go --with-expecter
|
||||
type BlobStoreClient interface {
|
||||
PutBlob(ctx context.Context, in *resource.PutBlobRequest, opts ...grpc.CallOption) (*resource.PutBlobResponse, error)
|
||||
}
|
||||
|
||||
// ScreenshotRenderer is an interface for rendering a preview of a file
|
||||
//
|
||||
//go:generate mockery --name ScreenshotRenderer --structname MockScreenshotRenderer --inpackage --filename render_mock.go --with-expecter
|
||||
type ScreenshotRenderer interface {
|
||||
IsAvailable(ctx context.Context) bool
|
||||
RenderScreenshot(ctx context.Context, repo provisioning.ResourceRepositoryInfo, path string, values url.Values) (string, error)
|
||||
}
|
||||
|
||||
type screenshotRenderer struct {
|
||||
render rendering.Service
|
||||
blobstore BlobStoreClient
|
||||
}
|
||||
|
||||
func NewScreenshotRenderer(render rendering.Service, blobstore BlobStoreClient) ScreenshotRenderer {
|
||||
return &screenshotRenderer{
|
||||
render: render,
|
||||
blobstore: blobstore,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *screenshotRenderer) IsAvailable(ctx context.Context) bool {
|
||||
return r.render != nil && r.render.IsAvailable(ctx) && r.blobstore != nil
|
||||
}
|
||||
|
||||
func (r *screenshotRenderer) RenderScreenshot(ctx context.Context, repo provisioning.ResourceRepositoryInfo, path string, values url.Values) (string, error) {
|
||||
if strings.Contains(path, "://") {
|
||||
return "", fmt.Errorf("path should be relative to the system root url")
|
||||
}
|
||||
if strings.HasPrefix(path, "/") {
|
||||
return "", fmt.Errorf("path should not start with slash")
|
||||
}
|
||||
if len(values) > 0 {
|
||||
path = path + "?" + values.Encode() + "&kiosk"
|
||||
} else {
|
||||
path = path + "?kiosk"
|
||||
}
|
||||
result, err := r.render.Render(ctx, rendering.RenderPNG, rendering.Opts{
|
||||
CommonOpts: rendering.CommonOpts{
|
||||
Path: path,
|
||||
AuthOpts: rendering.AuthOpts{
|
||||
OrgID: 1, // TODO!!!, use the worker identity
|
||||
UserID: 1,
|
||||
OrgRole: identity.RoleAdmin,
|
||||
},
|
||||
TimeoutOpts: rendering.TimeoutOpts{
|
||||
Timeout: time.Second * 30,
|
||||
},
|
||||
},
|
||||
Theme: models.ThemeDark, // from config?
|
||||
Width: 1024,
|
||||
Height: -1, // full page height
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ext := filepath.Ext(result.FilePath)
|
||||
body, err := os.ReadFile(result.FilePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
rsp, err := r.blobstore.PutBlob(ctx, &resource.PutBlobRequest{
|
||||
Resource: &resource.ResourceKey{
|
||||
Namespace: repo.Namespace,
|
||||
Group: provisioning.GROUP,
|
||||
Resource: provisioning.RepositoryResourceInfo.GroupResource().Resource,
|
||||
Name: repo.Name,
|
||||
},
|
||||
Method: resource.PutBlobRequest_GRPC,
|
||||
ContentType: mime.TypeByExtension(ext), // image/png
|
||||
Value: body,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if rsp.Url != "" {
|
||||
return rsp.Url, nil
|
||||
}
|
||||
return fmt.Sprintf("apis/%s/namespaces/%s/repositories/%s/render/%s",
|
||||
provisioning.APIVERSION, repo.Namespace, repo.Name, rsp.Uid), nil
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Code generated by mockery v2.52.4. DO NOT EDIT.
|
||||
|
||||
package pullrequest
|
||||
|
||||
import (
|
||||
context "context"
|
||||
url "net/url"
|
||||
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
|
||||
v0alpha1 "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||
)
|
||||
|
||||
// MockScreenshotRenderer is an autogenerated mock type for the ScreenshotRenderer type
|
||||
type MockScreenshotRenderer struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type MockScreenshotRenderer_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *MockScreenshotRenderer) EXPECT() *MockScreenshotRenderer_Expecter {
|
||||
return &MockScreenshotRenderer_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// IsAvailable provides a mock function with given fields: ctx
|
||||
func (_m *MockScreenshotRenderer) IsAvailable(ctx context.Context) bool {
|
||||
ret := _m.Called(ctx)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for IsAvailable")
|
||||
}
|
||||
|
||||
var r0 bool
|
||||
if rf, ok := ret.Get(0).(func(context.Context) bool); ok {
|
||||
r0 = rf(ctx)
|
||||
} else {
|
||||
r0 = ret.Get(0).(bool)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockScreenshotRenderer_IsAvailable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsAvailable'
|
||||
type MockScreenshotRenderer_IsAvailable_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// IsAvailable is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
func (_e *MockScreenshotRenderer_Expecter) IsAvailable(ctx interface{}) *MockScreenshotRenderer_IsAvailable_Call {
|
||||
return &MockScreenshotRenderer_IsAvailable_Call{Call: _e.mock.On("IsAvailable", ctx)}
|
||||
}
|
||||
|
||||
func (_c *MockScreenshotRenderer_IsAvailable_Call) Run(run func(ctx context.Context)) *MockScreenshotRenderer_IsAvailable_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockScreenshotRenderer_IsAvailable_Call) Return(_a0 bool) *MockScreenshotRenderer_IsAvailable_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockScreenshotRenderer_IsAvailable_Call) RunAndReturn(run func(context.Context) bool) *MockScreenshotRenderer_IsAvailable_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// RenderScreenshot provides a mock function with given fields: ctx, repo, path, values
|
||||
func (_m *MockScreenshotRenderer) RenderScreenshot(ctx context.Context, repo v0alpha1.ResourceRepositoryInfo, path string, values url.Values) (string, error) {
|
||||
ret := _m.Called(ctx, repo, path, values)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for RenderScreenshot")
|
||||
}
|
||||
|
||||
var r0 string
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, v0alpha1.ResourceRepositoryInfo, string, url.Values) (string, error)); ok {
|
||||
return rf(ctx, repo, path, values)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, v0alpha1.ResourceRepositoryInfo, string, url.Values) string); ok {
|
||||
r0 = rf(ctx, repo, path, values)
|
||||
} else {
|
||||
r0 = ret.Get(0).(string)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, v0alpha1.ResourceRepositoryInfo, string, url.Values) error); ok {
|
||||
r1 = rf(ctx, repo, path, values)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockScreenshotRenderer_RenderScreenshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RenderScreenshot'
|
||||
type MockScreenshotRenderer_RenderScreenshot_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// RenderScreenshot is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - repo v0alpha1.ResourceRepositoryInfo
|
||||
// - path string
|
||||
// - values url.Values
|
||||
func (_e *MockScreenshotRenderer_Expecter) RenderScreenshot(ctx interface{}, repo interface{}, path interface{}, values interface{}) *MockScreenshotRenderer_RenderScreenshot_Call {
|
||||
return &MockScreenshotRenderer_RenderScreenshot_Call{Call: _e.mock.On("RenderScreenshot", ctx, repo, path, values)}
|
||||
}
|
||||
|
||||
func (_c *MockScreenshotRenderer_RenderScreenshot_Call) Run(run func(ctx context.Context, repo v0alpha1.ResourceRepositoryInfo, path string, values url.Values)) *MockScreenshotRenderer_RenderScreenshot_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(v0alpha1.ResourceRepositoryInfo), args[2].(string), args[3].(url.Values))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockScreenshotRenderer_RenderScreenshot_Call) Return(_a0 string, _a1 error) *MockScreenshotRenderer_RenderScreenshot_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockScreenshotRenderer_RenderScreenshot_Call) RunAndReturn(run func(context.Context, v0alpha1.ResourceRepositoryInfo, string, url.Values) (string, error)) *MockScreenshotRenderer_RenderScreenshot_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// NewMockScreenshotRenderer creates a new instance of MockScreenshotRenderer. 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 NewMockScreenshotRenderer(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockScreenshotRenderer {
|
||||
mock := &MockScreenshotRenderer{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package pullrequest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/rendering"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
)
|
||||
|
||||
func setupTempFile(t *testing.T) (string, func()) {
|
||||
t.Helper()
|
||||
|
||||
// Create a temporary directory
|
||||
tmpDir, err := os.MkdirTemp("", "screenshot-renderer-test-*")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a temporary file
|
||||
tmpFile := filepath.Join(tmpDir, "test.png")
|
||||
err = os.WriteFile(tmpFile, []byte("test"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Return cleanup function
|
||||
cleanup := func() {
|
||||
err := os.RemoveAll(tmpDir)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
return tmpFile, cleanup
|
||||
}
|
||||
|
||||
func TestScreenshotRenderer_IsAvailable(t *testing.T) {
|
||||
t.Run("should return false when render service is nil", func(t *testing.T) {
|
||||
blobstore := NewMockBlobStoreClient(t)
|
||||
renderer := NewScreenshotRenderer(nil, blobstore)
|
||||
require.False(t, renderer.IsAvailable(context.Background()))
|
||||
})
|
||||
|
||||
t.Run("should return false when render service is not available", func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
render := rendering.NewMockService(ctrl)
|
||||
render.EXPECT().IsAvailable(gomock.Any()).Return(false)
|
||||
blobstore := NewMockBlobStoreClient(t)
|
||||
|
||||
renderer := NewScreenshotRenderer(render, blobstore)
|
||||
require.False(t, renderer.IsAvailable(context.Background()))
|
||||
})
|
||||
|
||||
t.Run("should return false when blobstore is nil", func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
render := rendering.NewMockService(ctrl)
|
||||
render.EXPECT().IsAvailable(gomock.Any()).Return(true)
|
||||
|
||||
renderer := NewScreenshotRenderer(render, nil)
|
||||
require.False(t, renderer.IsAvailable(context.Background()))
|
||||
})
|
||||
|
||||
t.Run("should return true when both services are available", func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
render := rendering.NewMockService(ctrl)
|
||||
render.EXPECT().IsAvailable(gomock.Any()).Return(true)
|
||||
blobstore := NewMockBlobStoreClient(t)
|
||||
|
||||
renderer := NewScreenshotRenderer(render, blobstore)
|
||||
require.True(t, renderer.IsAvailable(context.Background()))
|
||||
})
|
||||
}
|
||||
|
||||
func TestScreenshotRenderer_RenderScreenshot(t *testing.T) {
|
||||
type testCase struct {
|
||||
name string
|
||||
path string
|
||||
queryParams url.Values
|
||||
repoInfo provisioning.ResourceRepositoryInfo
|
||||
setupRender func(ctrl *gomock.Controller) rendering.Service
|
||||
setupBlobstore func(t *testing.T) BlobStoreClient
|
||||
expectedURL string
|
||||
expectedError string
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
name: "should fail when path contains protocol",
|
||||
path: "http://test",
|
||||
setupRender: func(ctrl *gomock.Controller) rendering.Service {
|
||||
return rendering.NewMockService(ctrl)
|
||||
},
|
||||
setupBlobstore: func(t *testing.T) BlobStoreClient {
|
||||
return NewMockBlobStoreClient(t)
|
||||
},
|
||||
expectedError: "path should be relative",
|
||||
},
|
||||
{
|
||||
name: "should fail when path starts with slash",
|
||||
path: "/test",
|
||||
setupRender: func(ctrl *gomock.Controller) rendering.Service {
|
||||
return rendering.NewMockService(ctrl)
|
||||
},
|
||||
setupBlobstore: func(t *testing.T) BlobStoreClient {
|
||||
return NewMockBlobStoreClient(t)
|
||||
},
|
||||
expectedError: "path should not start with slash",
|
||||
},
|
||||
{
|
||||
name: "should fail when render service fails",
|
||||
path: "test",
|
||||
setupRender: func(ctrl *gomock.Controller) rendering.Service {
|
||||
render := rendering.NewMockService(ctrl)
|
||||
render.EXPECT().Render(gomock.Any(), rendering.RenderPNG, gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, _ rendering.RenderType, opts rendering.Opts, _ rendering.AuthOpts) (*rendering.RenderResult, error) {
|
||||
require.Equal(t, "test?kiosk", opts.Path)
|
||||
require.Equal(t, int64(1), opts.OrgID)
|
||||
require.Equal(t, int64(1), opts.UserID)
|
||||
require.Equal(t, 1024, opts.Width)
|
||||
require.Equal(t, -1, opts.Height)
|
||||
require.Equal(t, models.ThemeDark, opts.Theme)
|
||||
return nil, errors.New("render error")
|
||||
})
|
||||
return render
|
||||
},
|
||||
setupBlobstore: func(t *testing.T) BlobStoreClient {
|
||||
return NewMockBlobStoreClient(t)
|
||||
},
|
||||
expectedError: "render error",
|
||||
},
|
||||
{
|
||||
name: "should fail when the rendered file does not exist",
|
||||
path: "test",
|
||||
setupRender: func(ctrl *gomock.Controller) rendering.Service {
|
||||
render := rendering.NewMockService(ctrl)
|
||||
render.EXPECT().Render(gomock.Any(), rendering.RenderPNG, gomock.Any(), gomock.Any()).
|
||||
Return(&rendering.RenderResult{
|
||||
FilePath: "/non/existent/file.png",
|
||||
}, nil)
|
||||
return render
|
||||
},
|
||||
setupBlobstore: func(t *testing.T) BlobStoreClient {
|
||||
return NewMockBlobStoreClient(t)
|
||||
},
|
||||
expectedError: "no such file or directory",
|
||||
},
|
||||
{
|
||||
name: "should fail when blobstore fails",
|
||||
path: "test",
|
||||
setupRender: func(ctrl *gomock.Controller) rendering.Service {
|
||||
tmpFile, cleanup := setupTempFile(t)
|
||||
t.Cleanup(cleanup)
|
||||
render := rendering.NewMockService(ctrl)
|
||||
render.EXPECT().Render(gomock.Any(), rendering.RenderPNG, gomock.Any(), gomock.Any()).
|
||||
Return(&rendering.RenderResult{
|
||||
FilePath: tmpFile,
|
||||
}, nil)
|
||||
return render
|
||||
},
|
||||
setupBlobstore: func(t *testing.T) BlobStoreClient {
|
||||
blobstore := NewMockBlobStoreClient(t)
|
||||
blobstore.On("PutBlob", mock.Anything, mock.MatchedBy(func(req *resource.PutBlobRequest) bool {
|
||||
return req.Resource.Group == provisioning.GROUP &&
|
||||
req.Resource.Resource == provisioning.RepositoryResourceInfo.GroupResource().Resource &&
|
||||
req.Method == resource.PutBlobRequest_GRPC &&
|
||||
req.ContentType == "image/png"
|
||||
})).Return(nil, errors.New("blobstore error"))
|
||||
return blobstore
|
||||
},
|
||||
expectedError: "blobstore error",
|
||||
},
|
||||
{
|
||||
name: "should return URL when blobstore provides one",
|
||||
path: "test",
|
||||
repoInfo: provisioning.ResourceRepositoryInfo{
|
||||
Name: "test-repo",
|
||||
Namespace: "test-ns",
|
||||
},
|
||||
setupRender: func(ctrl *gomock.Controller) rendering.Service {
|
||||
tmpFile, cleanup := setupTempFile(t)
|
||||
t.Cleanup(cleanup)
|
||||
render := rendering.NewMockService(ctrl)
|
||||
render.EXPECT().Render(gomock.Any(), rendering.RenderPNG, gomock.Any(), gomock.Any()).
|
||||
Return(&rendering.RenderResult{
|
||||
FilePath: tmpFile,
|
||||
}, nil)
|
||||
return render
|
||||
},
|
||||
setupBlobstore: func(t *testing.T) BlobStoreClient {
|
||||
blobstore := NewMockBlobStoreClient(t)
|
||||
blobstore.On("PutBlob", mock.Anything, mock.Anything).
|
||||
Return(&resource.PutBlobResponse{
|
||||
Url: "https://example.com/test.png",
|
||||
}, nil)
|
||||
return blobstore
|
||||
},
|
||||
expectedURL: "https://example.com/test.png",
|
||||
},
|
||||
{
|
||||
name: "should return API path when blobstore provides UID",
|
||||
path: "test",
|
||||
repoInfo: provisioning.ResourceRepositoryInfo{
|
||||
Name: "test-repo",
|
||||
Namespace: "test-ns",
|
||||
},
|
||||
setupRender: func(ctrl *gomock.Controller) rendering.Service {
|
||||
tmpFile, cleanup := setupTempFile(t)
|
||||
t.Cleanup(cleanup)
|
||||
render := rendering.NewMockService(ctrl)
|
||||
render.EXPECT().Render(gomock.Any(), rendering.RenderPNG, gomock.Any(), gomock.Any()).
|
||||
Return(&rendering.RenderResult{
|
||||
FilePath: tmpFile,
|
||||
}, nil)
|
||||
return render
|
||||
},
|
||||
setupBlobstore: func(t *testing.T) BlobStoreClient {
|
||||
blobstore := NewMockBlobStoreClient(t)
|
||||
blobstore.On("PutBlob", mock.Anything, mock.Anything).
|
||||
Return(&resource.PutBlobResponse{
|
||||
Uid: "test-uid",
|
||||
}, nil)
|
||||
return blobstore
|
||||
},
|
||||
expectedURL: "apis/provisioning.grafana.app/v0alpha1/namespaces/test-ns/repositories/test-repo/render/test-uid",
|
||||
},
|
||||
{
|
||||
name: "should append query parameters correctly",
|
||||
path: "test",
|
||||
queryParams: url.Values{
|
||||
"param1": []string{"value1"},
|
||||
"param2": []string{"value2"},
|
||||
},
|
||||
setupRender: func(ctrl *gomock.Controller) rendering.Service {
|
||||
tmpFile, cleanup := setupTempFile(t)
|
||||
t.Cleanup(cleanup)
|
||||
render := rendering.NewMockService(ctrl)
|
||||
render.EXPECT().Render(gomock.Any(), rendering.RenderPNG, gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, _ rendering.RenderType, opts rendering.Opts, _ rendering.AuthOpts) (*rendering.RenderResult, error) {
|
||||
require.Equal(t, "test?param1=value1¶m2=value2&kiosk", opts.Path)
|
||||
return &rendering.RenderResult{
|
||||
FilePath: tmpFile,
|
||||
}, nil
|
||||
})
|
||||
return render
|
||||
},
|
||||
setupBlobstore: func(t *testing.T) BlobStoreClient {
|
||||
blobstore := NewMockBlobStoreClient(t)
|
||||
blobstore.On("PutBlob", mock.Anything, mock.Anything).
|
||||
Return(&resource.PutBlobResponse{
|
||||
Uid: "test-uid",
|
||||
}, nil)
|
||||
return blobstore
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
render := tc.setupRender(ctrl)
|
||||
blobstore := tc.setupBlobstore(t)
|
||||
|
||||
renderer := NewScreenshotRenderer(render, blobstore)
|
||||
url, err := renderer.RenderScreenshot(context.Background(), tc.repoInfo, tc.path, tc.queryParams)
|
||||
|
||||
if tc.expectedError != "" {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tc.expectedError)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
if tc.expectedURL != "" {
|
||||
require.Equal(t, tc.expectedURL, url)
|
||||
}
|
||||
}
|
||||
|
||||
if mock, ok := blobstore.(*MockBlobStoreClient); ok {
|
||||
mock.AssertExpectations(t)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
Hey there! 🎉
|
||||
Grafana spotted some changes.
|
||||
|
||||
| Action | Kind | Resource | Preview |
|
||||
|--------|------|----------|---------|
|
||||
| create | Dashboard | Dash A | [preview](http://grafana/admin/preview) |
|
||||
| update | Dashboard | [Dash B](http://grafana/d/bbb) | [preview](http://grafana/admin/preview) |
|
||||
| create | Playlist | My Playlist | |
|
||||
|
||||
|
||||
and 5 more files.
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
Hey there! 🎉
|
||||
Grafana spotted some changes to your dashboard.
|
||||
### Preview of file.json
|
||||

|
||||
|
||||
|
||||
|
||||
See the [preview](http://grafana/admin/preview) of file.json.
|
||||
@@ -0,0 +1 @@
|
||||
Grafana didn't find any changes in this pull request.
|
||||
Vendored
Executable
+7
@@ -0,0 +1,7 @@
|
||||
Hey there! 🎉
|
||||
Grafana spotted some changes to your dashboard.
|
||||
|
||||
|
||||
See the [original](http://grafana/d/uid) and [preview](http://grafana/admin/preview) of file.json.
|
||||
|
||||
NOTE: The image renderer is not configured
|
||||
Vendored
Executable
+9
@@ -0,0 +1,9 @@
|
||||
Hey there! 🎉
|
||||
Grafana spotted some changes to your dashboard.
|
||||
### Side by Side Comparison of file.json
|
||||
| Before | After |
|
||||
|----------|---------|
|
||||
|  |  |
|
||||
|
||||
|
||||
See the [original](http://grafana/d/uid) and [preview](http://grafana/admin/preview) of file.json.
|
||||
@@ -0,0 +1,122 @@
|
||||
package pullrequest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
)
|
||||
|
||||
//go:generate mockery --name=PullRequestRepo --structname=MockPullRequestRepo --inpackage --filename=mock_pullrequest_repo.go --with-expecter
|
||||
type PullRequestRepo interface {
|
||||
Config() *provisioning.Repository
|
||||
Read(ctx context.Context, path, ref string) (*repository.FileInfo, error)
|
||||
CompareFiles(ctx context.Context, base, ref string) ([]repository.VersionedFileChange, error)
|
||||
CommentPullRequest(ctx context.Context, pr int, comment string) error
|
||||
}
|
||||
|
||||
//go:generate mockery --name=Evaluator --structname=MockEvaluator --inpackage --filename=mock_evaluator.go --with-expecter
|
||||
type Evaluator interface {
|
||||
Evaluate(ctx context.Context, repo repository.Reader, opts provisioning.PullRequestJobOptions, changes []repository.VersionedFileChange, progress jobs.JobProgressRecorder) (changeInfo, error)
|
||||
}
|
||||
|
||||
//go:generate mockery --name=Commenter --structname=MockCommenter --inpackage --filename=mock_commenter.go --with-expecter
|
||||
type Commenter interface {
|
||||
Comment(ctx context.Context, repo PullRequestRepo, pr int, changeInfo changeInfo) error
|
||||
}
|
||||
|
||||
type PullRequestWorker struct {
|
||||
evaluator Evaluator
|
||||
commenter Commenter
|
||||
}
|
||||
|
||||
func NewPullRequestWorker(evaluator Evaluator, commenter Commenter) *PullRequestWorker {
|
||||
return &PullRequestWorker{
|
||||
evaluator: evaluator,
|
||||
commenter: commenter,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *PullRequestWorker) IsSupported(ctx context.Context, job provisioning.Job) bool {
|
||||
return job.Spec.Action == provisioning.JobActionPullRequest
|
||||
}
|
||||
|
||||
func (c *PullRequestWorker) Process(ctx context.Context,
|
||||
repo repository.Repository,
|
||||
job provisioning.Job,
|
||||
progress jobs.JobProgressRecorder,
|
||||
) error {
|
||||
cfg := repo.Config().Spec
|
||||
opts := job.Spec.PullRequest
|
||||
if opts == nil {
|
||||
return apierrors.NewBadRequest("missing spec.pr")
|
||||
}
|
||||
|
||||
if opts.Ref == "" {
|
||||
return apierrors.NewBadRequest("missing spec.ref")
|
||||
}
|
||||
|
||||
// FIXME: this is leaky because it's supposed to be already a PullRequestRepo
|
||||
if cfg.GitHub == nil {
|
||||
return apierrors.NewBadRequest("expecting github configuration")
|
||||
}
|
||||
|
||||
reader, ok := repo.(repository.Reader)
|
||||
if !ok {
|
||||
return errors.New("pull request job submitted targeting repository that is not a Reader")
|
||||
}
|
||||
|
||||
prRepo, ok := repo.(PullRequestRepo)
|
||||
if !ok {
|
||||
return fmt.Errorf("repository is not a pull request repository")
|
||||
}
|
||||
|
||||
logger := logging.FromContext(ctx).With("pr", opts.PR)
|
||||
logger.Info("process pull request")
|
||||
defer logger.Info("pull request processed")
|
||||
|
||||
progress.SetMessage(ctx, "listing pull request files")
|
||||
// FIXME: this is leaky because it's supposed to be already a PullRequestRepo
|
||||
base := cfg.GitHub.Branch
|
||||
files, err := prRepo.CompareFiles(ctx, base, opts.Ref)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list pull request files: %w", err)
|
||||
}
|
||||
|
||||
files = onlySupportedFiles(files)
|
||||
if len(files) == 0 {
|
||||
progress.SetFinalMessage(ctx, "no files to process")
|
||||
return nil
|
||||
}
|
||||
|
||||
changeInfo, err := c.evaluator.Evaluate(ctx, reader, *opts, files, progress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("calculate changes: %w", err)
|
||||
}
|
||||
|
||||
if err := c.commenter.Comment(ctx, prRepo, opts.PR, changeInfo); err != nil {
|
||||
return fmt.Errorf("comment pull request: %w", err)
|
||||
}
|
||||
logger.Info("preview comment added")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove files we should not try to process
|
||||
func onlySupportedFiles(files []repository.VersionedFileChange) (ret []repository.VersionedFileChange) {
|
||||
for _, file := range files {
|
||||
if file.Action == repository.FileActionIgnored || resources.IsPathSupported(file.Path) != nil {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, file)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
package pullrequest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
|
||||
)
|
||||
|
||||
func TestPullRequestWorker_IsSupported(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
job provisioning.Job
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "pull request action is supported",
|
||||
job: provisioning.Job{
|
||||
Spec: provisioning.JobSpec{
|
||||
Action: provisioning.JobActionPullRequest,
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "non-pull request action is not supported",
|
||||
job: provisioning.Job{
|
||||
Spec: provisioning.JobSpec{
|
||||
Action: provisioning.JobActionPush,
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
evaluator := NewMockEvaluator(t)
|
||||
commenter := NewMockCommenter(t)
|
||||
worker := NewPullRequestWorker(evaluator, commenter)
|
||||
result := worker.IsSupported(context.Background(), tt.job)
|
||||
require.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullRequestWorker_Process_NotPullRequestRepository(t *testing.T) {
|
||||
evaluator := NewMockEvaluator(t)
|
||||
commenter := NewMockCommenter(t)
|
||||
repo := repository.NewMockRepository(t)
|
||||
progress := jobs.NewMockJobProgressRecorder(t)
|
||||
|
||||
// Configure the mock repository to return a GitHub config
|
||||
repo.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "test-repo",
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{Branch: "main"},
|
||||
},
|
||||
})
|
||||
|
||||
worker := NewPullRequestWorker(evaluator, commenter)
|
||||
job := provisioning.Job{
|
||||
Spec: provisioning.JobSpec{
|
||||
Action: provisioning.JobActionPullRequest,
|
||||
PullRequest: &provisioning.PullRequestJobOptions{
|
||||
PR: 123,
|
||||
Ref: "test-ref",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// The repository is not a PullRequestRepo, so it should fail
|
||||
err := worker.Process(context.Background(), repo, job, progress)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "repository is not a pull request repository")
|
||||
|
||||
repo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestPullRequestWorker_Process_NotReaderRepository(t *testing.T) {
|
||||
evaluator := NewMockEvaluator(t)
|
||||
commenter := NewMockCommenter(t)
|
||||
progress := jobs.NewMockJobProgressRecorder(t)
|
||||
|
||||
// Create a mock that implements PullRequestRepo but not Reader
|
||||
repo := repository.NewMockConfigRepository(t)
|
||||
|
||||
// Configure the mock to return a GitHub config
|
||||
repo.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "test-repo",
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{Branch: "main"},
|
||||
},
|
||||
})
|
||||
|
||||
worker := NewPullRequestWorker(evaluator, commenter)
|
||||
job := provisioning.Job{
|
||||
Spec: provisioning.JobSpec{
|
||||
Action: provisioning.JobActionPullRequest,
|
||||
PullRequest: &provisioning.PullRequestJobOptions{
|
||||
PR: 123,
|
||||
Ref: "test-ref",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// The repository is not a Reader, so it should fail
|
||||
err := worker.Process(context.Background(), repo, job, progress)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "repository that is not a Reader")
|
||||
repo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestPullRequestWorker_Process(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts *provisioning.PullRequestJobOptions
|
||||
setupMocks func(*MockEvaluator, *MockCommenter, *mockPullRequestRepo, *jobs.MockJobProgressRecorder)
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "missing pull request options",
|
||||
opts: nil,
|
||||
setupMocks: func(evaluator *MockEvaluator, commenter *MockCommenter, repo *mockPullRequestRepo, progress *jobs.MockJobProgressRecorder) {
|
||||
repo.MockRepository.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "test-repo",
|
||||
},
|
||||
})
|
||||
},
|
||||
expectedError: "missing spec.pr",
|
||||
},
|
||||
{
|
||||
name: "missing ref",
|
||||
opts: &provisioning.PullRequestJobOptions{
|
||||
PR: 123,
|
||||
},
|
||||
setupMocks: func(evaluator *MockEvaluator, commenter *MockCommenter, repo *mockPullRequestRepo, progress *jobs.MockJobProgressRecorder) {
|
||||
repo.MockRepository.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "test-repo",
|
||||
},
|
||||
})
|
||||
},
|
||||
expectedError: "missing spec.ref",
|
||||
},
|
||||
{
|
||||
name: "missing github configuration",
|
||||
opts: &provisioning.PullRequestJobOptions{
|
||||
PR: 123,
|
||||
Ref: "test-ref",
|
||||
},
|
||||
setupMocks: func(evaluator *MockEvaluator, commenter *MockCommenter, repo *mockPullRequestRepo, progress *jobs.MockJobProgressRecorder) {
|
||||
repo.MockRepository.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "test-repo",
|
||||
},
|
||||
})
|
||||
},
|
||||
expectedError: "expecting github configuration",
|
||||
},
|
||||
{
|
||||
name: "failed to list pull request files",
|
||||
opts: &provisioning.PullRequestJobOptions{
|
||||
PR: 123,
|
||||
Ref: "test-ref",
|
||||
},
|
||||
setupMocks: func(evaluator *MockEvaluator, commenter *MockCommenter, repo *mockPullRequestRepo, progress *jobs.MockJobProgressRecorder) {
|
||||
repo.MockRepository.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "test-repo",
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{Branch: "main"},
|
||||
},
|
||||
})
|
||||
progress.On("SetMessage", mock.Anything, "listing pull request files").Return()
|
||||
repo.MockPullRequestRepo.On("CompareFiles", mock.Anything, "main", "test-ref").Return(nil, errors.New("failed to list files"))
|
||||
},
|
||||
expectedError: "failed to list pull request files: failed to list files",
|
||||
},
|
||||
{
|
||||
name: "no files to process",
|
||||
opts: &provisioning.PullRequestJobOptions{
|
||||
PR: 123,
|
||||
Ref: "test-ref",
|
||||
},
|
||||
setupMocks: func(evaluator *MockEvaluator, commenter *MockCommenter, repo *mockPullRequestRepo, progress *jobs.MockJobProgressRecorder) {
|
||||
repo.MockRepository.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "test-repo",
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{Branch: "main"},
|
||||
},
|
||||
})
|
||||
progress.On("SetMessage", mock.Anything, "listing pull request files").Return()
|
||||
repo.MockPullRequestRepo.On("CompareFiles", mock.Anything, "main", "test-ref").Return([]repository.VersionedFileChange{}, nil)
|
||||
progress.On("SetFinalMessage", mock.Anything, "no files to process").Return()
|
||||
},
|
||||
expectedError: "",
|
||||
},
|
||||
{
|
||||
name: "ignored files are filtered out",
|
||||
opts: &provisioning.PullRequestJobOptions{
|
||||
PR: 123,
|
||||
Ref: "test-ref",
|
||||
},
|
||||
setupMocks: func(evaluator *MockEvaluator, commenter *MockCommenter, repo *mockPullRequestRepo, progress *jobs.MockJobProgressRecorder) {
|
||||
repo.MockRepository.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "test-repo",
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{Branch: "main"},
|
||||
},
|
||||
})
|
||||
progress.On("SetMessage", mock.Anything, "listing pull request files").Return()
|
||||
|
||||
// Create a mix of ignored and supported files
|
||||
files := []repository.VersionedFileChange{
|
||||
{Path: "test.yaml"}, // Supported file
|
||||
{Path: "ignored.txt", Action: repository.FileActionIgnored}, // Ignored file
|
||||
{Path: "another.yaml"}, // Supported file
|
||||
}
|
||||
|
||||
repo.MockPullRequestRepo.On("CompareFiles", mock.Anything, "main", "test-ref").Return(files, nil)
|
||||
|
||||
// Only non-ignored files should be passed to the evaluator
|
||||
expectedFiles := []repository.VersionedFileChange{
|
||||
{Path: "test.yaml"},
|
||||
{Path: "another.yaml"},
|
||||
}
|
||||
|
||||
evaluator.On("Evaluate", mock.Anything, mock.Anything, mock.Anything, expectedFiles, mock.Anything).Return(changeInfo{}, nil)
|
||||
commenter.On("Comment", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
},
|
||||
expectedError: "",
|
||||
},
|
||||
{
|
||||
name: "files with unsupported paths are filtered out",
|
||||
opts: &provisioning.PullRequestJobOptions{
|
||||
PR: 123,
|
||||
Ref: "test-ref",
|
||||
},
|
||||
setupMocks: func(evaluator *MockEvaluator, commenter *MockCommenter, repo *mockPullRequestRepo, progress *jobs.MockJobProgressRecorder) {
|
||||
repo.MockRepository.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "test-repo",
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{Branch: "main"},
|
||||
},
|
||||
})
|
||||
progress.On("SetMessage", mock.Anything, "listing pull request files").Return()
|
||||
|
||||
// Create a mix of supported and unsupported files
|
||||
files := []repository.VersionedFileChange{
|
||||
{Path: "test.yaml"}, // Supported file
|
||||
{Path: "unsupported/path.txt"}, // Unsupported file
|
||||
{Path: "another.yaml"}, // Supported file
|
||||
{Path: "invalid.doc"}, // Unsupported file
|
||||
{Path: ".github/something"}, // Unsupported file
|
||||
}
|
||||
|
||||
repo.MockPullRequestRepo.On("CompareFiles", mock.Anything, "main", "test-ref").Return(files, nil)
|
||||
|
||||
// Only supported files should be passed to the evaluator
|
||||
expectedFiles := []repository.VersionedFileChange{
|
||||
{Path: "test.yaml"},
|
||||
{Path: "another.yaml"},
|
||||
}
|
||||
|
||||
evaluator.On("Evaluate", mock.Anything, mock.Anything, mock.Anything, expectedFiles, mock.Anything).Return(changeInfo{}, nil)
|
||||
commenter.On("Comment", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
},
|
||||
expectedError: "",
|
||||
},
|
||||
{
|
||||
name: "evaluation fails",
|
||||
opts: &provisioning.PullRequestJobOptions{
|
||||
PR: 123,
|
||||
Ref: "test-ref",
|
||||
},
|
||||
setupMocks: func(evaluator *MockEvaluator, commenter *MockCommenter, repo *mockPullRequestRepo, progress *jobs.MockJobProgressRecorder) {
|
||||
repo.MockRepository.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "test-repo",
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{Branch: "main"},
|
||||
},
|
||||
})
|
||||
progress.On("SetMessage", mock.Anything, "listing pull request files").Return()
|
||||
files := []repository.VersionedFileChange{
|
||||
{Path: "test.yaml"},
|
||||
}
|
||||
repo.MockPullRequestRepo.On("CompareFiles", mock.Anything, "main", "test-ref").Return(files, nil)
|
||||
evaluator.On("Evaluate", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(changeInfo{}, errors.New("evaluation failed"))
|
||||
},
|
||||
expectedError: "calculate changes: evaluation failed",
|
||||
},
|
||||
{
|
||||
name: "comment fails",
|
||||
opts: &provisioning.PullRequestJobOptions{
|
||||
PR: 123,
|
||||
Ref: "test-ref",
|
||||
},
|
||||
setupMocks: func(evaluator *MockEvaluator, commenter *MockCommenter, repo *mockPullRequestRepo, progress *jobs.MockJobProgressRecorder) {
|
||||
repo.MockRepository.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "test-repo",
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{Branch: "main"},
|
||||
},
|
||||
})
|
||||
progress.On("SetMessage", mock.Anything, "listing pull request files").Return()
|
||||
files := []repository.VersionedFileChange{
|
||||
{Path: "test.yaml"},
|
||||
}
|
||||
repo.MockPullRequestRepo.On("CompareFiles", mock.Anything, "main", "test-ref").Return(files, nil)
|
||||
evaluator.On("Evaluate", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(changeInfo{}, nil)
|
||||
commenter.On("Comment", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(errors.New("comment failed"))
|
||||
},
|
||||
expectedError: "comment pull request: comment failed",
|
||||
},
|
||||
{
|
||||
name: "successful process",
|
||||
opts: &provisioning.PullRequestJobOptions{
|
||||
PR: 123,
|
||||
Ref: "test-ref",
|
||||
},
|
||||
setupMocks: func(evaluator *MockEvaluator, commenter *MockCommenter, repo *mockPullRequestRepo, progress *jobs.MockJobProgressRecorder) {
|
||||
repo.MockRepository.On("Config").Return(&provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "test-repo",
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{Branch: "main"},
|
||||
},
|
||||
})
|
||||
progress.On("SetMessage", mock.Anything, "listing pull request files").Return()
|
||||
files := []repository.VersionedFileChange{
|
||||
{Path: "test.yaml"},
|
||||
}
|
||||
repo.MockPullRequestRepo.On("CompareFiles", mock.Anything, "main", "test-ref").Return(files, nil)
|
||||
evaluator.On("Evaluate", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(changeInfo{}, nil)
|
||||
commenter.On("Comment", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
},
|
||||
expectedError: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
evaluator := NewMockEvaluator(t)
|
||||
commenter := NewMockCommenter(t)
|
||||
repo := mockPullRequestRepo{
|
||||
MockRepository: repository.NewMockRepository(t),
|
||||
MockPullRequestRepo: NewMockPullRequestRepo(t),
|
||||
}
|
||||
progress := jobs.NewMockJobProgressRecorder(t)
|
||||
tt.setupMocks(evaluator, commenter, &repo, progress)
|
||||
|
||||
worker := NewPullRequestWorker(evaluator, commenter)
|
||||
job := provisioning.Job{
|
||||
Spec: provisioning.JobSpec{
|
||||
Action: provisioning.JobActionPullRequest,
|
||||
PullRequest: tt.opts,
|
||||
},
|
||||
}
|
||||
|
||||
err := worker.Process(context.Background(), repo, job, progress)
|
||||
if tt.expectedError != "" {
|
||||
require.EqualError(t, err, tt.expectedError)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
evaluator.AssertExpectations(t)
|
||||
commenter.AssertExpectations(t)
|
||||
repo.AssertExpectations(t)
|
||||
progress.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type mockPullRequestRepo struct {
|
||||
*repository.MockRepository
|
||||
*MockPullRequestRepo
|
||||
}
|
||||
|
||||
// implemented by both mocks
|
||||
func (m mockPullRequestRepo) Config() *provisioning.Repository {
|
||||
return m.MockRepository.Config()
|
||||
}
|
||||
|
||||
// implemented by both mocks
|
||||
func (m mockPullRequestRepo) Read(ctx context.Context, path, ref string) (*repository.FileInfo, error) {
|
||||
return m.MockRepository.Read(ctx, path, ref)
|
||||
}
|
||||
|
||||
// implemented by both mocks
|
||||
func (m mockPullRequestRepo) AssertExpectations(t *testing.T) {
|
||||
m.MockRepository.AssertExpectations(t)
|
||||
m.MockPullRequestRepo.AssertExpectations(t)
|
||||
}
|
||||
Reference in New Issue
Block a user