sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/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  	"sigs.k8s.io/prow/pkg/config"
    28  	"sigs.k8s.io/prow/pkg/github"
    29  	"sigs.k8s.io/prow/pkg/labels"
    30  	"sigs.k8s.io/prow/pkg/pluginhelp"
    31  	"sigs.k8s.io/prow/pkg/plugins"
    32  )
    33  
    34  const (
    35  	pluginName     = "cla"
    36  	claContextName = "EasyCLA"
    37  	maxRetries     = 5
    38  )
    39  
    40  var (
    41  	checkCLARe = regexp.MustCompile(`(?mi)^/check-cla\s*$`)
    42  )
    43  
    44  func init() {
    45  	plugins.RegisterStatusEventHandler(pluginName, handleStatusEvent, helpProvider)
    46  	plugins.RegisterGenericCommentHandler(pluginName, handleCommentEvent, helpProvider)
    47  }
    48  
    49  func helpProvider(config *plugins.Configuration, _ []config.OrgRepo) (*pluginhelp.PluginHelp, error) {
    50  	// The {WhoCanUse, Usage, Examples, Config} fields are omitted because this plugin cannot be
    51  	// manually triggered and is not configurable.
    52  	pluginHelp := &pluginhelp.PluginHelp{
    53  		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.",
    54  	}
    55  	pluginHelp.AddCommand(pluginhelp.Command{
    56  		Usage:       "/check-cla",
    57  		Description: "Forces rechecking of the CLA status.",
    58  		Featured:    true,
    59  		WhoCanUse:   "Anyone",
    60  		Examples:    []string{"/check-cla"},
    61  	})
    62  	return pluginHelp, nil
    63  }
    64  
    65  type gitHubClient interface {
    66  	AddLabel(owner, repo string, number int, label string) error
    67  	RemoveLabel(owner, repo string, number int, label string) error
    68  	GetPullRequest(owner, repo string, number int) (*github.PullRequest, error)
    69  	FindIssues(query, sort string, asc bool) ([]github.Issue, error)
    70  	GetIssueLabels(org, repo string, number int) ([]github.Label, error)
    71  	GetCombinedStatus(org, repo, ref string) (*github.CombinedStatus, error)
    72  }
    73  
    74  func handleStatusEvent(pc plugins.Agent, se github.StatusEvent) error {
    75  	return handle(pc.GitHubClient, pc.Logger, se)
    76  }
    77  
    78  //  1. Check that the status event received from the webhook is for the CNCF-CLA.
    79  //  2. Use the github search API to search for the PRs which match the commit hash corresponding to the status event.
    80  //  3. For each issue that matches, check that the PR's HEAD commit hash against the commit hash for which the status
    81  //     was received. This is because we only care about the status associated with the last (latest) commit in a PR.
    82  //  4. Set the corresponding CLA label if needed.
    83  func handle(gc gitHubClient, log *logrus.Entry, se github.StatusEvent) error {
    84  	if se.State == "" || se.Context == "" {
    85  		return fmt.Errorf("invalid status event delivered with empty state/context")
    86  	}
    87  
    88  	if se.Context != claContextName {
    89  		// Not the CNCF CLA context, do not process this.
    90  		return nil
    91  	}
    92  
    93  	if se.State == github.StatusPending {
    94  		// do nothing and wait for state to be updated.
    95  		return nil
    96  	}
    97  
    98  	org := se.Repo.Owner.Login
    99  	repo := se.Repo.Name
   100  	log.Info("Searching for PRs matching the commit.")
   101  
   102  	var issues []github.Issue
   103  	var err error
   104  	for i := 0; i < maxRetries; i++ {
   105  		issues, err = gc.FindIssues(fmt.Sprintf("%s repo:%s/%s type:pr state:open", se.SHA, org, repo), "", false)
   106  		if err != nil {
   107  			return fmt.Errorf("error searching for issues matching commit: %w", err)
   108  		}
   109  		if len(issues) > 0 {
   110  			break
   111  		}
   112  		time.Sleep(10 * time.Second)
   113  	}
   114  	log.Infof("Found %d PRs matching commit.", len(issues))
   115  
   116  	for _, issue := range issues {
   117  		l := log.WithField("pr", issue.Number)
   118  		hasCncfYes := issue.HasLabel(labels.ClaYes)
   119  		hasCncfNo := issue.HasLabel(labels.ClaNo)
   120  		if hasCncfYes && se.State == github.StatusSuccess {
   121  			// Nothing to update.
   122  			l.Infof("PR has up-to-date %s label.", labels.ClaYes)
   123  			continue
   124  		}
   125  
   126  		if hasCncfNo && (se.State == github.StatusFailure || se.State == github.StatusError) {
   127  			// Nothing to update.
   128  			l.Infof("PR has up-to-date %s label.", labels.ClaNo)
   129  			continue
   130  		}
   131  
   132  		l.Info("PR labels may be out of date. Getting pull request info.")
   133  		pr, err := gc.GetPullRequest(org, repo, issue.Number)
   134  		if err != nil {
   135  			l.WithError(err).Warningf("Unable to fetch PR-%d from %s/%s.", issue.Number, org, repo)
   136  			continue
   137  		}
   138  
   139  		// Check if this is the latest commit in the PR.
   140  		if pr.Head.SHA != se.SHA {
   141  			l.Info("Event is not for PR HEAD, skipping.")
   142  			continue
   143  		}
   144  
   145  		number := pr.Number
   146  		if se.State == github.StatusSuccess {
   147  			if hasCncfNo {
   148  				if err := gc.RemoveLabel(org, repo, number, labels.ClaNo); err != nil {
   149  					l.WithError(err).Warningf("Could not remove %s label.", labels.ClaNo)
   150  				}
   151  			}
   152  			if err := gc.AddLabel(org, repo, number, labels.ClaYes); err != nil {
   153  				l.WithError(err).Warningf("Could not add %s label.", labels.ClaYes)
   154  			}
   155  			continue
   156  		}
   157  
   158  		// If we end up here, the status is a failure/error.
   159  		if hasCncfYes {
   160  			if err := gc.RemoveLabel(org, repo, number, labels.ClaYes); err != nil {
   161  				l.WithError(err).Warningf("Could not remove %s label.", labels.ClaYes)
   162  			}
   163  		}
   164  		if err := gc.AddLabel(org, repo, number, labels.ClaNo); err != nil {
   165  			l.WithError(err).Warningf("Could not add %s label.", labels.ClaNo)
   166  		}
   167  	}
   168  	return nil
   169  }
   170  
   171  func handleCommentEvent(pc plugins.Agent, ce github.GenericCommentEvent) error {
   172  	return handleComment(pc.GitHubClient, pc.Logger, &ce)
   173  }
   174  
   175  func handleComment(gc gitHubClient, log *logrus.Entry, e *github.GenericCommentEvent) error {
   176  	// Only consider open PRs and new comments.
   177  	if e.IssueState != "open" || e.Action != github.GenericCommentActionCreated {
   178  		return nil
   179  	}
   180  	// Only consider "/check-cla" comments.
   181  	if !checkCLARe.MatchString(e.Body) {
   182  		return nil
   183  	}
   184  
   185  	org := e.Repo.Owner.Login
   186  	repo := e.Repo.Name
   187  	number := e.Number
   188  	hasCLAYes := false
   189  	hasCLANo := false
   190  
   191  	// Check for existing cla labels.
   192  	issueLabels, err := gc.GetIssueLabels(org, repo, number)
   193  	if err != nil {
   194  		log.WithError(err).Errorf("Failed to get the labels on %s/%s#%d.", org, repo, number)
   195  	}
   196  	for _, candidate := range issueLabels {
   197  		if candidate.Name == labels.ClaYes {
   198  			hasCLAYes = true
   199  		}
   200  		// Could theoretically have both yes/no labels.
   201  		if candidate.Name == labels.ClaNo {
   202  			hasCLANo = true
   203  		}
   204  	}
   205  
   206  	pr, err := gc.GetPullRequest(org, repo, e.Number)
   207  	if err != nil {
   208  		log.WithError(err).Errorf("Unable to fetch PR-%d from %s/%s.", e.Number, org, repo)
   209  	}
   210  
   211  	// Check for the cla in past commit statuses, and add/remove corresponding cla label if necessary.
   212  	ref := pr.Head.SHA
   213  	combined, err := gc.GetCombinedStatus(org, repo, ref)
   214  	if err != nil {
   215  		log.WithError(err).Errorf("Failed to get statuses on %s/%s#%d", org, repo, number)
   216  	}
   217  
   218  	for _, status := range combined.Statuses {
   219  
   220  		// Only consider the context we care about
   221  		if status.Context == claContextName {
   222  
   223  			// Success state implies that the cla exists, so label should be cncf-cla:yes.
   224  			if status.State == github.StatusSuccess {
   225  
   226  				// Remove cncf-cla:no (if label exists).
   227  				if hasCLANo {
   228  					if err := gc.RemoveLabel(org, repo, number, labels.ClaNo); err != nil {
   229  						log.WithError(err).Warningf("Could not remove %s label.", labels.ClaNo)
   230  					}
   231  				}
   232  
   233  				// Add cncf-cla:yes (if label doesn't exist).
   234  				if !hasCLAYes {
   235  					if err := gc.AddLabel(org, repo, number, labels.ClaYes); err != nil {
   236  						log.WithError(err).Warningf("Could not add %s label.", labels.ClaYes)
   237  					}
   238  				}
   239  
   240  				// Failure state implies that the cla does not exist, so label should be cncf-cla:no.
   241  			} else if status.State == github.StatusFailure {
   242  
   243  				// Remove cncf-cla:yes (if label exists).
   244  				if hasCLAYes {
   245  					if err := gc.RemoveLabel(org, repo, number, labels.ClaYes); err != nil {
   246  						log.WithError(err).Warningf("Could not remove %s label.", labels.ClaYes)
   247  					}
   248  				}
   249  
   250  				// Add cncf-cla:no (if label doesn't exist).
   251  				if !hasCLANo {
   252  					if err := gc.AddLabel(org, repo, number, labels.ClaNo); err != nil {
   253  						log.WithError(err).Warningf("Could not add %s label.", labels.ClaNo)
   254  					}
   255  				}
   256  			}
   257  
   258  			// No need to consider other contexts once you find the one you need.
   259  			break
   260  		}
   261  	}
   262  	return nil
   263  }