github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/pkg/plugins/goose/goose.go (about)

     1  /*
     2  Copyright 2019 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  // Package goose adds goose images to an issue or PR in response to a /honk comment
    18  package goose
    19  
    20  import (
    21  	"encoding/json"
    22  	"errors"
    23  	"fmt"
    24  	"net/http"
    25  	"net/url"
    26  	"os"
    27  	"regexp"
    28  	"strings"
    29  	"sync"
    30  	"time"
    31  
    32  	"github.com/sirupsen/logrus"
    33  
    34  	"sigs.k8s.io/prow/pkg/config"
    35  	"sigs.k8s.io/prow/pkg/github"
    36  	"sigs.k8s.io/prow/pkg/pluginhelp"
    37  	"sigs.k8s.io/prow/pkg/plugins"
    38  )
    39  
    40  var (
    41  	match = regexp.MustCompile(`(?mi)^/(honk)\s*$`)
    42  	honk  = &realGaggle{
    43  		url: "https://api.unsplash.com/photos/random?query=goose",
    44  	}
    45  )
    46  
    47  const (
    48  	pluginName = "goose"
    49  )
    50  
    51  func init() {
    52  	plugins.RegisterGenericCommentHandler(pluginName, handleGenericComment, helpProvider)
    53  }
    54  
    55  func helpProvider(config *plugins.Configuration, _ []config.OrgRepo) (*pluginhelp.PluginHelp, error) {
    56  	yamlSnippet, err := plugins.CommentMap.GenYaml(&plugins.Configuration{
    57  		Goose: plugins.Goose{
    58  			KeyPath: "/etc/unsplash-api/honk.txt",
    59  		},
    60  	})
    61  	if err != nil {
    62  		logrus.WithError(err).Warnf("cannot generate comments for %s plugin", pluginName)
    63  	}
    64  	pluginHelp := &pluginhelp.PluginHelp{
    65  		Description: "The goose plugin adds a goose image to an issue or PR in response to the `/honk` command.",
    66  		Config: map[string]string{
    67  			"": fmt.Sprintf("The goose plugin uses an api key for unsplash.com stored in %s.", config.Goose.KeyPath),
    68  		},
    69  		Snippet: yamlSnippet,
    70  	}
    71  	pluginHelp.AddCommand(pluginhelp.Command{
    72  		Usage:       "/honk",
    73  		Description: "Add a goose image to the issue or PR",
    74  		Featured:    false,
    75  		WhoCanUse:   "Anyone",
    76  		Examples:    []string{"/honk"},
    77  	})
    78  	return pluginHelp, nil
    79  }
    80  
    81  type githubClient interface {
    82  	CreateComment(owner, repo string, number int, comment string) error
    83  }
    84  
    85  type gaggle interface {
    86  	readGoose() (string, error)
    87  }
    88  
    89  type realGaggle struct {
    90  	url    string
    91  	lock   sync.RWMutex
    92  	update time.Time
    93  	key    string
    94  }
    95  
    96  func (g *realGaggle) setKey(keyPath string, log *logrus.Entry) {
    97  	g.lock.Lock()
    98  	defer g.lock.Unlock()
    99  	if !time.Now().After(g.update) {
   100  		return
   101  	}
   102  	g.update = time.Now().Add(1 * time.Minute)
   103  	if keyPath == "" {
   104  		g.key = ""
   105  		return
   106  	}
   107  	b, err := os.ReadFile(keyPath)
   108  	if err == nil {
   109  		g.key = strings.TrimSpace(string(b))
   110  		return
   111  	}
   112  	log.WithError(err).Errorf("failed to read key at %s", keyPath)
   113  	g.key = ""
   114  }
   115  
   116  type gooseResult struct {
   117  	ID     string   `json:"id"`
   118  	Images imageSet `json:"urls"`
   119  }
   120  
   121  type imageSet struct {
   122  	Raw     string `json:"raw"`
   123  	Full    string `json:"full"`
   124  	Regular string `json:"regular"`
   125  	Small   string `json:"small"`
   126  	Thumb   string `json:"thumb"`
   127  }
   128  
   129  func (gr gooseResult) Format() (string, error) {
   130  	if gr.Images.Small == "" {
   131  		return "", errors.New("empty image url")
   132  	}
   133  	img, err := url.Parse(gr.Images.Small)
   134  	if err != nil {
   135  		return "", fmt.Errorf("invalid image url %s: %w", gr.Images.Small, err)
   136  	}
   137  
   138  	return fmt.Sprintf("\n![goose image](%s)", img), nil
   139  }
   140  
   141  func (g *realGaggle) URL() string {
   142  	g.lock.RLock()
   143  	defer g.lock.RUnlock()
   144  	uri := string(g.url)
   145  	if g.key != "" {
   146  		uri += "&client_id=" + url.QueryEscape(g.key)
   147  	}
   148  	return uri
   149  }
   150  
   151  func (g *realGaggle) readGoose() (string, error) {
   152  	geese := make([]gooseResult, 1)
   153  	uri := g.URL()
   154  	resp, err := http.Get(uri)
   155  	if err != nil {
   156  		return "", fmt.Errorf("could not read goose from %s: %w", uri, err)
   157  	}
   158  	defer resp.Body.Close()
   159  	if sc := resp.StatusCode; sc > 299 || sc < 200 {
   160  		return "", fmt.Errorf("failing %d response from %s", sc, uri)
   161  	}
   162  	if err = json.NewDecoder(resp.Body).Decode(&geese[0]); err != nil {
   163  		return "", err
   164  	}
   165  	if len(geese) < 1 {
   166  		return "", fmt.Errorf("no geese in response from %s", uri)
   167  	}
   168  	a := geese[0]
   169  	if a.Images.Small == "" {
   170  		return "", fmt.Errorf("no image url in response from %s", uri)
   171  	}
   172  	// checking size, GitHub doesn't support big images
   173  	toobig, err := github.ImageTooBig(a.Images.Small)
   174  	if err != nil {
   175  		return "", fmt.Errorf("could not validate image size %s: %w", a.Images.Small, err)
   176  	} else if toobig {
   177  		return "", fmt.Errorf("long goose is too long: %s", a.Images.Small)
   178  	}
   179  	return a.Format()
   180  }
   181  
   182  func handleGenericComment(pc plugins.Agent, e github.GenericCommentEvent) error {
   183  	return handle(
   184  		pc.GitHubClient,
   185  		pc.Logger,
   186  		&e,
   187  		honk,
   188  		func() { honk.setKey(pc.PluginConfig.Goose.KeyPath, pc.Logger) },
   189  	)
   190  }
   191  
   192  func handle(gc githubClient, log *logrus.Entry, e *github.GenericCommentEvent, g gaggle, setKey func()) error {
   193  	// Only consider new comments.
   194  	if e.Action != github.GenericCommentActionCreated {
   195  		return nil
   196  	}
   197  	// Make sure they are requesting a goose
   198  	mat := match.FindStringSubmatch(e.Body)
   199  	if mat == nil {
   200  		return nil
   201  	}
   202  
   203  	// Now that we know this is a relevant event we can set the key.
   204  	setKey()
   205  
   206  	org := e.Repo.Owner.Login
   207  	repo := e.Repo.Name
   208  	number := e.Number
   209  
   210  	for i := 0; i < 3; i++ {
   211  		resp, err := g.readGoose()
   212  		if err != nil {
   213  			log.WithError(err).Error("Failed to get goose img")
   214  			continue
   215  		}
   216  		return gc.CreateComment(org, repo, number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, e.User.Login, resp))
   217  	}
   218  
   219  	msg := "Unable to find goose. Have you checked the garden?"
   220  	if err := gc.CreateComment(org, repo, number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, e.User.Login, msg)); err != nil {
   221  		log.WithError(err).Error("Failed to leave comment")
   222  	}
   223  
   224  	return errors.New("could not find a valid goose image")
   225  }