sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/plugins/trigger/trigger.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  	"fmt"
    22  	"strings"
    23  	"time"
    24  
    25  	"github.com/sirupsen/logrus"
    26  
    27  	apierrors "k8s.io/apimachinery/pkg/api/errors"
    28  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    29  	utilerrors "k8s.io/apimachinery/pkg/util/errors"
    30  	"k8s.io/apimachinery/pkg/util/sets"
    31  	"k8s.io/apimachinery/pkg/util/wait"
    32  
    33  	prowapi "sigs.k8s.io/prow/pkg/apis/prowjobs/v1"
    34  	"sigs.k8s.io/prow/pkg/config"
    35  	"sigs.k8s.io/prow/pkg/git/v2"
    36  	"sigs.k8s.io/prow/pkg/github"
    37  	"sigs.k8s.io/prow/pkg/pjutil"
    38  	"sigs.k8s.io/prow/pkg/pluginhelp"
    39  	"sigs.k8s.io/prow/pkg/plugins"
    40  )
    41  
    42  const (
    43  	// PluginName is the name of the trigger plugin
    44  	PluginName = "trigger"
    45  )
    46  
    47  // untrustedReason represents a combination (by ORing the appropriate consts) of reasons
    48  // why a user is not trusted by TrustedUser. It is used to generate messaging for users.
    49  type untrustedReason int
    50  
    51  const (
    52  	notMember untrustedReason = 1 << iota
    53  	notCollaborator
    54  	notSecondaryMember
    55  )
    56  
    57  // String constructs a string explaining the reason for a user's denial of trust
    58  // from untrustedReason as described above.
    59  func (u untrustedReason) String() string {
    60  	var response string
    61  	if u&notMember != 0 {
    62  		response += "User is not a member of the org. "
    63  	}
    64  	if u&notCollaborator != 0 {
    65  		response += "User is not a collaborator. "
    66  	}
    67  	if u&notSecondaryMember != 0 {
    68  		response += "User is not a member of the trusted secondary org. "
    69  	}
    70  	response += "Satisfy at least one of these conditions to make the user trusted."
    71  	return response
    72  }
    73  
    74  func init() {
    75  	plugins.RegisterGenericCommentHandler(PluginName, handleGenericCommentEvent, helpProvider)
    76  	plugins.RegisterPullRequestHandler(PluginName, handlePullRequest, helpProvider)
    77  	plugins.RegisterPushEventHandler(PluginName, handlePush, helpProvider)
    78  }
    79  
    80  func helpProvider(config *plugins.Configuration, enabledRepos []config.OrgRepo) (*pluginhelp.PluginHelp, error) {
    81  	configInfo := map[string]string{}
    82  	for _, repo := range enabledRepos {
    83  		trigger := config.TriggerFor(repo.Org, repo.Repo)
    84  		org := repo.Org
    85  		if trigger.TrustedOrg != "" {
    86  			org = trigger.TrustedOrg
    87  		}
    88  		configInfo[repo.String()] = fmt.Sprintf("The trusted GitHub organization for this repository is %q.", org)
    89  	}
    90  	yamlSnippet, err := plugins.CommentMap.GenYaml(&plugins.Configuration{
    91  		Triggers: []plugins.Trigger{
    92  			{
    93  				Repos: []string{
    94  					"org/repo1",
    95  					"org/repo2",
    96  				},
    97  				JoinOrgURL:     "https://github.com/kubernetes/community/blob/master/community-membership.md",
    98  				OnlyOrgMembers: true,
    99  				IgnoreOkToTest: true,
   100  			},
   101  		},
   102  	})
   103  	if err != nil {
   104  		logrus.WithError(err).Warnf("cannot generate comments for %s plugin", PluginName)
   105  	}
   106  	pluginHelp := &pluginhelp.PluginHelp{
   107  		Description: `The trigger plugin starts jobs in reaction to various events.
   108  <br>Presubmit jobs are run automatically on pull requests that are trusted and not in a draft state with file changes matching the file filters and targeting a branch matching the branch filters.
   109  <br>A pull request is considered trusted if the author is a member of the 'trusted organization' for the repository or if such a member has left an '/ok-to-test' command on the PR.
   110  <br>Trigger will not automatically start jobs for a PR in draft state, and if a PR is changed to draft it cancels pending jobs.
   111  <br>If jobs are not run automatically for a PR because it is not trusted or is in draft state, a trusted user can still start jobs manually via the '/test' command.
   112  <br>The '/retest' command can be used to rerun jobs that have reported failure.
   113  <br>Trigger starts postsubmit jobs when commits are pushed if the filters on the job match files and branches affected by that push.`,
   114  		Config:  configInfo,
   115  		Snippet: yamlSnippet,
   116  	}
   117  	pluginHelp.AddCommand(pluginhelp.Command{
   118  		Usage:       "/ok-to-test",
   119  		Description: "Marks a PR as 'trusted' and starts tests.",
   120  		Featured:    false,
   121  		WhoCanUse:   "Members of the trusted organization for the repo.",
   122  		Examples:    []string{"/ok-to-test"},
   123  	})
   124  	pluginHelp.AddCommand(pluginhelp.Command{
   125  		Usage:       "/test [<job name>|all]",
   126  		Description: "Manually starts a/all automatically triggered test job(s). Lists all possible job(s) when no jobs/an invalid job are specified.",
   127  		Featured:    true,
   128  		WhoCanUse:   "Anyone can trigger this command on a trusted PR.",
   129  		Examples:    []string{"/test all", "/test pull-bazel-test"},
   130  	})
   131  	pluginHelp.AddCommand(pluginhelp.Command{
   132  		Usage:       "/retest",
   133  		Description: "Rerun test jobs that have failed.",
   134  		Featured:    true,
   135  		WhoCanUse:   "Anyone can trigger this command on a trusted PR.",
   136  		Examples:    []string{"/retest"},
   137  	})
   138  	pluginHelp.AddCommand(pluginhelp.Command{
   139  		Usage:       "/test ?",
   140  		Description: "List available test job(s) for a trusted PR.",
   141  		Featured:    true,
   142  		WhoCanUse:   "Anyone can trigger this command on a trusted PR.",
   143  		Examples:    []string{"/test ?"},
   144  	})
   145  	return pluginHelp, nil
   146  }
   147  
   148  type githubClient interface {
   149  	AddLabel(org, repo string, number int, label string) error
   150  	BotUserChecker() (func(candidate string) bool, error)
   151  	IsCollaborator(org, repo, user string) (bool, error)
   152  	IsMember(org, user string) (bool, error)
   153  	GetPullRequest(org, repo string, number int) (*github.PullRequest, error)
   154  	GetFailedActionRunsByHeadBranch(org, repo, branchName, headSHA string) ([]github.WorkflowRun, error)
   155  	GetRef(org, repo, ref string) (string, error)
   156  	CreateComment(owner, repo string, number int, comment string) error
   157  	ListIssueComments(owner, repo string, issue int) ([]github.IssueComment, error)
   158  	CreateStatus(owner, repo, ref string, status github.Status) error
   159  	GetCombinedStatus(org, repo, ref string) (*github.CombinedStatus, error)
   160  	GetPullRequestChanges(org, repo string, number int) ([]github.PullRequestChange, error)
   161  	RemoveLabel(org, repo string, number int, label string) error
   162  	TriggerGitHubWorkflow(org, repo string, id int) error
   163  	TriggerFailedGitHubWorkflow(org, repo string, id int) error
   164  	DeleteStaleComments(org, repo string, number int, comments []github.IssueComment, isStale func(github.IssueComment) bool) error
   165  	GetIssueLabels(org, repo string, number int) ([]github.Label, error)
   166  }
   167  
   168  type trustedPullRequestClient interface {
   169  	GetIssueLabels(org, repo string, number int) ([]github.Label, error)
   170  	trustedUserClient
   171  }
   172  
   173  type prowJobClient interface {
   174  	Create(context.Context, *prowapi.ProwJob, metav1.CreateOptions) (*prowapi.ProwJob, error)
   175  	List(ctx context.Context, opts metav1.ListOptions) (*prowapi.ProwJobList, error)
   176  	Update(context.Context, *prowapi.ProwJob, metav1.UpdateOptions) (*prowapi.ProwJob, error)
   177  }
   178  
   179  // Client holds the necessary structures to work with prow via logging, github, kubernetes and its configuration.
   180  //
   181  // TODO(fejta): consider exporting an interface rather than a struct
   182  type Client struct {
   183  	GitHubClient  githubClient
   184  	ProwJobClient prowJobClient
   185  	Config        *config.Config
   186  	Logger        *logrus.Entry
   187  	GitClient     git.ClientFactory
   188  }
   189  
   190  // trustedUserClient is used to check is user member and repo collaborator
   191  type trustedUserClient interface {
   192  	IsCollaborator(org, repo, user string) (bool, error)
   193  	IsMember(org, user string) (bool, error)
   194  	BotUserChecker() (func(candidate string) bool, error)
   195  }
   196  
   197  func getClient(pc plugins.Agent) Client {
   198  	return Client{
   199  		GitHubClient:  pc.GitHubClient,
   200  		Config:        pc.Config,
   201  		ProwJobClient: pc.ProwJobClient,
   202  		Logger:        pc.Logger,
   203  		GitClient:     pc.GitClient,
   204  	}
   205  }
   206  
   207  func handlePullRequest(pc plugins.Agent, pr github.PullRequestEvent) error {
   208  	org, repo, _ := orgRepoAuthor(pr.PullRequest)
   209  	return handlePR(getClient(pc), pc.PluginConfig.TriggerFor(org, repo), pr)
   210  }
   211  
   212  func handleGenericCommentEvent(pc plugins.Agent, gc github.GenericCommentEvent) error {
   213  	return handleGenericComment(getClient(pc), pc.PluginConfig.TriggerFor(gc.Repo.Owner.Login, gc.Repo.Name), gc)
   214  }
   215  
   216  func handlePush(pc plugins.Agent, pe github.PushEvent) error {
   217  	return handlePE(getClient(pc), pe)
   218  }
   219  
   220  // TrustedUserResponse is a response from TrustedUser. It contains the boolean response for trust as well
   221  // a reason for denial if the user is not trusted.
   222  type TrustedUserResponse struct {
   223  	IsTrusted bool
   224  	// Reason contains the reason that a user is not trusted if IsTrusted is false
   225  	Reason string
   226  }
   227  
   228  // TrustedUser returns true if user is trusted in repo.
   229  // Trusted users are either repo collaborators, org members or trusted org members.
   230  func TrustedUser(ghc trustedUserClient, onlyOrgMembers bool, trustedApps []string, trustedOrg, user, org, repo string) (TrustedUserResponse, error) {
   231  	errorResponse := TrustedUserResponse{IsTrusted: false}
   232  	okResponse := TrustedUserResponse{IsTrusted: true}
   233  
   234  	selfChecker, err := ghc.BotUserChecker()
   235  	if err != nil {
   236  		return errorResponse, fmt.Errorf("failed to check if comment came from myself: %w", err)
   237  	}
   238  	// Trust thyself
   239  	if selfChecker(user) {
   240  		return okResponse, nil
   241  	}
   242  
   243  	// TODO(fejta): consider dropping support for org checks in the future.
   244  
   245  	// First check if the user is an org member. This caches across all repos.
   246  	if member, err := ghc.IsMember(org, user); err != nil {
   247  		return errorResponse, fmt.Errorf("error in IsMember(%s): %w", org, err)
   248  	} else if member {
   249  		return okResponse, nil
   250  	}
   251  
   252  	// Next check if the user is a collaborator if that is allowed, this is more
   253  	// expensive as it only caches per repo.
   254  	if !onlyOrgMembers {
   255  		if ok, err := ghc.IsCollaborator(org, repo, user); err != nil {
   256  			return errorResponse, fmt.Errorf("error in IsCollaborator: %w", err)
   257  		} else if ok {
   258  			return okResponse, nil
   259  		}
   260  	}
   261  
   262  	// Determine if user is on trusted_apps list.
   263  	// This allows automatic tests execution for GitHub automations that cannot be added as collaborators.
   264  	for _, trustedApp := range trustedApps {
   265  		if tUser := strings.TrimSuffix(user, "[bot]"); tUser == trustedApp {
   266  			return okResponse, nil
   267  		}
   268  	}
   269  
   270  	// Determine if there is a second org to check. If there is no secondary org or they are the same, the result
   271  	// is the same because the user already failed the check for the primary org.
   272  	if trustedOrg == "" || trustedOrg == org {
   273  		// the if/else is only to improve error messaging
   274  		if onlyOrgMembers {
   275  			return TrustedUserResponse{IsTrusted: false, Reason: notMember.String()}, nil // No trusted org and/or it is the same
   276  		}
   277  		return TrustedUserResponse{IsTrusted: false, Reason: (notMember | notCollaborator).String()}, nil // No trusted org and/or it is the same
   278  	}
   279  
   280  	// Check the second trusted org.
   281  	member, err := ghc.IsMember(trustedOrg, user)
   282  	if err != nil {
   283  		return errorResponse, fmt.Errorf("error in IsMember(%s): %w", trustedOrg, err)
   284  	} else if member {
   285  		return okResponse, nil
   286  	}
   287  
   288  	// the if/else is only to improve error messaging
   289  	if onlyOrgMembers {
   290  		return TrustedUserResponse{IsTrusted: false, Reason: (notMember | notSecondaryMember).String()}, nil
   291  	}
   292  	return TrustedUserResponse{IsTrusted: false, Reason: (notMember | notSecondaryMember | notCollaborator).String()}, nil
   293  }
   294  
   295  // validateContextOverlap ensures that there will be no overlap in contexts between a set of jobs running and a set to skip
   296  func validateContextOverlap(toRun, toSkip []config.Presubmit) error {
   297  	requestedContexts := sets.New[string]()
   298  	for _, job := range toRun {
   299  		requestedContexts.Insert(job.Context)
   300  	}
   301  	skippedContexts := sets.New[string]()
   302  	for _, job := range toSkip {
   303  		skippedContexts.Insert(job.Context)
   304  	}
   305  	if overlap := sets.List(requestedContexts.Intersection(skippedContexts)); len(overlap) > 0 {
   306  		return fmt.Errorf("the following contexts are both triggered and skipped: %s", strings.Join(overlap, ", "))
   307  	}
   308  
   309  	return nil
   310  }
   311  
   312  // RunRequested executes the config.Presubmits that are requested
   313  func RunRequested(c Client, pr *github.PullRequest, baseSHA string, requestedJobs []config.Presubmit, eventGUID string) error {
   314  	return runRequested(c, pr, baseSHA, requestedJobs, eventGUID, nil)
   315  }
   316  
   317  // RunRequestedWithLabels executes the config.Presubmits that are requested with the additional labels
   318  func RunRequestedWithLabels(c Client, pr *github.PullRequest, baseSHA string, requestedJobs []config.Presubmit, eventGUID string, labels map[string]string) error {
   319  	return runRequested(c, pr, baseSHA, requestedJobs, eventGUID, labels)
   320  }
   321  
   322  func runRequested(c Client, pr *github.PullRequest, baseSHA string, requestedJobs []config.Presubmit, eventGUID string, labels map[string]string, millisecondOverride ...time.Duration) error {
   323  	var errors []error
   324  
   325  	// If the PR is not mergeable (e.g. due to merge conflicts),we will not trigger any jobs,
   326  	// to reduce the load on resources and reduce spam comments which will lead to a better review experience.
   327  	if pr.Mergable != nil && !*pr.Mergable {
   328  		return nil
   329  	}
   330  
   331  	for _, job := range requestedJobs {
   332  		c.Logger.Infof("Starting %s build.", job.Name)
   333  		pj := pjutil.NewPresubmit(*pr, baseSHA, job, eventGUID, labels, pjutil.RequireScheduling(c.Config.Scheduler.Enabled))
   334  		c.Logger.WithFields(pjutil.ProwJobFields(&pj)).Info("Creating a new prowjob.")
   335  		if err := createWithRetry(context.TODO(), c.ProwJobClient, &pj, millisecondOverride...); err != nil {
   336  			c.Logger.WithError(err).Error("Failed to create prowjob.")
   337  			errors = append(errors, err)
   338  		}
   339  	}
   340  	return utilerrors.NewAggregate(errors)
   341  }
   342  
   343  func getPresubmits(log *logrus.Entry, gc git.ClientFactory, cfg *config.Config, orgRepo string, baseSHAGetter, headSHAGetter config.RefGetter) []config.Presubmit {
   344  	presubmits, err := cfg.GetPresubmits(gc, orgRepo, "", baseSHAGetter, headSHAGetter)
   345  	if err != nil {
   346  		// Fall back to static presubmits to avoid deadlocking when a presubmit is used to verify
   347  		// inrepoconfig. Tide will still respect errors here and not merge.
   348  		log.WithError(err).Debug("Failed to get presubmits")
   349  		presubmits = cfg.GetPresubmitsStatic(orgRepo)
   350  	}
   351  	return presubmits
   352  }
   353  
   354  func getPostsubmits(log *logrus.Entry, gc git.ClientFactory, cfg *config.Config, orgRepo string, baseSHAGetter config.RefGetter) []config.Postsubmit {
   355  	postsubmits, err := cfg.GetPostsubmits(gc, orgRepo, "", baseSHAGetter)
   356  	if err != nil {
   357  		// Fall back to static postsubmits, loading inrepoconfig returned an error.
   358  		log.WithError(err).Error("Failed to get postsubmits")
   359  		postsubmits = cfg.GetPostsubmitsStatic(orgRepo)
   360  	}
   361  	return postsubmits
   362  }
   363  
   364  // createWithRetry will retry the cration of a ProwJob. The Name must be set, otherwise we might end up creating it multiple times
   365  // if one Create request errors but succeeds under the hood.
   366  func createWithRetry(ctx context.Context, client prowJobClient, pj *prowapi.ProwJob, millisecondOverride ...time.Duration) error {
   367  	millisecond := time.Millisecond
   368  	if len(millisecondOverride) == 1 {
   369  		millisecond = millisecondOverride[0]
   370  	}
   371  
   372  	var errs []error
   373  	if err := wait.ExponentialBackoff(wait.Backoff{Duration: 250 * millisecond, Factor: 2.0, Jitter: 0.1, Steps: 8}, func() (bool, error) {
   374  		if _, err := client.Create(ctx, pj, metav1.CreateOptions{}); err != nil {
   375  			// Can happen if a previous request was successful but returned an error
   376  			if apierrors.IsAlreadyExists(err) {
   377  				return true, nil
   378  			}
   379  			// Store and swallow errors, if we end up timing out we will return all of them
   380  			errs = append(errs, err)
   381  			return false, nil
   382  		}
   383  		return true, nil
   384  	}); err != nil {
   385  		if err != wait.ErrWaitTimeout {
   386  			return err
   387  		}
   388  		return utilerrors.NewAggregate(errs)
   389  	}
   390  
   391  	return nil
   392  }