sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/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", 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 }