github.com/munnerz/test-infra@v0.0.0-20190108210205-ce3d181dc989/prow/external-plugins/needs-rebase/plugin/plugin.go (about)

     1  /*
     2  Copyright 2017 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package plugin
    18  
    19  import (
    20  	"bytes"
    21  	"context"
    22  	"fmt"
    23  	"strings"
    24  	"time"
    25  
    26  	githubql "github.com/shurcooL/githubv4"
    27  	"github.com/sirupsen/logrus"
    28  
    29  	"k8s.io/test-infra/prow/github"
    30  	"k8s.io/test-infra/prow/labels"
    31  	"k8s.io/test-infra/prow/pluginhelp"
    32  	"k8s.io/test-infra/prow/plugins"
    33  )
    34  
    35  const (
    36  	// PluginName is the name of this plugin
    37  	PluginName         = labels.NeedsRebase
    38  	needsRebaseMessage = "PR needs rebase."
    39  )
    40  
    41  var sleep = time.Sleep
    42  
    43  type githubClient interface {
    44  	GetIssueLabels(org, repo string, number int) ([]github.Label, error)
    45  	CreateComment(org, repo string, number int, comment string) error
    46  	BotName() (string, error)
    47  	AddLabel(org, repo string, number int, label string) error
    48  	RemoveLabel(org, repo string, number int, label string) error
    49  	IsMergeable(org, repo string, number int, sha string) (bool, error)
    50  	DeleteStaleComments(org, repo string, number int, comments []github.IssueComment, isStale func(github.IssueComment) bool) error
    51  	Query(context.Context, interface{}, map[string]interface{}) error
    52  }
    53  
    54  type commentPruner interface {
    55  	PruneComments(shouldPrune func(github.IssueComment) bool)
    56  }
    57  
    58  // HelpProvider constructs the PluginHelp for this plugin that takes into account enabled repositories.
    59  // HelpProvider defines the type for function that construct the PluginHelp for plugins.
    60  func HelpProvider(enabledRepos []string) (*pluginhelp.PluginHelp, error) {
    61  	return &pluginhelp.PluginHelp{
    62  			Description: `The needs-rebase plugin manages the '` + labels.NeedsRebase + `' label by removing it from Pull Requests that are mergeable and adding it to those which are not.
    63  The plugin reacts to commit changes on PRs in addition to periodically scanning all open PRs for any changes to mergeability that could have resulted from changes in other PRs.`,
    64  		},
    65  		nil
    66  }
    67  
    68  // HandleEvent handles a Github PR event to determine if the "needs-rebase"
    69  // label needs to be added or removed. It depends on Github mergeability check
    70  // to decide the need for a rebase.
    71  func HandleEvent(log *logrus.Entry, ghc githubClient, pre *github.PullRequestEvent) error {
    72  	if pre.Action != github.PullRequestActionOpened && pre.Action != github.PullRequestActionSynchronize && pre.Action != github.PullRequestActionReopened {
    73  		return nil
    74  	}
    75  
    76  	// Before checking mergeability wait a few seconds to give github a chance to calculate it.
    77  	// This initial delay prevents us from always wasting the first API token.
    78  	sleep(time.Second * 5)
    79  
    80  	org := pre.Repo.Owner.Login
    81  	repo := pre.Repo.Name
    82  	number := pre.Number
    83  	sha := pre.PullRequest.Head.SHA
    84  
    85  	mergeable, err := ghc.IsMergeable(org, repo, number, sha)
    86  	if err != nil {
    87  		return err
    88  	}
    89  	issueLabels, err := ghc.GetIssueLabels(org, repo, number)
    90  	if err != nil {
    91  		return err
    92  	}
    93  	hasLabel := github.HasLabel(labels.NeedsRebase, issueLabels)
    94  
    95  	return takeAction(log, ghc, org, repo, number, pre.PullRequest.User.Login, hasLabel, mergeable)
    96  }
    97  
    98  // HandleAll checks all orgs and repos that enabled this plugin for open PRs to
    99  // determine if the "needs-rebase" label needs to be added or removed. It
   100  // depends on Github's mergeability check to decide the need for a rebase.
   101  func HandleAll(log *logrus.Entry, ghc githubClient, config *plugins.Configuration) error {
   102  	log.Info("Checking all PRs.")
   103  	orgs, repos := config.EnabledReposForExternalPlugin(PluginName)
   104  	if len(orgs) == 0 && len(repos) == 0 {
   105  		log.Warnf("No repos have been configured for the %s plugin", PluginName)
   106  		return nil
   107  	}
   108  	var buf bytes.Buffer
   109  	fmt.Fprint(&buf, "is:pr is:open")
   110  	for _, org := range orgs {
   111  		fmt.Fprintf(&buf, " org:\"%s\"", org)
   112  	}
   113  	for _, repo := range repos {
   114  		fmt.Fprintf(&buf, " repo:\"%s\"", repo)
   115  	}
   116  	prs, err := search(context.Background(), log, ghc, buf.String())
   117  	if err != nil {
   118  		return err
   119  	}
   120  	log.Infof("Considering %d PRs.", len(prs))
   121  
   122  	for _, pr := range prs {
   123  		// Skip PRs that are calculating mergeability. They will be updated by event or next loop.
   124  		if pr.Mergeable == githubql.MergeableStateUnknown {
   125  			continue
   126  		}
   127  		org := string(pr.Repository.Owner.Login)
   128  		repo := string(pr.Repository.Name)
   129  		num := int(pr.Number)
   130  		l := log.WithFields(logrus.Fields{
   131  			"org":  org,
   132  			"repo": repo,
   133  			"pr":   num,
   134  		})
   135  		hasLabel := false
   136  		for _, label := range pr.Labels.Nodes {
   137  			if label.Name == labels.NeedsRebase {
   138  				hasLabel = true
   139  				break
   140  			}
   141  		}
   142  		err := takeAction(
   143  			l,
   144  			ghc,
   145  			org,
   146  			repo,
   147  			num,
   148  			string(pr.Author.Login),
   149  			hasLabel,
   150  			pr.Mergeable == githubql.MergeableStateMergeable,
   151  		)
   152  		if err != nil {
   153  			l.WithError(err).Error("Error handling PR.")
   154  		}
   155  	}
   156  	return nil
   157  }
   158  
   159  // takeAction adds or removes the "needs-rebase" label based on the current
   160  // state of the PR (hasLabel and mergeable). It also handles adding and
   161  // removing Github comments notifying the PR author that a rebase is needed.
   162  func takeAction(log *logrus.Entry, ghc githubClient, org, repo string, num int, author string, hasLabel, mergeable bool) error {
   163  	if !mergeable && !hasLabel {
   164  		if err := ghc.AddLabel(org, repo, num, labels.NeedsRebase); err != nil {
   165  			log.WithError(err).Errorf("Failed to add %q label.", labels.NeedsRebase)
   166  		}
   167  		msg := plugins.FormatSimpleResponse(author, needsRebaseMessage)
   168  		return ghc.CreateComment(org, repo, num, msg)
   169  	} else if mergeable && hasLabel {
   170  		// remove label and prune comment
   171  		if err := ghc.RemoveLabel(org, repo, num, labels.NeedsRebase); err != nil {
   172  			log.WithError(err).Errorf("Failed to remove %q label.", labels.NeedsRebase)
   173  		}
   174  		botName, err := ghc.BotName()
   175  		if err != nil {
   176  			return err
   177  		}
   178  		return ghc.DeleteStaleComments(org, repo, num, nil, shouldPrune(botName))
   179  	}
   180  	return nil
   181  }
   182  
   183  func shouldPrune(botName string) func(github.IssueComment) bool {
   184  	return func(ic github.IssueComment) bool {
   185  		return github.NormLogin(botName) == github.NormLogin(ic.User.Login) &&
   186  			strings.Contains(ic.Body, needsRebaseMessage)
   187  	}
   188  }
   189  
   190  func search(ctx context.Context, log *logrus.Entry, ghc githubClient, q string) ([]pullRequest, error) {
   191  	var ret []pullRequest
   192  	vars := map[string]interface{}{
   193  		"query":        githubql.String(q),
   194  		"searchCursor": (*githubql.String)(nil),
   195  	}
   196  	var totalCost int
   197  	var remaining int
   198  	for {
   199  		sq := searchQuery{}
   200  		if err := ghc.Query(ctx, &sq, vars); err != nil {
   201  			return nil, err
   202  		}
   203  		totalCost += int(sq.RateLimit.Cost)
   204  		remaining = int(sq.RateLimit.Remaining)
   205  		for _, n := range sq.Search.Nodes {
   206  			ret = append(ret, n.PullRequest)
   207  		}
   208  		if !sq.Search.PageInfo.HasNextPage {
   209  			break
   210  		}
   211  		vars["searchCursor"] = githubql.NewString(sq.Search.PageInfo.EndCursor)
   212  	}
   213  	log.Infof("Search for query \"%s\" cost %d point(s). %d remaining.", q, totalCost, remaining)
   214  	return ret, nil
   215  }
   216  
   217  // TODO(spxtr): Add useful information for frontend stuff such as links.
   218  type pullRequest struct {
   219  	Number githubql.Int
   220  	Author struct {
   221  		Login githubql.String
   222  	}
   223  	Repository struct {
   224  		Name  githubql.String
   225  		Owner struct {
   226  			Login githubql.String
   227  		}
   228  	}
   229  	Labels struct {
   230  		Nodes []struct {
   231  			Name githubql.String
   232  		}
   233  	} `graphql:"labels(first:100)"`
   234  	Mergeable githubql.MergeableState
   235  }
   236  
   237  type searchQuery struct {
   238  	RateLimit struct {
   239  		Cost      githubql.Int
   240  		Remaining githubql.Int
   241  	}
   242  	Search struct {
   243  		PageInfo struct {
   244  			HasNextPage githubql.Boolean
   245  			EndCursor   githubql.String
   246  		}
   247  		Nodes []struct {
   248  			PullRequest pullRequest `graphql:"... on PullRequest"`
   249  		}
   250  	} `graphql:"search(type: ISSUE, first: 100, after: $searchCursor, query: $query)"`
   251  }