mirror of https://github.com/grafana/grafana.git
				
				
				
			
		
			
				
	
	
		
			881 lines
		
	
	
		
			28 KiB
		
	
	
	
		
			Go
		
	
	
	
			
		
		
	
	
			881 lines
		
	
	
		
			28 KiB
		
	
	
	
		
			Go
		
	
	
	
| package repository
 | |
| 
 | |
| import (
 | |
| 	"bytes"
 | |
| 	"context"
 | |
| 	"errors"
 | |
| 	"fmt"
 | |
| 	"log/slog"
 | |
| 	"net/http"
 | |
| 	"net/url"
 | |
| 	"path"
 | |
| 	"regexp"
 | |
| 	"strings"
 | |
| 	"text/template"
 | |
| 
 | |
| 	"github.com/google/go-github/v66/github"
 | |
| 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 | |
| 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 | |
| 	"k8s.io/apimachinery/pkg/util/validation/field"
 | |
| 	"k8s.io/apiserver/pkg/registry/rest"
 | |
| 
 | |
| 	provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
 | |
| 	"github.com/grafana/grafana/pkg/registry/apis/provisioning/lint"
 | |
| 	pgh "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
 | |
| )
 | |
| 
 | |
| type githubRepository struct {
 | |
| 	logger  *slog.Logger
 | |
| 	config  *provisioning.Repository
 | |
| 	gh      pgh.Client
 | |
| 	baseURL *url.URL
 | |
| 	linter  lint.Linter
 | |
| }
 | |
| 
 | |
| var _ Repository = (*githubRepository)(nil)
 | |
| 
 | |
