github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/pkg/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  	"sigs.k8s.io/prow/pkg/config"
    28  	"sigs.k8s.io/prow/pkg/github"
    29  	"sigs.k8s.io/prow/pkg/pluginhelp"
    30  	"sigs.k8s.io/prow/pkg/plugins"
    31  	"sigs.k8s.io/prow/pkg/plugins/trigger"
    32  )
    33  
    34  const (
    35  	pluginName               = "dco"
    36  	dcoContextName           = "dco"
    37  	dcoContextMessageFailed  = "Commits in PR missing Signed-off-by"
    38  	dcoContextMessageSuccess = "All commits have Signed-off-by"
    39  
    40  	dcoYesLabel        = "dco-signoff: yes"
    41  	dcoNoLabel         = "dco-signoff: no"
    42  	dcoMsgPruneMatch   = "Thanks for your pull request. Before we can look at it, you'll need to add a 'DCO signoff' to your commits."
    43  	dcoNotFoundMessage = `Thanks for your pull request. Before we can look at it, you'll need to add a 'DCO signoff' to your commits.
    44  
    45  :memo: **Please follow instructions in the [contributing guide](%s) to update your commits with the DCO**
    46  
    47  Full details of the Developer Certificate of Origin can be found at [developercertificate.org](https://developercertificate.org/).
    48  
    49  **The list of commits missing DCO signoff**:
    50  
    51  %s
    52  
    53  <details>
    54  
    55  %s
    56  </details>
    57  `
    58  )
    59  
    60  var (
    61  	checkDCORe = regexp.MustCompile(`(?mi)^/check-dco\s*$`)
    62  	testRe     = regexp.MustCompile(`(?mi)^signed-off-by:`)
    63  )
    64  
    65  func init() {
    66  	plugins.RegisterPullRequestHandler(pluginName, handlePullRequestEvent, helpProvider)
    67  	plugins.RegisterGenericCommentHandler(pluginName, handleCommentEvent, helpProvider)
    68  }
    69  
    70  func helpProvider(config *plugins.Configuration, enabledRepos []config.OrgRepo) (*pluginhelp.PluginHelp, error) {
    71  	configInfo := map[string]string{}
    72  	for _, repo := range enabledRepos {
    73  		opts := config.DcoFor(repo.Org, repo.Repo)
    74  		if opts.SkipDCOCheckForMembers || opts.SkipDCOCheckForCollaborators {
    75  			configInfo[repo.String()] = fmt.Sprintf("The trusted GitHub organization for this repository is %q.", repo)
    76  		}
    77  	}
    78  	yamlSnippet, err := plugins.CommentMap.GenYaml(&plugins.Configuration{
    79  		Dco: map[string]*plugins.Dco{
    80  			"org/repo": {
    81  				SkipDCOCheckForMembers:       true,
    82  				TrustedOrg:                   "org",
    83  				SkipDCOCheckForCollaborators: true,
    84  				ContributingRepo:             "other-org/other-repo",
    85  				ContributingBranch:           "main",
    86  				ContributingPath:             "docs/CONTRIBUTING.md",
    87  			},
    88  		},
    89  	})
    90  	if err != nil {
    91  		logrus.WithError(err).Warnf("cannot generate comments for %s plugin", pluginName)
    92  	}
    93  	pluginHelp := &pluginhelp.PluginHelp{
    94  		Description: "The dco plugin checks pull request commits for 'DCO sign off' and maintains the '" + dcoContextName + "' status context, as well as the 'dco' label.",
    95  		Config:      configInfo,
    96  		Snippet:     yamlSnippet,
    97  	}
    98  	pluginHelp.AddCommand(pluginhelp.Command{
    99  		Usage:       "/check-dco",
   100  		Description: "Forces rechecking of the DCO status.",
   101  		Featured:    true,
   102  		WhoCanUse:   "Anyone",
   103  		Examples:    []string{"/check-dco"},
   104  	})
   105  	return pluginHelp, nil
   106  }
   107  
   108  type gitHubClient interface {
   109  	IsMember(org, user string) (bool, error)
   110  	IsCollaborator(org, repo, user string) (bool, error)
   111  	CreateComment(owner, repo string, number int, comment string) error
   112  	GetIssueLabels(org, repo string, number int) ([]github.Label, error)
   113  	AddLabel(owner, repo string, number int, label string) error
   114  	RemoveLabel(owner, repo string, number int, label string) error
   115  	CreateStatus(owner, repo, ref string, status github.Status) error
   116  	ListPullRequestCommits(org, repo string, number int) ([]github.RepositoryCommit, error)
   117  	GetPullRequest(owner, repo string, number int) (*github.PullRequest, error)
   118  	GetCombinedStatus(org, repo, ref string) (*github.CombinedStatus, error)
   119  	BotUserChecker() (func(candidate string) bool, error)
   120  }
   121  
   122  type commentPruner interface {
   123  	PruneComments(shouldPrune func(github.IssueComment) bool)
   124  }
   125  
   126  // filterTrustedUsers checks whether the commits are from a trusted user and returns those that are not
   127  func filterTrustedUsers(gc gitHubClient, l *logrus.Entry, skipDCOCheckForCollaborators bool, trustedApps []string, trustedOrg, org, repo string, allCommits []github.RepositoryCommit) ([]github.RepositoryCommit, error) {
   128  	untrustedCommits := make([]github.RepositoryCommit, 0, len(allCommits))
   129  
   130  	for _, commit := range allCommits {
   131  		trustedResponse, err := trigger.TrustedUser(gc, !skipDCOCheckForCollaborators, trustedApps, trustedOrg, commit.Author.Login, org, repo)
   132  		if err != nil {
   133  			return nil, fmt.Errorf("Error checking is member trusted: %w", err)
   134  		}
   135  		if !trustedResponse.IsTrusted {
   136  			l.Debugf("Member %s is not trusted", commit.Author.Login)
   137  			untrustedCommits = append(untrustedCommits, commit)
   138  		}
   139  	}
   140  
   141  	l.Debugf("Unsigned commits from untrusted users: %d", len(untrustedCommits))
   142  	return untrustedCommits, nil
   143  }
   144  
   145  // checkCommitMessages will perform the actual DCO check by retrieving all
   146  // commits contained within the PR with the given number.
   147  // *All* commits in the pull request *must* match the 'testRe' in order to pass.
   148  func checkCommitMessages(gc gitHubClient, l *logrus.Entry, org, repo string, number int) ([]github.RepositoryCommit, error) {
   149  	allCommits, err := gc.ListPullRequestCommits(org, repo, number)
   150  	if err != nil {
   151  		return nil, fmt.Errorf("error listing commits for pull request: %w", err)
   152  	}
   153  	l.Debugf("Found %d commits in PR", len(allCommits))
   154  
   155  	var commitsMissingDCO []github.RepositoryCommit
   156  	for _, commit := range allCommits {
   157  		if !testRe.MatchString(commit.Commit.Message) {
   158  			commitsMissingDCO = append(commitsMissingDCO, commit)
   159  		}
   160  	}
   161  
   162  	l.Debugf("Commits in PR missing DCO signoff: %d", len(commitsMissingDCO))
   163  	return commitsMissingDCO, nil
   164  }
   165  
   166  // checkExistingStatus will retrieve the current status of the DCO context for
   167  // the provided SHA.
   168  func checkExistingStatus(gc gitHubClient, l *logrus.Entry, org, repo, sha string) (string, error) {
   169  	combinedStatus, err := gc.GetCombinedStatus(org, repo, sha)
   170  	if err != nil {
   171  		return "", fmt.Errorf("error listing pull request combined statuses: %w", err)
   172  	}
   173  
   174  	existingStatus := ""
   175  	for _, status := range combinedStatus.Statuses {
   176  		if status.Context != dcoContextName {
   177  			continue
   178  		}
   179  		existingStatus = status.State
   180  		break
   181  	}
   182  	l.Debugf("Existing DCO status context status is %q", existingStatus)
   183  	return existingStatus, nil
   184  }
   185  
   186  // checkExistingLabels will check the provided PR for the dco sign off labels,
   187  // returning bool's indicating whether the 'yes' and the 'no' label are present.
   188  func checkExistingLabels(gc gitHubClient, l *logrus.Entry, org, repo string, number int) (hasYesLabel, hasNoLabel bool, err error) {
   189  	labels, err := gc.GetIssueLabels(org, repo, number)
   190  	if err != nil {
   191  		return false, false, fmt.Errorf("error getting pull request labels: %w", err)
   192  	}
   193  
   194  	for _, l := range labels {
   195  		if l.Name == dcoYesLabel {
   196  			hasYesLabel = true
   197  		}
   198  		if l.Name == dcoNoLabel {
   199  			hasNoLabel = true
   200  		}
   201  	}
   202  
   203  	return hasYesLabel, hasNoLabel, nil
   204  }
   205  
   206  // takeAction will take appropriate action on the pull request according to its
   207  // current state.
   208  func takeAction(gc gitHubClient, cp commentPruner, l *logrus.Entry, org, repo string, pr github.PullRequest, commitsMissingDCO []github.RepositoryCommit, existingStatus, contributingUrl string, hasYesLabel, hasNoLabel, addComment bool) error {
   209  	signedOff := len(commitsMissingDCO) == 0
   210  
   211  	// handle the 'all commits signed off' case by adding appropriate labels
   212  	// TODO: clean-up old comments?
   213  	if signedOff {
   214  		if hasNoLabel {
   215  			l.Debugf("Removing %q label", dcoNoLabel)
   216  			// remove 'dco-signoff: no' label
   217  			if err := gc.RemoveLabel(org, repo, pr.Number, dcoNoLabel); err != nil {
   218  				return fmt.Errorf("error removing label: %w", err)
   219  			}
   220  		}
   221  		if !hasYesLabel {
   222  			l.Debugf("Adding %q label", dcoYesLabel)
   223  			// add 'dco-signoff: yes' label
   224  			if err := gc.AddLabel(org, repo, pr.Number, dcoYesLabel); err != nil {
   225  				return fmt.Errorf("error adding label: %w", err)
   226  			}
   227  		}
   228  		if existingStatus != github.StatusSuccess {
   229  			l.Debugf("Setting DCO status context to succeeded")
   230  			if err := gc.CreateStatus(org, repo, pr.Head.SHA, github.Status{
   231  				Context:     dcoContextName,
   232  				State:       github.StatusSuccess,
   233  				TargetURL:   contributingUrl,
   234  				Description: dcoContextMessageSuccess,
   235  			}); err != nil {
   236  				return fmt.Errorf("error setting pull request status: %w", err)
   237  			}
   238  		}
   239  
   240  		cp.PruneComments(shouldPrune(l))
   241  		return nil
   242  	}
   243  
   244  	// handle the 'not all commits signed off' case
   245  	if !hasNoLabel {
   246  		l.Debugf("Adding %q label", dcoNoLabel)
   247  		// add 'dco-signoff: no' label
   248  		if err := gc.AddLabel(org, repo, pr.Number, dcoNoLabel); err != nil {
   249  			return fmt.Errorf("error adding label: %w", err)
   250  		}
   251  	}
   252  	if hasYesLabel {
   253  		l.Debugf("Removing %q label", dcoYesLabel)
   254  		// remove 'dco-signoff: yes' label
   255  		if err := gc.RemoveLabel(org, repo, pr.Number, dcoYesLabel); err != nil {
   256  			return fmt.Errorf("error removing label: %w", err)
   257  		}
   258  	}
   259  	if existingStatus != github.StatusFailure {
   260  		l.Debugf("Setting DCO status context to failed")
   261  		if err := gc.CreateStatus(org, repo, pr.Head.SHA, github.Status{
   262  			Context:     dcoContextName,
   263  			State:       github.StatusFailure,
   264  			TargetURL:   contributingUrl,
   265  			Description: dcoContextMessageFailed,
   266  		}); err != nil {
   267  			return fmt.Errorf("error setting pull request status: %w", err)
   268  		}
   269  	}
   270  
   271  	if addComment {
   272  		// prune any old comments and add a new one with the latest list of
   273  		// failing commits
   274  		cp.PruneComments(shouldPrune(l))
   275  		l.Debugf("Commenting on PR to advise users of DCO check")
   276  		if err := gc.CreateComment(org, repo, pr.Number, fmt.Sprintf(dcoNotFoundMessage, contributingUrl, MarkdownSHAList(org, repo, commitsMissingDCO), plugins.AboutThisBot)); err != nil {
   277  			l.WithError(err).Warning("Could not create DCO not found comment.")
   278  		}
   279  	}
   280  
   281  	return nil
   282  }
   283  
   284  // 1. Check should commit messages from trusted users be checked
   285  // 2. Check commit messages in the pull request for the sign-off string
   286  // 3. Check the existing status context value
   287  // 4. Check the existing PR labels
   288  // 5. If signed off, apply appropriate labels and status context.
   289  // 6. If not signed off, apply appropriate labels and status context and add a comment.
   290  func handle(config plugins.Dco, gc gitHubClient, cp commentPruner, log *logrus.Entry, org, repo string, pr github.PullRequest, addComment bool) error {
   291  	l := log.WithField("pr", pr.Number)
   292  
   293  	commitsMissingDCO, err := checkCommitMessages(gc, l, org, repo, pr.Number)
   294  	if err != nil {
   295  		l.WithError(err).Infof("Error running DCO check against commits in PR")
   296  		return err
   297  	}
   298  
   299  	if config.SkipDCOCheckForMembers || config.SkipDCOCheckForCollaborators {
   300  		commitsMissingDCO, err = filterTrustedUsers(gc, l, config.SkipDCOCheckForCollaborators, config.TrustedApps, config.TrustedOrg, org, repo, commitsMissingDCO)
   301  		if err != nil {
   302  			l.WithError(err).Infof("Error running trusted org member check against commits in PR")
   303  			return err
   304  		}
   305  	}
   306  
   307  	existingStatus, err := checkExistingStatus(gc, l, org, repo, pr.Head.SHA)
   308  	if err != nil {
   309  		l.WithError(err).Infof("Error checking existing PR status")
   310  		return err
   311  	}
   312  
   313  	hasYesLabel, hasNoLabel, err := checkExistingLabels(gc, l, org, repo, pr.Number)
   314  	if err != nil {
   315  		l.WithError(err).Infof("Error checking existing PR labels")
   316  		return err
   317  	}
   318  
   319  	contributingRepo := fmt.Sprintf("%s/%s", org, repo)
   320  	if config.ContributingRepo != "" {
   321  		contributingRepo = config.ContributingRepo
   322  	}
   323  
   324  	contributingBranch := "master"
   325  	if config.ContributingBranch != "" {
   326  		contributingBranch = config.ContributingBranch
   327  	}
   328  
   329  	contributingPath := "CONTRIBUTING.md"
   330  	if config.ContributingPath != "" {
   331  		contributingPath = config.ContributingPath
   332  	}
   333  
   334  	contributingUrl := fmt.Sprintf("https://github.com/%s/blob/%s/%s", contributingRepo, contributingBranch, contributingPath)
   335  
   336  	return takeAction(gc, cp, l, org, repo, pr, commitsMissingDCO, existingStatus, contributingUrl, hasYesLabel, hasNoLabel, addComment)
   337  }
   338  
   339  // MarkdownSHAList prints the list of commits in a markdown-friendly way.
   340  func MarkdownSHAList(org, repo string, list []github.RepositoryCommit) string {
   341  	lines := make([]string, len(list))
   342  	lineFmt := "- [%s](https://github.com/%s/%s/commits/%s) %s"
   343  	for i, commit := range list {
   344  		if commit.SHA == "" {
   345  			continue
   346  		}
   347  		// if we somehow encounter a SHA that's less than 7 characters, we will
   348  		// just use it as is.
   349  		shortSHA := commit.SHA
   350  		if len(shortSHA) > 7 {
   351  			shortSHA = shortSHA[:7]
   352  		}
   353  
   354  		// get the first line of the commit
   355  		message := strings.Split(commit.Commit.Message, "\n")[0]
   356  
   357  		lines[i] = fmt.Sprintf(lineFmt, shortSHA, org, repo, commit.SHA, message)
   358  	}
   359  	return strings.Join(lines, "\n")
   360  }
   361  
   362  // shouldPrune finds comments left by this plugin.
   363  func shouldPrune(log *logrus.Entry) func(github.IssueComment) bool {
   364  	return func(comment github.IssueComment) bool {
   365  		return strings.Contains(comment.Body, dcoMsgPruneMatch)
   366  	}
   367  }
   368  
   369  func handlePullRequestEvent(pc plugins.Agent, pe github.PullRequestEvent) error {
   370  	config := pc.PluginConfig.DcoFor(pe.Repo.Owner.Login, pe.Repo.Name)
   371  
   372  	cp, err := pc.CommentPruner()
   373  	if err != nil {
   374  		return err
   375  	}
   376  
   377  	return handlePullRequest(*config, pc.GitHubClient, cp, pc.Logger, pe)
   378  }
   379  
   380  func handlePullRequest(config plugins.Dco, gc gitHubClient, cp commentPruner, log *logrus.Entry, pe github.PullRequestEvent) error {
   381  	org := pe.Repo.Owner.Login
   382  	repo := pe.Repo.Name
   383  
   384  	// we only reprocess on label, unlabel, open, reopen and synchronize events
   385  	// this will reduce our API token usage and save processing of unrelated events
   386  	switch pe.Action {
   387  	case github.PullRequestActionOpened,
   388  		github.PullRequestActionReopened,
   389  		github.PullRequestActionSynchronize:
   390  	default:
   391  		return nil
   392  	}
   393  
   394  	shouldComment := pe.Action == github.PullRequestActionSynchronize ||
   395  		pe.Action == github.PullRequestActionOpened
   396  
   397  	return handle(config, gc, cp, log, org, repo, pe.PullRequest, shouldComment)
   398  }
   399  
   400  func handleCommentEvent(pc plugins.Agent, ce github.GenericCommentEvent) error {
   401  	config := pc.PluginConfig.DcoFor(ce.Repo.Owner.Login, ce.Repo.Name)
   402  
   403  	cp, err := pc.CommentPruner()
   404  	if err != nil {
   405  		return err
   406  	}
   407  
   408  	return handleComment(*config, pc.GitHubClient, cp, pc.Logger, ce)
   409  }
   410  
   411  func handleComment(config plugins.Dco, gc gitHubClient, cp commentPruner, log *logrus.Entry, ce github.GenericCommentEvent) error {
   412  	// Only consider open PRs and new comments.
   413  	if ce.IssueState != "open" || ce.Action != github.GenericCommentActionCreated || !ce.IsPR {
   414  		return nil
   415  	}
   416  	// Only consider "/check-dco" comments.
   417  	if !checkDCORe.MatchString(ce.Body) {
   418  		return nil
   419  	}
   420  
   421  	org := ce.Repo.Owner.Login
   422  	repo := ce.Repo.Name
   423  
   424  	pr, err := gc.GetPullRequest(org, repo, ce.Number)
   425  	if err != nil {
   426  		return fmt.Errorf("error getting pull request for comment: %w", err)
   427  	}
   428  
   429  	return handle(config, gc, cp, log, org, repo, *pr, true)
   430  }