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