| func NewGitHub(
 | |
| 	ctx context.Context,
 | |
| 	config *provisioning.Repository,
 | |
| 	factory pgh.ClientFactory,
 | |
| 	baseURL *url.URL,
 | |
| 	linter lint.Linter,
 | |
| ) *githubRepository {
 | |
| 	return &githubRepository{
 | |
| 		config:  config,
 | |
| 		logger:  slog.Default().With("logger", "github-repository"),
 | |
| 		gh:      factory.New(ctx, config.Spec.GitHub.Token),
 | |
| 		baseURL: baseURL,
 | |
| 		linter:  linter,
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func (r *githubRepository) Config() *provisioning.Repository {
 | |
| 	return r.config
 | |
| }
 | |
| 
 | |
| // Validate implements provisioning.Repository.
 | |
| func (r *githubRepository) Validate() (list field.ErrorList) {
 | |
| 	gh := r.config.Spec.GitHub
 | |
| 	if gh == nil {
 | |
| 		list = append(list, field.Required(field.NewPath("spec", "github"), "a github config is required"))
 | |
| 		return list
 | |
| 	}
 | |
| 	if gh.Owner == "" {
 | |
| 		list = append(list, field.Required(field.NewPath("spec", "github", "owner"), "a github repo owner is required"))
 | |
| 	}
 | |
| 	if gh.Repository == "" {
 | |
| 		list = append(list, field.Required(field.NewPath("spec", "github", "repository"), "a github repo name is required"))
 | |
| 	}
 | |
| 	if gh.Branch == "" {
 | |
| 		list = append(list, field.Required(field.NewPath("spec", "github", "branch"), "a github branch is required"))
 | |
| 	}
 | |
| 	if !isValidGitBranchName(gh.Branch) {
 | |
| 		list = append(list, field.Invalid(field.NewPath("spec", "github", "branch"), gh.Branch, "invalid branch name"))
 | |
| 	}
 | |
| 	if gh.Token == "" {
 | |
| 		list = append(list, field.Required(field.NewPath("spec", "github", "token"), "a github access token is required"))
 | |
| 	}
 | |
| 	if gh.GenerateDashboardPreviews && !gh.BranchWorkflow {
 | |
| 		list = append(list, field.Forbidden(field.NewPath("spec", "github", "token"), "to generate dashboard previews, you must activate the branch workflow"))
 | |
| 	}
 | |
| 	if gh.WebhookURL == "" {
 | |
| 		list = append(list, field.Required(field.NewPath("spec", "github", "webhookURL"), "a webhook URL is required"))
 | |
| 	}
 | |
| 	if gh.WebhookSecret == "" {
 | |
| 		list = append(list, field.Required(field.NewPath("spec", "github", "webhookSecret"), "a webhook secret is required"))
 | |
| 	}
 | |
| 
 | |
| 	_, err := url.Parse(gh.WebhookURL)
 | |
| 	if err != nil {
 | |
| 		list = append(list, field.Invalid(field.NewPath("spec", "github", "webhookURL"), gh.WebhookURL, "invalid URL"))
 | |
| 	}
 | |
| 
 | |
| 	return list
 | |
| }
 | |
| 
 | |
| // Test implements provisioning.Repository.
 | |
| func (r *githubRepository) Test(ctx context.Context, logger *slog.Logger) error {
 | |
| 	return &apierrors.StatusError{
 | |
| 		ErrStatus: metav1.Status{
 | |
| 			Message: "test is not yet implemented",
 | |
| 			Code:    http.StatusNotImplemented,
 | |
| 		},
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // ReadResource implements provisioning.Repository.
 | |
| func (r *githubRepository) Read(ctx context.Context, logger *slog.Logger, filePath, ref string) (*FileInfo, error) {
 | |
| 	if ref == "" {
 | |
| 		ref = r.config.Spec.GitHub.Branch
 | |
| 	}
 | |
| 	owner := r.config.Spec.GitHub.Owner
 | |
| 	repo := r.config.Spec.GitHub.Repository
 | |
| 
 | |
| 	content, dirContent, err := r.gh.GetContents(ctx, owner, repo, filePath, ref)
 | |
| 	if err != nil {
 | |
| 		if errors.Is(err, pgh.ErrResourceNotFound) {
 | |
| 			return nil, &apierrors.StatusError{
 | |
| 				ErrStatus: metav1.Status{
 | |
| 					Message: fmt.Sprintf("file not found; path=%s ref=%s", filePath, ref),
 | |
| 					Code:    http.StatusNotFound,
 | |
| 				},
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		return nil, fmt.Errorf("get contents: %w", err)
 | |
| 	}
 | |
| 	if dirContent != nil {
 | |
| 		return nil, fmt.Errorf("input path was a directory")
 | |
| 	}
 | |
| 
 | |
| 	data, err := content.GetFileContent()
 | |
| 	if err != nil {
 | |
| 		return nil, fmt.Errorf("get content: %w", err)
 | |
| 	}
 | |
| 	return &FileInfo{
 | |
| 		Path: filePath,
 | |
| 		Ref:  ref,
 | |
| 		Data: []byte(data),
 | |
| 		Hash: content.GetSHA(),
 | |
| 	}, nil
 | |
| }
 | |
| 
 | |
| func (r *githubRepository) ReadTree(ctx context.Context, logger *slog.Logger, ref string) ([]FileTreeEntry, error) {
 | |
| 	if ref == "" {
 | |
| 		ref = r.config.Spec.GitHub.Branch
 | |
| 	}
 | |
| 	owner := r.config.Spec.GitHub.Owner
 | |
| 	repo := r.config.Spec.GitHub.Repository
 | |
| 	logger = logger.With("owner", owner, "repo", repo, "ref", ref)
 | |
| 
 | |
| 	tree, truncated, err := r.gh.GetTree(ctx, owner, repo, ref, true)
 | |
| 	if err != nil {
 | |
| 		if errors.Is(err, pgh.ErrResourceNotFound) {
 | |
| 			return nil, &apierrors.StatusError{
 | |
| 				ErrStatus: metav1.Status{
 | |
| 					Message: fmt.Sprintf("tree not found; ref=%s", ref),
 | |
| 					Code:    http.StatusNotFound,
 | |
| 				},
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 	if truncated {
 | |
| 		logger.WarnContext(ctx, "tree from github was truncated")
 | |
| 	}
 | |
| 
 | |
| 	entries := make([]FileTreeEntry, 0, len(tree))
 | |
| 	for _, entry := range tree {
 | |
| 		converted := FileTreeEntry{
 | |
| 			Path: entry.GetPath(),
 | |
| 			Size: entry.GetSize(),
 | |
| 			Hash: entry.GetSHA(),
 | |
| 			Blob: !entry.IsDirectory(),
 | |
| 		}
 | |
| 		entries = append(entries, converted)
 | |
| 	}
 | |
| 	return entries, nil
 | |
| }
 | |
| 
 | |
| func (r *githubRepository) Create(ctx context.Context, logger *slog.Logger, path, ref string, data []byte, comment string) error {
 | |
| 	if ref == "" {
 | |
| 		ref = r.config.Spec.GitHub.Branch
 | |
| 	}
 | |
| 
 | |
| 	if err := r.ensureBranchExists(ctx, ref); err != nil {
 | |
| 		return fmt.Errorf("create branch on create: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	owner := r.config.Spec.GitHub.Owner
 | |
| 	repo := r.config.Spec.GitHub.Repository
 | |
| 
 | |
| 	err := r.gh.CreateFile(ctx, owner, repo, path, ref, comment, data)
 | |
| 	if errors.Is(err, pgh.ErrResourceAlreadyExists) {
 | |
| 		return &apierrors.StatusError{
 | |
| 			ErrStatus: metav1.Status{
 | |
| 				Message: "file already exists",
 | |
| 				Code:    http.StatusConflict,
 | |
| 			},
 | |
| 		}
 | |
| 	}
 | |
| 	return err
 | |
| }
 | |
| 
 | |
| func (r *githubRepository) Update(ctx context.Context, logger *slog.Logger, path, ref string, data []byte, comment string) error {
 | |
| 	if ref == "" {
 | |
| 		ref = r.config.Spec.GitHub.Branch
 | |
| 	}
 | |
| 
 | |
| 	if err := r.ensureBranchExists(ctx, ref); err != nil {
 | |
| 		return fmt.Errorf("create branch on update: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	owner := r.config.Spec.GitHub.Owner
 | |
| 	repo := r.config.Spec.GitHub.Repository
 | |
| 
 | |
| 	file, _, err := r.gh.GetContents(ctx, owner, repo, path, ref)
 | |
| 	if err != nil {
 | |
| 		if errors.Is(err, pgh.ErrResourceNotFound) {
 | |
| 			return &apierrors.StatusError{
 | |
| 				ErrStatus: metav1.Status{
 | |
| 					Message: "file not found",
 | |
| 					Code:    http.StatusNotFound,
 | |
| 				},
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		return fmt.Errorf("get content before file update: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	if err := r.gh.UpdateFile(ctx, owner, repo, path, ref, comment, file.GetSHA(), data); err != nil {
 | |
| 		return fmt.Errorf("update file: %w", err)
 | |
| 	}
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func (r *githubRepository) Delete(ctx context.Context, logger *slog.Logger, path, ref, comment string) error {
 | |
| 	if ref == "" {
 | |
| 		ref = r.config.Spec.GitHub.Branch
 | |
| 	}
 | |
| 
 | |
| 	if err := r.ensureBranchExists(ctx, ref); err != nil {
 | |
| 		return fmt.Errorf("create branch on delete: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	owner := r.config.Spec.GitHub.Owner
 | |
| 	repo := r.config.Spec.GitHub.Repository
 | |
| 
 | |
| 	file, _, err := r.gh.GetContents(ctx, owner, repo, path, ref)
 | |
| 	if err != nil {
 | |
| 		if errors.Is(err, pgh.ErrResourceNotFound) {
 | |
| 			return &apierrors.StatusError{
 | |
| 				ErrStatus: metav1.Status{
 | |
| 					Message: "file not found",
 | |
| 					Code:    http.StatusNotFound,
 | |
| 				},
 | |
| 			}
 | |
| 		}
 | |
| 		return fmt.Errorf("finding file to delete: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	return r.gh.DeleteFile(ctx, owner, repo, path, ref, comment, file.GetSHA())
 | |
| }
 | |
| 
 | |
| // basicGitBranchNameRegex is a regular expression to validate a git branch name
 | |
| // it does not cover all cases as positive lookaheads are not supported in Go's regexp
 | |
| var basicGitBranchNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-\_\/\.]+$`)
 | |
| 
 | |
| // isValidGitBranchName checks if a branch name is valid.
 | |
| // It uses the following regexp `^[a-zA-Z0-9\-\_\/\.]+$` to validate the branch name with some additional checks that must satisfy the following rules:
 | |
| // 1. The branch name must have at least one character and must not be empty.
 | |
| // 2. The branch name cannot start with `/` or end with `/`, `.`, or whitespace.
 | |
| // 3. The branch name cannot contain consecutive slashes (`//`).
 | |
| // 4. The branch name cannot contain consecutive dots (`..`).
 | |
| // 5. The branch name cannot contain `@{`.
 | |
| // 6. The branch name cannot include the following characters: `~`, `^`, `:`, `?`, `*`, `[`, `\`, or `]`.
 | |
| func isValidGitBranchName(branch string) bool {
 | |
| 	if !basicGitBranchNameRegex.MatchString(branch) {
 | |
| 		return false
 | |
| 	}
 | |
| 
 | |
| 	// Additional checks for invalid patterns
 | |
| 	if strings.HasPrefix(branch, "/") || strings.HasSuffix(branch, "/") ||
 | |
| 		strings.HasSuffix(branch, ".") || strings.Contains(branch, "..") ||
 | |
| 		strings.Contains(branch, "//") || strings.HasSuffix(branch, ".lock") {
 | |
| 		return false
 | |
| 	}
 | |
| 
 | |
| 	return true
 | |
| }
 | |
| 
 | |
| func (r *githubRepository) ensureBranchExists(ctx context.Context, branchName string) error {
 | |
| 	if !isValidGitBranchName(branchName) {
 | |
| 		return &apierrors.StatusError{
 | |
| 			ErrStatus: metav1.Status{
 | |
| 				Code:    http.StatusBadRequest,
 | |
| 				Message: "invalid branch name",
 | |
| 			},
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	ok, err := r.gh.BranchExists(ctx, r.config.Spec.GitHub.Owner, r.config.Spec.GitHub.Repository, branchName)
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("check branch exists: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	if ok {
 | |
| 		r.logger.InfoContext(ctx, "branch already exists", "branch", branchName)
 | |
| 
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	owner := r.config.Spec.GitHub.Owner
 | |
| 	repo := r.config.Spec.GitHub.Repository
 | |
| 
 | |
| 	srcBranch := r.config.Spec.GitHub.Branch
 | |
| 	if err := r.gh.CreateBranch(ctx, owner, repo, srcBranch, branchName); err != nil {
 | |
| 		if errors.Is(err, pgh.ErrResourceAlreadyExists) {
 | |
| 			return &apierrors.StatusError{
 | |
| 				ErrStatus: metav1.Status{
 | |
| 					Code:    http.StatusConflict,
 | |
| 					Message: "branch already exists",
 | |
| 				},
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		return fmt.Errorf("create branch: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // Webhook implements provisioning.Repository.
 | |
| func (r *githubRepository) Webhook(ctx context.Context, logger *slog.Logger, responder rest.Responder, factory FileReplicatorFactory) http.HandlerFunc {
 | |
| 	return func(w http.ResponseWriter, req *http.Request) {
 | |
| 		// We don't want GitHub's request to cause a cancellation for us, but we also want the request context's data (primarily for logging).
 | |
| 		// This means we will just ignore when GH closes their connection to us. If we respond in time, fantastic. Otherwise, we'll still do the work.
 | |
| 		// The cancel we do here is mainly just to make sure that no goroutines can accidentally stay living forever.
 | |
| 		//
 | |
| 		// TODO: Should we have our own timeout here? Even if pretty crazy high (e.g. 30 min)?
 | |
| 		ctx, cancel := context.WithCancel(context.WithoutCancel(req.Context()))
 | |
| 		defer cancel()
 | |
| 
 | |
| 		payload, err := github.ValidatePayload(req, []byte(r.config.Spec.GitHub.WebhookSecret))
 | |
| 		if err != nil {
 | |
| 			responder.Error(apierrors.NewUnauthorized("invalid signature"))
 | |
| 			return
 | |
| 		}
 | |
| 
 | |
| 		eventType := github.WebHookType(req)
 | |
| 		event, err := github.ParseWebHook(github.WebHookType(req), payload)
 | |
| 		if err != nil {
 | |
| 			responder.Error(apierrors.NewBadRequest("invalid payload"))
 | |
| 			return
 | |
| 		}
 | |
| 
 | |
| 		switch event := event.(type) {
 | |
| 		case *github.PushEvent:
 | |
| 			if err := r.onPushEvent(ctx, logger, event, factory); err != nil {
 | |
| 				responder.Error(err)
 | |
| 				return
 | |
| 			}
 | |
| 
 | |
| 			responder.Object(200, &metav1.Status{
 | |
| 				Message: "push event processed",
 | |
| 				Code:    http.StatusOK,
 | |
| 			})
 | |
| 		case *github.PullRequestEvent:
 | |
| 			if err := r.onPullRequestEvent(ctx, logger, event, factory); err != nil {
 | |
| 				responder.Error(err)
 | |
| 				return
 | |
| 			}
 | |
| 			responder.Object(200, &metav1.Status{
 | |
| 				Message: "pull request event processed",
 | |
| 				Code:    http.StatusOK,
 | |
| 			})
 | |
| 		case *github.PingEvent:
 | |
| 			responder.Object(200, &metav1.Status{
 | |
| 				Message: "ping received",
 | |
| 				Code:    http.StatusOK,
 | |
| 			})
 | |
| 		default:
 | |
| 			responder.Error(apierrors.NewBadRequest("unsupported event type: " + eventType))
 | |
| 		}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func (r *githubRepository) onPushEvent(ctx context.Context, logger *slog.Logger, event *github.PushEvent, replicatorFactory FileReplicatorFactory) error {
 | |
| 	logger = logger.With("ref", event.GetRef())
 | |
| 
 | |
| 	if event.GetRepo() == nil {
 | |
| 		return fmt.Errorf("missing repository in push event")
 | |
| 	}
 | |
| 
 | |
| 	if event.GetRepo().GetFullName() != fmt.Sprintf("%s/%s", r.config.Spec.GitHub.Owner, r.config.Spec.GitHub.Repository) {
 | |
| 		return fmt.Errorf("repository mismatch")
 | |
| 	}
 | |
| 
 | |
| 	// Skip silently if the event is not for the main/master branch
 | |
| 	// as we cannot configure the webhook to only publish events for the main branch
 | |
| 	if event.GetRef() != fmt.Sprintf("refs/heads/%s", r.config.Spec.GitHub.Branch) {
 | |
| 		logger.DebugContext(ctx, "ignoring push event as it is not for the configured branch")
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	replicator, err := replicatorFactory.New()
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("create replicator: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	beforeRef := event.GetBefore()
 | |
| 
 | |
| 	for _, commit := range event.Commits {
 | |
| 		logger := logger.With("commit", commit.GetID(), "message", commit.GetMessage())
 | |
| 		logger.InfoContext(ctx, "process commit")
 | |
| 
 | |
| 		for _, file := range commit.Added {
 | |
| 			logger := logger.With("file", file)
 | |
| 
 | |
| 			fileInfo, err := r.Read(ctx, logger, file, commit.GetID())
 | |
| 			if err != nil {
 | |
| 				logger.ErrorContext(ctx, "failed to read added resource", "error", err)
 | |
| 				continue
 | |
| 			}
 | |
| 
 | |
| 			if err := replicator.Replicate(ctx, fileInfo); err != nil {
 | |
| 				// TODO: handle the case where the file does not contain a resource
 | |
| 				// after resolving the import cycles
 | |
| 				// if errors.Is(err, resources.ErrUnableToReadResourceBytes) {
 | |
| 				// 	logger.InfoContext(ctx, "added file does not contain a resource")
 | |
| 				// 	continue
 | |
| 				// }
 | |
| 
 | |
| 				logger.ErrorContext(ctx, "failed to replicate added resource", "error", err)
 | |
| 				continue
 | |
| 			}
 | |
| 
 | |
| 			logger.InfoContext(ctx, "added file", "path", file)
 | |
| 		}
 | |
| 
 | |
| 		for _, file := range commit.Modified {
 | |
| 			logger := logger.With("file", file)
 | |
| 			fileInfo, err := r.Read(ctx, logger, file, commit.GetID())
 | |
| 			if err != nil {
 | |
| 				logger.ErrorContext(ctx, "failed to read modified resource", "error", err)
 | |
| 				continue
 | |
| 			}
 | |
| 
 | |
| 			if err := replicator.Replicate(ctx, fileInfo); err != nil {
 | |
| 				// TODO: handle the case where the file does not contain a resource
 | |
| 				// after resolving the import cycles
 | |
| 				// if errors.Is(err, resources.ErrUnableToReadResourceBytes) {
 | |
| 				// 	logger.InfoContext(ctx, "modified file does not contain a resource")
 | |
| 				// 	continue
 | |
| 				// }
 | |
| 
 | |
| 				logger.ErrorContext(ctx, "failed to replicate modified resource", "error", err)
 | |
| 				continue
 | |
| 			}
 | |
| 
 | |
| 			logger.InfoContext(ctx, "modified file")
 | |
| 		}
 | |
| 
 | |
| 		for _, file := range commit.Removed {
 | |
| 			logger := logger.With("file", file)
 | |
| 
 | |
| 			fileInfo, err := r.Read(ctx, logger, file, beforeRef)
 | |
| 			if err != nil {
 | |
| 				logger.ErrorContext(ctx, "failed to read removed resource", "error", err)
 | |
| 				continue
 | |
| 			}
 | |
| 
 | |
| 			if err := replicator.Delete(ctx, fileInfo); err != nil {
 | |
| 				// TODO: handle the case where the file does not contain a resource
 | |
| 				// after resolving the import cycles
 | |
| 				// if errors.Is(err, resources.ErrUnableToReadResourceBytes) {
 | |
| 				// 	logger.InfoContext(ctx, "deleted file does not contain a resource")
 | |
| 				// 	continue
 | |
| 				// }
 | |
| 
 | |
| 				logger.ErrorContext(ctx, "failed to delete removed resource", "error", err)
 | |
| 				continue
 | |
| 			}
 | |
| 
 | |
| 			logger.InfoContext(ctx, "removed file")
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // changedResource represents a resource that has changed in a pull request.
 | |
| type changedResource struct {
 | |
| 	Filename string
 | |
| 	Path     string
 | |
| 	Action   string
 | |
| 	Type     string
 | |
| 	Original string
 | |
| 	Current  string
 | |
| 	Preview  string
 | |
| 	Data     []byte
 | |
| }
 | |
| 
 | |
| // onPullRequestEvent is called when a pull request event is received
 | |
| // If the pull request is opened, reponed or synchronize, we read the files changed.
 | |
| func (r *githubRepository) onPullRequestEvent(ctx context.Context, logger *slog.Logger, event *github.PullRequestEvent, factory FileReplicatorFactory) error {
 | |
| 	action := event.GetAction()
 | |
| 	logger = logger.With("pull_request", event.GetNumber(), "action", action, "nnumber", event.GetNumber())
 | |
| 	logger.InfoContext(
 | |
| 		ctx,
 | |
| 		"processing pull request event",
 | |
| 		"linter",
 | |
| 		r.Config().Spec.GitHub.PullRequestLinter,
 | |
| 		"previews",
 | |
| 		r.Config().Spec.GitHub.GenerateDashboardPreviews,
 | |
| 	)
 | |
| 
 | |
| 	if !r.Config().Spec.GitHub.PullRequestLinter && !r.Config().Spec.GitHub.GenerateDashboardPreviews {
 | |
| 		logger.DebugContext(ctx, "no action required on pull request event")
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	if event.GetRepo() == nil {
 | |
| 		return fmt.Errorf("missing repository in pull request event")
 | |
| 	}
 | |
| 
 | |
| 	if event.GetRepo().GetFullName() != fmt.Sprintf("%s/%s", r.config.Spec.GitHub.Owner, r.config.Spec.GitHub.Repository) {
 | |
| 		return fmt.Errorf("repository mismatch")
 | |
| 	}
 | |
| 
 | |
| 	if action != "opened" && action != "reopened" && action != "synchronize" {
 | |
| 		logger.InfoContext(ctx, "ignore pull request event", "action", event.GetAction())
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	// Get the files changed in the pull request
 | |
| 	files, err := r.gh.ListPullRequestFiles(ctx, r.config.Spec.GitHub.Owner, r.config.Spec.GitHub.Repository, event.GetNumber())
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("list pull request files: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	changedResources := make([]changedResource, 0)
 | |
| 
 | |
| 	baseBranch := event.GetPullRequest().GetBase().GetRef()
 | |
| 	mainBranch := r.config.Spec.GitHub.Branch
 | |
| 
 | |
| 	replicator, err := factory.New()
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("create replicator: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	prURL := event.GetPullRequest().GetHTMLURL()
 | |
| 
 | |
| 	for _, file := range files {
 | |
| 		resource := changedResource{
 | |
| 			Filename: path.Base(file.GetFilename()),
 | |
| 			Path:     file.GetFilename(),
 | |
| 			Action:   file.GetStatus(),
 | |
| 			Type:     "dashboard", // TODO: get this from the resource
 | |
| 		}
 | |
| 
 | |
| 		path := file.GetFilename()
 | |
| 		logger := logger.With("file", path, "status", file.GetStatus(), "sha", file.GetSHA())
 | |
| 
 | |
| 		ref := event.GetPullRequest().GetHead().GetRef()
 | |
| 		// reference: https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#get-a-commit
 | |
| 		switch file.GetStatus() {
 | |
| 		case "added":
 | |
| 			resource.Preview = r.previewURL(ref, path, prURL)
 | |
| 		case "modified":
 | |
| 			resource.Original = r.previewURL(baseBranch, path, prURL)
 | |
| 			resource.Current = r.previewURL(mainBranch, path, prURL)
 | |
| 			resource.Preview = r.previewURL(ref, path, prURL)
 | |
| 		case "removed":
 | |
| 			resource.Original = r.previewURL(baseBranch, path, prURL)
 | |
| 			resource.Current = r.previewURL(mainBranch, path, prURL)
 | |
| 			ref = baseBranch
 | |
| 		case "renamed":
 | |
| 			resource.Original = r.previewURL(baseBranch, file.GetPreviousFilename(), prURL)
 | |
| 			resource.Current = r.previewURL(mainBranch, file.GetPreviousFilename(), prURL)
 | |
| 			resource.Preview = r.previewURL(ref, path, prURL)
 | |
| 		case "changed":
 | |
| 			resource.Original = r.previewURL(baseBranch, path, prURL)
 | |
| 			resource.Current = r.previewURL(mainBranch, path, prURL)
 | |
| 			resource.Preview = r.previewURL(ref, path, prURL)
 | |
| 		case "unchanged":
 | |
| 			logger.InfoContext(ctx, "ignore unchanged file")
 | |
| 			continue
 | |
| 		default:
 | |
| 			logger.ErrorContext(ctx, "unhandled pull request file")
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		f, err := r.Read(ctx, logger, path, ref)
 | |
| 		if err != nil {
 | |
| 			logger.ErrorContext(ctx, "failed to read file", "error", err)
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		// TODO: how does this validation works vs linting?
 | |
| 		ok, err := replicator.Validate(ctx, f)
 | |
| 		if err != nil {
 | |
| 			logger.ErrorContext(ctx, "failed to validate file", "error", err)
 | |
| 			continue
 | |
| 		}
 | |
| 		if !ok {
 | |
| 			logger.InfoContext(ctx, "ignore file as it is not a valid resource")
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		resource.Data = f.Data
 | |
| 		logger.InfoContext(ctx, "resource changed")
 | |
| 		changedResources = append(changedResources, resource)
 | |
| 	}
 | |
| 
 | |
| 	if err := r.previewPullRequest(ctx, event.GetNumber(), changedResources); err != nil {
 | |
| 		logger.ErrorContext(ctx, "failed to comment previews", "error", err)
 | |
| 	}
 | |
| 
 | |
| 	headSha := event.GetPullRequest().GetHead().GetSHA()
 | |
| 	if err := r.lintPullRequest(ctx, logger, event.GetNumber(), headSha, changedResources); err != nil {
 | |
| 		logger.ErrorContext(ctx, "failed to lint pull request resources", "error", err)
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| var lintDashboardIssuesTemplate = `Hey there! 👋
 | |
| Grafana found some linting issues in this dashboard you may want to check:
 | |
| {{ range .}}
 | |
| {{ if eq .Severity 4 }}❌{{ else if eq .Severity 3 }}⚠️ {{ end }} [dashboard-linter/{{ .Rule }}](https://github.com/grafana/dashboard-linter/blob/main/docs/rules/{{ .Rule }}.md): {{ .Message }}.
 | |
| {{- end }}
 | |
| `
 | |
| 
 | |
| // lintPullRequest lints the files changed in the pull request and comments the issues found.
 | |
| // The linter is disabled if the configuration does not have PullRequestLinter enabled.
 | |
| // The only supported type of file to lint is a dashboard.
 | |
| func (r *githubRepository) lintPullRequest(ctx context.Context, logger *slog.Logger, prNumber int, ref string, resources []changedResource) error {
 | |
| 	if !r.Config().Spec.GitHub.PullRequestLinter {
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	logger.InfoContext(ctx, "lint pull request")
 | |
| 
 | |
| 	// Clear all previous comments because we don't know if the files have changed
 | |
| 	if err := r.gh.ClearAllPullRequestFileComments(ctx, r.config.Spec.GitHub.Owner, r.config.Spec.GitHub.Repository, prNumber); err != nil {
 | |
| 		return fmt.Errorf("clear pull request comments: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	for _, resource := range resources {
 | |
| 		if resource.Action == "removed" || resource.Type != "dashboard" {
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		logger := logger.With("file", resource.Path)
 | |
| 		if err := r.lintPullRequestFile(ctx, logger, prNumber, ref, resource); err != nil {
 | |
| 			logger.ErrorContext(ctx, "failed to lint file", "error", err)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // lintPullRequestFile lints a file and comments the issues found.
 | |
| func (r *githubRepository) lintPullRequestFile(ctx context.Context, logger *slog.Logger, prNumber int, ref string, resource changedResource) error {
 | |
| 	issues, err := r.linter.Lint(ctx, resource.Data)
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("lint file: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	if len(issues) == 0 {
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	// FIXME: we should not be compiling this all the time
 | |
| 	tmpl, err := template.New("comment").Parse(lintDashboardIssuesTemplate)
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("parse lint comment template: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	var buf bytes.Buffer
 | |
| 	if err := tmpl.Execute(&buf, issues); err != nil {
 | |
| 		return fmt.Errorf("execute lint comment template: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	comment := pgh.FileComment{
 | |
| 		Content:  buf.String(),
 | |
| 		Path:     resource.Path,
 | |
| 		Position: 1, // create a top-level comment
 | |
| 		Ref:      ref,
 | |
| 	}
 | |
| 
 | |
| 	// FIXME: comment with Grafana Logo
 | |
| 	// FIXME: comment author should be written by Grafana and not the user
 | |
| 	if err := r.gh.CreatePullRequestFileComment(ctx, r.config.Spec.GitHub.Owner, r.config.Spec.GitHub.Repository, prNumber, comment); err != nil {
 | |
| 		return fmt.Errorf("create pull request comment: %w", err)
 | |
| 	}
 | |
| 	logger.InfoContext(ctx, "lint comment created", "issues", len(issues))
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| const previewsCommentTemplate = `Hey there! 🎉
 | |
| Grafana spotted some changes for your resources in this pull request:
 | |
| 
 | |
| | File Name | Type | Path | Action | Links |
 | |
| |-----------|------|------|--------|-------|
 | |
| {{- range .}}
 | |
| | {{.Filename}} | {{.Type}} | {{.Path}} | {{.Action}} | {{if .Original}}[Original]({{.Original}}){{end}}{{if .Current}}, [Current]({{.Current}}){{end}}{{if .Preview}}, [Preview]({{.Preview}}){{end}}|
 | |
| {{- end}}
 | |
| 
 | |
| Click the preview links above to view how your changes will look and compare them with the original and current versions.`
 | |
| 
 | |
| func (r *githubRepository) previewPullRequest(ctx context.Context, prNumber int, resources []changedResource) error {
 | |
| 	if !r.Config().Spec.GitHub.GenerateDashboardPreviews || len(resources) == 0 {
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	// FIXME: we should not be compiling this all the time
 | |
| 	tmpl, err := template.New("comment").Parse(previewsCommentTemplate)
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("parse comment template: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	var buf bytes.Buffer
 | |
| 	if err := tmpl.Execute(&buf, resources); err != nil {
 | |
| 		return fmt.Errorf("execute comment template: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	comment := buf.String()
 | |
| 
 | |
| 	// FIXME: comment with Grafana Logo
 | |
| 	// FIXME: comment author should be written by Grafana and not the user
 | |
| 	if err := r.gh.CreatePullRequestComment(ctx, r.config.Spec.GitHub.Owner, r.config.Spec.GitHub.Repository, prNumber, comment); err != nil {
 | |
| 		return fmt.Errorf("create pull request comment: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // previewURL returns the URL to preview the file in Grafana
 | |
| func (r *githubRepository) previewURL(ref, filePath, pullRequestURL string) string {
 | |
| 	// Copy the baseURL to modify path and query
 | |
| 	baseURL := *r.baseURL
 | |
| 	baseURL.Path = path.Join(baseURL.Path, "/admin/provisioning", r.Config().GetName(), "dashboard/preview", filePath)
 | |
| 
 | |
| 	query := baseURL.Query()
 | |
| 	query.Set("ref", ref)
 | |
| 	query.Set("pull_request_url", url.QueryEscape(pullRequestURL))
 | |
| 	baseURL.RawQuery = query.Encode()
 | |
| 
 | |
| 	return baseURL.String()
 | |
| }
 | |
| 
 | |
| func (r *githubRepository) createWebhook(ctx context.Context, logger *slog.Logger) error {
 | |
| 	cfg := pgh.WebhookConfig{
 | |
| 		URL:         r.config.Spec.GitHub.WebhookURL,
 | |
| 		Secret:      r.config.Spec.GitHub.WebhookSecret,
 | |
| 		ContentType: "json",
 | |
| 		Events:      []string{"push", "pull_request"},
 | |
| 		Active:      true,
 | |
| 	}
 | |
| 
 | |
| 	if err := r.gh.CreateWebhook(ctx, r.config.Spec.GitHub.Owner, r.config.Spec.GitHub.Repository, cfg); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	logger.InfoContext(ctx, "webhook created", "url", cfg.URL)
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func (r *githubRepository) updateWebhook(ctx context.Context, logger *slog.Logger, oldRepo *githubRepository) (UndoFunc, error) {
 | |
| 	owner := r.config.Spec.GitHub.Owner
 | |
| 	repoName := r.config.Spec.GitHub.Repository
 | |
| 
 | |
| 	hooks, err := r.gh.ListWebhooks(ctx, owner, repoName)
 | |
| 	if err != nil {
 | |
| 		return nil, fmt.Errorf("list existing webhooks: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	newCfg := r.config.Spec.GitHub
 | |
| 	oldCfg := oldRepo.Config().Spec.GitHub
 | |
| 
 | |
| 	switch {
 | |
| 	case newCfg.WebhookURL != oldCfg.WebhookURL:
 | |
| 		// In this case we cannot find out out which webhook to update, so we delete the old one and create a new one
 | |
| 		if err := r.createWebhook(ctx, logger); err != nil {
 | |
| 			return nil, fmt.Errorf("create new webhook: %w", err)
 | |
| 		}
 | |
| 
 | |
| 		undoFunc := UndoFunc(func(ctx context.Context) error {
 | |
| 			if err := r.deleteWebhook(ctx, logger); err != nil {
 | |
| 				return fmt.Errorf("revert create new webhook: %w", err)
 | |
| 			}
 | |
| 
 | |
| 			logger.InfoContext(ctx, "create new webhook reverted", "url", newCfg.WebhookURL)
 | |
| 
 | |
| 			return nil
 | |
| 		})
 | |
| 
 | |
| 		if err := oldRepo.deleteWebhook(ctx, logger); err != nil {
 | |
| 			return undoFunc, fmt.Errorf("delete old webhook: %w", err)
 | |
| 		}
 | |
| 
 | |
| 		undoFunc = undoFunc.Chain(ctx, func(ctx context.Context) error {
 | |
| 			if err := oldRepo.createWebhook(ctx, logger); err != nil {
 | |
| 				return fmt.Errorf("revert delete old webhook: %w", err)
 | |
| 			}
 | |
| 
 | |
| 			logger.InfoContext(ctx, "delete old webhook reverted", "url", oldCfg.WebhookURL)
 | |
| 
 | |
| 			return nil
 | |
| 		})
 | |
| 
 | |
| 		return undoFunc, nil
 | |
| 	case newCfg.WebhookSecret != oldCfg.WebhookSecret:
 | |
| 		for _, hook := range hooks {
 | |
| 			if hook.URL == oldCfg.WebhookURL {
 | |
| 				hook.Secret = newCfg.WebhookSecret
 | |
| 				err := r.gh.EditWebhook(ctx, owner, repoName, hook)
 | |
| 				if err != nil {
 | |
| 					return nil, fmt.Errorf("update webhook secret: %w", err)
 | |
| 				}
 | |
| 
 | |
| 				logger.InfoContext(ctx, "webhook secret updated", "url", newCfg.WebhookURL)
 | |
| 
 | |
| 				return func(ctx context.Context) error {
 | |
| 					hook.Secret = oldCfg.WebhookSecret
 | |
| 					if err := r.gh.EditWebhook(ctx, owner, repoName, hook); err != nil {
 | |
| 						return fmt.Errorf("revert webhook secret: %w", err)
 | |
| 					}
 | |
| 
 | |
| 					logger.InfoContext(ctx, "webhook secret reverted", "url", oldCfg.WebhookURL)
 | |
| 					return nil
 | |
| 				}, nil
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		return nil, errors.New("webhook not found")
 | |
| 	default:
 | |
| 		return nil, nil
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func (r *githubRepository) deleteWebhook(ctx context.Context, logger *slog.Logger) error {
 | |
| 	owner := r.config.Spec.GitHub.Owner
 | |
| 	name := r.config.Spec.GitHub.Repository
 | |
| 
 | |
| 	hooks, err := r.gh.ListWebhooks(ctx, owner, name)
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("list existing webhooks: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	for _, hook := range hooks {
 | |
| 		if hook.URL == r.config.Spec.GitHub.WebhookURL {
 | |
| 			if err := r.gh.DeleteWebhook(ctx, owner, name, hook.ID); err != nil {
 | |
| 				return fmt.Errorf("delete webhook: %w", err)
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	logger.InfoContext(ctx, "webhook deleted", "url", r.config.Spec.GitHub.WebhookURL)
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func (r *githubRepository) AfterCreate(ctx context.Context, logger *slog.Logger) error {
 | |
| 	return r.createWebhook(ctx, logger)
 | |
| }
 | |
| 
 | |
| func (r *githubRepository) BeginUpdate(ctx context.Context, logger *slog.Logger, old Repository) (UndoFunc, error) {
 | |
| 	oldGitRepo, ok := old.(*githubRepository)
 | |
| 	if !ok {
 | |
| 		return nil, fmt.Errorf("old repository is not a github repository")
 | |
| 	}
 | |
| 
 | |
| 	return r.updateWebhook(ctx, logger, oldGitRepo)
 | |
| }
 | |
| 
 | |
| func (r *githubRepository) AfterDelete(ctx context.Context, logger *slog.Logger) error {
 | |
| 	return r.deleteWebhook(ctx, logger)
 | |
| }
 |