sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/plugins/cat/cat.go (about)

     1  /*
     2  Copyright 2018 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 cat adds cat images to an issue or PR in response to a /meow comment
    18  package cat
    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)^/meow(vie)?(?: (.+))?\s*$`)
    42  	grumpyKeywords = regexp.MustCompile(`(?mi)^(no|grumpy)\s*$`)
    43  	meow           = &realClowder{
    44  		url: "https://api.thecatapi.com/v1/images/search?format=json&results_per_page=1",
    45  	}
    46  )
    47  
    48  const (
    49  	pluginName        = "cat"
    50  	defaultGrumpyRoot = "https://upload.wikimedia.org/wikipedia/commons/e/ee/"
    51  	grumpyIMG         = "Grumpy_Cat_by_Gage_Skidmore.jpg"
    52  )
    53  
    54  func init() {
    55  	plugins.RegisterGenericCommentHandler(pluginName, handleGenericComment, helpProvider)
    56  }
    57  
    58  func helpProvider(config *plugins.Configuration, _ []config.OrgRepo) (*pluginhelp.PluginHelp, error) {
    59  	yamlSnippet, err := plugins.CommentMap.GenYaml(&plugins.Configuration{
    60  		Cat: plugins.Cat{
    61  			KeyPath: "/etc/cat-api/api-key",
    62  		},
    63  	})
    64  	if err != nil {
    65  		logrus.WithError(err).Warnf("cannot generate comments for %s plugin", pluginName)
    66  	}
    67  	pluginHelp := &pluginhelp.PluginHelp{
    68  		Description: "The cat plugin adds a cat image to an issue or PR in response to the `/meow` command.",
    69  		Config: map[string]string{
    70  			"": fmt.Sprintf("The cat plugin uses an api key for thecatapi.com stored in %s.", config.Cat.KeyPath),
    71  		},
    72  		Snippet: yamlSnippet,
    73  	}
    74  	pluginHelp.AddCommand(pluginhelp.Command{
    75  		Usage:       "/meow(vie) [CATegory]",
    76  		Description: "Add a cat image to the issue or PR",
    77  		Featured:    false,
    78  		WhoCanUse:   "Anyone",
    79  		Examples:    []string{"/meow", "/meow caturday", "/meowvie clothes"},
    80  	})
    81  	return pluginHelp, nil
    82  }
    83  
    84  type githubClient interface {
    85  	CreateComment(owner, repo string, number int, comment string) error
    86  }
    87  
    88  type clowder interface {
    89  	readCat(string, bool, string) (string, error)
    90  }
    91  
    92  type realClowder struct {
    93  	url    string
    94  	lock   sync.RWMutex
    95  	update time.Time
    96  	key    string
    97  }
    98  
    99  func (c *realClowder) setKey(keyPath string, log *logrus.Entry) {
   100  	c.lock.Lock()
   101  	defer c.lock.Unlock()
   102  	if !time.Now().After(c.update) {
   103  		return
   104  	}
   105  	c.update = time.Now().Add(1 * time.Minute)
   106  	if keyPath == "" {
   107  		c.key = ""
   108  		return
   109  	}
   110  	b, err := os.ReadFile(keyPath)
   111  	if err == nil {
   112  		c.key = strings.TrimSpace(string(b))
   113  		return
   114  	}
   115  	log.WithError(err).Errorf("failed to read key at %s", keyPath)
   116  	c.key = ""
   117  }
   118  
   119  type catResult struct {
   120  	Image string `json:"url"`
   121  }
   122  
   123  func (cr catResult) Format() (string, error) {
   124  	if cr.Image == "" {
   125  		return "", errors.New("empty image url")
   126  	}
   127  	img, err := url.Parse(cr.Image)
   128  	if err != nil {
   129  		return "", fmt.Errorf("invalid image url %s: %w", cr.Image, err)
   130  	}
   131  
   132  	return fmt.Sprintf("![cat image](%s)", img), nil
   133  }
   134  
   135  func (c *realClowder) URL(category string, movieCat bool) string {
   136  	c.lock.RLock()
   137  	defer c.lock.RUnlock()
   138  	uri := string(c.url)
   139  	if category != "" {
   140  		uri += "&category=" + url.QueryEscape(category)
   141  	}
   142  	if c.key != "" {
   143  		uri += "&api_key=" + url.QueryEscape(c.key)
   144  	}
   145  	if movieCat {
   146  		uri += "&mime_types=gif"
   147  	}
   148  	return uri
   149  }
   150  
   151  func (c *realClowder) readCat(category string, movieCat bool, grumpyRoot string) (string, error) {
   152  	cats := make([]catResult, 0)
   153  	uri := c.URL(category, movieCat)
   154  	if grumpyKeywords.MatchString(category) {
   155  		cats = append(cats, catResult{grumpyRoot + grumpyIMG})
   156  	} else {
   157  		resp, err := http.Get(uri)
   158  		if err != nil {
   159  			return "", fmt.Errorf("could not read cat from %s: %w", uri, err)
   160  		}
   161  		defer resp.Body.Close()
   162  		if sc := resp.StatusCode; sc > 299 || sc < 200 {
   163  			return "", fmt.Errorf("failing %d response from %s", sc, uri)
   164  		}
   165  		if err = json.NewDecoder(resp.Body).Decode(&cats); err != nil {
   166  			return "", err
   167  		}
   168  		if len(cats) < 1 {
   169  			return "", fmt.Errorf("no cats in response from %s", uri)
   170  		}
   171  	}
   172  	a := cats[0]
   173  	if a.Image == "" {
   174  		return "", fmt.Errorf("no image url in response from %s", uri)
   175  	}
   176  	// checking size, GitHub doesn't support big images
   177  	toobig, err := github.ImageTooBig(a.Image)
   178  	if err != nil {
   179  		return "", fmt.Errorf("could not validate image size %s: %w", a.Image, err)
   180  	} else if toobig {
   181  		return "", fmt.Errorf("longcat is too long: %s", a.Image)
   182  	}
   183  	return a.Format()
   184  }
   185  
   186  func handleGenericComment(pc plugins.Agent, e github.GenericCommentEvent) error {
   187  	return handle(
   188  		pc.GitHubClient,
   189  		pc.Logger,
   190  		&e,
   191  		meow,
   192  		func() { meow.setKey(pc.PluginConfig.Cat.KeyPath, pc.Logger) },
   193  	)
   194  }
   195  
   196  func handle(gc githubClient, log *logrus.Entry, e *github.GenericCommentEvent, c clowder, setKey func()) error {
   197  	// Only consider new comments.
   198  	if e.Action != github.GenericCommentActionCreated {
   199  		return nil
   200  	}
   201  	// Make sure they are requesting a cat
   202  	mat := match.FindStringSubmatch(e.Body)
   203  	if mat == nil {
   204  		return nil
   205  	}
   206  
   207  	category, movieCat, err := parseMatch(mat)
   208  	if err != nil {
   209  		return err
   210  	}
   211  
   212  	// Now that we know this is a relevant event we can set the key.
   213  	setKey()
   214  
   215  	org := e.Repo.Owner.Login
   216  	repo := e.Repo.Name
   217  	number := e.Number
   218  
   219  	for i := 0; i < 3; i++ {
   220  		resp, err := c.readCat(category, movieCat, defaultGrumpyRoot)
   221  		if err != nil {
   222  			log.WithError(err).Error("Failed to get cat img")
   223  			continue
   224  		}
   225  		return gc.CreateComment(org, repo, number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, e.User.Login, resp))
   226  	}
   227  
   228  	var msg string
   229  	if category != "" {
   230  		msg = "Bad category. Please see https://api.thecatapi.com/api/categories/list"
   231  	} else {
   232  		msg = "https://thecatapi.com appears to be down"
   233  	}
   234  	if err := gc.CreateComment(org, repo, number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, e.User.Login, msg)); err != nil {
   235  		log.WithError(err).Error("Failed to leave comment")
   236  	}
   237  
   238  	return errors.New("could not find a valid cat image")
   239  }
   240  
   241  func parseMatch(mat []string) (string, bool, error) {
   242  	if len(mat) != 3 {
   243  		err := fmt.Errorf("expected 3 capture groups in regexp match, but got %d", len(mat))
   244  		return "", false, err
   245  	}
   246  	category := strings.TrimSpace(mat[2])
   247  	movieCat := len(mat[1]) > 0 // "vie" suffix is present.
   248  	return category, movieCat, nil
   249  }