2025-04-11 18:06:16 +08:00
|
|
|
package pullrequest
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"fmt"
|
|
|
|
"net/url"
|
|
|
|
"path"
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
"github.com/grafana/grafana-app-sdk/logging"
|
2025-04-24 02:21:59 +08:00
|
|
|
dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
|
2025-04-11 18:06:16 +08:00
|
|
|
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
|
|
|
|
HasScreenshot 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
|
|
|
|
}
|
|
|
|
|
2025-04-11 23:18:41 +08:00
|
|
|
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,
|
|
|
|
}
|
2025-04-11 18:06:16 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
// This will process the list of versioned file changes into changeInfo
|
2025-04-11 23:18:41 +08:00
|
|
|
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)
|
2025-04-11 18:06:16 +08:00
|
|
|
}
|
|
|
|
|
2025-04-11 23:18:41 +08:00
|
|
|
baseURL := e.urlProvider(cfg.Namespace)
|
|
|
|
info := changeInfo{
|
|
|
|
GrafanaBaseURL: baseURL,
|
|
|
|
}
|
2025-04-11 18:06:16 +08:00
|
|
|
|
2025-04-11 23:18:41 +08:00
|
|
|
var shouldRender bool
|
|
|
|
switch {
|
|
|
|
case e.render == nil:
|
|
|
|
shouldRender = false
|
|
|
|
case !e.render.IsAvailable(ctx):
|
|
|
|
info.MissingImageRenderer = true
|
|
|
|
shouldRender = false
|
|
|
|
case len(changes) > 1 || !cfg.Spec.GitHub.GenerateDashboardPreviews:
|
2025-04-11 18:06:16 +08:00
|
|
|
// Only render images when there is just one change
|
2025-04-11 23:18:41 +08:00
|
|
|
shouldRender = false
|
|
|
|
default:
|
|
|
|
shouldRender = true
|
2025-04-11 18:06:16 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
logger := logging.FromContext(ctx)
|
2025-04-11 23:18:41 +08:00
|
|
|
for i, change := range changes {
|
2025-04-11 18:06:16 +08:00
|
|
|
// process maximum 10 files
|
|
|
|
if i >= 10 {
|
2025-04-11 23:18:41 +08:00
|
|
|
info.SkippedFiles = len(changes) - i
|
2025-04-11 18:06:16 +08:00
|
|
|
break
|
|
|
|
}
|
|
|
|
|
2025-04-11 23:18:41 +08:00
|
|
|
progress.SetMessage(ctx, fmt.Sprintf("processing: %s", change.Path))
|
2025-04-11 18:06:16 +08:00
|
|
|
logger.With("action", change.Action).With("path", change.Path)
|
|
|
|
|
2025-04-11 23:18:41 +08:00
|
|
|
v, err := calculateFileChangeInfo(ctx, repo, info.GrafanaBaseURL, change, opts, parser)
|
2025-04-11 18:06:16 +08:00
|
|
|
if err != nil {
|
|
|
|
return info, fmt.Errorf("error calculating changes %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// If everything applied OK, then render screenshots
|
2025-04-11 23:18:41 +08:00
|
|
|
if shouldRender && v.GrafanaURL != "" && v.Parsed != nil && v.Parsed.DryRunResponse != nil {
|
|
|
|
progress.SetMessage(ctx, fmt.Sprintf("rendering screenshots: %s", change.Path))
|
|
|
|
if err = v.renderScreenshots(ctx, info.GrafanaBaseURL, e.render); err != nil {
|
2025-04-11 18:06:16 +08:00
|
|
|
info.MissingImageRenderer = true
|
|
|
|
if v.Error == "" {
|
|
|
|
v.Error = "Error running image rendering"
|
|
|
|
}
|
|
|
|
|
|
|
|
if v.GrafanaScreenshotURL != "" || v.PreviewScreenshotURL != "" {
|
|
|
|
info.HasScreenshot = true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
info.Changes = append(info.Changes, v)
|
|
|
|
}
|
|
|
|
return info, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
var dashboardKind = dashboard.DashboardResourceInfo.GroupVersionKind().Kind
|
|
|
|
|
2025-04-11 23:18:41 +08:00
|
|
|
func calculateFileChangeInfo(ctx context.Context, repo repository.Reader, baseURL string, change repository.VersionedFileChange, opts provisioning.PullRequestJobOptions, parser resources.Parser) (fileChangeInfo, error) {
|
2025-04-11 18:06:16 +08:00
|
|
|
if change.Action == repository.FileActionDeleted {
|
2025-04-11 23:18:41 +08:00
|
|
|
return calculateFileDeleteInfo(ctx, baseURL, change)
|
2025-04-11 18:06:16 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
info := fileChangeInfo{Change: change}
|
2025-04-11 23:18:41 +08:00
|
|
|
fileInfo, err := repo.Read(ctx, change.Path, change.Ref)
|
2025-04-11 18:06:16 +08:00
|
|
|
if err != nil {
|
|
|
|
logger.Info("unable to read file", "err", err)
|
|
|
|
info.Error = err.Error()
|
|
|
|
return info, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Read the file as a resource
|
2025-04-11 23:18:41 +08:00
|
|
|
info.Parsed, err = parser.Parse(ctx, fileInfo)
|
2025-04-11 18:06:16 +08:00
|
|
|
if err != nil {
|
|
|
|
info.Error = err.Error()
|
|
|
|
return info, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Dashboards get special handling
|
|
|
|
if info.Parsed.GVK.Kind == dashboardKind {
|
|
|
|
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)
|
2025-04-11 23:18:41 +08:00
|
|
|
if opts.URL != "" {
|
|
|
|
query.Set("pull_request_url", url.QueryEscape(opts.URL))
|
2025-04-11 18:06:16 +08:00
|
|
|
}
|
|
|
|
info.PreviewURL += "?" + query.Encode()
|
|
|
|
}
|
|
|
|
|
|
|
|
return info, nil
|
|
|
|
}
|
|
|
|
|
2025-04-11 23:18:41 +08:00
|
|
|
func calculateFileDeleteInfo(_ context.Context, _ string, change repository.VersionedFileChange) (fileChangeInfo, error) {
|
2025-04-11 18:06:16 +08:00
|
|
|
// TODO -- read the old and verify
|
|
|
|
return fileChangeInfo{Change: change, Error: "delete feedback not yet implemented"}, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// This will update render the linked screenshots and update the screenshotURLs
|
|
|
|
func (f *fileChangeInfo) renderScreenshots(ctx context.Context, baseURL string, renderer ScreenshotRenderer) (err error) {
|
|
|
|
if f.GrafanaURL != "" {
|
|
|
|
f.GrafanaScreenshotURL, err = renderScreenshotFromGrafanaURL(ctx, baseURL, renderer, f.Parsed.Repo, f.GrafanaURL)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if f.PreviewURL != "" {
|
|
|
|
f.PreviewScreenshotURL, err = renderScreenshotFromGrafanaURL(ctx, baseURL, renderer, f.Parsed.Repo, f.PreviewURL)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|