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 }