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:
Roberto Jiménez Sánchez
2025-05-13 09:50:43 +02:00
committed by GitHub
parent 98df41235d
commit 047499a363
52 changed files with 2696 additions and 2299 deletions
@@ -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 |
|----------|---------|
| ![Before]({{.GrafanaScreenshotURL}}) | ![Preview]({{.PreviewScreenshotURL}}) |
{{- else if .GrafanaScreenshotURL}}
### Original of {{.Title}}
![Original]({{.GrafanaScreenshotURL}})
{{- else if .PreviewScreenshotURL}}
### Preview of {{.Parsed.Info.Path}}
![Preview]({{.PreviewScreenshotURL}})
{{ 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&param2=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)
}
})
}
}
@@ -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.
@@ -0,0 +1,8 @@
Hey there! 🎉
Grafana spotted some changes to your dashboard.
### Preview of file.json
![Preview](https://cdn2.thecatapi.com/images/99c.jpg)
See the [preview](http://grafana/admin/preview) of file.json.
@@ -0,0 +1 @@
Grafana didn't find any changes in this pull request.
@@ -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
@@ -0,0 +1,9 @@
Hey there! 🎉
Grafana spotted some changes to your dashboard.
### Side by Side Comparison of file.json
| Before | After |
|----------|---------|
| ![Before](https://cdn2.thecatapi.com/images/99c.jpg) | ![Preview](https://cdn2.thecatapi.com/images/99c.jpg) |
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)
}