github.com/munnerz/test-infra@v0.0.0-20190108210205-ce3d181dc989/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 implements the lgtm plugin 18 package lgtm 19 20 import ( 21 "fmt" 22 "regexp" 23 "strings" 24 25 "github.com/sirupsen/logrus" 26 "k8s.io/apimachinery/pkg/util/sets" 27 28 "k8s.io/test-infra/prow/github" 29 "k8s.io/test-infra/prow/labels" 30 "k8s.io/test-infra/prow/pluginhelp" 31 "k8s.io/test-infra/prow/plugins" 32 "k8s.io/test-infra/prow/repoowners" 33 ) 34 35 const ( 36 // PluginName defines this plugin's registered name. 37 PluginName = labels.LGTM 38 ) 39 40 var ( 41 addLGTMLabelNotification = "LGTM label has been added. <details>Git tree hash: %s</details>" 42 addLGTMLabelNotificationRe = regexp.MustCompile(fmt.Sprintf(addLGTMLabelNotification, "(.*)")) 43 // LGTMLabel is the name of the lgtm label applied by the lgtm plugin 44 LGTMLabel = labels.LGTM 45 lgtmRe = regexp.MustCompile(`(?mi)^/lgtm(?: no-issue)?\s*$`) 46 lgtmCancelRe = regexp.MustCompile(`(?mi)^/lgtm cancel\s*$`) 47 removeLGTMLabelNoti = "New changes are detected. LGTM label has been removed." 48 ) 49 50 type commentPruner interface { 51 PruneComments(shouldPrune func(github.IssueComment) bool) 52 } 53 54 func init() { 55 plugins.RegisterGenericCommentHandler(PluginName, handleGenericCommentEvent, helpProvider) 56 plugins.RegisterPullRequestHandler(PluginName, func(pc plugins.Agent, pe github.PullRequestEvent) error { 57 return handlePullRequestEvent(pc, pe) 58 }, helpProvider) 59 plugins.RegisterReviewEventHandler(PluginName, handlePullRequestReviewEvent, helpProvider) 60 } 61 62 func helpProvider(config *plugins.Configuration, enabledRepos []string) (*pluginhelp.PluginHelp, error) { 63 // The Config field is omitted because this plugin is not configurable. 64 pluginHelp := &pluginhelp.PluginHelp{ 65 Description: "The lgtm plugin manages the application and removal of the 'lgtm' (Looks Good To Me) label which is typically used to gate merging.", 66 } 67 pluginHelp.AddCommand(pluginhelp.Command{ 68 Usage: "/lgtm [cancel] or Github Review action", 69 Description: "Adds or removes the 'lgtm' label which is typically used to gate merging.", 70 Featured: true, 71 WhoCanUse: "Collaborators on the repository. '/lgtm cancel' can be used additionally by the PR author.", 72 Examples: []string{"/lgtm", "/lgtm cancel", "<a href=\"https://help.github.com/articles/about-pull-request-reviews/\">'Approve' or 'Request Changes'</a>"}, 73 }) 74 return pluginHelp, nil 75 } 76 77 // optionsForRepo gets the plugins.Lgtm struct that is applicable to the indicated repo. 78 func optionsForRepo(config *plugins.Configuration, org, repo string) *plugins.Lgtm { 79 fullName := fmt.Sprintf("%s/%s", org, repo) 80 for i := range config.Lgtm { 81 if !strInSlice(org, config.Lgtm[i].Repos) && !strInSlice(fullName, config.Lgtm[i].Repos) { 82 continue 83 } 84 return &config.Lgtm[i] 85 } 86 return &plugins.Lgtm{} 87 } 88 89 // strInSlice returns true if any string in slice matches str exactly 90 func strInSlice(str string, slice []string) bool { 91 for _, elem := range slice { 92 if elem == str { 93 return true 94 } 95 } 96 return false 97 } 98 99 type githubClient interface { 100 IsCollaborator(owner, repo, login string) (bool, error) 101 AddLabel(owner, repo string, number int, label string) error 102 AssignIssue(owner, repo string, number int, assignees []string) error 103 CreateComment(owner, repo string, number int, comment string) error 104 RemoveLabel(owner, repo string, number int, label string) error 105 GetIssueLabels(org, repo string, number int) ([]github.Label, error) 106 GetPullRequest(org, repo string, number int) (*github.PullRequest, error) 107 GetPullRequestChanges(org, repo string, number int) ([]github.PullRequestChange, error) 108 ListIssueComments(org, repo string, number int) ([]github.IssueComment, error) 109 DeleteComment(org, repo string, ID int) error 110 BotName() (string, error) 111 GetSingleCommit(org, repo, SHA string) (github.SingleCommit, error) 112 IsMember(org, user string) (bool, error) 113 ListTeams(org string) ([]github.Team, error) 114 ListTeamMembers(id int, role string) ([]github.TeamMember, error) 115 } 116 117 // reviewCtx contains information about each review event 118 type reviewCtx struct { 119 author, issueAuthor, body, htmlURL string 120 repo github.Repo 121 assignees []github.User 122 number int 123 } 124 125 func handleGenericCommentEvent(pc plugins.Agent, e github.GenericCommentEvent) error { 126 cp, err := pc.CommentPruner() 127 if err != nil { 128 return err 129 } 130 return handleGenericComment(pc.GitHubClient, pc.PluginConfig, pc.OwnersClient, pc.Logger, cp, e) 131 } 132 133 func handlePullRequestEvent(pc plugins.Agent, pre github.PullRequestEvent) error { 134 return handlePullRequest( 135 pc.Logger, 136 pc.GitHubClient, 137 pc.PluginConfig, 138 &pre, 139 ) 140 } 141 142 func handlePullRequestReviewEvent(pc plugins.Agent, e github.ReviewEvent) error { 143 // If ReviewActsAsLgtm is disabled, ignore review event. 144 opts := optionsForRepo(pc.PluginConfig, e.Repo.Owner.Login, e.Repo.Name) 145 if !opts.ReviewActsAsLgtm { 146 return nil 147 } 148 cp, err := pc.CommentPruner() 149 if err != nil { 150 return err 151 } 152 return handlePullRequestReview(pc.GitHubClient, pc.PluginConfig, pc.OwnersClient, pc.Logger, cp, e) 153 } 154 155 func handleGenericComment(gc githubClient, config *plugins.Configuration, ownersClient repoowners.Interface, log *logrus.Entry, cp commentPruner, e github.GenericCommentEvent) error { 156 rc := reviewCtx{ 157 author: e.User.Login, 158 issueAuthor: e.IssueAuthor.Login, 159 body: e.Body, 160 htmlURL: e.HTMLURL, 161 repo: e.Repo, 162 assignees: e.Assignees, 163 number: e.Number, 164 } 165 166 // Only consider open PRs and new comments. 167 if !e.IsPR || e.IssueState != "open" || e.Action != github.GenericCommentActionCreated { 168 return nil 169 } 170 171 // If we create an "/lgtm" comment, add lgtm if necessary. 172 // If we create a "/lgtm cancel" comment, remove lgtm if necessary. 173 wantLGTM := false 174 if lgtmRe.MatchString(rc.body) { 175 wantLGTM = true 176 } else if lgtmCancelRe.MatchString(rc.body) { 177 wantLGTM = false 178 } else { 179 return nil 180 } 181 182 // use common handler to do the rest 183 return handle(wantLGTM, config, ownersClient, rc, gc, log, cp) 184 } 185 186 func handlePullRequestReview(gc githubClient, config *plugins.Configuration, ownersClient repoowners.Interface, log *logrus.Entry, cp commentPruner, e github.ReviewEvent) error { 187 rc := reviewCtx{ 188 author: e.Review.User.Login, 189 issueAuthor: e.PullRequest.User.Login, 190 repo: e.Repo, 191 assignees: e.PullRequest.Assignees, 192 number: e.PullRequest.Number, 193 body: e.Review.Body, 194 htmlURL: e.Review.HTMLURL, 195 } 196 197 // If the review event body contains an '/lgtm' or '/lgtm cancel' comment, 198 // skip handling the review event 199 if lgtmRe.MatchString(rc.body) || lgtmCancelRe.MatchString(rc.body) { 200 return nil 201 } 202 203 // The review webhook returns state as lowercase, while the review API 204 // returns state as uppercase. Uppercase the value here so it always 205 // matches the constant. 206 reviewState := github.ReviewState(strings.ToUpper(string(e.Review.State))) 207 208 // If we review with Approve, add lgtm if necessary. 209 // If we review with Request Changes, remove lgtm if necessary. 210 wantLGTM := false 211 if reviewState == github.ReviewStateApproved { 212 wantLGTM = true 213 } else if reviewState == github.ReviewStateChangesRequested { 214 wantLGTM = false 215 } else { 216 return nil 217 } 218 219 // use common handler to do the rest 220 return handle(wantLGTM, config, ownersClient, rc, gc, log, cp) 221 } 222 223 func handle(wantLGTM bool, config *plugins.Configuration, ownersClient repoowners.Interface, rc reviewCtx, gc githubClient, log *logrus.Entry, cp commentPruner) error { 224 author := rc.author 225 issueAuthor := rc.issueAuthor 226 assignees := rc.assignees 227 number := rc.number 228 body := rc.body 229 htmlURL := rc.htmlURL 230 org := rc.repo.Owner.Login 231 repoName := rc.repo.Name 232 233 // Author cannot LGTM own PR, comment and abort 234 isAuthor := author == issueAuthor 235 if isAuthor && wantLGTM { 236 resp := "you cannot LGTM your own PR." 237 log.Infof("Commenting with \"%s\".", resp) 238 return gc.CreateComment(rc.repo.Owner.Login, rc.repo.Name, rc.number, plugins.FormatResponseRaw(rc.body, rc.htmlURL, rc.author, resp)) 239 } 240 241 // Determine if reviewer is already assigned 242 isAssignee := false 243 for _, assignee := range assignees { 244 if assignee.Login == author { 245 isAssignee = true 246 break 247 } 248 } 249 250 // check if skip collaborators is enabled for this org/repo 251 skipCollaborators := skipCollaborators(config, org, repoName) 252 253 // either ensure that the commentor is a collaborator or an approver/reviwer 254 if !isAuthor && !isAssignee && !skipCollaborators { 255 // in this case we need to ensure the commentor is assignable to the PR 256 // by assigning them 257 log.Infof("Assigning %s/%s#%d to %s", org, repoName, number, author) 258 if err := gc.AssignIssue(org, repoName, number, []string{author}); err != nil { 259 msg := "assigning you to the PR failed" 260 if ok, merr := gc.IsCollaborator(org, repoName, author); merr == nil && !ok { 261 msg = fmt.Sprintf("only %s/%s repo collaborators may be assigned issues", org, repoName) 262 } else if merr != nil { 263 log.WithError(merr).Error("Failed to check if author is a collaborator.") 264 } else { 265 log.WithError(err).Error("Failed to assign issue to author.") 266 } 267 resp := "changing LGTM is restricted to assignees, and " + msg + "." 268 log.Infof("Reply to assign via /lgtm request with comment: \"%s\"", resp) 269 return gc.CreateComment(org, repoName, number, plugins.FormatResponseRaw(body, htmlURL, author, resp)) 270 } 271 } else if !isAuthor && skipCollaborators { 272 // in this case we depend on OWNERS files instead to check if the author 273 // is an approver or reviwer of the changed files 274 log.Debugf("Skipping collaborator checks and loading OWNERS for %s/%s#%d", org, repoName, number) 275 ro, err := loadRepoOwners(gc, ownersClient, org, repoName, number) 276 if err != nil { 277 return err 278 } 279 filenames, err := getChangedFiles(gc, org, repoName, number) 280 if err != nil { 281 return err 282 } 283 if !loadReviewers(ro, filenames).Has(github.NormLogin(author)) { 284 resp := "adding LGTM is restricted to approvers and reviewers in OWNERS files." 285 log.Infof("Reply to /lgtm request with comment: \"%s\"", resp) 286 return gc.CreateComment(org, repoName, number, plugins.FormatResponseRaw(body, htmlURL, author, resp)) 287 } 288 } 289 290 // now we update the LGTM labels, having checked all cases where changing 291 // LGTM was not allowed for the commentor 292 293 // Only add the label if it doesn't have it, and vice versa. 294 labels, err := gc.GetIssueLabels(org, repoName, number) 295 if err != nil { 296 log.WithError(err).Error("Failed to get issue labels.") 297 } 298 hasLGTM := github.HasLabel(LGTMLabel, labels) 299 300 // remove the label if necessary, we're done after this 301 opts := optionsForRepo(config, rc.repo.Owner.Login, rc.repo.Name) 302 if hasLGTM && !wantLGTM { 303 log.Info("Removing LGTM label.") 304 if err := gc.RemoveLabel(org, repoName, number, LGTMLabel); err != nil { 305 return err 306 } 307 if opts.StoreTreeHash { 308 cp.PruneComments(func(comment github.IssueComment) bool { 309 return addLGTMLabelNotificationRe.MatchString(comment.Body) 310 }) 311 } 312 } else if !hasLGTM && wantLGTM { 313 log.Info("Adding LGTM label.") 314 if err := gc.AddLabel(org, repoName, number, LGTMLabel); err != nil { 315 return err 316 } 317 if !stickyLgtm(log, gc, config, opts, issueAuthor, org, repoName) { 318 if opts.StoreTreeHash { 319 pr, err := gc.GetPullRequest(org, repoName, number) 320 if err != nil { 321 log.WithError(err).Error("Failed to get pull request.") 322 } 323 commit, err := gc.GetSingleCommit(org, repoName, pr.Head.SHA) 324 if err != nil { 325 log.WithField("sha", pr.Head.SHA).WithError(err).Error("Failed to get commit.") 326 } 327 treeHash := commit.Commit.Tree.SHA 328 log.WithField("tree", treeHash).Info("Adding comment to store tree-hash.") 329 if err := gc.CreateComment(org, repoName, number, fmt.Sprintf(addLGTMLabelNotification, treeHash)); err != nil { 330 log.WithError(err).Error("Failed to add comment.") 331 } 332 } 333 // Delete the LGTM removed noti after the LGTM label is added. 334 cp.PruneComments(func(comment github.IssueComment) bool { 335 return strings.Contains(comment.Body, removeLGTMLabelNoti) 336 }) 337 } 338 } 339 340 return nil 341 } 342 343 func stickyLgtm(log *logrus.Entry, gc githubClient, config *plugins.Configuration, lgtm *plugins.Lgtm, author, org, repo string) bool { 344 if len(lgtm.StickyLgtmTeam) > 0 { 345 if teams, err := gc.ListTeams(org); err == nil { 346 for _, teamInOrg := range teams { 347 // lgtm.TrustedAuthorTeams is supposed to be a very short list. 348 if strings.Compare(teamInOrg.Name, lgtm.StickyLgtmTeam) == 0 { 349 if members, err := gc.ListTeamMembers(teamInOrg.ID, github.RoleAll); err == nil { 350 for _, member := range members { 351 if strings.Compare(member.Login, author) == 0 { 352 // The author is in a trusted team 353 return true 354 } 355 } 356 } else { 357 log.WithError(err).Errorf("Failed to list members in %s:%s.", org, teamInOrg.Name) 358 } 359 } 360 } 361 } else { 362 log.WithError(err).Errorf("Failed to list teams in org %s.", org) 363 } 364 } 365 return false 366 } 367 368 func handlePullRequest(log *logrus.Entry, gc githubClient, config *plugins.Configuration, pe *github.PullRequestEvent) error { 369 if pe.PullRequest.Merged { 370 return nil 371 } 372 373 if pe.Action != github.PullRequestActionSynchronize { 374 return nil 375 } 376 377 org := pe.PullRequest.Base.Repo.Owner.Login 378 repo := pe.PullRequest.Base.Repo.Name 379 number := pe.PullRequest.Number 380 381 opts := optionsForRepo(config, org, repo) 382 if stickyLgtm(log, gc, config, opts, pe.PullRequest.User.Login, org, repo) { 383 // If the author is trusted, skip tree hash verification and LGTM removal. 384 return nil 385 } 386 387 // If we don't have the lgtm label, we don't need to check anything 388 labels, err := gc.GetIssueLabels(org, repo, number) 389 if err != nil { 390 log.WithError(err).Error("Failed to get labels.") 391 } 392 if !github.HasLabel(LGTMLabel, labels) { 393 return nil 394 } 395 396 if opts.StoreTreeHash { 397 // Check if we have a tree-hash comment 398 var lastLgtmTreeHash string 399 botname, err := gc.BotName() 400 if err != nil { 401 return err 402 } 403 comments, err := gc.ListIssueComments(org, repo, number) 404 if err != nil { 405 log.WithError(err).Error("Failed to get issue comments.") 406 } 407 for _, comment := range comments { 408 m := addLGTMLabelNotificationRe.FindStringSubmatch(comment.Body) 409 if comment.User.Login == botname && m != nil && comment.UpdatedAt.Equal(comment.CreatedAt) { 410 lastLgtmTreeHash = m[1] 411 break 412 } 413 } 414 if lastLgtmTreeHash != "" { 415 // Get the current tree-hash 416 commit, err := gc.GetSingleCommit(org, repo, pe.PullRequest.Head.SHA) 417 if err != nil { 418 log.WithField("sha", pe.PullRequest.Head.SHA).WithError(err).Error("Failed to get commit.") 419 } 420 treeHash := commit.Commit.Tree.SHA 421 if treeHash == lastLgtmTreeHash { 422 // Don't remove the label, PR code hasn't changed 423 log.Infof("Keeping LGTM label as the tree-hash remained the same: %s", treeHash) 424 return nil 425 } 426 } 427 } 428 429 if err := gc.RemoveLabel(org, repo, number, LGTMLabel); err != nil { 430 return fmt.Errorf("failed removing lgtm label: %v", err) 431 } 432 433 // Create a comment to inform participants that LGTM label is removed due to new 434 // pull request changes. 435 log.Infof("Commenting with an LGTM removed notification to %s/%s#%d with a message: %s", org, repo, number, removeLGTMLabelNoti) 436 return gc.CreateComment(org, repo, number, removeLGTMLabelNoti) 437 } 438 439 func skipCollaborators(config *plugins.Configuration, org, repo string) bool { 440 full := fmt.Sprintf("%s/%s", org, repo) 441 for _, elem := range config.Owners.SkipCollaborators { 442 if elem == org || elem == full { 443 return true 444 } 445 } 446 return false 447 } 448 449 func loadRepoOwners(gc githubClient, ownersClient repoowners.Interface, org, repo string, number int) (repoowners.RepoOwner, error) { 450 pr, err := gc.GetPullRequest(org, repo, number) 451 if err != nil { 452 return nil, err 453 } 454 return ownersClient.LoadRepoOwners(org, repo, pr.Base.Ref) 455 } 456 457 // getChangedFiles returns all the changed files for the provided pull request. 458 func getChangedFiles(gc githubClient, org, repo string, number int) ([]string, error) { 459 changes, err := gc.GetPullRequestChanges(org, repo, number) 460 if err != nil { 461 return nil, fmt.Errorf("cannot get PR changes for %s/%s#%d", org, repo, number) 462 } 463 var filenames []string 464 for _, change := range changes { 465 filenames = append(filenames, change.Filename) 466 } 467 return filenames, nil 468 } 469 470 // loadReviewers returns all reviewers and approvers from all OWNERS files that 471 // cover the provided filenames. 472 func loadReviewers(ro repoowners.RepoOwner, filenames []string) sets.String { 473 reviewers := sets.String{} 474 for _, filename := range filenames { 475 reviewers = reviewers.Union(ro.Approvers(filename)).Union(ro.Reviewers(filename)) 476 } 477 return reviewers 478 }