sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/plugins/trigger/pull-request.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 trigger
    18  
    19  import (
    20  	"context"
    21  	"encoding/json"
    22  	"errors"
    23  	"fmt"
    24  	"net/url"
    25  	"strconv"
    26  
    27  	apierrors "k8s.io/apimachinery/pkg/api/errors"
    28  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    29  	klabels "k8s.io/apimachinery/pkg/labels"
    30  	utilerrors "k8s.io/apimachinery/pkg/util/errors"
    31  
    32  	prowapi "sigs.k8s.io/prow/pkg/apis/prowjobs/v1"
    33  	"sigs.k8s.io/prow/pkg/config"
    34  	"sigs.k8s.io/prow/pkg/github"
    35  	"sigs.k8s.io/prow/pkg/kube"
    36  	"sigs.k8s.io/prow/pkg/labels"
    37  	"sigs.k8s.io/prow/pkg/pjutil"
    38  	"sigs.k8s.io/prow/pkg/plugins"
    39  )
    40  
    41  const (
    42  	abortedDescription = "Aborted by trigger plugin."
    43  )
    44  
    45  func handlePR(c Client, trigger plugins.Trigger, pr github.PullRequestEvent) error {
    46  	org, repo, a := orgRepoAuthor(pr.PullRequest)
    47  	author := string(a)
    48  	num := pr.PullRequest.Number
    49  
    50  	baseSHA := ""
    51  	baseSHAGetter := func() (string, error) {
    52  		var err error
    53  		baseSHA, err = c.GitHubClient.GetRef(org, repo, "heads/"+pr.PullRequest.Base.Ref)
    54  		if err != nil {
    55  			return "", fmt.Errorf("failed to get baseSHA: %w", err)
    56  		}
    57  		return baseSHA, nil
    58  	}
    59  	headSHAGetter := func() (string, error) {
    60  		return pr.PullRequest.Head.SHA, nil
    61  	}
    62  
    63  	presubmits := getPresubmits(c.Logger, c.GitClient, c.Config, org+"/"+repo, baseSHAGetter, headSHAGetter)
    64  	if len(presubmits) == 0 {
    65  		return nil
    66  	}
    67  
    68  	if baseSHA == "" {
    69  		if _, err := baseSHAGetter(); err != nil {
    70  			return err
    71  		}
    72  	}
    73  
    74  	switch pr.Action {
    75  	case github.PullRequestActionOpened:
    76  		// When a PR is opened, if the author is in the org then build it.
    77  		// Otherwise, ask for "/ok-to-test". There's no need to look for previous
    78  		// "/ok-to-test" comments since the PR was just opened!
    79  		trustedResponse, err := TrustedUser(c.GitHubClient, trigger.OnlyOrgMembers, trigger.TrustedApps, trigger.TrustedOrg, author, org, repo)
    80  		member := trustedResponse.IsTrusted
    81  		if err != nil {
    82  			return fmt.Errorf("could not check membership: %s", err)
    83  		}
    84  		if member {
    85  			// dedicated draft check for create to comment on the PR
    86  			if pr.PullRequest.Draft {
    87  				c.Logger.Info("Skipping all jobs for draft PR.")
    88  				return draftMsg(c.GitHubClient, pr.PullRequest)
    89  			}
    90  			c.Logger.Info("Starting all jobs for new PR.")
    91  			return buildAllButDrafts(c, &pr.PullRequest, pr.GUID, baseSHA, presubmits)
    92  		}
    93  		c.Logger.Infof("Welcome message to PR author %q.", author)
    94  		if err := welcomeMsg(c.GitHubClient, trigger, pr.PullRequest); err != nil {
    95  			return fmt.Errorf("could not welcome non-org member %q: %w", author, err)
    96  		}
    97  	case github.PullRequestActionReopened:
    98  		return buildAllIfTrusted(c, trigger, pr, baseSHA, presubmits)
    99  	case github.PullRequestActionEdited:
   100  		// if someone changes the base of their PR, we will get this
   101  		// event and the changes field will list that the base SHA and
   102  		// ref changes so we can detect such a case and retrigger tests
   103  		var changes struct {
   104  			Base struct {
   105  				Ref struct {
   106  					From string `json:"from"`
   107  				} `json:"ref"`
   108  				Sha struct {
   109  					From string `json:"from"`
   110  				} `json:"sha"`
   111  			} `json:"base"`
   112  		}
   113  		if err := json.Unmarshal(pr.Changes, &changes); err != nil {
   114  			// we're detecting this best-effort so we can forget about
   115  			// the event
   116  			return nil
   117  		} else if changes.Base.Ref.From != "" || changes.Base.Sha.From != "" {
   118  			// the base of the PR changed and we need to re-test it
   119  			return buildAllIfTrusted(c, trigger, pr, baseSHA, presubmits)
   120  		}
   121  	case github.PullRequestActionSynchronize:
   122  		var errs []error
   123  		if err := abortAllJobs(c, &pr.PullRequest); err != nil {
   124  			errs = append(errs, fmt.Errorf("failed to abort jobs: %w", err))
   125  		}
   126  		return utilerrors.NewAggregate(append(errs, buildAllIfTrusted(c, trigger, pr, baseSHA, presubmits)))
   127  	case github.PullRequestActionLabeled:
   128  		// When a PR is LGTMd, if it is untrusted then build it once.
   129  		if pr.Label.Name == labels.LGTM {
   130  			_, trusted, err := TrustedPullRequest(c.GitHubClient, trigger, author, org, repo, num, nil)
   131  			if err != nil {
   132  				return fmt.Errorf("could not validate PR: %s", err)
   133  			} else if !trusted {
   134  				c.Logger.Info("Starting all jobs for untrusted PR with LGTM.")
   135  				return buildAllButDrafts(c, &pr.PullRequest, pr.GUID, baseSHA, presubmits)
   136  			}
   137  		}
   138  		if pr.Label.Name == labels.OkToTest {
   139  			// When the bot adds the label from an /ok-to-test command,
   140  			// we will trigger tests based on the comment event and do not
   141  			// need to trigger them here from the label, as well
   142  			botUserChecker, err := c.GitHubClient.BotUserChecker()
   143  			if err != nil {
   144  				return err
   145  			}
   146  			if botUserChecker(pr.Sender.Login) {
   147  				c.Logger.Debug("Label added by the bot, skipping.")
   148  				return nil
   149  			}
   150  			return buildAllButDrafts(c, &pr.PullRequest, pr.GUID, baseSHA, presubmits)
   151  		}
   152  	case github.PullRequestActionClosed:
   153  		if err := abortAllJobs(c, &pr.PullRequest); err != nil {
   154  			c.Logger.WithError(err).Error("Failed to abort jobs for closed pull request")
   155  			return err
   156  		}
   157  	case github.PullRequestActionReadyForReview:
   158  		return buildAllIfTrusted(c, trigger, pr, baseSHA, presubmits)
   159  	case github.PullRequestActionConvertedToDraft:
   160  		if err := abortAllJobs(c, &pr.PullRequest); err != nil {
   161  			c.Logger.WithError(err).Error("Failed to abort jobs for pull request converted to draft")
   162  			return err
   163  		}
   164  	}
   165  
   166  	return nil
   167  }
   168  
   169  func abortAllJobs(c Client, pr *github.PullRequest) error {
   170  	selector, err := labelSelectorForPR(pr)
   171  	if err != nil {
   172  		return fmt.Errorf("failed to construct label selector: %w", err)
   173  	}
   174  
   175  	jobs, err := c.ProwJobClient.List(context.TODO(), metav1.ListOptions{LabelSelector: selector.String()})
   176  	if err != nil {
   177  		return fmt.Errorf("failed to list prowjobs for pr: %w", err)
   178  	}
   179  
   180  	var errs []error
   181  	for _, job := range jobs.Items {
   182  		// Do not abort jobs that already completed
   183  		if job.Complete() {
   184  			continue
   185  		}
   186  		job.Status.State = prowapi.AbortedState
   187  		job.Status.Description = abortedDescription
   188  		// We use Update and not Patch here, because we are not the authority of the .Status.State field
   189  		// and must not overwrite changes made to it in the interim by the responsible agent.
   190  		// The accepted trade-off for now is that this leads to failure if unrelated fields where changed
   191  		// by another different actor.
   192  		if _, err := c.ProwJobClient.Update(context.TODO(), &job, metav1.UpdateOptions{}); err != nil && !apierrors.IsConflict(err) {
   193  			errs = append(errs, fmt.Errorf("failed to abort job %s: %w", job.Name, err))
   194  		}
   195  	}
   196  
   197  	return utilerrors.NewAggregate(errs)
   198  }
   199  
   200  func labelSelectorForPR(pr *github.PullRequest) (klabels.Selector, error) {
   201  	set := klabels.Set{
   202  		kube.OrgLabel:         pr.Base.Repo.Owner.Login,
   203  		kube.RepoLabel:        pr.Base.Repo.Name,
   204  		kube.PullLabel:        strconv.Itoa(pr.Number),
   205  		kube.ProwJobTypeLabel: string(prowapi.PresubmitJob),
   206  	}
   207  	selector := klabels.SelectorFromSet(set)
   208  	// Needed because of this gem:
   209  	// https://github.com/kubernetes/apimachinery/blob/f8e71527369e696bf041722b248ffcb32bae9edf/pkg/labels/selector.go#L883
   210  	if selector.Empty() {
   211  		return nil, errors.New("got back empty selector")
   212  	}
   213  
   214  	return selector, nil
   215  }
   216  
   217  type login string
   218  
   219  func orgRepoAuthor(pr github.PullRequest) (string, string, login) {
   220  	org := pr.Base.Repo.Owner.Login
   221  	repo := pr.Base.Repo.Name
   222  	author := pr.User.Login
   223  	return org, repo, login(author)
   224  }
   225  
   226  func buildAllIfTrusted(c Client, trigger plugins.Trigger, pr github.PullRequestEvent, baseSHA string, presubmits []config.Presubmit) error {
   227  	// When a PR is updated, check that the user is in the org or that an org
   228  	// member has said "/ok-to-test" before building. There's no need to ask
   229  	// for "/ok-to-test" because we do that once when the PR is created.
   230  	org, repo, a := orgRepoAuthor(pr.PullRequest)
   231  	author := string(a)
   232  	num := pr.PullRequest.Number
   233  	l, trusted, err := TrustedPullRequest(c.GitHubClient, trigger, author, org, repo, num, nil)
   234  	if err != nil {
   235  		return fmt.Errorf("could not validate PR: %s", err)
   236  	} else if trusted {
   237  		// Eventually remove needs-ok-to-test
   238  		// Will not work for org members since labels are not fetched in this case
   239  		if github.HasLabel(labels.NeedsOkToTest, l) {
   240  			if err := c.GitHubClient.RemoveLabel(org, repo, num, labels.NeedsOkToTest); err != nil {
   241  				return err
   242  			}
   243  		}
   244  		c.Logger.Info("Starting all jobs for updated PR.")
   245  		return buildAllButDrafts(c, &pr.PullRequest, pr.GUID, baseSHA, presubmits)
   246  	}
   247  	return nil
   248  }
   249  
   250  func welcomeMsg(ghc githubClient, trigger plugins.Trigger, pr github.PullRequest) error {
   251  	var errors []error
   252  	org, repo, a := orgRepoAuthor(pr)
   253  	author := string(a)
   254  	encodedRepoFullName := url.QueryEscape(pr.Base.Repo.FullName)
   255  	var more string
   256  	if trigger.TrustedOrg != "" && trigger.TrustedOrg != org {
   257  		more = fmt.Sprintf("or [%s](https://github.com/orgs/%s/people) ", trigger.TrustedOrg, trigger.TrustedOrg)
   258  	}
   259  
   260  	var joinOrgURL string
   261  	if trigger.JoinOrgURL != "" {
   262  		joinOrgURL = trigger.JoinOrgURL
   263  	} else {
   264  		joinOrgURL = fmt.Sprintf("https://github.com/orgs/%s/people", org)
   265  	}
   266  
   267  	var comment string
   268  	if trigger.IgnoreOkToTest {
   269  		comment = fmt.Sprintf(`Hi @%s. Thanks for your PR.
   270  
   271  PRs from untrusted users cannot be marked as trusted with `+"`/ok-to-test`"+` in this repo meaning untrusted PR authors can never trigger tests themselves. Collaborators can still trigger tests on the PR using `+"`/test all`"+`.
   272  
   273  I understand the commands that are listed [here](https://go.k8s.io/bot-commands?repo=%s).
   274  
   275  <details>
   276  
   277  %s
   278  </details>
   279  `, author, encodedRepoFullName, plugins.AboutThisBotWithoutCommands)
   280  	} else {
   281  		comment = fmt.Sprintf(`Hi @%s. Thanks for your PR.
   282  
   283  I'm waiting for a [%s](https://github.com/orgs/%s/people) %smember to verify that this patch is reasonable to test. If it is, they should reply with `+"`/ok-to-test`"+` on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work. Regular contributors should [join the org](%s) to skip this step.
   284  
   285  Once the patch is verified, the new status will be reflected by the `+"`%s`"+` label.
   286  
   287  I understand the commands that are listed [here](https://go.k8s.io/bot-commands?repo=%s).
   288  
   289  <details>
   290  
   291  %s
   292  </details>
   293  `, author, org, org, more, joinOrgURL, labels.OkToTest, encodedRepoFullName, plugins.AboutThisBotWithoutCommands)
   294  
   295  		l, err := ghc.GetIssueLabels(org, repo, pr.Number)
   296  		if err != nil {
   297  			errors = append(errors, err)
   298  		} else if !github.HasLabel(labels.OkToTest, l) {
   299  			// It is possible for bots and other automations to automatically
   300  			// add the ok-to-test label. If that's the case, then we will not
   301  			// add the needs-ok-to-test-label any more.
   302  			if err := ghc.AddLabel(org, repo, pr.Number, labels.NeedsOkToTest); err != nil {
   303  				errors = append(errors, err)
   304  			}
   305  		}
   306  	}
   307  
   308  	if err := ghc.CreateComment(org, repo, pr.Number, comment); err != nil {
   309  		errors = append(errors, err)
   310  	}
   311  
   312  	if len(errors) > 0 {
   313  		return utilerrors.NewAggregate(errors)
   314  	}
   315  	return nil
   316  }
   317  
   318  func draftMsg(ghc githubClient, pr github.PullRequest) error {
   319  	org, repo, _ := orgRepoAuthor(pr)
   320  
   321  	comment := "Skipping CI for Draft Pull Request.\nIf you want CI signal for your change, please convert it to an actual PR.\nYou can still manually trigger a test run with `/test all`"
   322  	return ghc.CreateComment(org, repo, pr.Number, comment)
   323  }
   324  
   325  // TrustedPullRequest returns whether or not the given PR should be tested.
   326  // It first checks if the author is in the org, then looks for "ok-to-test" label.
   327  // If already known, GitHub labels should be provided to save tokens. Otherwise, it fetches them.
   328  func TrustedPullRequest(tprc trustedPullRequestClient, trigger plugins.Trigger, author, org, repo string, num int, l []github.Label) ([]github.Label, bool, error) {
   329  	// First check if the author is a member of the org.
   330  	if trustedResponse, err := TrustedUser(tprc, trigger.OnlyOrgMembers, trigger.TrustedApps, trigger.TrustedOrg, author, org, repo); err != nil {
   331  		return l, false, fmt.Errorf("error checking %s for trust: %w", author, err)
   332  	} else if trustedResponse.IsTrusted {
   333  		return l, true, nil
   334  	}
   335  	// Then check if PR has ok-to-test label
   336  	if l == nil {
   337  		var err error
   338  		l, err = tprc.GetIssueLabels(org, repo, num)
   339  		if err != nil {
   340  			return l, false, err
   341  		}
   342  	}
   343  	return l, github.HasLabel(labels.OkToTest, l), nil
   344  }
   345  
   346  // buildAllButDrafts ensures that all builds that should run and will be required are built, but skips draft PRs
   347  func buildAllButDrafts(c Client, pr *github.PullRequest, eventGUID string, baseSHA string, presubmits []config.Presubmit) error {
   348  	if pr.Draft {
   349  		c.Logger.Info("Skipping all jobs for draft PR.")
   350  		return nil
   351  	}
   352  	return buildAll(c, pr, eventGUID, baseSHA, presubmits)
   353  }
   354  
   355  // buildAll ensures that all builds that should run and will be required are built
   356  func buildAll(c Client, pr *github.PullRequest, eventGUID string, baseSHA string, presubmits []config.Presubmit) error {
   357  	org, repo, number, branch := pr.Base.Repo.Owner.Login, pr.Base.Repo.Name, pr.Number, pr.Base.Ref
   358  	changes := config.NewGitHubDeferredChangedFilesProvider(c.GitHubClient, org, repo, number)
   359  	toTest, err := pjutil.FilterPresubmits(pjutil.NewTestAllFilter(), changes, branch, presubmits, c.Logger)
   360  	if err != nil {
   361  		return err
   362  	}
   363  	return RunRequested(c, pr, baseSHA, toTest, eventGUID)
   364  }