github.com/munnerz/test-infra@v0.0.0-20190108210205-ce3d181dc989/prow/plugins/cla/cla.go (about)

     1  /*
     2  Copyright 2016 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 cla
    18  
    19  import (
    20  	"fmt"
    21  	"time"
    22  
    23  	"github.com/sirupsen/logrus"
    24  
    25  	"regexp"
    26  
    27  	"k8s.io/test-infra/prow/github"
    28  	"k8s.io/test-infra/prow/labels"
    29  	"k8s.io/test-infra/prow/pluginhelp"
    30  	"k8s.io/test-infra/prow/plugins"
    31  )
    32  
    33  const (
    34  	pluginName             = "cla"
    35  	claContextName         = "cla/linuxfoundation"
    36  	cncfclaNotFoundMessage = `Thanks for your pull request. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).
    37  
    38  :memo: **Please follow instructions at <https://git.k8s.io/community/CLA.md#the-contributor-license-agreement> to sign the CLA.**
    39  
    40  It may take a couple minutes for the CLA signature to be fully registered; after that, please reply here with a new comment and we'll verify.  Thanks.
    41  
    42  ---
    43  
    44  - If you've already signed a CLA, it's possible we don't have your GitHub username or you're using a different email address.  Check your existing CLA data and verify that your [email is set on your git commits](https://help.github.com/articles/setting-your-email-in-git/).
    45  - If you signed the CLA as a corporation, please sign in with your organization's credentials at <https://identity.linuxfoundation.org/projects/cncf> to be authorized.
    46  - If you have done the above and are still having issues with the CLA being reported as unsigned, please email the CNCF helpdesk: helpdesk@rt.linuxfoundation.org
    47  
    48  <!-- need_sender_cla -->
    49  
    50  <details>
    51  
    52  %s
    53  </details>
    54  	`
    55  	maxRetries = 5
    56  )
    57  
    58  var (
    59  	checkCLARe = regexp.MustCompile(`(?mi)^/check-cla\s*$`)
    60  )
    61  
    62  func init() {
    63  	plugins.RegisterStatusEventHandler(pluginName, handleStatusEvent, helpProvider)
    64  	plugins.RegisterGenericCommentHandler(pluginName, handleCommentEvent, helpProvider)
    65  }
    66  
    67  func helpProvider(config *plugins.Configuration, enabledRepos []string) (*pluginhelp.PluginHelp, error) {
    68  	// The {WhoCanUse, Usage, Examples, Config} fields are omitted because this plugin cannot be
    69  	// manually triggered and is not configurable.
    70  	pluginHelp := &pluginhelp.PluginHelp{
    71  		Description: "The cla plugin manages the application and removal of the 'cncf-cla' prefixed labels on pull requests as a reaction to the " + claContextName + " github status context. It is also responsible for warning unauthorized PR authors that they need to sign the CNCF CLA before their PR will be merged.",
    72  	}
    73  	pluginHelp.AddCommand(pluginhelp.Command{
    74  		Usage:       "/check-cla",
    75  		Description: "Forces rechecking of the CLA status.",
    76  		Featured:    true,
    77  		WhoCanUse:   "Anyone",
    78  		Examples:    []string{"/check-cla"},
    79  	})
    80  	return pluginHelp, nil
    81  }
    82  
    83  type gitHubClient interface {
    84  	CreateComment(owner, repo string, number int, comment string) error
    85  	AddLabel(owner, repo string, number int, label string) error
    86  	RemoveLabel(owner, repo string, number int, label string) error
    87  	GetPullRequest(owner, repo string, number int) (*github.PullRequest, error)
    88  	FindIssues(query, sort string, asc bool) ([]github.Issue, error)
    89  	GetIssueLabels(org, repo string, number int) ([]github.Label, error)
    90  	ListStatuses(org, repo, ref string) ([]github.Status, error)
    91  }
    92  
    93  func handleStatusEvent(pc plugins.Agent, se github.StatusEvent) error {
    94  	return handle(pc.GitHubClient, pc.Logger, se)
    95  }
    96  
    97  // 1. Check that the status event received from the webhook is for the CNCF-CLA.
    98  // 2. Use the github search API to search for the PRs which match the commit hash corresponding to the status event.
    99  // 3. For each issue that matches, check that the PR's HEAD commit hash against the commit hash for which the status
   100  //    was received. This is because we only care about the status associated with the last (latest) commit in a PR.
   101  // 4. Set the corresponding CLA label if needed.
   102  func handle(gc gitHubClient, log *logrus.Entry, se github.StatusEvent) error {
   103  	if se.State == "" || se.Context == "" {
   104  		return fmt.Errorf("invalid status event delivered with empty state/context")
   105  	}
   106  
   107  	if se.Context != claContextName {
   108  		// Not the CNCF CLA context, do not process this.
   109  		return nil
   110  	}
   111  
   112  	if se.State == github.StatusPending {
   113  		// do nothing and wait for state to be updated.
   114  		return nil
   115  	}
   116  
   117  	org := se.Repo.Owner.Login
   118  	repo := se.Repo.Name
   119  	log.Info("Searching for PRs matching the commit.")
   120  
   121  	var issues []github.Issue
   122  	var err error
   123  	for i := 0; i < maxRetries; i++ {
   124  		issues, err = gc.FindIssues(fmt.Sprintf("%s repo:%s/%s type:pr state:open", se.SHA, org, repo), "", false)
   125  		if err != nil {
   126  			return fmt.Errorf("error searching for issues matching commit: %v", err)
   127  		}
   128  		if len(issues) > 0 {
   129  			break
   130  		}
   131  		time.Sleep(10 * time.Second)
   132  	}
   133  	log.Infof("Found %d PRs matching commit.", len(issues))
   134  
   135  	for _, issue := range issues {
   136  		l := log.WithField("pr", issue.Number)
   137  		hasCncfYes := issue.HasLabel(labels.ClaYes)
   138  		hasCncfNo := issue.HasLabel(labels.ClaNo)
   139  		if hasCncfYes && se.State == github.StatusSuccess {
   140  			// Nothing to update.
   141  			l.Infof("PR has up-to-date %s label.", labels.ClaYes)
   142  			continue
   143  		}
   144  
   145  		if hasCncfNo && (se.State == github.StatusFailure || se.State == github.StatusError) {
   146  			// Nothing to update.
   147  			l.Infof("PR has up-to-date %s label.", labels.ClaNo)
   148  			continue
   149  		}
   150  
   151  		l.Info("PR labels may be out of date. Getting pull request info.")
   152  		pr, err := gc.GetPullRequest(org, repo, issue.Number)
   153  		if err != nil {
   154  			l.WithError(err).Warningf("Unable to fetch PR-%d from %s/%s.", issue.Number, org, repo)
   155  			continue
   156  		}
   157  
   158  		// Check if this is the latest commit in the PR.
   159  		if pr.Head.SHA != se.SHA {
   160  			l.Info("Event is not for PR HEAD, skipping.")
   161  			continue
   162  		}
   163  
   164  		number := pr.Number
   165  		if se.State == github.StatusSuccess {
   166  			if hasCncfNo {
   167  				if err := gc.RemoveLabel(org, repo, number, labels.ClaNo); err != nil {
   168  					l.WithError(err).Warningf("Could not remove %s label.", labels.ClaNo)
   169  				}
   170  			}
   171  			if err := gc.AddLabel(org, repo, number, labels.ClaYes); err != nil {
   172  				l.WithError(err).Warningf("Could not add %s label.", labels.ClaYes)
   173  			}
   174  			continue
   175  		}
   176  
   177  		// If we end up here, the status is a failure/error.
   178  		if hasCncfYes {
   179  			if err := gc.RemoveLabel(org, repo, number, labels.ClaYes); err != nil {
   180  				l.WithError(err).Warningf("Could not remove %s label.", labels.ClaYes)
   181  			}
   182  		}
   183  		if err := gc.CreateComment(org, repo, number, fmt.Sprintf(cncfclaNotFoundMessage, plugins.AboutThisBot)); err != nil {
   184  			l.WithError(err).Warning("Could not create CLA not found comment.")
   185  		}
   186  		if err := gc.AddLabel(org, repo, number, labels.ClaNo); err != nil {
   187  			l.WithError(err).Warningf("Could not add %s label.", labels.ClaNo)
   188  		}
   189  	}
   190  	return nil
   191  }
   192  
   193  func handleCommentEvent(pc plugins.Agent, ce github.GenericCommentEvent) error {
   194  	return handleComment(pc.GitHubClient, pc.Logger, &ce)
   195  }
   196  
   197  func handleComment(gc gitHubClient, log *logrus.Entry, e *github.GenericCommentEvent) error {
   198  	// Only consider open PRs and new comments.
   199  	if e.IssueState != "open" || e.Action != github.GenericCommentActionCreated {
   200  		return nil
   201  	}
   202  	// Only consider "/check-cla" comments.
   203  	if !checkCLARe.MatchString(e.Body) {
   204  		return nil
   205  	}
   206  
   207  	org := e.Repo.Owner.Login
   208  	repo := e.Repo.Name
   209  	number := e.Number
   210  	hasCLAYes := false
   211  	hasCLANo := false
   212  
   213  	// Check for existing cla labels.
   214  	issueLabels, err := gc.GetIssueLabels(org, repo, number)
   215  	if err != nil {
   216  		log.WithError(err).Errorf("Failed to get the labels on %s/%s#%d.", org, repo, number)
   217  	}
   218  	for _, candidate := range issueLabels {
   219  		if candidate.Name == labels.ClaYes {
   220  			hasCLAYes = true
   221  		}
   222  		// Could theoretically have both yes/no labels.
   223  		if candidate.Name == labels.ClaNo {
   224  			hasCLANo = true
   225  		}
   226  	}
   227  
   228  	pr, err := gc.GetPullRequest(org, repo, e.Number)
   229  	if err != nil {
   230  		log.WithError(err).Errorf("Unable to fetch PR-%d from %s/%s.", e.Number, org, repo)
   231  	}
   232  
   233  	// Check for the cla in past commit statuses, and add/remove corresponding cla label if necessary.
   234  	ref := pr.Head.SHA
   235  	statuses, err := gc.ListStatuses(org, repo, ref)
   236  	if err != nil {
   237  		log.WithError(err).Errorf("Failed to get statuses on %s/%s#%d", org, repo, number)
   238  	}
   239  
   240  	for _, status := range statuses {
   241  
   242  		// Only consider "cla/linuxfoundation" status.
   243  		if status.Context == claContextName {
   244  
   245  			// Success state implies that the cla exists, so label should be cncf-cla:yes.
   246  			if status.State == github.StatusSuccess {
   247  
   248  				// Remove cncf-cla:no (if label exists).
   249  				if hasCLANo {
   250  					if err := gc.RemoveLabel(org, repo, number, labels.ClaNo); err != nil {
   251  						log.WithError(err).Warningf("Could not remove %s label.", labels.ClaNo)
   252  					}
   253  				}
   254  
   255  				// Add cncf-cla:yes (if label doesn't exist).
   256  				if !hasCLAYes {
   257  					if err := gc.AddLabel(org, repo, number, labels.ClaYes); err != nil {
   258  						log.WithError(err).Warningf("Could not add %s label.", labels.ClaYes)
   259  					}
   260  				}
   261  
   262  				// Failure state implies that the cla does not exist, so label should be cncf-cla:no.
   263  			} else if status.State == github.StatusFailure {
   264  
   265  				// Remove cncf-cla:yes (if label exists).
   266  				if hasCLAYes {
   267  					if err := gc.RemoveLabel(org, repo, number, labels.ClaYes); err != nil {
   268  						log.WithError(err).Warningf("Could not remove %s label.", labels.ClaYes)
   269  					}
   270  				}
   271  
   272  				// Add cncf-cla:no (if label doesn't exist).
   273  				if !hasCLANo {
   274  					if err := gc.AddLabel(org, repo, number, labels.ClaNo); err != nil {
   275  						log.WithError(err).Warningf("Could not add %s label.", labels.ClaNo)
   276  					}
   277  				}
   278  			}
   279  
   280  			// Only consider the latest relevant status.
   281  			break
   282  		}
   283  	}
   284  	return nil
   285  }