github.com/munnerz/test-infra@v0.0.0-20190108210205-ce3d181dc989/prow/plugins/dco/dco.go (about) 1 /* 2 Copyright 2018 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 dco implements a DCO (https://developercertificate.org/) checker plugin 18 package dco 19 20 import ( 21 "fmt" 22 "regexp" 23 "strings" 24 25 "github.com/sirupsen/logrus" 26 27 "k8s.io/test-infra/prow/github" 28 "k8s.io/test-infra/prow/pluginhelp" 29 "k8s.io/test-infra/prow/plugins" 30 ) 31 32 const ( 33 pluginName = "dco" 34 dcoContextName = "dco" 35 dcoContextMessageFailed = "Commits in PR missing Signed-off-by" 36 dcoContextMessageSuccess = "All commits have Signed-off-by" 37 38 dcoYesLabel = "dco-signoff: yes" 39 dcoNoLabel = "dco-signoff: no" 40 dcoMsgPruneMatch = "Thanks for your pull request. Before we can look at it, you'll need to add a 'DCO signoff' to your commits." 41 dcoNotFoundMessage = `Thanks for your pull request. Before we can look at it, you'll need to add a 'DCO signoff' to your commits. 42 43 :memo: **Please follow instructions in the [contributing guide](%s) to update your commits with the DCO** 44 45 Full details of the Developer Certificate of Origin can be found at [developercertificate.org](https://developercertificate.org/). 46 47 **The list of commits missing DCO signoff**: 48 49 %s 50 51 <details> 52 53 %s 54 </details> 55 ` 56 ) 57 58 var ( 59 checkDCORe = regexp.MustCompile(`(?mi)^/check-dco\s*$`) 60 testRe = regexp.MustCompile(`(?mi)^signed-off-by:`) 61 ) 62 63 func init() { 64 plugins.RegisterPullRequestHandler(pluginName, handlePullRequestEvent, helpProvider) 65 plugins.RegisterGenericCommentHandler(pluginName, handleCommentEvent, helpProvider) 66 } 67 68 func helpProvider(config *plugins.Configuration, enabledRepos []string) (*pluginhelp.PluginHelp, error) { 69 // The Config field is omitted because this plugin does not support 70 // per-repo config 71 pluginHelp := &pluginhelp.PluginHelp{ 72 Description: "The dco plugin checks pull request commits for 'DCO sign off' and maintains the '" + dcoContextName + "' status context, as well as the 'dco' label.", 73 } 74 pluginHelp.AddCommand(pluginhelp.Command{ 75 Usage: "/check-dco", 76 Description: "Forces rechecking of the DCO status.", 77 Featured: true, 78 WhoCanUse: "Anyone", 79 Examples: []string{"/check-dco"}, 80 }) 81 return pluginHelp, nil 82 } 83 84 type gitHubClient interface { 85 BotName() (string, error) 86 CreateComment(owner, repo string, number int, comment string) error 87 GetIssueLabels(org, repo string, number int) ([]github.Label, error) 88 AddLabel(owner, repo string, number int, label string) error 89 RemoveLabel(owner, repo string, number int, label string) error 90 ListStatuses(org, repo, ref string) ([]github.Status, error) 91 CreateStatus(owner, repo, ref string, status github.Status) error 92 ListPRCommits(org, repo string, number int) ([]github.RepositoryCommit, error) 93 GetPullRequest(owner, repo string, number int) (*github.PullRequest, error) 94 } 95 96 type commentPruner interface { 97 PruneComments(shouldPrune func(github.IssueComment) bool) 98 } 99 100 // checkCommitMessages will perform the actual DCO check by retrieving all 101 // commits contained within the PR with the given number. 102 // *All* commits in the pull request *must* match the 'testRe' in order to pass. 103 func checkCommitMessages(gc gitHubClient, l *logrus.Entry, org, repo string, number int) ([]github.GitCommit, error) { 104 allCommits, err := gc.ListPRCommits(org, repo, number) 105 if err != nil { 106 return nil, fmt.Errorf("error listing commits for pull request: %v", err) 107 } 108 l.Debugf("Found %d commits in PR", len(allCommits)) 109 110 var commitsMissingDCO []github.GitCommit 111 for _, commit := range allCommits { 112 if !testRe.MatchString(commit.Commit.Message) { 113 c := commit.Commit 114 c.SHA = commit.SHA 115 commitsMissingDCO = append(commitsMissingDCO, c) 116 } 117 } 118 119 l.Debugf("All commits in PR have DCO signoff: %t", len(commitsMissingDCO) == 0) 120 return commitsMissingDCO, nil 121 } 122 123 // checkExistingStatus will retrieve the current status of the DCO context for 124 // the provided SHA. 125 func checkExistingStatus(gc gitHubClient, l *logrus.Entry, org, repo, sha string) (string, error) { 126 statuses, err := gc.ListStatuses(org, repo, sha) 127 if err != nil { 128 return "", fmt.Errorf("error listing pull request statuses: %v", err) 129 } 130 131 existingStatus := "" 132 for _, status := range statuses { 133 if status.Context != dcoContextName { 134 continue 135 } 136 existingStatus = status.State 137 break 138 } 139 l.Debugf("Existing DCO status context status is %q", existingStatus) 140 return existingStatus, nil 141 } 142 143 // checkExistingLabels will check the provided PR for the dco sign off labels, 144 // returning bool's indicating whether the 'yes' and the 'no' label are present. 145 func checkExistingLabels(gc gitHubClient, l *logrus.Entry, org, repo string, number int) (hasYesLabel, hasNoLabel bool, err error) { 146 labels, err := gc.GetIssueLabels(org, repo, number) 147 if err != nil { 148 return false, false, fmt.Errorf("error getting pull request labels: %v", err) 149 } 150 151 for _, l := range labels { 152 if l.Name == dcoYesLabel { 153 hasYesLabel = true 154 } 155 if l.Name == dcoNoLabel { 156 hasNoLabel = true 157 } 158 } 159 160 return hasYesLabel, hasNoLabel, nil 161 } 162 163 // takeAction will take appropriate action on the pull request according to its 164 // current state. 165 func takeAction(gc gitHubClient, cp commentPruner, l *logrus.Entry, org, repo string, pr github.PullRequest, commitsMissingDCO []github.GitCommit, existingStatus string, hasYesLabel, hasNoLabel, addComment bool) error { 166 targetURL := fmt.Sprintf("https://github.com/%s/%s/blob/master/CONTRIBUTING.md", org, repo) 167 168 signedOff := len(commitsMissingDCO) == 0 169 170 // handle the 'all commits signed off' case by adding appropriate labels 171 // TODO: clean-up old comments? 172 if signedOff { 173 if hasNoLabel { 174 l.Debugf("Removing %q label", dcoNoLabel) 175 // remove 'dco-signoff: no' label 176 if err := gc.RemoveLabel(org, repo, pr.Number, dcoNoLabel); err != nil { 177 return fmt.Errorf("error removing label: %v", err) 178 } 179 } 180 if !hasYesLabel { 181 l.Debugf("Adding %q label", dcoYesLabel) 182 // add 'dco-signoff: yes' label 183 if err := gc.AddLabel(org, repo, pr.Number, dcoYesLabel); err != nil { 184 return fmt.Errorf("error adding label: %v", err) 185 } 186 } 187 if existingStatus != github.StatusSuccess { 188 l.Debugf("Setting DCO status context to succeeded") 189 if err := gc.CreateStatus(org, repo, pr.Head.SHA, github.Status{ 190 Context: dcoContextName, 191 State: github.StatusSuccess, 192 TargetURL: targetURL, 193 Description: dcoContextMessageSuccess, 194 }); err != nil { 195 return fmt.Errorf("error setting pull request status: %v", err) 196 } 197 } 198 199 cp.PruneComments(shouldPrune(l)) 200 return nil 201 } 202 203 // handle the 'not all commits signed off' case 204 if !hasNoLabel { 205 l.Debugf("Adding %q label", dcoNoLabel) 206 // add 'dco-signoff: no' label 207 if err := gc.AddLabel(org, repo, pr.Number, dcoNoLabel); err != nil { 208 return fmt.Errorf("error adding label: %v", err) 209 } 210 } 211 if hasYesLabel { 212 l.Debugf("Removing %q label", dcoYesLabel) 213 // remove 'dco-signoff: yes' label 214 if err := gc.RemoveLabel(org, repo, pr.Number, dcoYesLabel); err != nil { 215 return fmt.Errorf("error removing label: %v", err) 216 } 217 } 218 if existingStatus != github.StatusFailure { 219 l.Debugf("Setting DCO status context to failed") 220 if err := gc.CreateStatus(org, repo, pr.Head.SHA, github.Status{ 221 Context: dcoContextName, 222 State: github.StatusFailure, 223 TargetURL: targetURL, 224 Description: dcoContextMessageFailed, 225 }); err != nil { 226 return fmt.Errorf("error setting pull request status: %v", err) 227 } 228 } 229 230 if addComment { 231 // prune any old comments and add a new one with the latest list of 232 // failing commits 233 cp.PruneComments(shouldPrune(l)) 234 l.Debugf("Commenting on PR to advise users of DCO check") 235 if err := gc.CreateComment(org, repo, pr.Number, fmt.Sprintf(dcoNotFoundMessage, targetURL, markdownSHAList(org, repo, commitsMissingDCO), plugins.AboutThisBot)); err != nil { 236 l.WithError(err).Warning("Could not create DCO not found comment.") 237 } 238 } 239 240 return nil 241 } 242 243 // 1. Check commit messages in the pull request for the sign-off string 244 // 2. Check the existing status context value 245 // 3. Check the existing PR labels 246 // 4. If signed off, apply appropriate labels and status context. 247 // 5. If not signed off, apply appropriate labels and status context and add a comment. 248 func handle(gc gitHubClient, cp commentPruner, log *logrus.Entry, org, repo string, pr github.PullRequest, addComment bool) error { 249 l := log.WithField("pr", pr.Number) 250 251 commitsMissingDCO, err := checkCommitMessages(gc, l, org, repo, pr.Number) 252 if err != nil { 253 l.WithError(err).Infof("Error running DCO check against commits in PR") 254 return err 255 } 256 257 existingStatus, err := checkExistingStatus(gc, l, org, repo, pr.Head.SHA) 258 if err != nil { 259 l.WithError(err).Infof("Error checking existing PR status") 260 return err 261 } 262 263 hasYesLabel, hasNoLabel, err := checkExistingLabels(gc, l, org, repo, pr.Number) 264 if err != nil { 265 l.WithError(err).Infof("Error checking existing PR labels") 266 return err 267 } 268 269 return takeAction(gc, cp, l, org, repo, pr, commitsMissingDCO, existingStatus, hasYesLabel, hasNoLabel, addComment) 270 } 271 272 func markdownSHAList(org, repo string, list []github.GitCommit) string { 273 lines := make([]string, len(list)) 274 lineFmt := "- [%s](https://github.com/%s/%s/commits/%s) %s" 275 for i, commit := range list { 276 if commit.SHA == "" { 277 continue 278 } 279 // if we somehow encounter a SHA that's less than 7 characters, we will 280 // just use it as is. 281 shortSHA := commit.SHA 282 if len(shortSHA) > 7 { 283 shortSHA = shortSHA[:7] 284 } 285 286 // get the first line of the commit 287 message := strings.Split(commit.Message, "\n")[0] 288 289 lines[i] = fmt.Sprintf(lineFmt, shortSHA, org, repo, commit.SHA, message) 290 } 291 return strings.Join(lines, "\n") 292 } 293 294 // shouldPrune finds comments left by this plugin. 295 func shouldPrune(log *logrus.Entry) func(github.IssueComment) bool { 296 return func(comment github.IssueComment) bool { 297 return strings.Contains(comment.Body, dcoMsgPruneMatch) 298 } 299 } 300 301 func handlePullRequestEvent(pc plugins.Agent, pe github.PullRequestEvent) error { 302 cp, err := pc.CommentPruner() 303 if err != nil { 304 return err 305 } 306 307 return handlePullRequest(pc.GitHubClient, cp, pc.Logger, pe) 308 } 309 310 func handlePullRequest(gc gitHubClient, cp commentPruner, log *logrus.Entry, pe github.PullRequestEvent) error { 311 org := pe.Repo.Owner.Login 312 repo := pe.Repo.Name 313 314 // we only reprocess on label, unlabel, open, reopen and synchronize events 315 // this will reduce our API token usage and save processing of unrelated events 316 switch pe.Action { 317 case github.PullRequestActionOpened, 318 github.PullRequestActionReopened, 319 github.PullRequestActionSynchronize: 320 default: 321 return nil 322 } 323 324 shouldComment := pe.Action == github.PullRequestActionSynchronize || 325 pe.Action == github.PullRequestActionOpened 326 327 return handle(gc, cp, log, org, repo, pe.PullRequest, shouldComment) 328 } 329 330 func handleCommentEvent(pc plugins.Agent, ce github.GenericCommentEvent) error { 331 cp, err := pc.CommentPruner() 332 if err != nil { 333 return err 334 } 335 336 return handleComment(pc.GitHubClient, cp, pc.Logger, ce) 337 } 338 339 func handleComment(gc gitHubClient, cp commentPruner, log *logrus.Entry, ce github.GenericCommentEvent) error { 340 // Only consider open PRs and new comments. 341 if ce.IssueState != "open" || ce.Action != github.GenericCommentActionCreated || !ce.IsPR { 342 return nil 343 } 344 // Only consider "/check-dco" comments. 345 if !checkDCORe.MatchString(ce.Body) { 346 return nil 347 } 348 349 org := ce.Repo.Owner.Login 350 repo := ce.Repo.Name 351 352 pr, err := gc.GetPullRequest(org, repo, ce.Number) 353 if err != nil { 354 return fmt.Errorf("error getting pull request for comment: %v", err) 355 } 356 357 return handle(gc, cp, log, org, repo, *pr, true) 358 }