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