github.com/munnerz/test-infra@v0.0.0-20190108210205-ce3d181dc989/prow/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  	"fmt"
    21  	"strings"
    22  
    23  	"github.com/sirupsen/logrus"
    24  
    25  	"k8s.io/apimachinery/pkg/util/sets"
    26  
    27  	"k8s.io/test-infra/prow/config"
    28  	"k8s.io/test-infra/prow/github"
    29  	"k8s.io/test-infra/prow/kube"
    30  	"k8s.io/test-infra/prow/pjutil"
    31  	"k8s.io/test-infra/prow/pluginhelp"
    32  	"k8s.io/test-infra/prow/plugins"
    33  )
    34  
    35  const (
    36  	pluginName = "trigger"
    37  )
    38  
    39  func init() {
    40  	plugins.RegisterGenericCommentHandler(pluginName, handleGenericCommentEvent, helpProvider)
    41  	plugins.RegisterPullRequestHandler(pluginName, handlePullRequest, helpProvider)
    42  	plugins.RegisterPushEventHandler(pluginName, handlePush, helpProvider)
    43  }
    44  
    45  func helpProvider(config *plugins.Configuration, enabledRepos []string) (*pluginhelp.PluginHelp, error) {
    46  	configInfo := map[string]string{}
    47  	for _, orgRepo := range enabledRepos {
    48  		parts := strings.Split(orgRepo, "/")
    49  		if len(parts) != 2 {
    50  			return nil, fmt.Errorf("invalid repo in enabledRepos: %q", orgRepo)
    51  		}
    52  		org, repoName := parts[0], parts[1]
    53  		if trigger := config.TriggerFor(org, repoName); trigger != nil && trigger.TrustedOrg != "" {
    54  			org = trigger.TrustedOrg
    55  		}
    56  		configInfo[orgRepo] = fmt.Sprintf("The trusted Github organization for this repository is %q.", org)
    57  	}
    58  	pluginHelp := &pluginhelp.PluginHelp{
    59  		Description: `The trigger plugin starts tests in reaction to commands and pull request events. It is responsible for ensuring that test jobs are only run on trusted PRs. A PR 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.
    60  <br>Trigger starts jobs automatically when a new trusted PR is created or when an untrusted PR becomes trusted, but it can also be used to start jobs manually via the '/test' command.
    61  <br>The '/retest' command can be used to rerun jobs that have reported failure.`,
    62  		Config: configInfo,
    63  	}
    64  	pluginHelp.AddCommand(pluginhelp.Command{
    65  		Usage:       "/ok-to-test",
    66  		Description: "Marks a PR as 'trusted' and starts tests.",
    67  		Featured:    false,
    68  		WhoCanUse:   "Members of the trusted organization for the repo.",
    69  		Examples:    []string{"/ok-to-test"},
    70  	})
    71  	pluginHelp.AddCommand(pluginhelp.Command{
    72  		Usage:       "/test (<job name>|all)",
    73  		Description: "Manually starts a/all test job(s).",
    74  		Featured:    true,
    75  		WhoCanUse:   "Anyone can trigger this command on a trusted PR.",
    76  		Examples:    []string{"/test all", "/test pull-bazel-test"},
    77  	})
    78  	pluginHelp.AddCommand(pluginhelp.Command{
    79  		Usage:       "/retest",
    80  		Description: "Rerun test jobs that have failed.",
    81  		Featured:    true,
    82  		WhoCanUse:   "Anyone can trigger this command on a trusted PR.",
    83  		Examples:    []string{"/retest"},
    84  	})
    85  	return pluginHelp, nil
    86  }
    87  
    88  type githubClient interface {
    89  	AddLabel(org, repo string, number int, label string) error
    90  	BotName() (string, error)
    91  	IsCollaborator(org, repo, user string) (bool, error)
    92  	IsMember(org, user string) (bool, error)
    93  	GetPullRequest(org, repo string, number int) (*github.PullRequest, error)
    94  	GetRef(org, repo, ref string) (string, error)
    95  	CreateComment(owner, repo string, number int, comment string) error
    96  	ListIssueComments(owner, repo string, issue int) ([]github.IssueComment, error)
    97  	CreateStatus(owner, repo, ref string, status github.Status) error
    98  	GetCombinedStatus(org, repo, ref string) (*github.CombinedStatus, error)
    99  	GetPullRequestChanges(org, repo string, number int) ([]github.PullRequestChange, error)
   100  	RemoveLabel(org, repo string, number int, label string) error
   101  	DeleteStaleComments(org, repo string, number int, comments []github.IssueComment, isStale func(github.IssueComment) bool) error
   102  	GetIssueLabels(org, repo string, number int) ([]github.Label, error)
   103  }
   104  
   105  type kubeClient interface {
   106  	CreateProwJob(kube.ProwJob) (kube.ProwJob, error)
   107  }
   108  
   109  // Client holds the necessary structures to work with prow via logging, github, kubernetes and its configuration.
   110  //
   111  // TODO(fejta): consider exporting an interface rather than a struct
   112  type Client struct {
   113  	GitHubClient githubClient
   114  	KubeClient   kubeClient
   115  	Config       *config.Config
   116  	Logger       *logrus.Entry
   117  }
   118  
   119  type trustedUserClient interface {
   120  	IsCollaborator(org, repo, user string) (bool, error)
   121  	IsMember(org, user string) (bool, error)
   122  }
   123  
   124  func getClient(pc plugins.Agent) Client {
   125  	return Client{
   126  		GitHubClient: pc.GitHubClient,
   127  		Config:       pc.Config,
   128  		KubeClient:   pc.KubeClient,
   129  		Logger:       pc.Logger,
   130  	}
   131  }
   132  
   133  func handlePullRequest(pc plugins.Agent, pr github.PullRequestEvent) error {
   134  	org, repo, _ := orgRepoAuthor(pr.PullRequest)
   135  	return handlePR(getClient(pc), pc.PluginConfig.TriggerFor(org, repo), pr)
   136  }
   137  
   138  func handleGenericCommentEvent(pc plugins.Agent, gc github.GenericCommentEvent) error {
   139  	return handleGenericComment(getClient(pc), pc.PluginConfig.TriggerFor(gc.Repo.Owner.Login, gc.Repo.Name), gc)
   140  }
   141  
   142  func handlePush(pc plugins.Agent, pe github.PushEvent) error {
   143  	return handlePE(getClient(pc), pe)
   144  }
   145  
   146  // TrustedUser returns true if user is trusted in repo.
   147  //
   148  // Trusted users are either repo collaborators, org members or trusted org members.
   149  // Whether repo collaborators and/or a second org is trusted is configured by trigger.
   150  func TrustedUser(ghc trustedUserClient, trigger *plugins.Trigger, user, org, repo string) (bool, error) {
   151  	// First check if user is a collaborator, assuming this is allowed
   152  	allowCollaborators := trigger == nil || !trigger.OnlyOrgMembers
   153  	if allowCollaborators {
   154  		if ok, err := ghc.IsCollaborator(org, repo, user); err != nil {
   155  			return false, fmt.Errorf("error in IsCollaborator: %v", err)
   156  		} else if ok {
   157  			return true, nil
   158  		}
   159  	}
   160  
   161  	// TODO(fejta): consider dropping support for org checks in the future.
   162  
   163  	// Next see if the user is an org member
   164  	if member, err := ghc.IsMember(org, user); err != nil {
   165  		return false, fmt.Errorf("error in IsMember(%s): %v", org, err)
   166  	} else if member {
   167  		return true, nil
   168  	}
   169  
   170  	// Determine if there is a second org to check
   171  	if trigger == nil || trigger.TrustedOrg == "" || trigger.TrustedOrg == org {
   172  		return false, nil // No trusted org and/or it is the same
   173  	}
   174  
   175  	// Check the second trusted org.
   176  	member, err := ghc.IsMember(trigger.TrustedOrg, user)
   177  	if err != nil {
   178  		return false, fmt.Errorf("error in IsMember(%s): %v", trigger.TrustedOrg, err)
   179  	}
   180  	return member, nil
   181  }
   182  
   183  func fileChangesGetter(ghc githubClient, org, repo string, num int) func() ([]string, error) {
   184  	var changedFiles []string
   185  	return func() ([]string, error) {
   186  		// Fetch the changed files from github at most once.
   187  		if changedFiles == nil {
   188  			changes, err := ghc.GetPullRequestChanges(org, repo, num)
   189  			if err != nil {
   190  				return nil, fmt.Errorf("error getting pull request changes: %v", err)
   191  			}
   192  			changedFiles = []string{}
   193  			for _, change := range changes {
   194  				changedFiles = append(changedFiles, change.Filename)
   195  			}
   196  		}
   197  		return changedFiles, nil
   198  	}
   199  }
   200  
   201  func allContexts(parent config.Presubmit) []string {
   202  	contexts := []string{parent.Context}
   203  	for _, child := range parent.RunAfterSuccess {
   204  		contexts = append(contexts, allContexts(child)...)
   205  	}
   206  	return contexts
   207  }
   208  
   209  // RunOrSkipRequested evaluates requestJobs to determine which config.Presubmits to
   210  // run and which ones to skip and once execute the ones that should be ran.
   211  func RunOrSkipRequested(c Client, pr *github.PullRequest, requestedJobs []config.Presubmit, forceRunContexts map[string]bool, body, eventGUID string) error {
   212  	org := pr.Base.Repo.Owner.Login
   213  	repo := pr.Base.Repo.Name
   214  	number := pr.Number
   215  
   216  	baseSHA, err := c.GitHubClient.GetRef(org, repo, "heads/"+pr.Base.Ref)
   217  	if err != nil {
   218  		return err
   219  	}
   220  
   221  	// Use a closure to lazily retrieve the file changes only if they are needed.
   222  	// We only have to fetch the changes if there is at least one RunIfChanged
   223  	// job that is not being force run (due to a `/retest` after a failure or
   224  	// because it is explicitly triggered with `/test foo`).
   225  	getChanges := fileChangesGetter(c.GitHubClient, org, repo, number)
   226  	// shouldRun indicates if a job should actually run.
   227  	shouldRun := func(j config.Presubmit) (bool, error) {
   228  		if !j.RunsAgainstBranch(pr.Base.Ref) {
   229  			return false, nil
   230  		}
   231  		if j.RunIfChanged == "" || forceRunContexts[j.Context] || j.TriggerMatches(body) {
   232  			return true, nil
   233  		}
   234  		changes, err := getChanges()
   235  		if err != nil {
   236  			return false, err
   237  		}
   238  		return j.RunsAgainstChanges(changes), nil
   239  	}
   240  
   241  	// For each job determine if any sharded version of the job runs.
   242  	// This in turn determines which jobs to run and which contexts to mark as "Skipped".
   243  	//
   244  	// Note: Job sharding is achieved with presubmit configurations that overlap on
   245  	// name, but run under disjoint circumstances. For example, a job 'foo' can be
   246  	// sharded to have different pod specs for different branches by
   247  	// creating 2 presubmit configurations with the name foo, but different pod
   248  	// specs, and specifying different branches for each job.
   249  	var toRunJobs []config.Presubmit
   250  	toRun := sets.NewString()
   251  	toSkip := sets.NewString()
   252  	for _, job := range requestedJobs {
   253  		runs, err := shouldRun(job)
   254  		if err != nil {
   255  			return err
   256  		}
   257  		if runs {
   258  			toRunJobs = append(toRunJobs, job)
   259  			toRun.Insert(job.Context)
   260  		} else if !job.SkipReport {
   261  			// we need to post context statuses for all jobs; if a job is slated to
   262  			// run after the success of a parent job that is skipped, it must be
   263  			// skipped as well
   264  			toSkip.Insert(allContexts(job)...)
   265  		}
   266  	}
   267  	// 'Skip' any context that is requested, but doesn't have any job shards that
   268  	// will run.
   269  	for _, context := range toSkip.Difference(toRun).List() {
   270  		if err := c.GitHubClient.CreateStatus(org, repo, pr.Head.SHA, github.Status{
   271  			State:       github.StatusSuccess,
   272  			Context:     context,
   273  			Description: "Skipped",
   274  		}); err != nil {
   275  			return err
   276  		}
   277  	}
   278  
   279  	var errors []error
   280  	for _, job := range toRunJobs {
   281  		c.Logger.Infof("Starting %s build.", job.Name)
   282  		pj := pjutil.NewPresubmit(*pr, baseSHA, job, eventGUID)
   283  		c.Logger.WithFields(pjutil.ProwJobFields(&pj)).Info("Creating a new prowjob.")
   284  		if _, err := c.KubeClient.CreateProwJob(pj); err != nil {
   285  			errors = append(errors, err)
   286  		}
   287  	}
   288  	if len(errors) > 0 {
   289  		return fmt.Errorf("errors starting jobs: %v", errors)
   290  	}
   291  	return nil
   292  }