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  }