github.com/abayer/test-infra@v0.0.5/prow/plugins/lgtm/lgtm.go (about) 1 /* 2 Copyright 2016 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 lgtm 18 19 import ( 20 "fmt" 21 "regexp" 22 23 "github.com/sirupsen/logrus" 24 "k8s.io/apimachinery/pkg/util/sets" 25 26 "k8s.io/test-infra/prow/github" 27 "k8s.io/test-infra/prow/pluginhelp" 28 "k8s.io/test-infra/prow/plugins" 29 "k8s.io/test-infra/prow/repoowners" 30 ) 31 32 const pluginName = "lgtm" 33 34 var ( 35 lgtmLabel = "lgtm" 36 lgtmRe = regexp.MustCompile(`(?mi)^/lgtm(?: no-issue)?\s*$`) 37 lgtmCancelRe = regexp.MustCompile(`(?mi)^/lgtm cancel\s*$`) 38 removeLGTMLabelNoti = "New changes are detected. LGTM label has been removed." 39 ) 40 41 func init() { 42 plugins.RegisterGenericCommentHandler(pluginName, handleGenericCommentEvent, helpProvider) 43 plugins.RegisterPullRequestHandler(pluginName, func(pc plugins.PluginClient, pe github.PullRequestEvent) error { 44 return handlePullRequest(pc.GitHubClient, pe, pc.Logger) 45 }, helpProvider) 46 plugins.RegisterReviewEventHandler(pluginName, handlePullRequestReviewEvent, helpProvider) 47 } 48 49 func helpProvider(config *plugins.Configuration, enabledRepos []string) (*pluginhelp.PluginHelp, error) { 50 // The Config field is omitted because this plugin is not configurable. 51 pluginHelp := &pluginhelp.PluginHelp{ 52 Description: "The lgtm plugin manages the application and removal of the 'lgtm' (Looks Good To Me) label which is typically used to gate merging.", 53 } 54 pluginHelp.AddCommand(pluginhelp.Command{ 55 Usage: "/lgtm [cancel] or Github Review action", 56 Description: "Adds or removes the 'lgtm' label which is typically used to gate merging.", 57 Featured: true, 58 WhoCanUse: "Collaborators on the repository. '/lgtm cancel' can be used additionally by the PR author.", 59 Examples: []string{"/lgtm", "/lgtm cancel", "toggle the Review button to 'Approve' or 'Request Changes' in the github GUI"}, 60 }) 61 return pluginHelp, nil 62 } 63 64 // optionsForRepo gets the plugins.Lgtm struct that is applicable to the indicated repo. 65 func optionsForRepo(config *plugins.Configuration, org, repo string) *plugins.Lgtm { 66 fullName := fmt.Sprintf("%s/%s", org, repo) 67 for i := range config.Lgtm { 68 if !strInSlice(org, config.Lgtm[i].Repos) && !strInSlice(fullName, config.Lgtm[i].Repos) { 69 continue 70 } 71 return &config.Lgtm[i] 72 } 73 return &plugins.Lgtm{} 74 } 75 func strInSlice(str string, slice []string) bool { 76 for _, elem := range slice { 77 if elem == str { 78 return true 79 } 80 } 81 return false 82 } 83 84 type githubClient interface { 85 IsCollaborator(owner, repo, login string) (bool, error) 86 AddLabel(owner, repo string, number int, label string) error 87 AssignIssue(owner, repo string, number int, assignees []string) error 88 CreateComment(owner, repo string, number int, comment string) error 89 RemoveLabel(owner, repo string, number int, label string) error 90 GetIssueLabels(org, repo string, number int) ([]github.Label, error) 91 GetPullRequest(org, repo string, number int) (*github.PullRequest, error) 92 GetPullRequestChanges(org, repo string, number int) ([]github.PullRequestChange, error) 93 ListIssueComments(org, repo string, number int) ([]github.IssueComment, error) 94 DeleteComment(org, repo string, ID int) error 95 BotName() (string, error) 96 } 97 98 // reviewCtx contains information about each review event 99 type reviewCtx struct { 100 author, issueAuthor, body, htmlURL string 101 repo github.Repo 102 assignees []github.User 103 number int 104 } 105 106 func handleGenericCommentEvent(pc plugins.PluginClient, e github.GenericCommentEvent) error { 107 return handleGenericComment(pc.GitHubClient, pc.PluginConfig, pc.OwnersClient, pc.Logger, e) 108 } 109 110 func handlePullRequestReviewEvent(pc plugins.PluginClient, e github.ReviewEvent) error { 111 // If ReviewActsAsLgtm is disabled, ignore review event. 112 opts := optionsForRepo(pc.PluginConfig, e.Repo.Owner.Login, e.Repo.Name) 113 if !opts.ReviewActsAsLgtm { 114 return nil 115 } 116 return handlePullRequestReview(pc.GitHubClient, pc.PluginConfig, pc.OwnersClient, pc.Logger, e) 117 } 118 119 func handleGenericComment(gc githubClient, config *plugins.Configuration, ownersClient repoowners.Interface, log *logrus.Entry, e github.GenericCommentEvent) error { 120 rc := reviewCtx{ 121 author: e.User.Login, 122 issueAuthor: e.IssueAuthor.Login, 123 body: e.Body, 124 htmlURL: e.HTMLURL, 125 repo: e.Repo, 126 assignees: e.Assignees, 127 number: e.Number, 128 } 129 130 // Only consider open PRs and new comments. 131 if !e.IsPR || e.IssueState != "open" || e.Action != github.GenericCommentActionCreated { 132 return nil 133 } 134 135 // If we create an "/lgtm" comment, add lgtm if necessary. 136 // If we create a "/lgtm cancel" comment, remove lgtm if necessary. 137 wantLGTM := false 138 if lgtmRe.MatchString(rc.body) { 139 wantLGTM = true 140 } else if lgtmCancelRe.MatchString(rc.body) { 141 wantLGTM = false 142 } else { 143 return nil 144 } 145 146 // Author cannot LGTM own PR 147 isAuthor := rc.author == rc.issueAuthor 148 if isAuthor && wantLGTM { 149 resp := "you cannot LGTM your own PR." 150 log.Infof("Commenting with \"%s\".", resp) 151 return gc.CreateComment(rc.repo.Owner.Login, rc.repo.Name, rc.number, plugins.FormatResponseRaw(rc.body, rc.htmlURL, rc.author, resp)) 152 } 153 154 return handle(wantLGTM, config, ownersClient, rc, gc, log) 155 } 156 157 func handlePullRequestReview(gc githubClient, config *plugins.Configuration, ownersClient repoowners.Interface, log *logrus.Entry, e github.ReviewEvent) error { 158 rc := reviewCtx{ 159 author: e.Review.User.Login, 160 issueAuthor: e.PullRequest.User.Login, 161 repo: e.Repo, 162 assignees: e.PullRequest.Assignees, 163 number: e.PullRequest.Number, 164 body: e.Review.Body, 165 htmlURL: e.Review.HTMLURL, 166 } 167 168 // If the review event body contains an '/lgtm' or '/lgtm cancel' comment, 169 // skip handling the review event 170 if lgtmRe.MatchString(rc.body) || lgtmCancelRe.MatchString(rc.body) { 171 return nil 172 } 173 174 // If we review with Approve, add lgtm if necessary. 175 // If we review with Request Changes, remove lgtm if necessary. 176 wantLGTM := false 177 if e.Review.State == github.ReviewStateApproved { 178 wantLGTM = true 179 } else if e.Review.State == github.ReviewStateChangesRequested { 180 wantLGTM = false 181 } else { 182 return nil 183 } 184 return handle(wantLGTM, config, ownersClient, rc, gc, log) 185 } 186 187 func handle(wantLGTM bool, config *plugins.Configuration, ownersClient repoowners.Interface, rc reviewCtx, gc githubClient, log *logrus.Entry) error { 188 author := rc.author 189 issueAuthor := rc.issueAuthor 190 assignees := rc.assignees 191 number := rc.number 192 body := rc.body 193 htmlURL := rc.htmlURL 194 org := rc.repo.Owner.Login 195 repoName := rc.repo.Name 196 197 // Determine if reviewer is already assigned 198 isAssignee := false 199 for _, assignee := range assignees { 200 if assignee.Login == author { 201 isAssignee = true 202 break 203 } 204 } 205 // If we need to skip collaborator checks for this repo, what we actually need 206 // to do is skip assignment checks and use OWNERS files to determine whether the 207 // commenter can lgtm the PR. 208 skipCollaborators := skipCollaborators(config, org, repoName) 209 isAuthor := author == issueAuthor 210 if isAuthor && wantLGTM { 211 resp := "you cannot LGTM your own PR." 212 log.Infof("Commenting with \"%s\".", resp) 213 return gc.CreateComment(org, repoName, number, plugins.FormatResponseRaw(body, htmlURL, author, resp)) 214 } else if !isAuthor && !isAssignee && !skipCollaborators { 215 log.Infof("Assigning %s/%s#%d to %s", org, repoName, number, author) 216 if err := gc.AssignIssue(org, repoName, number, []string{author}); err != nil { 217 msg := "assigning you to the PR failed" 218 if ok, merr := gc.IsCollaborator(org, repoName, author); merr == nil && !ok { 219 msg = fmt.Sprintf("only %s/%s repo collaborators may be assigned issues", org, repoName) 220 } else if merr != nil { 221 log.WithError(merr).Errorf("Failed IsCollaborator(%s, %s, %s)", org, repoName, author) 222 } else { 223 log.WithError(err).Errorf("Failed AssignIssue(%s, %s, %d, %s)", org, repoName, number, author) 224 } 225 resp := "changing LGTM is restricted to assignees, and " + msg + "." 226 log.Infof("Reply to assign via /lgtm request with comment: \"%s\"", resp) 227 return gc.CreateComment(org, repoName, number, plugins.FormatResponseRaw(body, htmlURL, author, resp)) 228 } 229 } else if !isAuthor && skipCollaborators { 230 log.Debugf("Skipping collaborator checks and loading OWNERS for %s/%s#%d", org, repoName, number) 231 ro, err := loadRepoOwners(gc, ownersClient, org, repoName, number) 232 if err != nil { 233 return err 234 } 235 filenames, err := getChangedFiles(gc, org, repoName, number) 236 if err != nil { 237 return err 238 } 239 if !loadReviewers(ro, filenames).Has(github.NormLogin(author)) { 240 resp := "adding LGTM is restricted to approvers and reviewers in OWNERS files." 241 log.Infof("Reply to /lgtm request with comment: \"%s\"", resp) 242 return gc.CreateComment(org, repoName, number, plugins.FormatResponseRaw(body, htmlURL, author, resp)) 243 } 244 } 245 246 // Only add the label if it doesn't have it, and vice versa. 247 hasLGTM := false 248 labels, err := gc.GetIssueLabels(org, repoName, number) 249 if err != nil { 250 log.WithError(err).Errorf("Failed to get the labels on %s/%s#%d.", org, repoName, number) 251 } 252 253 hasLGTM = github.HasLabel(lgtmLabel, labels) 254 255 if hasLGTM && !wantLGTM { 256 log.Info("Removing LGTM label.") 257 return gc.RemoveLabel(org, repoName, number, lgtmLabel) 258 } else if !hasLGTM && wantLGTM { 259 log.Info("Adding LGTM label.") 260 if err := gc.AddLabel(org, repoName, number, lgtmLabel); err != nil { 261 return err 262 } 263 // Delete the LGTM removed noti after the LGTM label is added. 264 botname, err := gc.BotName() 265 if err != nil { 266 log.WithError(err).Errorf("Failed to get bot name.") 267 } 268 comments, err := gc.ListIssueComments(org, repoName, number) 269 if err != nil { 270 log.WithError(err).Errorf("Failed to get the list of issue comments on %s/%s#%d.", org, repoName, number) 271 } 272 for _, comment := range comments { 273 if comment.User.Login == botname && comment.Body == removeLGTMLabelNoti { 274 if err := gc.DeleteComment(org, repoName, comment.ID); err != nil { 275 log.WithError(err).Errorf("Failed to delete comment from %s/%s#%d, ID:%d.", org, repoName, number, comment.ID) 276 } 277 } 278 } 279 } 280 return nil 281 } 282 283 type ghLabelClient interface { 284 RemoveLabel(owner, repo string, number int, label string) error 285 CreateComment(owner, repo string, number int, comment string) error 286 } 287 288 func handlePullRequest(gc ghLabelClient, pe github.PullRequestEvent, log *logrus.Entry) error { 289 if pe.PullRequest.Merged { 290 return nil 291 } 292 293 if pe.Action != github.PullRequestActionSynchronize { 294 return nil 295 } 296 297 // Don't bother checking if it has the label...it's a race, and we'll have 298 // to handle failure due to not being labeled anyway. 299 org := pe.PullRequest.Base.Repo.Owner.Login 300 repo := pe.PullRequest.Base.Repo.Name 301 number := pe.PullRequest.Number 302 303 var labelNotFound bool 304 if err := gc.RemoveLabel(org, repo, number, lgtmLabel); err != nil { 305 if _, labelNotFound = err.(*github.LabelNotFound); !labelNotFound { 306 return fmt.Errorf("failed removing lgtm label: %v", err) 307 } 308 309 // If the error is indeed *github.LabelNotFound, consider it a success. 310 } 311 // Creates a comment to inform participants that LGTM label is removed due to new 312 // pull request changes. 313 if !labelNotFound { 314 log.Infof("Create a LGTM removed notification to %s/%s#%d with a message: %s", org, repo, number, removeLGTMLabelNoti) 315 return gc.CreateComment(org, repo, number, removeLGTMLabelNoti) 316 } 317 return nil 318 } 319 320 func skipCollaborators(config *plugins.Configuration, org, repo string) bool { 321 full := fmt.Sprintf("%s/%s", org, repo) 322 for _, elem := range config.Owners.SkipCollaborators { 323 if elem == org || elem == full { 324 return true 325 } 326 } 327 return false 328 } 329 330 func loadRepoOwners(gc githubClient, ownersClient repoowners.Interface, org, repo string, number int) (repoowners.RepoOwnerInterface, error) { 331 pr, err := gc.GetPullRequest(org, repo, number) 332 if err != nil { 333 return nil, err 334 } 335 return ownersClient.LoadRepoOwners(org, repo, pr.Base.Ref) 336 } 337 338 // getChangedFiles returns all the changed files for the provided pull request. 339 func getChangedFiles(gc githubClient, org, repo string, number int) ([]string, error) { 340 changes, err := gc.GetPullRequestChanges(org, repo, number) 341 if err != nil { 342 return nil, fmt.Errorf("cannot get PR changes for %s/%s#%d", org, repo, number) 343 } 344 var filenames []string 345 for _, change := range changes { 346 filenames = append(filenames, change.Filename) 347 } 348 return filenames, nil 349 } 350 351 // loadReviewers returns all reviewers and approvers from all OWNERS files that 352 // cover the provided filenames. 353 func loadReviewers(ro repoowners.RepoOwnerInterface, filenames []string) sets.String { 354 reviewers := sets.String{} 355 for _, filename := range filenames { 356 reviewers = reviewers.Union(ro.Approvers(filename)).Union(ro.Reviewers(filename)) 357 } 358 return reviewers 359 }