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("", 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 }