From 7da4c012cdb6fcda910bfb32ca4ead10fb77114e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E6=89=8B=E6=8E=89=E5=8C=85=E5=B7=A5=E7=A8=8B?= =?UTF-8?q?=E5=B8=88?= Date: Fri, 2 Apr 2021 18:40:40 +0800 Subject: [PATCH] feat: impl cherrypicker (#451) --- .codecov.yml | 2 +- .goreleaser.yml | 18 + cmd/ticommunitycherrypicker/main.go | 139 ++ deployments/plugins/cherrypicker/Dockerfile | 4 + .../cherrypicker/cherrypicker.go | 688 ++++++++ .../cherrypicker/cherrypicker_test.go | 1390 +++++++++++++++++ internal/pkg/externalplugins/config.go | 52 + internal/pkg/externalplugins/config_test.go | 97 ++ test/testdata/lgtm_comment.json | 258 +++ test/testdata/opened_pr.json | 467 ++++++ 10 files changed, 3114 insertions(+), 1 deletion(-) create mode 100644 cmd/ticommunitycherrypicker/main.go create mode 100644 deployments/plugins/cherrypicker/Dockerfile create mode 100644 internal/pkg/externalplugins/cherrypicker/cherrypicker.go create mode 100644 internal/pkg/externalplugins/cherrypicker/cherrypicker_test.go create mode 100644 test/testdata/lgtm_comment.json create mode 100644 test/testdata/opened_pr.json diff --git a/.codecov.yml b/.codecov.yml index c2804a795..be3a9124d 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -5,5 +5,5 @@ coverage: status: project: default: - # Fail the status if coverage drops by >= 1% + # Fail the status if coverage drops by >= 5% threshold: 1 \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml index 560622457..46c19fcfe 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -88,6 +88,15 @@ builds: main: ./cmd/ticommunitycontribution/main.go env: - CGO_ENABLED=0 + - id: "ti-community-cherrypicker" + binary: ticommunitycherrypicker + goos: + - linux + goarch: + - amd64 + main: ./cmd/ticommunitycherrypicker/main.go + env: + - CGO_ENABLED=0 - id: "check-external-plugin-config" binary: check-external-plugin-config goos: @@ -203,6 +212,15 @@ dockers: - "ticommunityinfra/tichi-contribution-plugin:{{ .Tag }}" - "ticommunityinfra/tichi-contribution-plugin:{{ .Major }}" dockerfile: ./deployments/plugins/contribution/Dockerfile + - binaries: + - ticommunitycherrypicker + builds: + - ti-community-cherrypicker + image_templates: + - "ticommunityinfra/tichi-cherrypicker-plugin:latest" + - "ticommunityinfra/tichi-cherrypicker-plugin:{{ .Tag }}" + - "ticommunityinfra/tichi-cherrypicker-plugin:{{ .Major }}" + dockerfile: ./deployments/plugins/cherrypicker/Dockerfile - image_templates: - "ticommunityinfra/tichi-web:latest" diff --git a/cmd/ticommunitycherrypicker/main.go b/cmd/ticommunitycherrypicker/main.go new file mode 100644 index 000000000..edb33cee3 --- /dev/null +++ b/cmd/ticommunitycherrypicker/main.go @@ -0,0 +1,139 @@ +package main + +import ( + "flag" + "fmt" + "net/http" + "os" + "strconv" + "time" + + "github.com/sirupsen/logrus" + tiexternalplugins "github.com/ti-community-infra/tichi/internal/pkg/externalplugins" + "github.com/ti-community-infra/tichi/internal/pkg/externalplugins/cherrypicker" + "k8s.io/test-infra/pkg/flagutil" + "k8s.io/test-infra/prow/config/secret" + prowflagutil "k8s.io/test-infra/prow/flagutil" + "k8s.io/test-infra/prow/git/v2" + "k8s.io/test-infra/prow/interrupts" + "k8s.io/test-infra/prow/pjutil" + "k8s.io/test-infra/prow/pluginhelp/externalplugins" +) + +type options struct { + port int + + dryRun bool + github prowflagutil.GitHubOptions + + externalPluginsConfig string + + webhookSecretFile string +} + +// validate validates github options. +func (o *options) validate() error { + for idx, group := range []flagutil.OptionGroup{&o.github} { + if err := group.Validate(o.dryRun); err != nil { + return fmt.Errorf("%d: %w", idx, err) + } + } + + return nil +} + +func gatherOptions() options { + o := options{} + fs := flag.NewFlagSet(os.Args[0], flag.ExitOnError) + fs.IntVar(&o.port, "port", 80, "Port to listen on.") + fs.StringVar(&o.externalPluginsConfig, "external-plugins-config", + "/etc/external_plugins_config/external_plugins_config.yaml", "Path to external plugin config file.") + fs.BoolVar(&o.dryRun, "dry-run", true, "Dry run for testing. Uses API tokens but does not mutate.") + fs.StringVar(&o.webhookSecretFile, "hmac-secret-file", + "/etc/webhook/hmac", "Path to the file containing the GitHub HMAC secret.") + + for _, group := range []flagutil.OptionGroup{&o.github} { + group.AddFlags(fs) + } + _ = fs.Parse(os.Args[1:]) + return o +} + +func main() { + o := gatherOptions() + if err := o.validate(); err != nil { + logrus.Fatalf("Invalid options: %v", err) + } + + log := logrus.StandardLogger().WithField("plugin", cherrypicker.PluginName) + + epa := &tiexternalplugins.ConfigAgent{} + if err := epa.Start(o.externalPluginsConfig, false); err != nil { + log.WithError(err).Fatalf("Error loading external plugin config from %q.", o.externalPluginsConfig) + } + + secretAgent := &secret.Agent{} + if err := secretAgent.Start([]string{o.github.TokenPath, o.webhookSecretFile}); err != nil { + logrus.WithError(err).Fatal("Error starting secrets agent.") + } + + githubClient, err := o.github.GitHubClient(secretAgent, o.dryRun) + if err != nil { + logrus.WithError(err).Fatal("Error getting GitHub client.") + } + githubClient.Throttle(360, 360) + + gitClient, err := o.github.GitClient(secretAgent, o.dryRun) + if err != nil { + logrus.WithError(err).Fatal("Error getting Git client.") + } + interrupts.OnInterrupt(func() { + if err := gitClient.Clean(); err != nil { + logrus.WithError(err).Error("Could not clean up git client cache.") + } + }) + + email, err := githubClient.Email() + if err != nil { + log.WithError(err).Fatal("Error getting bot e-mail.") + } + + botUser, err := githubClient.BotUser() + if err != nil { + logrus.WithError(err).Fatal("Error getting bot name.") + } + + repos, err := githubClient.GetRepos(botUser.Login, true) + if err != nil { + log.WithError(err).Fatal("Error listing bot repositories.") + } + + server := &cherrypicker.Server{ + TokenGenerator: secretAgent.GetTokenGenerator(o.webhookSecretFile), + BotUser: botUser, + Email: email, + ConfigAgent: epa, + + GitClient: git.ClientFactoryFrom(gitClient), + GitHubClient: githubClient, + Log: log, + + Bare: &http.Client{}, + PatchURL: "https://patch-diff.githubusercontent.com", + + Repos: repos, + } + + health := pjutil.NewHealth() + health.ServeReady() + + mux := http.NewServeMux() + mux.Handle("/", server) + + helpProvider := cherrypicker.HelpProvider(epa) + externalplugins.ServeExternalPluginHelp(mux, log, helpProvider) + httpServer := &http.Server{Addr: ":" + strconv.Itoa(o.port), Handler: mux} + + defer interrupts.WaitForGracefulShutdown() + interrupts.ListenAndServe(httpServer, 5*time.Second) +} diff --git a/deployments/plugins/cherrypicker/Dockerfile b/deployments/plugins/cherrypicker/Dockerfile new file mode 100644 index 000000000..ad5a5ecad --- /dev/null +++ b/deployments/plugins/cherrypicker/Dockerfile @@ -0,0 +1,4 @@ +FROM alpine:3.12 +ADD ticommunitycherrypicker /usr/local/bin/ +EXPOSE 80 +ENTRYPOINT ["/usr/local/bin/ticommunitycherrypicker"] \ No newline at end of file diff --git a/internal/pkg/externalplugins/cherrypicker/cherrypicker.go b/internal/pkg/externalplugins/cherrypicker/cherrypicker.go new file mode 100644 index 000000000..9876d244e --- /dev/null +++ b/internal/pkg/externalplugins/cherrypicker/cherrypicker.go @@ -0,0 +1,688 @@ +/* +Copyright 2017 The Kubernetes Authors. +Copyright 2021 The TiChi Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +The original file of the code is at: +https://github.com/kubernetes/test-infra/blob/master/prow/external-plugins/cherrypicker/server.go, +which we modified to add support for copying the labels and reviewers. +*/ +package cherrypicker + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "regexp" + "strings" + "sync" + "time" + + "github.com/sirupsen/logrus" + tiexternalplugins "github.com/ti-community-infra/tichi/internal/pkg/externalplugins" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/test-infra/prow/config" + "k8s.io/test-infra/prow/git/v2" + "k8s.io/test-infra/prow/github" + "k8s.io/test-infra/prow/pluginhelp" + "k8s.io/test-infra/prow/pluginhelp/externalplugins" + "k8s.io/test-infra/prow/plugins" +) + +const PluginName = "ti-community-cherrypicker" + +var ( + cherryPickRe = regexp.MustCompile(`(?m)^(?:/cherrypick|/cherry-pick)\s+(.+)$`) + cherryPickBranchFmt = "cherry-pick-%d-to-%s" +) + +type githubClient interface { + AddLabel(org, repo string, number int, label string) error + AssignIssue(org, repo string, number int, logins []string) error + RequestReview(org, repo string, number int, logins []string) error + CreateComment(org, repo string, number int, comment string) error + CreateFork(org, repo string) (string, error) + CreatePullRequest(org, repo, title, body, head, base string, canModify bool) (int, error) + CreateIssue(org, repo, title, body string, milestone int, labels, assignees []string) (int, error) + EnsureFork(forkingUser, org, repo string) (string, error) + GetPullRequest(org, repo string, number int) (*github.PullRequest, error) + GetPullRequestPatch(org, repo string, number int) ([]byte, error) + GetPullRequests(org, repo string) ([]github.PullRequest, error) + GetRepo(owner, name string) (github.FullRepo, error) + IsMember(org, user string) (bool, error) + ListIssueComments(org, repo string, number int) ([]github.IssueComment, error) + GetIssueLabels(org, repo string, number int) ([]github.Label, error) + ListOrgMembers(org, role string) ([]github.TeamMember, error) +} + +// HelpProvider constructs the PluginHelp for this plugin that takes into account enabled repositories. +// HelpProvider defines the type for function that construct the PluginHelp for plugins. +func HelpProvider(epa *tiexternalplugins.ConfigAgent) externalplugins.ExternalPluginHelpProvider { + return func(enabledRepos []config.OrgRepo) (*pluginhelp.PluginHelp, error) { + configInfo := map[string]string{} + cfg := epa.Config() + + for _, repo := range enabledRepos { + opts := cfg.CherrypickerFor(repo.Org, repo.Repo) + var configInfoStrings []string + + configInfoStrings = append(configInfoStrings, "The plugin has these configurations:") + configInfo[repo.String()] = strings.Join(configInfoStrings, "\n") + } + + yamlSnippet, err := plugins.CommentMap.GenYaml(&tiexternalplugins.Configuration{ + TiCommunityCherrypicker: []tiexternalplugins.TiCommunityCherrypicker{ + { + Repos: []string{"ti-community-infra/test-dev"}, + LabelPrefix: "needs-cherry-pick-", + }, + }, + }) + if err != nil { + logrus.WithError(err).Warnf("cannot generate comments for %s plugin", PluginName) + } + + pluginHelp := &pluginhelp.PluginHelp{ + 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 requestor. " + + "If the parent PR contains a release note, it is copied to the cherrypick PR.", + Config: configInfo, + Snippet: yamlSnippet, + Events: []string{tiexternalplugins.PullRequestEvent, tiexternalplugins.IssueCommentEvent}, + } + + pluginHelp.AddCommand(pluginhelp.Command{ + Usage: "/cherrypick [branch]", + 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).", + Featured: true, + // depends on how the cherrypick server runs; needs auth by default (--allow-all=false) + WhoCanUse: "Members of the trusted organization for the repo.", + Examples: []string{"/cherrypick release-3.9", "/cherry-pick release-1.15"}, + }) + + return pluginHelp, nil + } +} + +// Server implements http.Handler. It validates incoming GitHub webhooks and +// then dispatches them to the appropriate plugins. +type Server struct { + TokenGenerator func() []byte + BotUser *github.UserData + Email string + + GitClient git.ClientFactory + // Used for unit testing + Push func(forkName, newBranch string, force bool) error + GitHubClient githubClient + Log *logrus.Entry + ConfigAgent *tiexternalplugins.ConfigAgent + + Bare *http.Client + PatchURL string + + repoLock sync.Mutex + Repos []github.Repo + + mapLock sync.Mutex + lockMap map[cherryPickRequest]*sync.Mutex +} + +type cherryPickRequest struct { + org string + repo string + pr int + targetBranch string +} + +// ServeHTTP validates an incoming webhook and puts it into the event channel. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + eventType, eventGUID, payload, ok, _ := github.ValidateWebhook(w, r, s.TokenGenerator) + if !ok { + return + } + fmt.Fprint(w, "Event received. Have a nice day.") + + if err := s.handleEvent(eventType, eventGUID, payload); err != nil { + logrus.WithError(err).Error("Error parsing event.") + } +} + +func (s *Server) handleEvent(eventType, eventGUID string, payload []byte) error { + l := logrus.WithFields(logrus.Fields{ + "event-type": eventType, + github.EventGUID: eventGUID, + }) + switch eventType { + case "issue_comment": + var ic github.IssueCommentEvent + if err := json.Unmarshal(payload, &ic); err != nil { + return err + } + go func() { + if err := s.handleIssueComment(l, ic); err != nil { + s.Log.WithError(err).WithFields(l.Data).Info("Cherry-pick failed.") + } + }() + case "pull_request": + var pr github.PullRequestEvent + if err := json.Unmarshal(payload, &pr); err != nil { + return err + } + go func() { + if err := s.handlePullRequest(l, pr); err != nil { + s.Log.WithError(err).WithFields(l.Data).Info("Cherry-pick failed.") + } + }() + default: + logrus.Debugf("skipping event of type %q", eventType) + } + return nil +} + +func (s *Server) handleIssueComment(l *logrus.Entry, ic github.IssueCommentEvent) error { + // Only consider new comments in PRs. + if !ic.Issue.IsPullRequest() || ic.Action != github.IssueCommentActionCreated { + return nil + } + + org := ic.Repo.Owner.Login + repo := ic.Repo.Name + num := ic.Issue.Number + commentAuthor := ic.Comment.User.Login + opts := s.ConfigAgent.Config().CherrypickerFor(org, repo) + + // Do not create a new logger, its fields are re-used by the caller in case of errors. + *l = *l.WithFields(logrus.Fields{ + github.OrgLogField: org, + github.RepoLogField: repo, + github.PrLogField: num, + }) + + cherryPickMatches := cherryPickRe.FindAllStringSubmatch(ic.Comment.Body, -1) + if len(cherryPickMatches) == 0 || len(cherryPickMatches[0]) != 2 { + return nil + } + targetBranch := strings.TrimSpace(cherryPickMatches[0][1]) + + if ic.Issue.State != "closed" { + if !opts.AllowAll { + // Only members should be able to do cherry-picks. + ok, err := s.GitHubClient.IsMember(org, commentAuthor) + if err != nil { + return err + } + if !ok { + 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) + l.Info(resp) + return s.GitHubClient.CreateComment(org, repo, num, tiexternalplugins.FormatICResponse(ic.Comment, resp)) + } + } + 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) + l.Info(resp) + return s.GitHubClient.CreateComment(org, repo, num, tiexternalplugins.FormatICResponse(ic.Comment, resp)) + } + + pr, err := s.GitHubClient.GetPullRequest(org, repo, num) + if err != nil { + return fmt.Errorf("failed to get pull request %s/%s#%d: %w", org, repo, num, err) + } + baseBranch := pr.Base.Ref + + // Cherry-pick only merged PRs. + if !pr.Merged { + resp := "cannot cherry-pick an unmerged PR." + l.Info(resp) + return s.GitHubClient.CreateComment(org, repo, num, tiexternalplugins.FormatICResponse(ic.Comment, resp)) + } + + if baseBranch == targetBranch { + resp := fmt.Sprintf("base branch (%s) needs to differ from target branch (%s).", baseBranch, targetBranch) + l.Info(resp) + return s.GitHubClient.CreateComment(org, repo, num, tiexternalplugins.FormatICResponse(ic.Comment, resp)) + } + + if !opts.AllowAll { + // Only org members should be able to do cherry-picks. + ok, err := s.GitHubClient.IsMember(org, commentAuthor) + if err != nil { + return err + } + if !ok { + 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) + l.Info(resp) + return s.GitHubClient.CreateComment(org, repo, num, tiexternalplugins.FormatICResponse(ic.Comment, resp)) + } + } + + *l = *l.WithFields(logrus.Fields{ + "requestor": ic.Comment.User.Login, + "target_branch": targetBranch, + }) + l.Debug("Cherrypick request.") + return s.handle(l, ic.Comment.User.Login, &ic.Comment, org, repo, targetBranch, pr) +} + +func (s *Server) handlePullRequest(l *logrus.Entry, pre github.PullRequestEvent) error { + // Only consider newly merged PRs. + if pre.Action != github.PullRequestActionClosed && pre.Action != github.PullRequestActionLabeled { + return nil + } + + pr := pre.PullRequest + if !pr.Merged || pr.MergeSHA == nil { + return nil + } + + org := pr.Base.Repo.Owner.Login + repo := pr.Base.Repo.Name + baseBranch := pr.Base.Ref + num := pr.Number + opts := s.ConfigAgent.Config().CherrypickerFor(org, repo) + + // Do not create a new logger, its fields are re-used by the caller in case of errors. + *l = *l.WithFields(logrus.Fields{ + github.OrgLogField: org, + github.RepoLogField: repo, + github.PrLogField: num, + }) + + comments, err := s.GitHubClient.ListIssueComments(org, repo, num) + if err != nil { + return fmt.Errorf("failed to list comments: %w", err) + } + + // requestor -> target branch -> issue comment. + requestorToComments := make(map[string]map[string]*github.IssueComment) + + // First look for our special comments. + for i := range comments { + c := comments[i] + cherryPickMatches := cherryPickRe.FindAllStringSubmatch(c.Body, -1) + if len(cherryPickMatches) == 0 || len(cherryPickMatches[0]) != 2 { + continue + } + // TODO: Support comments with multiple cherrypick invocations. + targetBranch := strings.TrimSpace(cherryPickMatches[0][1]) + if requestorToComments[c.User.Login] == nil { + requestorToComments[c.User.Login] = make(map[string]*github.IssueComment) + } + requestorToComments[c.User.Login][targetBranch] = &c + } + + foundCherryPickComments := len(requestorToComments) != 0 + + // Now look for our special labels. + labels, err := s.GitHubClient.GetIssueLabels(org, repo, num) + if err != nil { + return fmt.Errorf("failed to get issue labels: %w", err) + } + + // NOTICE: This will set the requestor to the author of the PR. + if requestorToComments[pr.User.Login] == nil { + requestorToComments[pr.User.Login] = make(map[string]*github.IssueComment) + } + + foundCherryPickLabels := false + for _, label := range labels { + if strings.HasPrefix(label.Name, opts.LabelPrefix) { + // leave this nil which indicates a label-initiated cherry-pick. + requestorToComments[pr.User.Login][label.Name[len(opts.LabelPrefix):]] = nil + foundCherryPickLabels = true + } + } + + // No need to cherry pick. + if !foundCherryPickComments && !foundCherryPickLabels { + return nil + } + + if !foundCherryPickLabels && pre.Action == github.PullRequestActionLabeled { + return nil + } + + // Figure out membership. + if !opts.AllowAll { + members, err := s.GitHubClient.ListOrgMembers(org, "all") + if err != nil { + return err + } + for requestor := range requestorToComments { + isMember := false + for _, m := range members { + if requestor == m.Login { + isMember = true + break + } + } + if !isMember { + delete(requestorToComments, requestor) + } + } + } + + // Handle multiple comments serially. Make sure to filter out + // comments targeting the same branch. + handledBranches := make(map[string]bool) + for requestor, branches := range requestorToComments { + for targetBranch, ic := range branches { + if handledBranches[targetBranch] { + // Branch already handled. Skip. + continue + } + if targetBranch == baseBranch { + resp := fmt.Sprintf("base branch (%s) needs to differ from target branch (%s).", baseBranch, targetBranch) + l.Info(resp) + if err := s.createComment(l, org, repo, num, ic, resp); err != nil { + l.WithError(err).WithField("response", resp).Error("Failed to create comment.") + } + continue + } + handledBranches[targetBranch] = true + l := l.WithFields(logrus.Fields{ + "requestor": requestor, + "target_branch": targetBranch, + }) + l.Debug("Cherrypick request.") + err := s.handle(l, requestor, ic, org, repo, targetBranch, &pr) + if err != nil { + l.WithError(err).Error("failed to create cherrypick") + return err + } + } + } + return nil +} + +//nolint:gocyclo +// TODO: refactoring to reduce complexity. +func (s *Server) handle(logger *logrus.Entry, requestor string, + comment *github.IssueComment, org, repo, targetBranch string, pr *github.PullRequest) error { + num := pr.Number + title := pr.Title + body := pr.Body + var lock *sync.Mutex + func() { + s.mapLock.Lock() + defer s.mapLock.Unlock() + if _, ok := s.lockMap[cherryPickRequest{org, repo, num, targetBranch}]; !ok { + if s.lockMap == nil { + s.lockMap = map[cherryPickRequest]*sync.Mutex{} + } + s.lockMap[cherryPickRequest{org, repo, num, targetBranch}] = &sync.Mutex{} + } + lock = s.lockMap[cherryPickRequest{org, repo, num, targetBranch}] + }() + lock.Lock() + defer lock.Unlock() + + opts := s.ConfigAgent.Config().CherrypickerFor(org, repo) + + forkName, err := s.ensureForkExists(org, repo) + if err != nil { + logger.WithError(err).Warn("failed to ensure fork exists") + resp := fmt.Sprintf("cannot fork %s/%s: %v.", org, repo, err) + return s.createComment(logger, org, repo, num, comment, resp) + } + + // Clone the repo, checkout the target branch. + startClone := time.Now() + r, err := s.GitClient.ClientFor(org, repo) + if err != nil { + return fmt.Errorf("failed to get git client for %s/%s: %w", org, forkName, err) + } + defer func() { + if err := r.Clean(); err != nil { + logger.WithError(err).Error("Error cleaning up repo.") + } + }() + if err := r.Checkout(targetBranch); err != nil { + logger.WithError(err).Warn("failed to checkout target branch") + resp := fmt.Sprintf("cannot checkout `%s`: %v.", targetBranch, err) + return s.createComment(logger, org, repo, num, comment, resp) + } + logger.WithField("duration", time.Since(startClone)).Info("Cloned and checked out target branch.") + + // Fetch the patch from GitHub + localPath, err := s.getPatch(org, repo, targetBranch, num) + if err != nil { + return fmt.Errorf("failed to get patch: %w", err) + } + + // Setup git name and email. + if err := r.Config("user.name", s.BotUser.Login); err != nil { + return fmt.Errorf("failed to configure git user: %w", err) + } + email := s.Email + if email == "" { + email = s.BotUser.Email + } + if err := r.Config("user.email", email); err != nil { + return fmt.Errorf("failed to configure git Email: %w", err) + } + + // New branch for the cherry-pick. + newBranch := fmt.Sprintf(cherryPickBranchFmt, num, targetBranch) + + // Check if that branch already exists, which means there is already a PR for that cherry-pick. + if r.BranchExists(newBranch) { + // Find the PR and link to it. + prs, err := s.GitHubClient.GetPullRequests(org, repo) + if err != nil { + return fmt.Errorf("failed to get pullrequests for %s/%s: %w", org, repo, err) + } + for _, pr := range prs { + if pr.Head.Ref == fmt.Sprintf("%s:%s", s.BotUser.Login, newBranch) { + logger.WithField("preexisting_cherrypick", pr.HTMLURL).Info("PR already has cherrypick") + resp := fmt.Sprintf("Looks like #%d has already been cherry picked in %s.", num, pr.HTMLURL) + return s.createComment(logger, org, repo, num, comment, resp) + } + } + } + + // Create the branch for the cherry-pick. + if err := r.CheckoutNewBranch(newBranch); err != nil { + return fmt.Errorf("failed to checkout %s: %w", newBranch, err) + } + + // Title for GitHub issue/PR. + title = fmt.Sprintf("%s (#%d)[%s]", title, num, targetBranch) + + // Apply the patch. + if err := r.Am(localPath); err != nil { + errs := []error{fmt.Errorf("failed to `git am`: %w", err)} + logger.WithError(err).Warn("failed to apply PR on top of target branch") + resp := fmt.Sprintf("#%d failed to apply on top of branch %q:\n```\n%v\n```.", num, targetBranch, err) + if err := s.createComment(logger, org, repo, num, comment, resp); err != nil { + errs = append(errs, fmt.Errorf("failed to create comment: %w", err)) + } + + if opts.IssueOnConflict { + resp = fmt.Sprintf("Manual cherrypick required.\n\n%v", resp) + if err := s.createIssue(logger, org, repo, title, resp, num, comment, nil, []string{requestor}); err != nil { + errs = append(errs, fmt.Errorf("failed to create issue: %w", err)) + } + } + + return utilerrors.NewAggregate(errs) + } + + push := r.PushToNamedFork + if s.Push != nil { + push = s.Push + } + + // Push the new branch in the bot's fork. + if err := push(forkName, newBranch, true); err != nil { + logger.WithError(err).Warn("failed to Push chery-picked changes to GitHub") + resp := fmt.Sprintf("failed to Push cherry-picked changes in GitHub: %v.", err) + return utilerrors.NewAggregate([]error{err, s.createComment(logger, org, repo, num, comment, resp)}) + } + + // Open a PR in GitHub. + cherryPickBody := createCherrypickBody(num, body) + head := fmt.Sprintf("%s:%s", s.BotUser.Login, newBranch) + createdNum, err := s.GitHubClient.CreatePullRequest(org, repo, title, cherryPickBody, head, targetBranch, true) + if err != nil { + logger.WithError(err).Warn("failed to create new pull request") + resp := fmt.Sprintf("new pull request could not be created: %v.", err) + return utilerrors.NewAggregate([]error{err, s.createComment(logger, org, repo, num, comment, resp)}) + } + *logger = *logger.WithField("new_pull_request_number", createdNum) + resp := fmt.Sprintf("new pull request created: #%d.", createdNum) + logger.Info("new pull request created") + if err := s.createComment(logger, org, repo, num, comment, resp); err != nil { + return fmt.Errorf("failed to create comment: %w", err) + } + + // Copying original pull request labels. + // TODO: better to use addLabels API. + excludeLabelsSet := sets.NewString(opts.ExcludeLabels...) + for _, label := range pr.Labels { + if !excludeLabelsSet.Has(label.Name) && !strings.HasPrefix(label.Name, opts.LabelPrefix) { + if err := s.GitHubClient.AddLabel(org, repo, createdNum, label.Name); err != nil { + logger.WithError(err).Warnf("failed to add label %s", label) + } + } + } + + // Copying original pull request reviewers. + var reviewers []string + for _, reviewer := range pr.RequestedReviewers { + reviewers = append(reviewers, reviewer.Login) + } + if err := s.GitHubClient.RequestReview(org, repo, createdNum, reviewers); err != nil { + logger.WithError(err).Warn("failed to request review to new PR") + // Ignore returning errors on failure to request review as this is likely + // due to users not being members of the org so that they can't be requested + // in PRs. + return nil + } + + // Assign pull request to requestor. + if err := s.GitHubClient.AssignIssue(org, repo, createdNum, []string{requestor}); err != nil { + logger.WithError(err).Warn("failed to assign to new PR") + // Ignore returning errors on failure to assign as this is most likely + // due to users not being members of the org so that they can't be assigned + // in PRs. + return nil + } + return nil +} + +func (s *Server) createComment(l *logrus.Entry, org, repo string, + num int, comment *github.IssueComment, resp string) error { + if err := func() error { + if comment != nil { + return s.GitHubClient.CreateComment(org, repo, num, tiexternalplugins.FormatICResponse(*comment, resp)) + } + return s.GitHubClient.CreateComment(org, repo, num, fmt.Sprintf("In response to a cherrypick label: %s", resp)) + }(); err != nil { + l.WithError(err).Warn("failed to create comment") + return err + } + logrus.Debug("Created comment") + return nil +} + +// createIssue creates an issue on GitHub. +func (s *Server) createIssue(l *logrus.Entry, org, repo, title, body string, num int, + comment *github.IssueComment, labels, assignees []string) error { + issueNum, err := s.GitHubClient.CreateIssue(org, repo, title, body, 0, labels, assignees) + if err != nil { + return s.createComment(l, org, repo, num, + comment, fmt.Sprintf("new issue could not be created for failed cherrypick: %v", err)) + } + + return s.createComment(l, org, repo, num, comment, + fmt.Sprintf("new issue created for failed cherrypick: #%d", issueNum)) +} + +// ensureForkExists ensures a fork of org/repo exists for the bot. +func (s *Server) ensureForkExists(org, repo string) (string, error) { + fork := s.BotUser.Login + "/" + repo + + // fork repo if it doesn't exist. + repo, err := s.GitHubClient.EnsureFork(s.BotUser.Login, org, repo) + if err != nil { + return repo, err + } + + s.repoLock.Lock() + defer s.repoLock.Unlock() + s.Repos = append(s.Repos, github.Repo{FullName: fork, Fork: true}) + return repo, nil +} + +// getPatch gets the patch for the provided PR and creates a local +// copy of it. It returns its location in the filesystem and any +// encountered error. +func (s *Server) getPatch(org, repo, targetBranch string, num int) (string, error) { + patch, err := s.GitHubClient.GetPullRequestPatch(org, repo, num) + if err != nil { + return "", err + } + localPath := fmt.Sprintf("/tmp/%s_%s_%d_%s.patch", org, repo, num, normalize(targetBranch)) + out, err := os.Create(localPath) + if err != nil { + return "", err + } + defer out.Close() + if _, err := io.Copy(out, bytes.NewBuffer(patch)); err != nil { + return "", err + } + return localPath, nil +} + +func normalize(input string) string { + return strings.ReplaceAll(input, "/", "-") +} + +// CreateCherrypickBody creates the body of a cherrypick PR +func createCherrypickBody(num int, note string) string { + cherryPickBody := fmt.Sprintf("This is an automated cherry-pick of #%d", num) + if len(note) != 0 { + cherryPickBody = fmt.Sprintf("%s\n\n%s", cherryPickBody, note) + } + return cherryPickBody +} diff --git a/internal/pkg/externalplugins/cherrypicker/cherrypicker_test.go b/internal/pkg/externalplugins/cherrypicker/cherrypicker_test.go new file mode 100644 index 000000000..8d33e0533 --- /dev/null +++ b/internal/pkg/externalplugins/cherrypicker/cherrypicker_test.go @@ -0,0 +1,1390 @@ +/* +Copyright 2017 The Kubernetes Authors. +Copyright 2021 The TiChi Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +The original file of the code is at: +https://github.com/kubernetes/test-infra/blob/master/prow/external-plugins/cherrypicker/server_test.go, +which we modified to add support for copying the labels and reviewers. +*/ + +package cherrypicker + +import ( + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync" + "testing" + + "github.com/sirupsen/logrus" + "github.com/ti-community-infra/tichi/internal/pkg/externalplugins" + "k8s.io/test-infra/prow/config" + "k8s.io/test-infra/prow/git/localgit" + "k8s.io/test-infra/prow/github" + "k8s.io/test-infra/prow/plugins" +) + +var commentFormat = "%s/%s#%d %s" + +type fghc struct { + sync.Mutex + pr *github.PullRequest + isMember bool + + patch []byte + comments []string + prs []github.PullRequest + prComments []github.IssueComment + prLabels []github.Label + orgMembers []github.TeamMember + issues []github.Issue +} + +func (f *fghc) AddLabel(org, repo string, number int, label string) error { + f.Lock() + defer f.Unlock() + for i := range f.prs { + if number == f.prs[i].Number { + f.prs[i].Labels = append(f.prs[i].Labels, github.Label{Name: label}) + } + } + return nil +} + +func (f *fghc) AssignIssue(org, repo string, number int, logins []string) error { + f.Lock() + defer f.Unlock() + var users []github.User + for _, login := range logins { + users = append(users, github.User{Login: login}) + } + for i := range f.prs { + if number == f.prs[i].Number { + f.prs[i].Assignees = users + } + } + return nil +} + +func (f *fghc) RequestReview(org, repo string, number int, logins []string) error { + f.Lock() + defer f.Unlock() + var users []github.User + for _, login := range logins { + users = append(users, github.User{Login: login}) + } + for i := range f.prs { + if number == f.prs[i].Number { + f.prs[i].RequestedReviewers = users + } + } + return nil +} + +func (f *fghc) GetPullRequest(org, repo string, number int) (*github.PullRequest, error) { + f.Lock() + defer f.Unlock() + return f.pr, nil +} + +func (f *fghc) GetPullRequestPatch(org, repo string, number int) ([]byte, error) { + f.Lock() + defer f.Unlock() + return f.patch, nil +} + +func (f *fghc) GetPullRequests(org, repo string) ([]github.PullRequest, error) { + f.Lock() + defer f.Unlock() + return f.prs, nil +} + +func (f *fghc) CreateComment(org, repo string, number int, comment string) error { + f.Lock() + defer f.Unlock() + f.comments = append(f.comments, fmt.Sprintf(commentFormat, org, repo, number, comment)) + return nil +} + +func (f *fghc) IsMember(org, user string) (bool, error) { + f.Lock() + defer f.Unlock() + return f.isMember, nil +} + +func (f *fghc) GetRepo(owner, name string) (github.FullRepo, error) { + f.Lock() + defer f.Unlock() + return github.FullRepo{}, nil +} + +func (f *fghc) EnsureFork(forkingUser, org, repo string) (string, error) { + if repo == "changeme" { + return "changed", nil + } + if repo == "error" { + return repo, errors.New("errors") + } + return repo, nil +} + +var expectedFmt = `title=%q body=%q head=%s base=%s labels=%v reviewers=%v assignees=%v` + +func prToString(pr github.PullRequest) string { + var labels []string + for _, label := range pr.Labels { + labels = append(labels, label.Name) + } + + var reviewers []string + for _, reviewer := range pr.RequestedReviewers { + reviewers = append(reviewers, reviewer.Login) + } + + var assignees []string + for _, assignee := range pr.Assignees { + assignees = append(assignees, assignee.Login) + } + return fmt.Sprintf(expectedFmt, pr.Title, pr.Body, pr.Head.Ref, pr.Base.Ref, labels, reviewers, assignees) +} + +func (f *fghc) CreateIssue(org, repo, title, body string, milestone int, labels, assignees []string) (int, error) { + f.Lock() + defer f.Unlock() + + var ghLabels []github.Label + var ghAssignees []github.User + + num := len(f.issues) + 1 + + for _, label := range labels { + ghLabels = append(ghLabels, github.Label{Name: label}) + } + + for _, assignee := range assignees { + ghAssignees = append(ghAssignees, github.User{Login: assignee}) + } + + f.issues = append(f.issues, github.Issue{ + Title: title, + Body: body, + Number: num, + Labels: ghLabels, + Assignees: ghAssignees, + }) + + return num, nil +} + +func (f *fghc) CreatePullRequest(org, repo, title, body, head, base string, canModify bool) (int, error) { + f.Lock() + defer f.Unlock() + num := len(f.prs) + 1 + f.prs = append(f.prs, github.PullRequest{ + Title: title, + Body: body, + Number: num, + Head: github.PullRequestBranch{Ref: head}, + Base: github.PullRequestBranch{Ref: base}, + }) + return num, nil +} + +func (f *fghc) ListIssueComments(org, repo string, number int) ([]github.IssueComment, error) { + f.Lock() + defer f.Unlock() + return f.prComments, nil +} + +func (f *fghc) GetIssueLabels(org, repo string, number int) ([]github.Label, error) { + f.Lock() + defer f.Unlock() + return f.prLabels, nil +} + +func (f *fghc) ListOrgMembers(org, role string) ([]github.TeamMember, error) { + f.Lock() + defer f.Unlock() + if role != "all" { + return nil, fmt.Errorf("all is only supported role, not: %s", role) + } + return f.orgMembers, nil +} + +func (f *fghc) CreateFork(org, repo string) (string, error) { + return repo, nil +} + +var initialFiles = map[string][]byte{ + "bar.go": []byte(`// Package bar does an interesting thing. +package bar + +// Foo does a thing. +func Foo(wow int) int { + return 42 + wow +} +`), +} + +var patch = []byte(`From af468c9e69dfdf39db591f1e3e8de5b64b0e62a2 Mon Sep 17 00:00:00 2001 +From: Wise Guy +Date: Thu, 19 Oct 2017 15:14:36 +0200 +Subject: [PATCH] Update magic number + +--- + bar.go | 3 ++- + 1 file changed, 2 insertions(+), 1 deletion(-) + +diff --git a/bar.go b/bar.go +index 1ea52dc..5bd70a9 100644 +--- a/bar.go ++++ b/bar.go +@@ -3,5 +3,6 @@ package bar + + // Foo does a thing. + func Foo(wow int) int { +- return 42 + wow ++ // Needs to be 49 because of a reason. ++ return 49 + wow + } +`) + +var body = "This PR updates the magic number.\n\n" + +func TestCherryPickIC(t *testing.T) { + t.Parallel() + testCherryPickIC(localgit.New, t) +} + +func TestCherryPickICV2(t *testing.T) { + t.Parallel() + testCherryPickIC(localgit.NewV2, t) +} + +func testCherryPickIC(clients localgit.Clients, t *testing.T) { + lg, c, err := clients() + if err != nil { + t.Fatalf("Making localgit: %v", err) + } + defer func() { + if err := lg.Clean(); err != nil { + t.Errorf("Cleaning up localgit: %v", err) + } + if err := c.Clean(); err != nil { + t.Errorf("Cleaning up client: %v", err) + } + }() + if err := lg.MakeFakeRepo("foo", "bar"); err != nil { + t.Fatalf("Making fake repo: %v", err) + } + if err := lg.AddCommit("foo", "bar", initialFiles); err != nil { + t.Fatalf("Adding initial commit: %v", err) + } + if err := lg.CheckoutNewBranch("foo", "bar", "stage"); err != nil { + t.Fatalf("Checking out pull branch: %v", err) + } + + ghc := &fghc{ + pr: &github.PullRequest{ + Base: github.PullRequestBranch{ + Ref: "master", + }, + Number: 2, + Merged: true, + Title: "This is a fix for X", + Body: body, + RequestedReviewers: []github.User{ + { + Login: "user1", + }, + }, + Assignees: []github.User{ + { + Login: "user2", + }, + }, + }, + isMember: true, + patch: patch, + } + + ic := github.IssueCommentEvent{ + Action: github.IssueCommentActionCreated, + Repo: github.Repo{ + Owner: github.User{ + Login: "foo", + }, + Name: "bar", + FullName: "foo/bar", + }, + Issue: github.Issue{ + Number: 2, + State: "closed", + PullRequest: &struct{}{}, + }, + Comment: github.IssueComment{ + User: github.User{ + Login: "wiseguy", + }, + Body: "/cherrypick stage", + }, + } + + botUser := &github.UserData{Login: "ci-robot", Email: "ci-robot@users.noreply.github.com"} + expectedTitle := "This is a fix for X (#2)[stage]" + expectedBody := "This is an automated cherry-pick of #2\n\nThis PR updates the magic number.\n\n" + expectedBase := "stage" + expectedHead := fmt.Sprintf(botUser.Login+":"+cherryPickBranchFmt, 2, expectedBase) + var expectedLabels []string + expectedReviewers := []string{"user1"} + expectedAssignees := []string{"wiseguy"} + expected := fmt.Sprintf(expectedFmt, expectedTitle, expectedBody, expectedHead, + expectedBase, expectedLabels, expectedReviewers, expectedAssignees) + + getSecret := func() []byte { + return []byte("sha=abcdefg") + } + + cfg := &externalplugins.Configuration{} + cfg.TiCommunityCherrypicker = []externalplugins.TiCommunityCherrypicker{ + { + Repos: []string{"foo/bar"}, + LabelPrefix: "cherrypick/", + }, + } + ca := &externalplugins.ConfigAgent{} + ca.Set(cfg) + + s := &Server{ + BotUser: botUser, + GitClient: c, + ConfigAgent: ca, + Push: func(forkName, newBranch string, force bool) error { return nil }, + GitHubClient: ghc, + TokenGenerator: getSecret, + Log: logrus.StandardLogger().WithField("client", "cherrypicker"), + Repos: []github.Repo{{Fork: true, FullName: "ci-robot/bar"}}, + } + + if err := s.handleIssueComment(logrus.NewEntry(logrus.StandardLogger()), ic); err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := prToString(ghc.prs[0]) + if got != expected { + t.Errorf("Expected (%d):\n%s\nGot (%d):\n%+v\n", len(expected), expected, len(got), got) + } +} + +func TestCherryPickPR(t *testing.T) { + t.Parallel() + testCherryPickPR(localgit.New, t) +} + +func TestCherryPickPRV2(t *testing.T) { + t.Parallel() + testCherryPickPR(localgit.NewV2, t) +} + +func testCherryPickPR(clients localgit.Clients, t *testing.T) { + lg, c, err := clients() + if err != nil { + t.Fatalf("Making localgit: %v", err) + } + defer func() { + if err := lg.Clean(); err != nil { + t.Errorf("Cleaning up localgit: %v", err) + } + if err := c.Clean(); err != nil { + t.Errorf("Cleaning up client: %v", err) + } + }() + if err := lg.MakeFakeRepo("foo", "bar"); err != nil { + t.Fatalf("Making fake repo: %v", err) + } + if err := lg.AddCommit("foo", "bar", initialFiles); err != nil { + t.Fatalf("Adding initial commit: %v", err) + } + if err := lg.CheckoutNewBranch("foo", "bar", "release-1.5"); err != nil { + t.Fatalf("Checking out pull branch: %v", err) + } + if err := lg.CheckoutNewBranch("foo", "bar", "release-1.6"); err != nil { + t.Fatalf("Checking out pull branch: %v", err) + } + if err := lg.CheckoutNewBranch("foo", "bar", "cherry-pick-2-to-release-1.5"); err != nil { + t.Fatalf("Checking out existing PR branch: %v", err) + } + + ghc := &fghc{ + orgMembers: []github.TeamMember{ + { + Login: "approver", + }, + { + Login: "merge-bot", + }, + }, + prComments: []github.IssueComment{ + { + User: github.User{ + Login: "developer", + }, + Body: "a review comment", + }, + { + User: github.User{ + Login: "approver", + }, + Body: "/cherrypick release-1.5\r", + }, + { + User: github.User{ + Login: "approver", + }, + Body: "/cherrypick release-1.6", + }, + { + User: github.User{ + Login: "fan", + }, + Body: "/cherrypick release-1.7", + }, + { + User: github.User{ + Login: "approver", + }, + Body: "/approve", + }, + { + User: github.User{ + Login: "merge-bot", + }, + Body: "Automatic merge from submit-queue.", + }, + }, + prs: []github.PullRequest{ + { + Title: "This is a fix for Y (#2)[release-1.5]", + Body: "This is an automated cherry-pick of #2", + Base: github.PullRequestBranch{ + Ref: "release-1.5", + }, + Head: github.PullRequestBranch{ + Ref: "ci-robot:cherry-pick-2-to-release-1.5", + }, + Labels: []github.Label{ + { + Name: "test", + }, + }, + RequestedReviewers: []github.User{ + { + Login: "user1", + }, + }, + Assignees: []github.User{ + { + Login: "approver", + }, + }, + }, + }, + isMember: true, + patch: patch, + } + + pr := github.PullRequestEvent{ + Action: github.PullRequestActionClosed, + PullRequest: github.PullRequest{ + Base: github.PullRequestBranch{ + Ref: "master", + Repo: github.Repo{ + Owner: github.User{ + Login: "foo", + }, + Name: "bar", + }, + }, + Number: 2, + Merged: true, + MergeSHA: new(string), + Title: "This is a fix for Y", + Labels: []github.Label{ + { + Name: "test", + }, + }, + RequestedReviewers: []github.User{ + { + Login: "user1", + }, + }, + Assignees: []github.User{ + { + Login: "approver", + }, + }, + }, + } + + botUser := &github.UserData{Login: "ci-robot", Email: "ci-robot@users.noreply.github.com"} + + getSecret := func() []byte { + return []byte("sha=abcdefg") + } + + cfg := &externalplugins.Configuration{} + cfg.TiCommunityCherrypicker = []externalplugins.TiCommunityCherrypicker{ + { + Repos: []string{"foo/bar"}, + LabelPrefix: "cherrypick/", + }, + } + ca := &externalplugins.ConfigAgent{} + ca.Set(cfg) + + s := &Server{ + BotUser: botUser, + GitClient: c, + ConfigAgent: ca, + Push: func(forkName, newBranch string, force bool) error { return nil }, + GitHubClient: ghc, + TokenGenerator: getSecret, + Log: logrus.StandardLogger().WithField("client", "cherrypicker"), + Repos: []github.Repo{{Fork: true, FullName: "ci-robot/bar"}}, + } + + if err := s.handlePullRequest(logrus.NewEntry(logrus.StandardLogger()), pr); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var expectedFn = func(branch string) string { + expectedTitle := fmt.Sprintf("This is a fix for Y (#2)[%s]", branch) + expectedBody := "This is an automated cherry-pick of #2" + expectedHead := fmt.Sprintf(botUser.Login+":"+cherryPickBranchFmt, 2, branch) + var expectedLabels []string + for _, label := range pr.PullRequest.Labels { + expectedLabels = append(expectedLabels, label.Name) + } + var reviewers []string + for _, reviewer := range pr.PullRequest.RequestedReviewers { + reviewers = append(reviewers, reviewer.Login) + } + expectedAssignees := []string{"approver"} + return fmt.Sprintf(expectedFmt, expectedTitle, expectedBody, expectedHead, + branch, expectedLabels, reviewers, expectedAssignees) + } + + if len(ghc.prs) != 2 { + t.Fatalf("Expected %d PRs, got %d", 2, len(ghc.prs)) + } + + expectedBranches := []string{"release-1.5", "release-1.6"} + seenBranches := make(map[string]struct{}) + for _, p := range ghc.prs { + pr := prToString(p) + if pr != expectedFn("release-1.5") && pr != expectedFn("release-1.6") { + t.Errorf("Unexpected PR:\n%s\nExpected to target one of the following branches: %v\n%s", + pr, expectedBranches, expectedFn("release-1.5")) + } + if pr == expectedFn("release-1.5") { + seenBranches["release-1.5"] = struct{}{} + } + if pr == expectedFn("release-1.6") { + seenBranches["release-1.6"] = struct{}{} + } + } + if len(seenBranches) != 2 { + t.Fatalf("Expected to see PRs for %d branches, got %d (%v)", 2, len(seenBranches), seenBranches) + } +} + +func TestCherryPickPRWithLabels(t *testing.T) { + t.Parallel() + testCherryPickPRWithLabels(localgit.New, t) +} + +func TestCherryPickPRWithLabelsV2(t *testing.T) { + t.Parallel() + testCherryPickPRWithLabels(localgit.NewV2, t) +} + +func testCherryPickPRWithLabels(clients localgit.Clients, t *testing.T) { + lg, c, err := clients() + if err != nil { + t.Fatalf("Making localgit: %v", err) + } + defer func() { + if err := lg.Clean(); err != nil { + t.Errorf("Cleaning up localgit: %v", err) + } + if err := c.Clean(); err != nil { + t.Errorf("Cleaning up client: %v", err) + } + }() + if err := lg.MakeFakeRepo("foo", "bar"); err != nil { + t.Fatalf("Making fake repo: %v", err) + } + if err := lg.AddCommit("foo", "bar", initialFiles); err != nil { + t.Fatalf("Adding initial commit: %v", err) + } + if err := lg.CheckoutNewBranch("foo", "bar", "release-1.5"); err != nil { + t.Fatalf("Checking out pull branch: %v", err) + } + if err := lg.CheckoutNewBranch("foo", "bar", "release-1.6"); err != nil { + t.Fatalf("Checking out pull branch: %v", err) + } + + pr := func(evt github.PullRequestEventAction) github.PullRequestEvent { + return github.PullRequestEvent{ + Action: evt, + PullRequest: github.PullRequest{ + User: github.User{ + Login: "developer", + }, + Base: github.PullRequestBranch{ + Ref: "master", + Repo: github.Repo{ + Owner: github.User{ + Login: "foo", + }, + Name: "bar", + }, + }, + Number: 2, + Merged: true, + MergeSHA: new(string), + Title: "This is a fix for Y", + RequestedReviewers: []github.User{ + { + Login: "user1", + }, + }, + }, + } + } + + events := []github.PullRequestEventAction{github.PullRequestActionClosed, github.PullRequestActionLabeled} + + botUser := &github.UserData{Login: "ci-robot", Email: "ci-robot@users.noreply.github.com"} + + getSecret := func() []byte { + return []byte("sha=abcdefg") + } + + testCases := []struct { + name string + labelPrefix string + prLabels []github.Label + prComments []github.IssueComment + }{ + { + name: "Default label prefix", + labelPrefix: externalplugins.DefaultCherryPickLabelPrefix, + prLabels: []github.Label{ + { + Name: "cherrypick/release-1.5", + }, + { + Name: "cherrypick/release-1.6", + }, + { + Name: "cherrypick/release-1.7", + }, + }, + }, + { + name: "Custom label prefix", + labelPrefix: "needs-cherry-pick-", + prLabels: []github.Label{ + { + Name: "needs-cherry-pick-release-1.5", + }, + { + Name: "needs-cherry-pick-release-1.6", + }, + { + Name: "needs-cherry-pick-release-1.7", + }, + }, + }, + { + name: "No labels, label gets ignored", + labelPrefix: "needs-cherry-pick-", + }, + } + + for _, test := range testCases { + tc := test + t.Run(tc.name, func(t *testing.T) { + for _, event := range events { + evt := event + t.Run(string(evt), func(t *testing.T) { + ghc := &fghc{ + orgMembers: []github.TeamMember{ + { + Login: "approver", + }, + { + Login: "merge-bot", + }, + { + Login: "developer", + }, + }, + prComments: []github.IssueComment{ + { + User: github.User{ + Login: "developer", + }, + Body: "a review comment", + }, + { + User: github.User{ + Login: "developer", + }, + Body: "/cherrypick release-1.5\r", + }, + }, + prLabels: tc.prLabels, + isMember: true, + patch: patch, + } + + cfg := &externalplugins.Configuration{} + cfg.TiCommunityCherrypicker = []externalplugins.TiCommunityCherrypicker{ + { + Repos: []string{"foo/bar"}, + LabelPrefix: tc.labelPrefix, + }, + } + ca := &externalplugins.ConfigAgent{} + ca.Set(cfg) + + s := &Server{ + BotUser: botUser, + GitClient: c, + ConfigAgent: ca, + Push: func(forkName, newBranch string, force bool) error { return nil }, + GitHubClient: ghc, + TokenGenerator: getSecret, + Log: logrus.StandardLogger().WithField("client", "cherrypicker"), + Repos: []github.Repo{{Fork: true, FullName: "ci-robot/bar"}}, + } + + if err := s.handlePullRequest(logrus.NewEntry(logrus.StandardLogger()), pr(evt)); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectedFn := func(branch string) string { + expectedTitle := fmt.Sprintf("This is a fix for Y (#2)[%s]", branch) + expectedBody := "This is an automated cherry-pick of #2" + expectedHead := fmt.Sprintf(botUser.Login+":"+cherryPickBranchFmt, 2, branch) + var expectedLabels []string + for _, label := range pr(evt).PullRequest.Labels { + expectedLabels = append(expectedLabels, label.Name) + } + var reviewers []string + for _, reviewer := range pr(evt).PullRequest.RequestedReviewers { + reviewers = append(reviewers, reviewer.Login) + } + expectedAssignees := []string{"developer"} + return fmt.Sprintf(expectedFmt, expectedTitle, expectedBody, expectedHead, + branch, expectedLabels, reviewers, expectedAssignees) + } + + expectedPRs := 2 + if len(tc.prLabels) == 0 { + if evt == github.PullRequestActionLabeled { + expectedPRs = 0 + } else { + expectedPRs = 1 + } + } + if len(ghc.prs) != expectedPRs { + t.Errorf("Expected %d PRs, got %d", expectedPRs, len(ghc.prs)) + } + + expectedBranches := []string{"release-1.5", "release-1.6"} + seenBranches := make(map[string]struct{}) + for _, p := range ghc.prs { + pr := prToString(p) + if pr != expectedFn("release-1.5") && pr != expectedFn("release-1.6") { + t.Errorf("Unexpected PR:\n%s\nExpected to target one of the following branches: %v", pr, expectedBranches) + } + if pr == expectedFn("release-1.5") { + seenBranches["release-1.5"] = struct{}{} + } + if pr == expectedFn("release-1.6") { + seenBranches["release-1.6"] = struct{}{} + } + } + if len(seenBranches) != expectedPRs { + t.Fatalf("Expected to see PRs for %d branches, got %d (%v)", expectedPRs, len(seenBranches), seenBranches) + } + }) + } + }) + } +} + +func TestCherryPickCreateIssue(t *testing.T) { + t.Parallel() + testCases := []struct { + org string + repo string + title string + body string + prNum int + labels []string + assignees []string + }{ + { + org: "istio", + repo: "istio", + title: "brand new feature", + body: "automated cherry-pick", + prNum: 2190, + labels: nil, + assignees: []string{"clarketm"}, + }, + { + org: "kubernetes", + repo: "kubernetes", + title: "alpha feature", + body: "automated cherry-pick", + prNum: 3444, + labels: []string{"new", "1.18"}, + assignees: nil, + }, + } + + errMsg := func(field string) string { + return fmt.Sprintf("GH issue %q does not match: \nexpected: \"%%v\" \nactual: \"%%v\"", field) + } + + for _, tc := range testCases { + ghc := &fghc{} + + s := &Server{ + GitHubClient: ghc, + } + + if err := s.createIssue(logrus.WithField("test", t.Name()), tc.org, tc.repo, tc.title, tc.body, tc.prNum, + nil, tc.labels, tc.assignees); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(ghc.issues) < 1 { + t.Fatalf("Expected 1 GH issue to be created but got: %d", len(ghc.issues)) + } + + ghIssue := ghc.issues[len(ghc.issues)-1] + + if tc.title != ghIssue.Title { + t.Fatalf(errMsg("title"), tc.title, ghIssue.Title) + } + + if tc.body != ghIssue.Body { + t.Fatalf(errMsg("body"), tc.title, ghIssue.Title) + } + + if len(ghc.issues) != ghIssue.Number { + t.Fatalf(errMsg("number"), len(ghc.issues), ghIssue.Number) + } + + var actualAssignees []string + for _, assignee := range ghIssue.Assignees { + actualAssignees = append(actualAssignees, assignee.Login) + } + + if !reflect.DeepEqual(tc.assignees, actualAssignees) { + t.Fatalf(errMsg("assignees"), tc.assignees, actualAssignees) + } + + var actualLabels []string + for _, label := range ghIssue.Labels { + actualLabels = append(actualLabels, label.Name) + } + + if !reflect.DeepEqual(tc.labels, actualLabels) { + t.Fatalf(errMsg("labels"), tc.labels, actualLabels) + } + + cpFormat := fmt.Sprintf(commentFormat, tc.org, tc.repo, tc.prNum, "In response to a cherrypick label: %s") + expectedComment := fmt.Sprintf(cpFormat, fmt.Sprintf("new issue created for failed cherrypick: #%d", ghIssue.Number)) + actualComment := ghc.comments[len(ghc.comments)-1] + + if expectedComment != actualComment { + t.Fatalf(errMsg("comment"), expectedComment, actualComment) + } + } +} + +func TestHandleLocks(t *testing.T) { + t.Parallel() + cfg := &externalplugins.Configuration{} + cfg.TiCommunityCherrypicker = []externalplugins.TiCommunityCherrypicker{ + { + Repos: []string{"org/repo"}, + }, + } + ca := &externalplugins.ConfigAgent{} + ca.Set(cfg) + + s := &Server{ + ConfigAgent: ca, + GitHubClient: &threadUnsafeFGHC{fghc: &fghc{}}, + BotUser: &github.UserData{}, + } + + routine1Done := make(chan struct{}) + routine2Done := make(chan struct{}) + + l := logrus.WithField("test", t.Name()) + pr := &github.PullRequest{ + Title: "title", + Body: "body", + Number: 0, + } + + go func() { + defer close(routine1Done) + if err := s.handle(l, "", &github.IssueComment{}, "org", "repo", "targetBranch", pr); err != nil { + t.Errorf("routine failed: %v", err) + } + }() + go func() { + defer close(routine2Done) + if err := s.handle(l, "", &github.IssueComment{}, "org", "repo", "targetBranch", pr); err != nil { + t.Errorf("routine failed: %v", err) + } + }() + + <-routine1Done + <-routine2Done + + if actual := s.GitHubClient.(*threadUnsafeFGHC).orgRepoCountCalled; actual != 2 { + t.Errorf("expected two EnsureFork calls, got %d", actual) + } +} + +func TestEnsureForkExists(t *testing.T) { + botUser := &github.UserData{Login: "ci-robot", Email: "ci-robot@users.noreply.github.com"} + + ghc := &fghc{} + + cfg := &externalplugins.Configuration{} + cfg.TiCommunityCherrypicker = []externalplugins.TiCommunityCherrypicker{ + { + Repos: []string{"org/repo"}, + }, + } + ca := &externalplugins.ConfigAgent{} + ca.Set(cfg) + + s := &Server{ + BotUser: botUser, + ConfigAgent: ca, + GitHubClient: ghc, + Repos: []github.Repo{{Fork: true, FullName: "ci-robot/bar"}}, + } + + testCases := []struct { + name string + org string + repo string + expected string + errors bool + }{ + { + name: "Repo name does not change after ensured", + org: "whatever", + repo: "repo", + expected: "repo", + errors: false, + }, + { + name: "EnsureFork changes repo name", + org: "whatever", + repo: "changeme", + expected: "changed", + errors: false, + }, + { + name: "EnsureFork errors", + org: "whatever", + repo: "error", + expected: "error", + errors: true, + }, + } + + for _, test := range testCases { + tc := test + t.Run(tc.name, func(t *testing.T) { + res, err := s.ensureForkExists(tc.org, tc.repo) + if tc.errors && err == nil { + t.Errorf("expected error, but did not get one") + } + if !tc.errors && err != nil { + t.Errorf("expected no error, but got one") + } + if res != tc.expected { + t.Errorf("expected %s but got %s", tc.expected, res) + } + }) + } +} + +type threadUnsafeFGHC struct { + *fghc + orgRepoCountCalled int +} + +func (tuf *threadUnsafeFGHC) EnsureFork(login, org, repo string) (string, error) { + tuf.orgRepoCountCalled++ + return "", errors.New("that is enough") +} + +func TestServeHTTPErrors(t *testing.T) { + pa := &plugins.ConfigAgent{} + pa.Set(&plugins.Configuration{}) + + getSecret := func() []byte { + var repoLevelSec = ` +'*': + - value: abc + created_at: 2019-10-02T15:00:00Z + - value: key2 + created_at: 2020-10-02T15:00:00Z +foo/bar: + - value: 123abc + created_at: 2019-10-02T15:00:00Z + - value: key6 + created_at: 2020-10-02T15:00:00Z +` + return []byte(repoLevelSec) + } + + // This is the SHA1 signature for payload "{}" and signature "abc" + // echo -n '{}' | openssl dgst -sha1 -hmac abc + const hmac string = "sha1=db5c76f4264d0ad96cf21baec394964b4b8ce580" + const body string = "{}" + var testcases = []struct { + name string + + Method string + Header map[string]string + Body string + Code int + }{ + { + name: "Delete", + + Method: http.MethodDelete, + Header: map[string]string{ + "X-GitHub-Event": "ping", + "X-GitHub-Delivery": "I am unique", + "X-Hub-Signature": hmac, + "content-type": "application/json", + }, + Body: body, + Code: http.StatusMethodNotAllowed, + }, + { + name: "No event", + + Method: http.MethodPost, + Header: map[string]string{ + "X-GitHub-Delivery": "I am unique", + "X-Hub-Signature": hmac, + "content-type": "application/json", + }, + Body: body, + Code: http.StatusBadRequest, + }, + { + name: "No content type", + + Method: http.MethodPost, + Header: map[string]string{ + "X-GitHub-Event": "ping", + "X-GitHub-Delivery": "I am unique", + "X-Hub-Signature": hmac, + }, + Body: body, + Code: http.StatusBadRequest, + }, + { + name: "No event guid", + + Method: http.MethodPost, + Header: map[string]string{ + "X-GitHub-Event": "ping", + "X-Hub-Signature": hmac, + "content-type": "application/json", + }, + Body: body, + Code: http.StatusBadRequest, + }, + { + name: "No signature", + + Method: http.MethodPost, + Header: map[string]string{ + "X-GitHub-Event": "ping", + "X-GitHub-Delivery": "I am unique", + "content-type": "application/json", + }, + Body: body, + Code: http.StatusForbidden, + }, + { + name: "Bad signature", + + Method: http.MethodPost, + Header: map[string]string{ + "X-GitHub-Event": "ping", + "X-GitHub-Delivery": "I am unique", + "X-Hub-Signature": "this doesn't work", + "content-type": "application/json", + }, + Body: body, + Code: http.StatusForbidden, + }, + { + name: "Good", + + Method: http.MethodPost, + Header: map[string]string{ + "X-GitHub-Event": "ping", + "X-GitHub-Delivery": "I am unique", + "X-Hub-Signature": hmac, + "content-type": "application/json", + }, + Body: body, + Code: http.StatusOK, + }, + { + name: "Good, again", + + Method: http.MethodGet, + Header: map[string]string{ + "content-type": "application/json", + }, + Body: body, + Code: http.StatusMethodNotAllowed, + }, + } + + for _, tc := range testcases { + t.Logf("Running scenario %q", tc.name) + + w := httptest.NewRecorder() + r, err := http.NewRequest(tc.Method, "", strings.NewReader(tc.Body)) + if err != nil { + t.Fatal(err) + } + for k, v := range tc.Header { + r.Header.Set(k, v) + } + + s := Server{ + TokenGenerator: getSecret, + } + + s.ServeHTTP(w, r) + if w.Code != tc.Code { + t.Errorf("For test case: %+v\nExpected code %v, got code %v", tc, tc.Code, w.Code) + } + } +} + +func TestServeHTTP(t *testing.T) { + pa := &plugins.ConfigAgent{} + pa.Set(&plugins.Configuration{}) + + getSecret := func() []byte { + var repoLevelSec = ` +'*': + - value: abc + created_at: 2019-10-02T15:00:00Z + - value: key2 + created_at: 2020-10-02T15:00:00Z +foo/bar: + - value: 123abc + created_at: 2019-10-02T15:00:00Z + - value: key6 + created_at: 2020-10-02T15:00:00Z +` + return []byte(repoLevelSec) + } + + lgtmComment, err := ioutil.ReadFile("../../../../test/testdata/lgtm_comment.json") + if err != nil { + t.Fatalf("read lgtm comment file failed: %v", err) + } + + openedPR, err := ioutil.ReadFile("../../../../test/testdata/opened_pr.json") + if err != nil { + t.Fatalf("read opened PR file failed: %v", err) + } + + // This is the SHA1 signature for payload "{}" and signature "abc" + // echo -n '{}' | openssl dgst -sha1 -hmac abc + var testcases = []struct { + name string + + Method string + Header map[string]string + Body string + Code int + }{ + { + name: "Issue comment event", + + Method: http.MethodPost, + Header: map[string]string{ + "X-GitHub-Event": "issue_comment", + "X-GitHub-Delivery": "I am unique", + "X-Hub-Signature": "sha1=f3fee26b22d3748f393f7e37f71baa467495971a", + "content-type": "application/json", + }, + Body: string(lgtmComment), + Code: http.StatusOK, + }, + { + name: "Pull request event", + + Method: http.MethodPost, + Header: map[string]string{ + "X-GitHub-Event": "pull_request", + "X-GitHub-Delivery": "I am unique", + "X-Hub-Signature": "sha1=9a62c443a5ab561e023e64610dc467523188defc", + "content-type": "application/json", + }, + Body: string(openedPR), + Code: http.StatusOK, + }, + } + + for _, tc := range testcases { + t.Logf("Running scenario %q", tc.name) + + w := httptest.NewRecorder() + r, err := http.NewRequest(tc.Method, "", strings.NewReader(tc.Body)) + if err != nil { + t.Fatal(err) + } + for k, v := range tc.Header { + r.Header.Set(k, v) + } + + cfg := &externalplugins.Configuration{} + cfg.TiCommunityCherrypicker = []externalplugins.TiCommunityCherrypicker{ + { + Repos: []string{"foo/bar"}, + }, + } + ca := &externalplugins.ConfigAgent{} + ca.Set(cfg) + + s := Server{ + TokenGenerator: getSecret, + ConfigAgent: ca, + } + + s.ServeHTTP(w, r) + if w.Code != tc.Code { + t.Errorf("For test case: %+v\nExpected code %v, got code %v", tc, tc.Code, w.Code) + } + } +} + +func TestHelpProvider(t *testing.T) { + enabledRepos := []config.OrgRepo{ + {Org: "org1", Repo: "repo"}, + {Org: "org2", Repo: "repo"}, + } + cases := []struct { + name string + config *externalplugins.Configuration + enabledRepos []config.OrgRepo + err bool + configInfoIncludes []string + configInfoExcludes []string + }{ + { + name: "Empty config", + config: &externalplugins.Configuration{ + TiCommunityCherrypicker: []externalplugins.TiCommunityCherrypicker{ + { + Repos: []string{"org2/repo"}, + }, + }, + }, + enabledRepos: enabledRepos, + configInfoIncludes: []string{"For this repository, only Org members are allowed to do cherry picking."}, + configInfoExcludes: []string{"The current label prefix for cherry pick is: ", + "For this repository, cherry picking is available to all.", + "When a cherry picking PR conflicts, an issue will be created to track it."}, + }, + { + name: "All configs enabled", + config: &externalplugins.Configuration{ + TiCommunityCherrypicker: []externalplugins.TiCommunityCherrypicker{ + { + Repos: []string{"org2/repo"}, + LabelPrefix: "needs-cherry-pick-", + AllowAll: true, + IssueOnConflict: true, + }, + }, + }, + enabledRepos: enabledRepos, + configInfoIncludes: []string{"The current label prefix for cherry pick is: ", + "For this repository, cherry picking is available to all.", + "When a cherry picking PR conflicts, an issue will be created to track it."}, + configInfoExcludes: []string{"For this repository, only Org members are allowed to do cherry picking."}, + }, + } + for _, testcase := range cases { + tc := testcase + t.Run(tc.name, func(t *testing.T) { + epa := &externalplugins.ConfigAgent{} + epa.Set(tc.config) + + helpProvider := HelpProvider(epa) + pluginHelp, err := helpProvider(tc.enabledRepos) + if err != nil && !tc.err { + t.Fatalf("helpProvider error: %v", err) + } + for _, msg := range tc.configInfoExcludes { + if strings.Contains(pluginHelp.Config["org2/repo"], msg) { + t.Fatalf("helpProvider.Config error mismatch: got %v, but didn't want it", msg) + } + } + for _, msg := range tc.configInfoIncludes { + if !strings.Contains(pluginHelp.Config["org2/repo"], msg) { + t.Fatalf("helpProvider.Config error mismatch: didn't get %v, but wanted it", msg) + } + } + }) + } +} diff --git a/internal/pkg/externalplugins/config.go b/internal/pkg/externalplugins/config.go index 248a5a880..ccd3b97f3 100644 --- a/internal/pkg/externalplugins/config.go +++ b/internal/pkg/externalplugins/config.go @@ -24,6 +24,11 @@ const ( UnlabeledAction = "unlabeled" ) +const ( + // DefaultCherryPickLabelPrefix defines the default label prefix for cherrypicker plugin. + DefaultCherryPickLabelPrefix = "cherrypick/" +) + // Configuration is the top-level serialization target for external plugin Configuration. type Configuration struct { TichiWebURL string `json:"tichi_web_url,omitempty"` @@ -49,6 +54,7 @@ type Configuration struct { TiCommunityTars []TiCommunityTars `json:"ti-community-tars,omitempty"` TiCommunityLabelBlocker []TiCommunityLabelBlocker `json:"ti-community-label-blocker,omitempty"` TiCommunityContribution []TiCommunityContribution `json:"ti-community-contribution,omitempty"` + TiCommunityCherrypicker []TiCommunityCherrypicker `json:"ti-community-cherrypicker,omitempty"` } // TiCommunityLgtm specifies a configuration for a single ti community lgtm. @@ -214,6 +220,27 @@ type TiCommunityContribution struct { Message string `json:"message,omitempty"` } +// TiCommunityCherrypicker is the config for the cherrypicker plugin. +type TiCommunityCherrypicker struct { + // Repos is either of the form org/repo or just org. + Repos []string `json:"repos,omitempty"` + // AllowAll specifies whether everyone is allowed to cherry pick. + AllowAll bool `json:"allow_all,omitempty"` + // IssueOnConflict specifies whether to create an Issue when there is a PR conflict. + IssueOnConflict bool `json:"create_issue_on_conflict,omitempty"` + // LabelPrefix specifies the label prefix for cherrypicker. + LabelPrefix string `json:"label_prefix,omitempty"` + // ExcludeLabels specifies the labels that need to be excluded when copying the labels of the original PR. + ExcludeLabels []string `json:"excludeLabels,omitempty"` +} + +// setDefaults will set the default value for the config of blunderbuss plugin. +func (c *TiCommunityCherrypicker) setDefaults() { + if len(c.LabelPrefix) == 0 { + c.LabelPrefix = DefaultCherryPickLabelPrefix + } +} + // LgtmFor finds the Lgtm for a repo, if one exists // a trigger can be listed for the repo itself or for the // owning organization @@ -403,12 +430,37 @@ func (c *Configuration) LabelBlockerFor(org, repo string) *TiCommunityLabelBlock return &TiCommunityLabelBlocker{} } +// CherrypickerFor finds the TiCommunityCherrypicker for a repo, if one exists. +// TiCommunityCherrypicker configuration can be listed for a repository +// or an organization. +func (c *Configuration) CherrypickerFor(org, repo string) *TiCommunityCherrypicker { + fullName := fmt.Sprintf("%s/%s", org, repo) + for _, cherrypicker := range c.TiCommunityCherrypicker { + if !sets.NewString(cherrypicker.Repos...).Has(fullName) { + continue + } + return &cherrypicker + } + // If you don't find anything, loop again looking for an org config + for _, cherrypicker := range c.TiCommunityCherrypicker { + if !sets.NewString(cherrypicker.Repos...).Has(org) { + continue + } + return &cherrypicker + } + return &TiCommunityCherrypicker{} +} + // setDefaults will set the default value for the configuration of all plugins. func (c *Configuration) setDefaults() { for i := range c.TiCommunityBlunderbuss { c.TiCommunityBlunderbuss[i].setDefaults() } + for i := range c.TiCommunityCherrypicker { + c.TiCommunityCherrypicker[i].setDefaults() + } + if len(c.LogLevel) == 0 { c.LogLevel = defaultLogLevel.String() } diff --git a/internal/pkg/externalplugins/config_test.go b/internal/pkg/externalplugins/config_test.go index 3a72379bb..f1c346763 100644 --- a/internal/pkg/externalplugins/config_test.go +++ b/internal/pkg/externalplugins/config_test.go @@ -1352,3 +1352,100 @@ func TestContributionFor(t *testing.T) { }) } } + +func TestCherrypickerFor(t *testing.T) { + testcases := []struct { + name string + cherrypicker *TiCommunityCherrypicker + org string + repo string + expectEmpty *TiCommunityCherrypicker + }{ + { + name: "Full name", + cherrypicker: &TiCommunityCherrypicker{ + Repos: []string{"ti-community-infra/test-dev"}, + LabelPrefix: "cherrypick/", + }, + org: "ti-community-infra", + repo: "test-dev", + }, + { + name: "Only org", + cherrypicker: &TiCommunityCherrypicker{ + Repos: []string{"ti-community-infra"}, + LabelPrefix: "cherrypick/", + }, + org: "ti-community-infra", + repo: "test-dev", + }, + { + name: "Can not find", + cherrypicker: &TiCommunityCherrypicker{ + Repos: []string{"ti-community-infra"}, + LabelPrefix: "cherrypick/", + }, + org: "ti-community-infra1", + repo: "test-dev1", + expectEmpty: &TiCommunityCherrypicker{}, + }, + } + + for _, testcase := range testcases { + tc := testcase + t.Run(tc.name, func(t *testing.T) { + config := Configuration{TiCommunityCherrypicker: []TiCommunityCherrypicker{ + *tc.cherrypicker, + }} + + cherrypicker := config.CherrypickerFor(tc.org, tc.repo) + + if tc.expectEmpty != nil { + assert.DeepEqual(t, cherrypicker, &TiCommunityCherrypicker{}) + } else { + assert.DeepEqual(t, cherrypicker.Repos, tc.cherrypicker.Repos) + } + }) + } +} + +func TestSetCherrypickerDefaults(t *testing.T) { + testcases := []struct { + name string + labelPrefix string + expectLabelPrefix string + }{ + { + name: "default", + labelPrefix: "", + expectLabelPrefix: "cherrypick/", + }, + { + name: "overwrite", + labelPrefix: "needs-cherry-pick-", + expectLabelPrefix: "needs-cherry-pick-", + }, + } + + for _, testcase := range testcases { + tc := testcase + t.Run(tc.name, func(t *testing.T) { + c := &Configuration{ + TiCommunityCherrypicker: []TiCommunityCherrypicker{ + { + LabelPrefix: tc.labelPrefix, + }, + }, + } + + c.setDefaults() + + for _, cherrypicker := range c.TiCommunityCherrypicker { + if cherrypicker.LabelPrefix != tc.expectLabelPrefix { + t.Errorf("unexpected labelPrefix: %v, expected: %v", + cherrypicker.LabelPrefix, tc.expectLabelPrefix) + } + } + }) + } +} diff --git a/test/testdata/lgtm_comment.json b/test/testdata/lgtm_comment.json new file mode 100644 index 000000000..85fefc817 --- /dev/null +++ b/test/testdata/lgtm_comment.json @@ -0,0 +1,258 @@ +{ + "action": "created", + "issue": { + "url": "https://api.github.com/repos/kubernetes/test-infra/issues/947", + "repository_url": "https://api.github.com/repos/kubernetes/test-infra", + "labels_url": "https://api.github.com/repos/kubernetes/test-infra/issues/947/labels{/name}", + "comments_url": "https://api.github.com/repos/kubernetes/test-infra/issues/947/comments", + "events_url": "https://api.github.com/repos/kubernetes/test-infra/issues/947/events", + "html_url": "https://github.com/kubernetes/test-infra/pull/947", + "id": 185981642, + "number": 947, + "title": "Dummy PR, do not merge", + "user": { + "login": "spxtr", + "id": 7368979, + "avatar_url": "https://avatars.githubusercontent.com/u/7368979?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/spxtr", + "html_url": "https://github.com/spxtr", + "followers_url": "https://api.github.com/users/spxtr/followers", + "following_url": "https://api.github.com/users/spxtr/following{/other_user}", + "gists_url": "https://api.github.com/users/spxtr/gists{/gist_id}", + "starred_url": "https://api.github.com/users/spxtr/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/spxtr/subscriptions", + "organizations_url": "https://api.github.com/users/spxtr/orgs", + "repos_url": "https://api.github.com/users/spxtr/repos", + "events_url": "https://api.github.com/users/spxtr/events{/privacy}", + "received_events_url": "https://api.github.com/users/spxtr/received_events", + "type": "User", + "site_admin": false + }, + "labels": [ + { + "id": 369949136, + "url": "https://api.github.com/repos/kubernetes/test-infra/labels/cla:%20yes", + "name": "cla: yes", + "color": "bfe5bf", + "default": false + }, + { + "id": 484580953, + "url": "https://api.github.com/repos/kubernetes/test-infra/labels/cncf-cla:%20yes", + "name": "cncf-cla: yes", + "color": "0e8a16", + "default": false + }, + { + "id": 369949531, + "url": "https://api.github.com/repos/kubernetes/test-infra/labels/do-not-merge", + "name": "do-not-merge", + "color": "e11d21", + "default": false + } + ], + "state": "open", + "locked": false, + "assignee": { + "login": "spxtr", + "id": 7368979, + "avatar_url": "https://avatars.githubusercontent.com/u/7368979?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/spxtr", + "html_url": "https://github.com/spxtr", + "followers_url": "https://api.github.com/users/spxtr/followers", + "following_url": "https://api.github.com/users/spxtr/following{/other_user}", + "gists_url": "https://api.github.com/users/spxtr/gists{/gist_id}", + "starred_url": "https://api.github.com/users/spxtr/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/spxtr/subscriptions", + "organizations_url": "https://api.github.com/users/spxtr/orgs", + "repos_url": "https://api.github.com/users/spxtr/repos", + "events_url": "https://api.github.com/users/spxtr/events{/privacy}", + "received_events_url": "https://api.github.com/users/spxtr/received_events", + "type": "User", + "site_admin": false + }, + "assignees": [ + { + "login": "spxtr", + "id": 7368979, + "avatar_url": "https://avatars.githubusercontent.com/u/7368979?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/spxtr", + "html_url": "https://github.com/spxtr", + "followers_url": "https://api.github.com/users/spxtr/followers", + "following_url": "https://api.github.com/users/spxtr/following{/other_user}", + "gists_url": "https://api.github.com/users/spxtr/gists{/gist_id}", + "starred_url": "https://api.github.com/users/spxtr/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/spxtr/subscriptions", + "organizations_url": "https://api.github.com/users/spxtr/orgs", + "repos_url": "https://api.github.com/users/spxtr/repos", + "events_url": "https://api.github.com/users/spxtr/events{/privacy}", + "received_events_url": "https://api.github.com/users/spxtr/received_events", + "type": "User", + "site_admin": false + } + ], + "milestone": null, + "comments": 15, + "created_at": "2016-10-28T17:48:19Z", + "updated_at": "2016-11-24T05:32:37Z", + "closed_at": null, + "pull_request": { + "url": "https://api.github.com/repos/kubernetes/test-infra/pulls/947", + "html_url": "https://github.com/kubernetes/test-infra/pull/947", + "diff_url": "https://github.com/kubernetes/test-infra/pull/947.diff", + "patch_url": "https://github.com/kubernetes/test-infra/pull/947.patch" + }, + "body": "\n\nThis change is [\"Reviewable\"/](https://reviewable.kubernetes.io/reviews/kubernetes/test-infra/947)\n\n\n" + }, + "comment": { + "url": "https://api.github.com/repos/kubernetes/test-infra/issues/comments/262693732", + "html_url": "https://github.com/kubernetes/test-infra/pull/947#issuecomment-262693732", + "issue_url": "https://api.github.com/repos/kubernetes/test-infra/issues/947", + "id": 262693732, + "user": { + "login": "spxtr", + "id": 7368979, + "avatar_url": "https://avatars.githubusercontent.com/u/7368979?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/spxtr", + "html_url": "https://github.com/spxtr", + "followers_url": "https://api.github.com/users/spxtr/followers", + "following_url": "https://api.github.com/users/spxtr/following{/other_user}", + "gists_url": "https://api.github.com/users/spxtr/gists{/gist_id}", + "starred_url": "https://api.github.com/users/spxtr/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/spxtr/subscriptions", + "organizations_url": "https://api.github.com/users/spxtr/orgs", + "repos_url": "https://api.github.com/users/spxtr/repos", + "events_url": "https://api.github.com/users/spxtr/events{/privacy}", + "received_events_url": "https://api.github.com/users/spxtr/received_events", + "type": "User", + "site_admin": false + }, + "created_at": "2016-11-24T05:32:37Z", + "updated_at": "2016-11-24T05:32:37Z", + "body": "/lgtm" + }, + "repository": { + "id": 57333709, + "name": "test-infra", + "full_name": "kubernetes/test-infra", + "owner": { + "login": "kubernetes", + "id": 13629408, + "avatar_url": "https://avatars.githubusercontent.com/u/13629408?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/kubernetes", + "html_url": "https://github.com/kubernetes", + "followers_url": "https://api.github.com/users/kubernetes/followers", + "following_url": "https://api.github.com/users/kubernetes/following{/other_user}", + "gists_url": "https://api.github.com/users/kubernetes/gists{/gist_id}", + "starred_url": "https://api.github.com/users/kubernetes/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/kubernetes/subscriptions", + "organizations_url": "https://api.github.com/users/kubernetes/orgs", + "repos_url": "https://api.github.com/users/kubernetes/repos", + "events_url": "https://api.github.com/users/kubernetes/events{/privacy}", + "received_events_url": "https://api.github.com/users/kubernetes/received_events", + "type": "Organization", + "site_admin": false + }, + "private": false, + "html_url": "https://github.com/kubernetes/test-infra", + "description": "Test infrastructure for the Kubernetes project.", + "fork": false, + "url": "https://api.github.com/repos/kubernetes/test-infra", + "forks_url": "https://api.github.com/repos/kubernetes/test-infra/forks", + "keys_url": "https://api.github.com/repos/kubernetes/test-infra/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/kubernetes/test-infra/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/kubernetes/test-infra/teams", + "hooks_url": "https://api.github.com/repos/kubernetes/test-infra/hooks", + "issue_events_url": "https://api.github.com/repos/kubernetes/test-infra/issues/events{/number}", + "events_url": "https://api.github.com/repos/kubernetes/test-infra/events", + "assignees_url": "https://api.github.com/repos/kubernetes/test-infra/assignees{/user}", + "branches_url": "https://api.github.com/repos/kubernetes/test-infra/branches{/branch}", + "tags_url": "https://api.github.com/repos/kubernetes/test-infra/tags", + "blobs_url": "https://api.github.com/repos/kubernetes/test-infra/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/kubernetes/test-infra/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/kubernetes/test-infra/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/kubernetes/test-infra/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/kubernetes/test-infra/statuses/{sha}", + "languages_url": "https://api.github.com/repos/kubernetes/test-infra/languages", + "stargazers_url": "https://api.github.com/repos/kubernetes/test-infra/stargazers", + "contributors_url": "https://api.github.com/repos/kubernetes/test-infra/contributors", + "subscribers_url": "https://api.github.com/repos/kubernetes/test-infra/subscribers", + "subscription_url": "https://api.github.com/repos/kubernetes/test-infra/subscription", + "commits_url": "https://api.github.com/repos/kubernetes/test-infra/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/kubernetes/test-infra/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/kubernetes/test-infra/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/kubernetes/test-infra/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/kubernetes/test-infra/contents/{+path}", + "compare_url": "https://api.github.com/repos/kubernetes/test-infra/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/kubernetes/test-infra/merges", + "archive_url": "https://api.github.com/repos/kubernetes/test-infra/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/kubernetes/test-infra/downloads", + "issues_url": "https://api.github.com/repos/kubernetes/test-infra/issues{/number}", + "pulls_url": "https://api.github.com/repos/kubernetes/test-infra/pulls{/number}", + "milestones_url": "https://api.github.com/repos/kubernetes/test-infra/milestones{/number}", + "notifications_url": "https://api.github.com/repos/kubernetes/test-infra/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/kubernetes/test-infra/labels{/name}", + "releases_url": "https://api.github.com/repos/kubernetes/test-infra/releases{/id}", + "deployments_url": "https://api.github.com/repos/kubernetes/test-infra/deployments", + "created_at": "2016-04-28T21:05:35Z", + "updated_at": "2016-11-23T18:03:45Z", + "pushed_at": "2016-11-24T03:28:18Z", + "git_url": "git://github.com/kubernetes/test-infra.git", + "ssh_url": "git@github.com:kubernetes/test-infra.git", + "clone_url": "https://github.com/kubernetes/test-infra.git", + "svn_url": "https://github.com/kubernetes/test-infra", + "homepage": null, + "size": 6673, + "stargazers_count": 31, + "watchers_count": 31, + "language": "Shell", + "has_issues": true, + "has_downloads": true, + "has_wiki": false, + "has_pages": false, + "forks_count": 73, + "mirror_url": null, + "open_issues_count": 88, + "forks": 73, + "open_issues": 88, + "watchers": 31, + "default_branch": "master" + }, + "organization": { + "login": "kubernetes", + "id": 13629408, + "url": "https://api.github.com/orgs/kubernetes", + "repos_url": "https://api.github.com/orgs/kubernetes/repos", + "events_url": "https://api.github.com/orgs/kubernetes/events", + "hooks_url": "https://api.github.com/orgs/kubernetes/hooks", + "issues_url": "https://api.github.com/orgs/kubernetes/issues", + "members_url": "https://api.github.com/orgs/kubernetes/members{/member}", + "public_members_url": "https://api.github.com/orgs/kubernetes/public_members{/member}", + "avatar_url": "https://avatars.githubusercontent.com/u/13629408?v=3", + "description": "Kubernetes" + }, + "sender": { + "login": "spxtr", + "id": 7368979, + "avatar_url": "https://avatars.githubusercontent.com/u/7368979?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/spxtr", + "html_url": "https://github.com/spxtr", + "followers_url": "https://api.github.com/users/spxtr/followers", + "following_url": "https://api.github.com/users/spxtr/following{/other_user}", + "gists_url": "https://api.github.com/users/spxtr/gists{/gist_id}", + "starred_url": "https://api.github.com/users/spxtr/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/spxtr/subscriptions", + "organizations_url": "https://api.github.com/users/spxtr/orgs", + "repos_url": "https://api.github.com/users/spxtr/repos", + "events_url": "https://api.github.com/users/spxtr/events{/privacy}", + "received_events_url": "https://api.github.com/users/spxtr/received_events", + "type": "User", + "site_admin": false + } +} diff --git a/test/testdata/opened_pr.json b/test/testdata/opened_pr.json new file mode 100644 index 000000000..785ae3632 --- /dev/null +++ b/test/testdata/opened_pr.json @@ -0,0 +1,467 @@ +{ + "action": "created", + "number": 2445, + "pull_request": { + "url": "https://api.github.com/repos/kubernetes/test-infra/pulls/2445", + "id": 91422909, + "html_url": "https://github.com/kubernetes/test-infra/pull/2445", + "diff_url": "https://github.com/kubernetes/test-infra/pull/2445.diff", + "patch_url": "https://github.com/kubernetes/test-infra/pull/2445.patch", + "issue_url": "https://api.github.com/repos/kubernetes/test-infra/issues/2445", + "number": 2445, + "state": "open", + "locked": false, + "title": "Dummy PR, do not merge", + "user": { + "login": "crimsonfaith91", + "id": 7368979, + "avatar_url": "https://avatars.githubusercontent.com/u/7368979?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/crimsonfaith91", + "html_url": "https://github.com/crimsonfaith91", + "followers_url": "https://api.github.com/users/crimsonfaith91/followers", + "following_url": "https://api.github.com/users/crimsonfaith91/following{/other_user}", + "gists_url": "https://api.github.com/users/crimsonfaith91/gists{/gist_id}", + "starred_url": "https://api.github.com/users/crimsonfaith91/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/crimsonfaith91/subscriptions", + "organizations_url": "https://api.github.com/users/crimsonfaith91/orgs", + "repos_url": "https://api.github.com/users/crimsonfaith91/repos", + "events_url": "https://api.github.com/users/crimsonfaith91/events{/privacy}", + "received_events_url": "https://api.github.com/users/crimsonfaith91/received_events", + "type": "User", + "site_admin": false + }, + "body": "\n\nThis change is [\"Reviewable\"/](https://reviewable.kubernetes.io/reviews/kubernetes/test-infra/2445)\n\n\n", + "created_at": "2016-10-28T17:48:19Z", + "updated_at": "2016-11-24T05:29:36Z", + "closed_at": null, + "merged_at": null, + "merge_commit_sha": "32d23fa94f80db58566e8c0825decd3f412a6fa3", + "assignee": { + "login": "crimsonfaith91", + "id": 7368979, + "avatar_url": "https://avatars.githubusercontent.com/u/7368979?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/crimsonfaith91", + "html_url": "https://github.com/crimsonfaith91", + "followers_url": "https://api.github.com/users/crimsonfaith91/followers", + "following_url": "https://api.github.com/users/crimsonfaith91/following{/other_user}", + "gists_url": "https://api.github.com/users/crimsonfaith91/gists{/gist_id}", + "starred_url": "https://api.github.com/users/crimsonfaith91/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/crimsonfaith91/subscriptions", + "organizations_url": "https://api.github.com/users/crimsonfaith91/orgs", + "repos_url": "https://api.github.com/users/crimsonfaith91/repos", + "events_url": "https://api.github.com/users/crimsonfaith91/events{/privacy}", + "received_events_url": "https://api.github.com/users/crimsonfaith91/received_events", + "type": "User", + "site_admin": false + }, + "assignees": [ + { + "login": "crimsonfaith91", + "id": 7368979, + "avatar_url": "https://avatars.githubusercontent.com/u/7368979?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/crimsonfaith91", + "html_url": "https://github.com/crimsonfaith91", + "followers_url": "https://api.github.com/users/crimsonfaith91/followers", + "following_url": "https://api.github.com/users/crimsonfaith91/following{/other_user}", + "gists_url": "https://api.github.com/users/crimsonfaith91/gists{/gist_id}", + "starred_url": "https://api.github.com/users/crimsonfaith91/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/crimsonfaith91/subscriptions", + "organizations_url": "https://api.github.com/users/crimsonfaith91/orgs", + "repos_url": "https://api.github.com/users/crimsonfaith91/repos", + "events_url": "https://api.github.com/users/crimsonfaith91/events{/privacy}", + "received_events_url": "https://api.github.com/users/crimsonfaith91/received_events", + "type": "User", + "site_admin": false + } + ], + "milestone": null, + "commits_url": "https://api.github.com/repos/kubernetes/test-infra/pulls/2445/commits", + "review_comments_url": "https://api.github.com/repos/kubernetes/test-infra/pulls/2445/comments", + "review_comment_url": "https://api.github.com/repos/kubernetes/test-infra/pulls/comments{/number}", + "comments_url": "https://api.github.com/repos/kubernetes/test-infra/issues/2445/comments", + "statuses_url": "https://api.github.com/repos/kubernetes/test-infra/statuses/cc40e85eea54003aee18a6c9496eaae5c4b20780", + "head": { + "label": "crimsonfaith91:dummy", + "ref": "dummy", + "sha": "cc40e85eea54003aee18a6c9496eaae5c4b20780", + "user": { + "login": "crimsonfaith91", + "id": 7368979, + "avatar_url": "https://avatars.githubusercontent.com/u/7368979?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/crimsonfaith91", + "html_url": "https://github.com/crimsonfaith91", + "followers_url": "https://api.github.com/users/crimsonfaith91/followers", + "following_url": "https://api.github.com/users/crimsonfaith91/following{/other_user}", + "gists_url": "https://api.github.com/users/crimsonfaith91/gists{/gist_id}", + "starred_url": "https://api.github.com/users/crimsonfaith91/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/crimsonfaith91/subscriptions", + "organizations_url": "https://api.github.com/users/crimsonfaith91/orgs", + "repos_url": "https://api.github.com/users/crimsonfaith91/repos", + "events_url": "https://api.github.com/users/crimsonfaith91/events{/privacy}", + "received_events_url": "https://api.github.com/users/crimsonfaith91/received_events", + "type": "User", + "site_admin": false + }, + "repo": { + "id": 57335659, + "name": "test-infra", + "full_name": "crimsonfaith91/test-infra", + "owner": { + "login": "crimsonfaith91", + "id": 7368979, + "avatar_url": "https://avatars.githubusercontent.com/u/7368979?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/crimsonfaith91", + "html_url": "https://github.com/crimsonfaith91", + "followers_url": "https://api.github.com/users/crimsonfaith91/followers", + "following_url": "https://api.github.com/users/crimsonfaith91/following{/other_user}", + "gists_url": "https://api.github.com/users/crimsonfaith91/gists{/gist_id}", + "starred_url": "https://api.github.com/users/crimsonfaith91/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/crimsonfaith91/subscriptions", + "organizations_url": "https://api.github.com/users/crimsonfaith91/orgs", + "repos_url": "https://api.github.com/users/crimsonfaith91/repos", + "events_url": "https://api.github.com/users/crimsonfaith91/events{/privacy}", + "received_events_url": "https://api.github.com/users/crimsonfaith91/received_events", + "type": "User", + "site_admin": false + }, + "private": false, + "html_url": "https://github.com/crimsonfaith91/test-infra", + "description": "Test infrastructure for the Kubernetes project.", + "fork": true, + "url": "https://api.github.com/repos/crimsonfaith91/test-infra", + "forks_url": "https://api.github.com/repos/crimsonfaith91/test-infra/forks", + "keys_url": "https://api.github.com/repos/crimsonfaith91/test-infra/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/crimsonfaith91/test-infra/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/crimsonfaith91/test-infra/teams", + "hooks_url": "https://api.github.com/repos/crimsonfaith91/test-infra/hooks", + "issue_events_url": "https://api.github.com/repos/crimsonfaith91/test-infra/issues/events{/number}", + "events_url": "https://api.github.com/repos/crimsonfaith91/test-infra/events", + "assignees_url": "https://api.github.com/repos/crimsonfaith91/test-infra/assignees{/user}", + "branches_url": "https://api.github.com/repos/crimsonfaith91/test-infra/branches{/branch}", + "tags_url": "https://api.github.com/repos/crimsonfaith91/test-infra/tags", + "blobs_url": "https://api.github.com/repos/crimsonfaith91/test-infra/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/crimsonfaith91/test-infra/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/crimsonfaith91/test-infra/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/crimsonfaith91/test-infra/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/crimsonfaith91/test-infra/statuses/{sha}", + "languages_url": "https://api.github.com/repos/crimsonfaith91/test-infra/languages", + "stargazers_url": "https://api.github.com/repos/crimsonfaith91/test-infra/stargazers", + "contributors_url": "https://api.github.com/repos/crimsonfaith91/test-infra/contributors", + "subscribers_url": "https://api.github.com/repos/crimsonfaith91/test-infra/subscribers", + "subscription_url": "https://api.github.com/repos/crimsonfaith91/test-infra/subscription", + "commits_url": "https://api.github.com/repos/crimsonfaith91/test-infra/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/crimsonfaith91/test-infra/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/crimsonfaith91/test-infra/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/crimsonfaith91/test-infra/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/crimsonfaith91/test-infra/contents/{+path}", + "compare_url": "https://api.github.com/repos/crimsonfaith91/test-infra/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/crimsonfaith91/test-infra/merges", + "archive_url": "https://api.github.com/repos/crimsonfaith91/test-infra/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/crimsonfaith91/test-infra/downloads", + "issues_url": "https://api.github.com/repos/crimsonfaith91/test-infra/issues{/number}", + "pulls_url": "https://api.github.com/repos/crimsonfaith91/test-infra/pulls{/number}", + "milestones_url": "https://api.github.com/repos/crimsonfaith91/test-infra/milestones{/number}", + "notifications_url": "https://api.github.com/repos/crimsonfaith91/test-infra/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/crimsonfaith91/test-infra/labels{/name}", + "releases_url": "https://api.github.com/repos/crimsonfaith91/test-infra/releases{/id}", + "deployments_url": "https://api.github.com/repos/crimsonfaith91/test-infra/deployments", + "created_at": "2016-04-28T21:42:21Z", + "updated_at": "2016-11-16T19:12:55Z", + "pushed_at": "2016-11-24T03:28:38Z", + "git_url": "git://github.com/crimsonfaith91/test-infra.git", + "ssh_url": "git@github.com:crimsonfaith91/test-infra.git", + "clone_url": "https://github.com/crimsonfaith91/test-infra.git", + "svn_url": "https://github.com/crimsonfaith91/test-infra", + "homepage": null, + "size": 6114, + "stargazers_count": 1, + "watchers_count": 1, + "language": "Shell", + "has_issues": false, + "has_downloads": true, + "has_wiki": false, + "has_pages": false, + "forks_count": 0, + "mirror_url": null, + "open_issues_count": 0, + "forks": 0, + "open_issues": 0, + "watchers": 1, + "default_branch": "master" + } + }, + "base": { + "label": "kubernetes:master", + "ref": "master", + "sha": "470d4adda3efc04aa5de591feb4b66f2b3f33153", + "user": { + "login": "kubernetes", + "id": 13629408, + "avatar_url": "https://avatars.githubusercontent.com/u/13629408?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/kubernetes", + "html_url": "https://github.com/kubernetes", + "followers_url": "https://api.github.com/users/kubernetes/followers", + "following_url": "https://api.github.com/users/kubernetes/following{/other_user}", + "gists_url": "https://api.github.com/users/kubernetes/gists{/gist_id}", + "starred_url": "https://api.github.com/users/kubernetes/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/kubernetes/subscriptions", + "organizations_url": "https://api.github.com/users/kubernetes/orgs", + "repos_url": "https://api.github.com/users/kubernetes/repos", + "events_url": "https://api.github.com/users/kubernetes/events{/privacy}", + "received_events_url": "https://api.github.com/users/kubernetes/received_events", + "type": "Organization", + "site_admin": false + }, + "repo": { + "id": 57333709, + "name": "test-infra", + "full_name": "kubernetes/test-infra", + "owner": { + "login": "kubernetes", + "id": 13629408, + "avatar_url": "https://avatars.githubusercontent.com/u/13629408?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/kubernetes", + "html_url": "https://github.com/kubernetes", + "followers_url": "https://api.github.com/users/kubernetes/followers", + "following_url": "https://api.github.com/users/kubernetes/following{/other_user}", + "gists_url": "https://api.github.com/users/kubernetes/gists{/gist_id}", + "starred_url": "https://api.github.com/users/kubernetes/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/kubernetes/subscriptions", + "organizations_url": "https://api.github.com/users/kubernetes/orgs", + "repos_url": "https://api.github.com/users/kubernetes/repos", + "events_url": "https://api.github.com/users/kubernetes/events{/privacy}", + "received_events_url": "https://api.github.com/users/kubernetes/received_events", + "type": "Organization", + "site_admin": false + }, + "private": false, + "html_url": "https://github.com/kubernetes/test-infra", + "description": "Test infrastructure for the Kubernetes project.", + "fork": false, + "url": "https://api.github.com/repos/kubernetes/test-infra", + "forks_url": "https://api.github.com/repos/kubernetes/test-infra/forks", + "keys_url": "https://api.github.com/repos/kubernetes/test-infra/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/kubernetes/test-infra/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/kubernetes/test-infra/teams", + "hooks_url": "https://api.github.com/repos/kubernetes/test-infra/hooks", + "issue_events_url": "https://api.github.com/repos/kubernetes/test-infra/issues/events{/number}", + "events_url": "https://api.github.com/repos/kubernetes/test-infra/events", + "assignees_url": "https://api.github.com/repos/kubernetes/test-infra/assignees{/user}", + "branches_url": "https://api.github.com/repos/kubernetes/test-infra/branches{/branch}", + "tags_url": "https://api.github.com/repos/kubernetes/test-infra/tags", + "blobs_url": "https://api.github.com/repos/kubernetes/test-infra/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/kubernetes/test-infra/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/kubernetes/test-infra/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/kubernetes/test-infra/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/kubernetes/test-infra/statuses/{sha}", + "languages_url": "https://api.github.com/repos/kubernetes/test-infra/languages", + "stargazers_url": "https://api.github.com/repos/kubernetes/test-infra/stargazers", + "contributors_url": "https://api.github.com/repos/kubernetes/test-infra/contributors", + "subscribers_url": "https://api.github.com/repos/kubernetes/test-infra/subscribers", + "subscription_url": "https://api.github.com/repos/kubernetes/test-infra/subscription", + "commits_url": "https://api.github.com/repos/kubernetes/test-infra/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/kubernetes/test-infra/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/kubernetes/test-infra/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/kubernetes/test-infra/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/kubernetes/test-infra/contents/{+path}", + "compare_url": "https://api.github.com/repos/kubernetes/test-infra/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/kubernetes/test-infra/merges", + "archive_url": "https://api.github.com/repos/kubernetes/test-infra/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/kubernetes/test-infra/downloads", + "issues_url": "https://api.github.com/repos/kubernetes/test-infra/issues{/number}", + "pulls_url": "https://api.github.com/repos/kubernetes/test-infra/pulls{/number}", + "milestones_url": "https://api.github.com/repos/kubernetes/test-infra/milestones{/number}", + "notifications_url": "https://api.github.com/repos/kubernetes/test-infra/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/kubernetes/test-infra/labels{/name}", + "releases_url": "https://api.github.com/repos/kubernetes/test-infra/releases{/id}", + "deployments_url": "https://api.github.com/repos/kubernetes/test-infra/deployments", + "created_at": "2016-04-28T21:05:35Z", + "updated_at": "2016-11-23T18:03:45Z", + "pushed_at": "2016-11-24T03:28:18Z", + "git_url": "git://github.com/kubernetes/test-infra.git", + "ssh_url": "git@github.com:kubernetes/test-infra.git", + "clone_url": "https://github.com/kubernetes/test-infra.git", + "svn_url": "https://github.com/kubernetes/test-infra", + "homepage": null, + "size": 6673, + "stargazers_count": 31, + "watchers_count": 31, + "language": "Shell", + "has_issues": true, + "has_downloads": true, + "has_wiki": false, + "has_pages": false, + "forks_count": 73, + "mirror_url": null, + "open_issues_count": 88, + "forks": 73, + "open_issues": 88, + "watchers": 31, + "default_branch": "master" + } + }, + "_links": { + "self": { + "href": "https://api.github.com/repos/kubernetes/test-infra/pulls/2445" + }, + "html": { + "href": "https://github.com/kubernetes/test-infra/pull/2445" + }, + "issue": { + "href": "https://api.github.com/repos/kubernetes/test-infra/issues/2445" + }, + "comments": { + "href": "https://api.github.com/repos/kubernetes/test-infra/issues/2445/comments" + }, + "review_comments": { + "href": "https://api.github.com/repos/kubernetes/test-infra/pulls/2445/comments" + }, + "review_comment": { + "href": "https://api.github.com/repos/kubernetes/test-infra/pulls/comments{/number}" + }, + "commits": { + "href": "https://api.github.com/repos/kubernetes/test-infra/pulls/2445/commits" + }, + "statuses": { + "href": "https://api.github.com/repos/kubernetes/test-infra/statuses/cc40e85eea54003aee18a6c9496eaae5c4b20780" + } + }, + "merged": false, + "mergeable": true, + "mergeable_state": "unstable", + "merged_by": null, + "comments": 14, + "review_comments": 0, + "commits": 1, + "additions": 0, + "deletions": 0, + "changed_files": 1 + }, + "repository": { + "id": 57333709, + "name": "test-infra", + "full_name": "kubernetes/test-infra", + "owner": { + "login": "kubernetes", + "id": 13629408, + "avatar_url": "https://avatars.githubusercontent.com/u/13629408?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/kubernetes", + "html_url": "https://github.com/kubernetes", + "followers_url": "https://api.github.com/users/kubernetes/followers", + "following_url": "https://api.github.com/users/kubernetes/following{/other_user}", + "gists_url": "https://api.github.com/users/kubernetes/gists{/gist_id}", + "starred_url": "https://api.github.com/users/kubernetes/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/kubernetes/subscriptions", + "organizations_url": "https://api.github.com/users/kubernetes/orgs", + "repos_url": "https://api.github.com/users/kubernetes/repos", + "events_url": "https://api.github.com/users/kubernetes/events{/privacy}", + "received_events_url": "https://api.github.com/users/kubernetes/received_events", + "type": "Organization", + "site_admin": false + }, + "private": false, + "html_url": "https://github.com/kubernetes/test-infra", + "description": "Test infrastructure for the Kubernetes project.", + "fork": false, + "url": "https://api.github.com/repos/kubernetes/test-infra", + "forks_url": "https://api.github.com/repos/kubernetes/test-infra/forks", + "keys_url": "https://api.github.com/repos/kubernetes/test-infra/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/kubernetes/test-infra/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/kubernetes/test-infra/teams", + "hooks_url": "https://api.github.com/repos/kubernetes/test-infra/hooks", + "issue_events_url": "https://api.github.com/repos/kubernetes/test-infra/issues/events{/number}", + "events_url": "https://api.github.com/repos/kubernetes/test-infra/events", + "assignees_url": "https://api.github.com/repos/kubernetes/test-infra/assignees{/user}", + "branches_url": "https://api.github.com/repos/kubernetes/test-infra/branches{/branch}", + "tags_url": "https://api.github.com/repos/kubernetes/test-infra/tags", + "blobs_url": "https://api.github.com/repos/kubernetes/test-infra/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/kubernetes/test-infra/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/kubernetes/test-infra/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/kubernetes/test-infra/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/kubernetes/test-infra/statuses/{sha}", + "languages_url": "https://api.github.com/repos/kubernetes/test-infra/languages", + "stargazers_url": "https://api.github.com/repos/kubernetes/test-infra/stargazers", + "contributors_url": "https://api.github.com/repos/kubernetes/test-infra/contributors", + "subscribers_url": "https://api.github.com/repos/kubernetes/test-infra/subscribers", + "subscription_url": "https://api.github.com/repos/kubernetes/test-infra/subscription", + "commits_url": "https://api.github.com/repos/kubernetes/test-infra/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/kubernetes/test-infra/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/kubernetes/test-infra/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/kubernetes/test-infra/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/kubernetes/test-infra/contents/{+path}", + "compare_url": "https://api.github.com/repos/kubernetes/test-infra/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/kubernetes/test-infra/merges", + "archive_url": "https://api.github.com/repos/kubernetes/test-infra/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/kubernetes/test-infra/downloads", + "issues_url": "https://api.github.com/repos/kubernetes/test-infra/issues{/number}", + "pulls_url": "https://api.github.com/repos/kubernetes/test-infra/pulls{/number}", + "milestones_url": "https://api.github.com/repos/kubernetes/test-infra/milestones{/number}", + "notifications_url": "https://api.github.com/repos/kubernetes/test-infra/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/kubernetes/test-infra/labels{/name}", + "releases_url": "https://api.github.com/repos/kubernetes/test-infra/releases{/id}", + "deployments_url": "https://api.github.com/repos/kubernetes/test-infra/deployments", + "created_at": "2016-04-28T21:05:35Z", + "updated_at": "2016-11-23T18:03:45Z", + "pushed_at": "2016-11-24T03:28:18Z", + "git_url": "git://github.com/kubernetes/test-infra.git", + "ssh_url": "git@github.com:kubernetes/test-infra.git", + "clone_url": "https://github.com/kubernetes/test-infra.git", + "svn_url": "https://github.com/kubernetes/test-infra", + "homepage": null, + "size": 6673, + "stargazers_count": 31, + "watchers_count": 31, + "language": "Shell", + "has_issues": true, + "has_downloads": true, + "has_wiki": false, + "has_pages": false, + "forks_count": 73, + "mirror_url": null, + "open_issues_count": 88, + "forks": 73, + "open_issues": 88, + "watchers": 31, + "default_branch": "master" + }, + "organization": { + "login": "kubernetes", + "id": 13629408, + "url": "https://api.github.com/orgs/kubernetes", + "repos_url": "https://api.github.com/orgs/kubernetes/repos", + "events_url": "https://api.github.com/orgs/kubernetes/events", + "hooks_url": "https://api.github.com/orgs/kubernetes/hooks", + "issues_url": "https://api.github.com/orgs/kubernetes/issues", + "members_url": "https://api.github.com/orgs/kubernetes/members{/member}", + "public_members_url": "https://api.github.com/orgs/kubernetes/public_members{/member}", + "avatar_url": "https://avatars.githubusercontent.com/u/13629408?v=3", + "description": "Kubernetes" + }, + "sender": { + "login": "crimsonfaith91", + "id": 13921630, + "avatar_url": "https://avatars.githubusercontent.com/u/7368979?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/crimsonfaith91", + "html_url": "https://github.com/crimsonfaith91", + "followers_url": "https://api.github.com/users/crimsonfaith91/followers", + "following_url": "https://api.github.com/users/crimsonfaith91/following{/other_user}", + "gists_url": "https://api.github.com/users/crimsonfaith91/gists{/gist_id}", + "starred_url": "https://api.github.com/users/crimsonfaith91/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/crimsonfaith91/subscriptions", + "organizations_url": "https://api.github.com/users/crimsonfaith91/orgs", + "repos_url": "https://api.github.com/users/crimsonfaith91/repos", + "events_url": "https://api.github.com/users/crimsonfaith91/events{/privacy}", + "received_events_url": "https://api.github.com/users/crimsonfaith91/received_events", + "type": "User", + "site_admin": false + } +}