github.com/abayer/test-infra@v0.0.5/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  	"github.com/shurcooL/githubv4"
    27  	"github.com/sirupsen/logrus"
    28  
    29  	"k8s.io/test-infra/prow/github"
    30  	"k8s.io/test-infra/prow/pluginhelp"
    31  	"k8s.io/test-infra/prow/plugins"
    32  )
    33  
    34  const (
    35  	pluginName         = "needs-rebase"
    36  	needsRebaseLabel   = "needs-rebase"
    37  	needsRebaseMessage = "PR needs rebase."
    38  )
    39  
    40  var sleep = time.Sleep
    41  
    42  type githubClient interface {
    43  	GetIssueLabels(org, repo string, number int) ([]github.Label, error)
    44  	CreateComment(org, repo string, number int, comment string) error
    45  	BotName() (string, error)
    46  	AddLabel(org, repo string, number int, label string) error
    47  	RemoveLabel(org, repo string, number int, label string) error
    48  	IsMergeable(org, repo string, number int, sha string) (bool, error)
    49  	DeleteStaleComments(org, repo string, number int, comments []github.IssueComment, isStale func(github.IssueComment) bool) error
    50  	Query(context.Context, interface{}, map[string]interface{}) error
    51  }
    52  
    53  type commentPruner interface {
    54  	PruneComments(shouldPrune func(github.IssueComment) bool)
    55  }
    56  
    57  // func init() {
    58  // 	plugins.RegisterPullRequestHandler(pluginName, handlePullRequestEvent, helpProvider)
    59  // }
    60  
    61  // func handlePullRequestEvent(pc plugins.PluginClient, pre github.PullRequestEvent) error {
    62  // 	return handleEvent(pc.Logger, pc.GitHubClient, pc.CommentPruner, &pre)
    63  // }
    64  
    65  func HelpProvider(enabledRepos []string) (*pluginhelp.PluginHelp, error) {
    66  	return &pluginhelp.PluginHelp{
    67  			Description: `The needs-rebase plugin manages the '` + needsRebaseLabel + `' label by removing it from Pull Requests that are mergeable and adding it to those which are not.
    68  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.`,
    69  		},
    70  		nil
    71  }
    72  
    73  func HandleEvent(log *logrus.Entry, ghc githubClient, pre *github.PullRequestEvent) error {
    74  	if pre.Action != github.PullRequestActionOpened && pre.Action != github.PullRequestActionSynchronize && pre.Action != github.PullRequestActionReopened {
    75  		return nil
    76  	}
    77  
    78  	// Before checking mergeability wait a few seconds to give github a chance to calculate it.
    79  	// This initial delay prevents us from always wasting the first API token.
    80  	sleep(time.Second * 5)
    81  
    82  	org := pre.Repo.Owner.Login
    83  	repo := pre.Repo.Name
    84  	number := pre.Number
    85  	sha := pre.PullRequest.Head.SHA
    86  
    87  	mergeable, err := ghc.IsMergeable(org, repo, number, sha)
    88  	if err != nil {
    89  		return err
    90  	}
    91  	labels, err := ghc.GetIssueLabels(org, repo, number)
    92  	if err != nil {
    93  		return err
    94  	}
    95  	hasLabel := github.HasLabel(needsRebaseLabel, labels)
    96  
    97  	return takeAction(log, ghc, org, repo, number, pre.PullRequest.User.Login, hasLabel, mergeable)
    98  }
    99  
   100  func HandleAll(log *logrus.Entry, ghc githubClient, config *plugins.Configuration) error {
   101  	log.Info("Checking all PRs.")
   102  	orgs, repos := config.EnabledReposForExternalPlugin(pluginName)
   103  	if len(orgs) == 0 && len(repos) == 0 {
   104  		log.Warnf("No repos have been configured for the %s plugin", pluginName)
   105  		return nil
   106  	}
   107  	var buf bytes.Buffer
   108  	fmt.Fprint(&buf, "is:pr is:open")
   109  	for _, org := range orgs {
   110  		fmt.Fprintf(&buf, " org:\"%s\"", org)
   111  	}
   112  	for _, repo := range repos {
   113  		fmt.Fprintf(&buf, " repo:\"%s\"", repo)
   114  	}
   115  	prs, err := search(context.Background(), log, ghc, buf.String())
   116  	if err != nil {
   117  		return err
   118  	}
   119  	log.Infof("Considering %d PRs.", len(prs))
   120  
   121  	for _, pr := range prs {
   122  		// Skip PRs that are calculating mergeability. They will be updated by event or next loop.
   123  		if pr.Mergeable == githubv4.MergeableStateUnknown {
   124  			continue
   125  		}
   126  		org := string(pr.Repository.Owner.Login)
   127  		repo := string(pr.Repository.Name)
   128  		num := int(pr.Number)
   129  		l := log.WithFields(logrus.Fields{
   130  			"org":  org,
   131  			"repo": repo,
   132  			"pr":   num,
   133  		})
   134  		hasLabel := false
   135  		for _, label := range pr.Labels.Nodes {
   136  			if label.Name == needsRebaseLabel {
   137  				hasLabel = true
   138  				break
   139  			}
   140  		}
   141  		err := takeAction(
   142  			l,
   143  			ghc,
   144  			org,
   145  			repo,
   146  			num,
   147  			string(pr.Author.Login),
   148  			hasLabel,
   149  			pr.Mergeable == githubv4.MergeableStateMergeable,
   150  		)
   151  		if err != nil {
   152  			l.WithError(err).Error("Error handling PR.")
   153  		}
   154  	}
   155  	return nil
   156  }
   157  
   158  func takeAction(log *logrus.Entry, ghc githubClient, org, repo string, num int, author string, hasLabel, mergeable bool) error {
   159  	if !mergeable && !hasLabel {
   160  		if err := ghc.AddLabel(org, repo, num, needsRebaseLabel); err != nil {
   161  			log.WithError(err).Errorf("Failed to add %q label.", needsRebaseLabel)
   162  		}
   163  		msg := plugins.FormatSimpleResponse(author, needsRebaseMessage)
   164  		return ghc.CreateComment(org, repo, num, msg)
   165  	} else if mergeable && hasLabel {
   166  		// remove label and prune comment
   167  		if err := ghc.RemoveLabel(org, repo, num, needsRebaseLabel); err != nil {
   168  			log.WithError(err).Errorf("Failed to remove %q label.", needsRebaseLabel)
   169  		}
   170  		botName, err := ghc.BotName()
   171  		if err != nil {
   172  			return err
   173  		}
   174  		return ghc.DeleteStaleComments(org, repo, num, nil, shouldPrune(botName))
   175  	}
   176  	return nil
   177  }
   178  
   179  func shouldPrune(botName string) func(github.IssueComment) bool {
   180  	return func(ic github.IssueComment) bool {
   181  		return github.NormLogin(botName) == github.NormLogin(ic.User.Login) &&
   182  			strings.Contains(ic.Body, needsRebaseMessage)
   183  	}
   184  }
   185  
   186  func search(ctx context.Context, log *logrus.Entry, ghc githubClient, q string) ([]pullRequest, error) {
   187  	var ret []pullRequest
   188  	vars := map[string]interface{}{
   189  		"query":        githubv4.String(q),
   190  		"searchCursor": (*githubv4.String)(nil),
   191  	}
   192  	var totalCost int
   193  	var remaining int
   194  	for {
   195  		sq := searchQuery{}
   196  		if err := ghc.Query(ctx, &sq, vars); err != nil {
   197  			return nil, err
   198  		}
   199  		totalCost += int(sq.RateLimit.Cost)
   200  		remaining = int(sq.RateLimit.Remaining)
   201  		for _, n := range sq.Search.Nodes {
   202  			ret = append(ret, n.PullRequest)
   203  		}
   204  		if !sq.Search.PageInfo.HasNextPage {
   205  			break
   206  		}
   207  		vars["searchCursor"] = githubv4.NewString(sq.Search.PageInfo.EndCursor)
   208  	}
   209  	log.Infof("Search for query \"%s\" cost %d point(s). %d remaining.", q, totalCost, remaining)
   210  	return ret, nil
   211  }
   212  
   213  // TODO(spxtr): Add useful information for frontend stuff such as links.
   214  type pullRequest struct {
   215  	Number githubv4.Int
   216  	Author struct {
   217  		Login githubv4.String
   218  	}
   219  	Repository struct {
   220  		Name  githubv4.String
   221  		Owner struct {
   222  			Login githubv4.String
   223  		}
   224  	}
   225  	Labels struct {
   226  		Nodes []struct {
   227  			Name githubv4.String
   228  		}
   229  	} `graphql:"labels(first:100)"`
   230  	Mergeable githubv4.MergeableState
   231  }
   232  
   233  type searchQuery struct {
   234  	RateLimit struct {
   235  		Cost      githubv4.Int
   236  		Remaining githubv4.Int
   237  	}
   238  	Search struct {
   239  		PageInfo struct {
   240  			HasNextPage githubv4.Boolean
   241  			EndCursor   githubv4.String
   242  		}
   243  		Nodes []struct {
   244  			PullRequest pullRequest `graphql:"... on PullRequest"`
   245  		}
   246  	} `graphql:"search(type: ISSUE, first: 100, after: $searchCursor, query: $query)"`
   247  }