github.com/web-platform-tests/wpt.fyi@v0.0.0-20240530210107-70cf978996f1/api/checks/webhook.go (about) 1 // Copyright 2018 The WPT Dashboard Project. All rights reserved. 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 5 package checks 6 7 import ( 8 "context" 9 "encoding/json" 10 "fmt" 11 "net/http" 12 "regexp" 13 14 "github.com/google/go-github/v47/github" 15 "github.com/web-platform-tests/wpt.fyi/shared" 16 ) 17 18 const requestedAction = "requested" 19 const rerequestedAction = "rerequested" 20 21 var runNameRegex = regexp.MustCompile(`^(?:(?:staging\.)?wpt\.fyi - )(.*)$`) 22 23 func isWPTFYIApp(appID int64) bool { 24 return appID == wptfyiCheckAppID || appID == wptfyiStagingCheckAppID 25 } 26 27 // checkWebhookHandler handles GitHub events relating to our wpt.fyi and 28 // staging.wpt.fyi GitHub Apps[0], sent to the /api/webhook/check endpoint. 29 // 30 // [0]: https://github.com/apps/wpt-fyi and https://github.com/apps/staging-wpt-fyi 31 func checkWebhookHandler(w http.ResponseWriter, r *http.Request) { 32 ctx := r.Context() 33 log := shared.GetLogger(ctx) 34 ds := shared.NewAppEngineDatastore(ctx, false) 35 36 contentType := r.Header.Get("Content-Type") 37 if contentType != "application/json" { 38 log.Errorf("Invalid content-type: %s", contentType) 39 w.WriteHeader(http.StatusBadRequest) 40 41 return 42 } 43 event := r.Header.Get("X-GitHub-Event") 44 switch event { 45 case "check_suite", "check_run", "pull_request": 46 break 47 default: 48 log.Debugf("Ignoring %s event", event) 49 w.WriteHeader(http.StatusBadRequest) 50 51 return 52 } 53 54 secret, err := shared.GetSecret(ds, "github-check-webhook-secret") 55 if err != nil { 56 log.Errorf("Missing secret: github-check-webhook-secret") 57 http.Error(w, "Unable to verify request: secret not found", http.StatusInternalServerError) 58 59 return 60 } 61 62 payload, err := github.ValidatePayload(r, []byte(secret)) 63 if err != nil { 64 log.Errorf("%v", err) 65 http.Error(w, err.Error(), http.StatusInternalServerError) 66 67 return 68 } 69 70 log.Debugf("GitHub Delivery: %s", r.Header.Get("X-GitHub-Delivery")) 71 72 var processed bool 73 api := NewAPI(ctx) 74 if event == "check_suite" { 75 processed, err = handleCheckSuiteEvent(api, payload) 76 } else if event == "check_run" { 77 processed, err = handleCheckRunEvent(api, payload) 78 } else if event == "pull_request" { 79 processed, err = handlePullRequestEvent(api, payload) 80 } 81 if err != nil { 82 log.Errorf("%v", err) 83 http.Error(w, err.Error(), http.StatusInternalServerError) 84 85 return 86 } 87 if processed { 88 w.WriteHeader(http.StatusOK) 89 fmt.Fprintln(w, "wpt.fyi check(s) scheduled successfully") 90 } else { 91 w.WriteHeader(http.StatusNoContent) 92 fmt.Fprintln(w, "Status was ignored") 93 } 94 } 95 96 // handleCheckSuiteEvent handles a check_suite (re)requested event by ensuring 97 // that a check_run exists for each product that contains results for the head SHA. 98 // nolint:gocognit // TODO: Fix gocognit lint error 99 func handleCheckSuiteEvent(api API, payload []byte) (bool, error) { 100 log := shared.GetLogger(api.Context()) 101 var checkSuite github.CheckSuiteEvent 102 if err := json.Unmarshal(payload, &checkSuite); err != nil { 103 return false, err 104 } 105 106 action := checkSuite.GetAction() 107 owner := checkSuite.GetRepo().GetOwner().GetLogin() 108 repo := checkSuite.GetRepo().GetName() 109 sha := checkSuite.GetCheckSuite().GetHeadSHA() 110 appName := checkSuite.GetCheckSuite().GetApp().GetName() 111 appID := checkSuite.GetCheckSuite().GetApp().GetID() 112 113 log.Debugf("Check suite %s: %s/%s @ %s (App %v, ID %v)", 114 action, 115 owner, 116 repo, 117 shared.CropString(sha, 7), 118 appName, 119 appID, 120 ) 121 122 if !isWPTFYIApp(appID) { 123 log.Infof("Ignoring check_suite App ID %v", appID) 124 125 return false, nil 126 } 127 128 login := checkSuite.GetSender().GetLogin() 129 if !checksEnabledForUser(api, login) { 130 log.Infof("Checks not enabled for sender %s", login) 131 132 return false, nil 133 } 134 135 // nolint:nestif // TODO: Fix nestif lint error 136 if action == requestedAction || action == rerequestedAction { 137 pullRequests := checkSuite.GetCheckSuite().PullRequests 138 prNumbers := []int{} 139 for _, pr := range pullRequests { 140 if pr.GetBase().GetRepo().GetID() == wptRepoID { 141 prNumbers = append(prNumbers, pr.GetNumber()) 142 } 143 } 144 145 installationID := checkSuite.GetInstallation().GetID() 146 if action == requestedAction { 147 for _, p := range pullRequests { 148 destRepoID := p.GetBase().GetRepo().GetID() 149 if destRepoID == wptRepoID && p.GetHead().GetRepo().GetID() != destRepoID { 150 // Errors are already logged by CreateWPTCheckSuite 151 _, _ = api.CreateWPTCheckSuite(appID, installationID, sha, prNumbers...) 152 } 153 } 154 } 155 156 suite, err := getOrCreateCheckSuite(api.Context(), sha, owner, repo, appID, installationID, prNumbers...) 157 if err != nil || suite == nil { 158 return false, err 159 } 160 161 if action == rerequestedAction { 162 return scheduleProcessingForExistingRuns(api.Context(), sha) 163 } 164 } 165 166 return false, nil 167 } 168 169 // handleCheckRunEvent handles a check_run rerequested events by updating 170 // the status based on whether results for the check_run's product exist. 171 func handleCheckRunEvent( 172 api API, 173 payload []byte) (bool, error) { 174 175 log := shared.GetLogger(api.Context()) 176 checkRun := new(github.CheckRunEvent) 177 if err := json.Unmarshal(payload, checkRun); err != nil { 178 return false, err 179 } 180 181 action := checkRun.GetAction() 182 owner := checkRun.GetRepo().GetOwner().GetLogin() 183 repo := checkRun.GetRepo().GetName() 184 sha := checkRun.GetCheckRun().GetHeadSHA() 185 appName := checkRun.GetCheckRun().GetApp().GetName() 186 appID := checkRun.GetCheckRun().GetApp().GetID() 187 188 log.Debugf("Check run %s: %s/%s @ %s (App %v, ID %v)", action, owner, repo, shared.CropString(sha, 7), appName, appID) 189 190 if !isWPTFYIApp(appID) { 191 log.Infof("Ignoring check_run App ID %v", appID) 192 193 return false, nil 194 } 195 196 login := checkRun.GetSender().GetLogin() 197 if !checksEnabledForUser(api, login) { 198 log.Infof("Checks not enabled for sender %s", login) 199 200 return false, nil 201 } 202 203 // Determine whether or not we need to schedule processing the results 204 // of a CheckRun. The 'requested_action' event occurs when a user 205 // clicks on one of the 'action' buttons we setup as part of our 206 // CheckRuns[0]; see summaries.Summary.GetActions(). 207 // 208 // [0]: https://developer.github.com/v3/checks/runs/#check-runs-and-requested-actions 209 status := checkRun.GetCheckRun().GetStatus() 210 shouldSchedule := false 211 if (action == "created" && status != "completed") || action == "rerequested" { 212 shouldSchedule = true 213 } else if action == "requested_action" { 214 actionID := checkRun.GetRequestedAction().Identifier 215 switch actionID { 216 case "recompute": 217 shouldSchedule = true 218 case "ignore": 219 err := api.IgnoreFailure( 220 login, 221 owner, 222 repo, 223 checkRun.GetCheckRun(), 224 checkRun.GetInstallation()) 225 226 return err == nil, err 227 case "cancel": 228 err := api.CancelRun( 229 login, 230 owner, 231 repo, 232 checkRun.GetCheckRun(), 233 checkRun.GetInstallation()) 234 235 return err == nil, err 236 default: 237 log.Debugf("Ignoring %s action with id %s", action, actionID) 238 239 return false, nil 240 } 241 } 242 243 if shouldSchedule { 244 name := checkRun.GetCheckRun().GetName() 245 log.Debugf("GitHub check run %v (%s @ %s) was %s", checkRun.GetCheckRun().GetID(), name, sha, action) 246 // Strip any "wpt.fyi - " prefix. 247 if runNameRegex.MatchString(name) { 248 name = runNameRegex.FindStringSubmatch(name)[1] 249 } 250 spec, err := shared.ParseProductSpec(name) 251 if err != nil { 252 log.Errorf("Failed to parse \"%s\" as product spec", name) 253 254 return false, err 255 } 256 // Errors are logged by ScheduleResultsProcessing 257 _ = api.ScheduleResultsProcessing(sha, spec) 258 259 return true, nil 260 } 261 log.Debugf("Ignoring %s action for %s check_run", action, status) 262 263 return false, nil 264 } 265 266 // handlePullRequestEvent reaches to pull requests from forks, ensuring that a 267 // GitHub check_suite is created in the main WPT repository for those. GitHub 268 // automatically creates a check_suite for code pushed to the WPT repository, 269 // so we don't need to do anything for same-repo pull requests. 270 func handlePullRequestEvent(api API, payload []byte) (bool, error) { 271 log := shared.GetLogger(api.Context()) 272 var pullRequest github.PullRequestEvent 273 if err := json.Unmarshal(payload, &pullRequest); err != nil { 274 return false, err 275 } 276 277 login := pullRequest.GetPullRequest().GetUser().GetLogin() 278 if !checksEnabledForUser(api, login) { 279 log.Infof("Checks not enabled for sender %s", login) 280 281 return false, nil 282 } 283 284 switch pullRequest.GetAction() { 285 case "opened", "synchronize": 286 break 287 default: 288 log.Debugf("Skipping pull request action %s", pullRequest.GetAction()) 289 290 return false, nil 291 } 292 293 sha := pullRequest.GetPullRequest().GetHead().GetSHA() 294 destRepoID := pullRequest.GetPullRequest().GetBase().GetRepo().GetID() 295 if destRepoID == wptRepoID && pullRequest.GetPullRequest().GetHead().GetRepo().GetID() != destRepoID { 296 // Pull is across forks; request a check suite on the main fork too. 297 appID, installationID := api.GetWPTRepoAppInstallationIDs() 298 299 return api.CreateWPTCheckSuite(appID, installationID, sha, pullRequest.GetNumber()) 300 } 301 302 return false, nil 303 } 304 305 func scheduleProcessingForExistingRuns(ctx context.Context, sha string, products ...shared.ProductSpec) (bool, error) { 306 // Jump straight to completed check_run for already-present runs for the SHA. 307 store := shared.NewAppEngineDatastore(ctx, false) 308 products = shared.ProductSpecs(products).OrDefault() 309 runsByProduct, err := store.TestRunQuery().LoadTestRuns(products, nil, shared.SHAs{sha}, nil, nil, nil, nil) 310 if err != nil { 311 return false, fmt.Errorf("Failed to load test runs: %s", err.Error()) 312 } 313 createdSome := false 314 api := NewAPI(ctx) 315 for _, rbp := range runsByProduct { 316 if len(rbp.TestRuns) > 0 { 317 err := api.ScheduleResultsProcessing(sha, rbp.Product) 318 createdSome = createdSome || err == nil 319 if err != nil { 320 return createdSome, err 321 } 322 } 323 } 324 325 return createdSome, nil 326 } 327 328 // createCheckRun submits an http POST to create the check run on GitHub, handling JWT auth for the app. 329 func createCheckRun(ctx context.Context, suite shared.CheckSuite, opts github.CreateCheckRunOptions) (bool, error) { 330 log := shared.GetLogger(ctx) 331 status := "" 332 if opts.Status != nil { 333 status = *opts.Status 334 } 335 log.Debugf("Creating %s %s check_run for %s/%s @ %s", status, opts.Name, suite.Owner, suite.Repo, suite.SHA) 336 if suite.AppID == 0 { 337 suite.AppID = wptfyiStagingCheckAppID 338 } 339 client, err := getGitHubClient(ctx, suite.AppID, suite.InstallationID) 340 if err != nil { 341 log.Errorf("Failed to create JWT client: %s", err.Error()) 342 343 return false, err 344 } 345 346 checkRun, resp, err := client.Checks.CreateCheckRun(ctx, suite.Owner, suite.Repo, opts) 347 if err != nil { 348 msg := "Failed to create check_run" 349 if resp != nil { 350 msg = fmt.Sprintf("%s: %s", msg, resp.Status) 351 } 352 log.Warningf(msg) 353 354 return false, err 355 } else if checkRun != nil { 356 log.Infof("Created check_run %v", checkRun.GetID()) 357 } 358 359 return true, nil 360 } 361 362 // checksEnabledForUser returns if a commit from a given GitHub username should 363 // cause wpt.fyi or staging.wpt.fyi summary results to show up in the GitHub 364 // UI. Currently this is enabled for all users on prod, but only for some users 365 // on staging to avoid having a confusing double-set of checks appear. 366 func checksEnabledForUser(api API, login string) bool { 367 if api.IsFeatureEnabled(checksForAllUsersFeature) { 368 return true 369 } 370 enabledLogins := []string{ 371 "chromium-wpt-export-bot", 372 "gsnedders", 373 "jgraham", 374 "jugglinmike", 375 "lukebjerring", 376 "Ms2ger", 377 } 378 379 return shared.StringSliceContains(enabledLogins, login) 380 }