github.com/munnerz/test-infra@v0.0.0-20190108210205-ce3d181dc989/prow/external-plugins/cherrypicker/server.go (about) 1 /* 2 Copyright 2017 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 main 18 19 import ( 20 "bytes" 21 "encoding/json" 22 "fmt" 23 "io" 24 "net/http" 25 "os" 26 "regexp" 27 "strings" 28 "sync" 29 "time" 30 31 "github.com/sirupsen/logrus" 32 33 "k8s.io/test-infra/prow/git" 34 "k8s.io/test-infra/prow/github" 35 "k8s.io/test-infra/prow/pluginhelp" 36 "k8s.io/test-infra/prow/plugins" 37 ) 38 39 const pluginName = "cherrypick" 40 41 var cherryPickRe = regexp.MustCompile(`(?m)^/cherrypick\s+(.+)$`) 42 var releaseNoteRe = regexp.MustCompile(`(?s)(?:Release note\*\*:\s*(?:<!--[^<>]*-->\s*)?` + "```(?:release-note)?|```release-note)(.+?)```") 43 44 type githubClient interface { 45 AssignIssue(org, repo string, number int, logins []string) error 46 CreateComment(org, repo string, number int, comment string) error 47 CreateFork(org, repo string) error 48 CreatePullRequest(org, repo, title, body, head, base string, canModify bool) (int, error) 49 GetPullRequest(org, repo string, number int) (*github.PullRequest, error) 50 GetPullRequestPatch(org, repo string, number int) ([]byte, error) 51 GetRepo(owner, name string) (github.Repo, error) 52 IsMember(org, user string) (bool, error) 53 ListIssueComments(org, repo string, number int) ([]github.IssueComment, error) 54 ListOrgMembers(org, role string) ([]github.TeamMember, error) 55 } 56 57 // HelpProvider construct the pluginhelp.PluginHelp for this plugin. 58 func HelpProvider(enabledRepos []string) (*pluginhelp.PluginHelp, error) { 59 pluginHelp := &pluginhelp.PluginHelp{ 60 Description: `The cherrypick plugin is used for cherrypicking PRs across branches. For every successful cherrypick invocation a new PR is opened against the target branch and assigned to the requester. If the parent PR contains a release note, it is copied to the cherrypick PR.`, 61 } 62 pluginHelp.AddCommand(pluginhelp.Command{ 63 Usage: "/cherrypick [branch]", 64 Description: "Cherrypick a PR to a different branch. This command works both in merged PRs (the cherrypick PR is opened immediately) and open PRs (the cherrypick PR opens as soon as the original PR merges).", 65 Featured: true, 66 // depends on how the cherrypick server runs; needs auth by default (--allow-all=false) 67 WhoCanUse: "Members of the trusted organization for the repo.", 68 Examples: []string{"/cherrypick release-3.9"}, 69 }) 70 return pluginHelp, nil 71 } 72 73 // Server implements http.Handler. It validates incoming GitHub webhooks and 74 // then dispatches them to the appropriate plugins. 75 type Server struct { 76 tokenGenerator func() []byte 77 botName string 78 email string 79 80 gc *git.Client 81 // Used for unit testing 82 push func(repo, newBranch string) error 83 ghc githubClient 84 log *logrus.Entry 85 86 // Use prow to assign users to cherrypicked PRs. 87 prowAssignments bool 88 // Allow anybody to do cherrypicks. 89 allowAll bool 90 91 bare *http.Client 92 patchURL string 93 94 repoLock sync.Mutex 95 repos []github.Repo 96 } 97 98 // ServeHTTP validates an incoming webhook and puts it into the event channel. 99 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 100 eventType, eventGUID, payload, ok, _ := github.ValidateWebhook(w, r, s.tokenGenerator()) 101 if !ok { 102 return 103 } 104 fmt.Fprint(w, "Event received. Have a nice day.") 105 106 if err := s.handleEvent(eventType, eventGUID, payload); err != nil { 107 logrus.WithError(err).Error("Error parsing event.") 108 } 109 } 110 111 func (s *Server) handleEvent(eventType, eventGUID string, payload []byte) error { 112 l := logrus.WithFields( 113 logrus.Fields{ 114 "event-type": eventType, 115 github.EventGUID: eventGUID, 116 }, 117 ) 118 switch eventType { 119 case "issue_comment": 120 var ic github.IssueCommentEvent 121 if err := json.Unmarshal(payload, &ic); err != nil { 122 return err 123 } 124 go func() { 125 if err := s.handleIssueComment(l, ic); err != nil { 126 s.log.WithError(err).WithFields(l.Data).Info("Cherry-pick failed.") 127 } 128 }() 129 case "pull_request": 130 var pr github.PullRequestEvent 131 if err := json.Unmarshal(payload, &pr); err != nil { 132 return err 133 } 134 go func() { 135 if err := s.handlePullRequest(l, pr); err != nil { 136 s.log.WithError(err).WithFields(l.Data).Info("Cherry-pick failed.") 137 } 138 }() 139 default: 140 logrus.Debugf("skipping event of type %q", eventType) 141 } 142 return nil 143 } 144 145 func (s *Server) handleIssueComment(l *logrus.Entry, ic github.IssueCommentEvent) error { 146 // Only consider new comments in PRs. 147 if !ic.Issue.IsPullRequest() || ic.Action != github.IssueCommentActionCreated { 148 return nil 149 } 150 151 org := ic.Repo.Owner.Login 152 repo := ic.Repo.Name 153 num := ic.Issue.Number 154 commentAuthor := ic.Comment.User.Login 155 156 l = l.WithFields(logrus.Fields{ 157 github.OrgLogField: org, 158 github.RepoLogField: repo, 159 github.PrLogField: num, 160 }) 161 162 cherryPickMatches := cherryPickRe.FindAllStringSubmatch(ic.Comment.Body, -1) 163 if len(cherryPickMatches) == 0 || len(cherryPickMatches[0]) != 2 { 164 return nil 165 } 166 targetBranch := strings.TrimSpace(cherryPickMatches[0][1]) 167 168 if ic.Issue.State != "closed" { 169 if !s.allowAll { 170 // Only members should be able to do cherry-picks. 171 ok, err := s.ghc.IsMember(org, commentAuthor) 172 if err != nil { 173 return err 174 } 175 if !ok { 176 resp := fmt.Sprintf("only [%s](https://github.com/orgs/%s/people) org members may request cherry picks. You can still do the cherry-pick manually.", org, org) 177 s.log.WithFields(l.Data).Info(resp) 178 return s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(ic.Comment, resp)) 179 } 180 } 181 resp := fmt.Sprintf("once the present PR merges, I will cherry-pick it on top of %s in a new PR and assign it to you.", targetBranch) 182 s.log.WithFields(l.Data).Info(resp) 183 return s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(ic.Comment, resp)) 184 } 185 186 pr, err := s.ghc.GetPullRequest(org, repo, num) 187 if err != nil { 188 return err 189 } 190 baseBranch := pr.Base.Ref 191 title := pr.Title 192 body := pr.Body 193 194 // Cherry-pick only merged PRs. 195 if !pr.Merged { 196 resp := "cannot cherry-pick an unmerged PR" 197 s.log.WithFields(l.Data).Info(resp) 198 return s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(ic.Comment, resp)) 199 } 200 201 // TODO: Use a whitelist for allowed base and target branches. 202 if baseBranch == targetBranch { 203 resp := fmt.Sprintf("base branch (%s) needs to differ from target branch (%s)", baseBranch, targetBranch) 204 s.log.WithFields(l.Data).Info(resp) 205 return s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(ic.Comment, resp)) 206 } 207 208 if !s.allowAll { 209 // Only org members should be able to do cherry-picks. 210 ok, err := s.ghc.IsMember(org, commentAuthor) 211 if err != nil { 212 return err 213 } 214 if !ok { 215 resp := fmt.Sprintf("only [%s](https://github.com/orgs/%s/people) org members may request cherry picks. You can still do the cherry-pick manually.", org, org) 216 s.log.WithFields(l.Data).Info(resp) 217 return s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(ic.Comment, resp)) 218 } 219 } 220 221 s.log.WithFields(l.Data). 222 WithField("requestor", ic.Comment.User.Login). 223 WithField("target_branch", targetBranch). 224 Debug("Cherrypick request.") 225 return s.handle(l, ic.Comment.User.Login, ic.Comment, org, repo, targetBranch, title, body, num) 226 } 227 228 func (s *Server) handlePullRequest(l *logrus.Entry, pre github.PullRequestEvent) error { 229 // Only consider newly merged PRs 230 if pre.Action != github.PullRequestActionClosed { 231 return nil 232 } 233 234 pr := pre.PullRequest 235 if !pr.Merged || pr.MergeSHA == nil { 236 return nil 237 } 238 239 org := pr.Base.Repo.Owner.Login 240 repo := pr.Base.Repo.Name 241 baseBranch := pr.Base.Ref 242 num := pr.Number 243 title := pr.Title 244 body := pr.Body 245 246 l = l.WithFields(logrus.Fields{ 247 github.OrgLogField: org, 248 github.RepoLogField: repo, 249 github.PrLogField: num, 250 }) 251 252 comments, err := s.ghc.ListIssueComments(org, repo, num) 253 if err != nil { 254 return err 255 } 256 257 // requestor -> target branch -> issue comment 258 requestorToComments := make(map[string]map[string]*github.IssueComment) 259 for i := range comments { 260 c := comments[i] 261 cherryPickMatches := cherryPickRe.FindAllStringSubmatch(c.Body, -1) 262 if len(cherryPickMatches) == 0 || len(cherryPickMatches[0]) != 2 { 263 continue 264 } 265 // TODO: Support comments with multiple cherrypick invocations. 266 targetBranch := strings.TrimSpace(cherryPickMatches[0][1]) 267 if requestorToComments[c.User.Login] == nil { 268 requestorToComments[c.User.Login] = make(map[string]*github.IssueComment) 269 } 270 requestorToComments[c.User.Login][targetBranch] = &c 271 } 272 if len(requestorToComments) == 0 { 273 return nil 274 } 275 // Figure out membership. 276 if !s.allowAll { 277 // TODO: Possibly cache this. 278 members, err := s.ghc.ListOrgMembers(org, "all") 279 if err != nil { 280 return err 281 } 282 for requestor := range requestorToComments { 283 isMember := false 284 for _, m := range members { 285 if requestor == m.Login { 286 isMember = true 287 break 288 } 289 } 290 if !isMember { 291 delete(requestorToComments, requestor) 292 } 293 } 294 } 295 296 // Handle multiple comments serially. Make sure to filter out 297 // comments targeting the same branch. 298 handledBranches := make(map[string]bool) 299 for requestor, branches := range requestorToComments { 300 for targetBranch, ic := range branches { 301 if targetBranch == baseBranch { 302 resp := fmt.Sprintf("base branch (%s) needs to differ from target branch (%s)", baseBranch, targetBranch) 303 s.log.WithFields(l.Data).Info(resp) 304 s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(*ic, resp)) 305 continue 306 } 307 if handledBranches[targetBranch] { 308 // Branch already handled. Skip. 309 continue 310 } 311 handledBranches[targetBranch] = true 312 s.log.WithFields(l.Data). 313 WithField("requestor", requestor). 314 WithField("target_branch", targetBranch). 315 Debug("Cherrypick request.") 316 err := s.handle(l, requestor, *ic, org, repo, targetBranch, title, body, num) 317 if err != nil { 318 return err 319 } 320 } 321 } 322 return nil 323 } 324 325 var cherryPickBranchFmt = "cherry-pick-%d-to-%s" 326 327 func (s *Server) handle(l *logrus.Entry, requestor string, comment github.IssueComment, org, repo, targetBranch, title, body string, num int) error { 328 if err := s.ensureForkExists(org, repo); err != nil { 329 return err 330 } 331 332 // Clone the repo, checkout the target branch. 333 startClone := time.Now() 334 r, err := s.gc.Clone(org + "/" + repo) 335 if err != nil { 336 return err 337 } 338 defer func() { 339 if err := r.Clean(); err != nil { 340 s.log.WithError(err).WithFields(l.Data).Error("Error cleaning up repo.") 341 } 342 }() 343 if err := r.Checkout(targetBranch); err != nil { 344 resp := fmt.Sprintf("cannot checkout %s: %v", targetBranch, err) 345 s.log.WithFields(l.Data).Info(resp) 346 return s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(comment, resp)) 347 } 348 s.log.WithFields(l.Data).WithField("duration", time.Since(startClone)).Info("Cloned and checked out target branch.") 349 350 // Fetch the patch from Github 351 localPath, err := s.getPatch(org, repo, targetBranch, num) 352 if err != nil { 353 return err 354 } 355 356 if err := r.Config("user.name", s.botName); err != nil { 357 return err 358 } 359 email := s.email 360 if email == "" { 361 email = fmt.Sprintf("%s@localhost", s.botName) 362 } 363 if err := r.Config("user.email", email); err != nil { 364 return err 365 } 366 367 // Checkout a new branch for the cherry-pick. 368 newBranch := fmt.Sprintf(cherryPickBranchFmt, num, targetBranch) 369 if err := r.CheckoutNewBranch(newBranch); err != nil { 370 return err 371 } 372 373 // Apply the patch. 374 if err := r.Am(localPath); err != nil { 375 resp := fmt.Sprintf("#%d failed to apply on top of branch %q:\n```%v\n```", num, targetBranch, err) 376 s.log.WithFields(l.Data).Info(resp) 377 return s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(comment, resp)) 378 } 379 380 push := r.Push 381 if s.push != nil { 382 push = s.push 383 } 384 // Push the new branch in the bot's fork. 385 if err := push(repo, newBranch); err != nil { 386 resp := fmt.Sprintf("failed to push cherry-picked changes in Github: %v", err) 387 s.log.WithFields(l.Data).Info(resp) 388 return s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(comment, resp)) 389 } 390 391 // Open a PR in Github. 392 title = fmt.Sprintf("[%s] %s", targetBranch, title) 393 cherryPickBody := fmt.Sprintf("This is an automated cherry-pick of #%d", num) 394 if s.prowAssignments { 395 cherryPickBody = fmt.Sprintf("%s\n\n/assign %s", cherryPickBody, requestor) 396 } 397 if releaseNote := releaseNoteFromParentPR(body); len(releaseNote) != 0 { 398 cherryPickBody = fmt.Sprintf("%s\n\n%s", cherryPickBody, releaseNote) 399 } 400 401 head := fmt.Sprintf("%s:%s", s.botName, newBranch) 402 createdNum, err := s.ghc.CreatePullRequest(org, repo, title, cherryPickBody, head, targetBranch, true) 403 if err != nil { 404 resp := fmt.Sprintf("new pull request could not be created: %v", err) 405 s.log.WithFields(l.Data).Info(resp) 406 return s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(comment, resp)) 407 } 408 resp := fmt.Sprintf("new pull request created: #%d", createdNum) 409 s.log.WithFields(l.Data).Info(resp) 410 if err := s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(comment, resp)); err != nil { 411 return err 412 } 413 if !s.prowAssignments { 414 if err := s.ghc.AssignIssue(org, repo, createdNum, []string{comment.User.Login}); err != nil { 415 s.log.WithFields(l.Data).Warningf("Cannot assign to new PR: %v", err) 416 // Ignore returning errors on failure to assign as this is most likely 417 // due to users not being members of the org so that they can be assigned 418 // in PRs. 419 return nil 420 } 421 } 422 return nil 423 } 424 425 // ensureForkExists ensures a fork of org/repo exists for the bot. 426 func (s *Server) ensureForkExists(org, repo string) error { 427 s.repoLock.Lock() 428 defer s.repoLock.Unlock() 429 430 // Fork repo if it doesn't exist. 431 fork := s.botName + "/" + repo 432 if !repoExists(fork, s.repos) { 433 if err := s.ghc.CreateFork(org, repo); err != nil { 434 return fmt.Errorf("cannot fork %s/%s: %v", org, repo, err) 435 } 436 if err := waitForRepo(s.botName, repo, s.ghc); err != nil { 437 return fmt.Errorf("fork of %s/%s cannot show up on Github: %v", org, repo, err) 438 } 439 s.repos = append(s.repos, github.Repo{FullName: fork, Fork: true}) 440 } 441 return nil 442 } 443 444 func waitForRepo(owner, name string, ghc githubClient) error { 445 // Wait for at most 5 minutes for the fork to appear on Github. 446 after := time.After(5 * time.Minute) 447 tick := time.Tick(5 * time.Second) 448 449 var ghErr string 450 for { 451 select { 452 case <-tick: 453 repo, err := ghc.GetRepo(owner, name) 454 if err != nil { 455 ghErr = fmt.Sprintf(": %v", err) 456 logrus.WithError(err).Warn("Error getting bot repository.") 457 continue 458 } 459 ghErr = "" 460 if repoExists(owner+"/"+name, []github.Repo{repo}) { 461 return nil 462 } 463 case <-after: 464 return fmt.Errorf("timed out waiting for %s to appear on Github%s", owner+"/"+name, ghErr) 465 } 466 } 467 } 468 469 func repoExists(repo string, repos []github.Repo) bool { 470 for _, r := range repos { 471 if !r.Fork { 472 continue 473 } 474 if r.FullName == repo { 475 return true 476 } 477 } 478 return false 479 } 480 481 // getPatch gets the patch for the provided PR and creates a local 482 // copy of it. It returns its location in the filesystem and any 483 // encountered error. 484 func (s *Server) getPatch(org, repo, targetBranch string, num int) (string, error) { 485 patch, err := s.ghc.GetPullRequestPatch(org, repo, num) 486 if err != nil { 487 return "", err 488 } 489 localPath := fmt.Sprintf("/tmp/%s_%s_%d_%s.patch", org, repo, num, normalize(targetBranch)) 490 out, err := os.Create(localPath) 491 if err != nil { 492 return "", err 493 } 494 defer out.Close() 495 if _, err := io.Copy(out, bytes.NewBuffer(patch)); err != nil { 496 return "", err 497 } 498 return localPath, nil 499 } 500 501 func normalize(input string) string { 502 return strings.Replace(input, "/", "-", -1) 503 } 504 505 // releaseNoteNoteFromParentPR gets the release note from the 506 // parent PR and formats it as per the PR template so that 507 // it can be copied to the cherry-pick PR. 508 func releaseNoteFromParentPR(body string) string { 509 potentialMatch := releaseNoteRe.FindStringSubmatch(body) 510 if potentialMatch == nil { 511 return "" 512 } 513 return fmt.Sprintf("```release-note\n%s\n```", strings.TrimSpace(potentialMatch[1])) 514 }