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 }