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 }