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