github.com/web-platform-tests/wpt.fyi@v0.0.0-20240530210107-70cf978996f1/api/taskcluster/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 //go:generate mockgen -destination mock_taskcluster/webhook_mock.go github.com/web-platform-tests/wpt.fyi/api/taskcluster API 6 7 package taskcluster 8 9 import ( 10 "context" 11 "encoding/json" 12 "errors" 13 "fmt" 14 "net/http" 15 "regexp" 16 "strings" 17 "sync" 18 19 mapset "github.com/deckarep/golang-set" 20 "github.com/google/go-github/v47/github" 21 tcurls "github.com/taskcluster/taskcluster-lib-urls" 22 "github.com/taskcluster/taskcluster/v44/clients/client-go/tcqueue" 23 uc "github.com/web-platform-tests/wpt.fyi/api/receiver/client" 24 "github.com/web-platform-tests/wpt.fyi/shared" 25 ) 26 27 // AppID is the ID of the Community-TC GitHub app. 28 const AppID = int64(40788) 29 30 const uploaderName = "taskcluster" 31 const completedState = "completed" 32 33 var ( 34 // TaskNameRegex is based on task names in 35 // https://github.com/web-platform-tests/wpt/blob/master/tools/ci/tc/tasks/test.yml. 36 TaskNameRegex = regexp.MustCompile(`^wpt-([a-z_]+-[a-z]+)-([a-z]+(?:-[a-z]+)*)(?:-\d+)?$`) 37 // Taskcluster has used different forms of URLs in their Check & Status 38 // updates in history. We accept all of them. 39 // See TestExtractTaskGroupID for examples. 40 inspectorURLRegex = regexp.MustCompile(`^(https://[^/]*)/task-group-inspector/#/([^/]*)`) 41 taskURLRegex = regexp.MustCompile(`^(https://[^/]*)(?:/tasks)?/groups/([^/]*)(?:/tasks/([^/]*))?`) 42 checkRunDetailsURLRegex = regexp.MustCompile(`^(https://[^/]*)/tasks/([^/]*)`) 43 ) 44 45 // Non-fatal error when there is no result (e.g. nothing finishes yet). 46 var errNoResults = errors.New("no result URLs found in task group") 47 48 // TaskInfo is an abstraction of a Taskcluster task, containing the necessary 49 // information for us to process the task in wpt.fyi. 50 type TaskInfo struct { 51 Name string 52 TaskID string 53 State string 54 } 55 56 // TaskGroupInfo is an abstraction of a Taskcluster task group, containing the 57 // necessary information for us to process the group in wpt.fyi. 58 type TaskGroupInfo struct { 59 TaskGroupID string 60 Tasks []TaskInfo 61 } 62 63 // EventInfo is an abstraction of a GitHub Status event, containing the 64 // necessary information for us to process the event in wpt.fyi. 65 type EventInfo struct { 66 Sha string 67 RootURL string 68 TaskID string 69 Master bool 70 Sender string 71 Group *TaskGroupInfo 72 } 73 74 // API wraps externally provided methods so we can mock them for testing. 75 type API interface { 76 GetTaskGroupInfo(string, string) (*TaskGroupInfo, error) 77 ListCheckRuns(owner string, repo string, checkSuiteID int64) ([]*github.CheckRun, error) 78 } 79 80 type apiImpl struct { 81 ctx context.Context // nolint:containedctx // TODO: Fix containedctx lint error 82 ghClient *github.Client 83 } 84 85 // GetStatusEventInfo turns a StatusEventPayload into an EventInfo struct. 86 func GetStatusEventInfo(status StatusEventPayload, log shared.Logger, api API) (EventInfo, error) { 87 if status.SHA == nil { 88 return EventInfo{}, errors.New("No sha on taskcluster status event") 89 } 90 91 if status.TargetURL == nil { 92 return EventInfo{}, errors.New("No target_url on taskcluster status event") 93 } 94 95 rootURL, taskGroupID, taskID := ParseTaskclusterURL(*status.TargetURL) 96 if taskGroupID == "" { 97 return EventInfo{}, fmt.Errorf("unrecognized target_url: %s", *status.TargetURL) 98 } 99 100 log.Debugf("Taskcluster task group %s", taskGroupID) 101 102 group, err := api.GetTaskGroupInfo(rootURL, taskGroupID) 103 if err != nil { 104 return EventInfo{}, err 105 } 106 107 event := EventInfo{ 108 Sha: *status.SHA, 109 RootURL: rootURL, 110 TaskID: taskID, 111 Master: status.IsOnMaster(), 112 Sender: status.GetCommit().GetAuthor().GetLogin(), 113 Group: group, 114 } 115 116 return event, nil 117 } 118 119 // GetCheckSuiteEventInfo turns a github.CheckSuiteEvent into an EventInfo struct. 120 func GetCheckSuiteEventInfo(checkSuite github.CheckSuiteEvent, log shared.Logger, api API) (EventInfo, error) { 121 if checkSuite.GetCheckSuite().GetHeadSHA() == "" { 122 return EventInfo{}, errors.New("No sha on taskcluster check_suite event") 123 } 124 125 log.Debugf("Parsing check_suite event for commit %s", checkSuite.GetCheckSuite().GetHeadSHA()) 126 127 owner := checkSuite.GetRepo().GetOwner().GetLogin() 128 repo := checkSuite.GetRepo().GetName() 129 if owner != shared.WPTRepoOwner || repo != shared.WPTRepoName { 130 log.Errorf("Received check_suite event from invalid repo %s/%s", owner, repo) 131 132 return EventInfo{}, errors.New("Invalid source repository") 133 } 134 135 runs, err := api.ListCheckRuns(owner, repo, checkSuite.GetCheckSuite().GetID()) 136 if err != nil { 137 log.Errorf("Failed to fetch check runs for suite %v: %s", checkSuite.GetCheckSuite().GetID(), err.Error()) 138 139 return EventInfo{}, err 140 } 141 142 if len(runs) == 0 { 143 return EventInfo{}, errors.New("No check_runs for check_suite") 144 } 145 146 log.Debugf("Found %d check_runs for check_suite", len(runs)) 147 148 rootURL := "" 149 group := TaskGroupInfo{} // nolint:exhaustruct // TODO: Fix exhaustruct lint error 150 for _, run := range runs { 151 matches := checkRunDetailsURLRegex.FindStringSubmatch(run.GetDetailsURL()) 152 if matches == nil { 153 log.Errorf( 154 "Unable to parse details URL for suite %v, run %v: %s", 155 checkSuite.GetCheckSuite().GetID(), 156 run.GetID(), 157 run.GetDetailsURL(), 158 ) 159 160 return EventInfo{}, errors.New("Unable to parse check_run details URL") 161 } 162 if rootURL != "" && rootURL != matches[1] { 163 log.Errorf( 164 "Conflicting root URLs for runs for suite %v (%s vs %s)", 165 checkSuite.GetCheckSuite().GetID(), 166 rootURL, 167 matches[1], 168 ) 169 170 return EventInfo{}, errors.New("Conflicting root URLs for runs in check_suite") 171 } 172 rootURL = matches[1] 173 taskID := matches[2] 174 175 // The task group's ID appear to be equivalent to the ID of the initial task 176 // that was created. For WPT, this is the decision task. 177 if run.GetName() == "wpt-decision-task" { 178 group.TaskGroupID = taskID 179 } 180 181 log.Debugf("Adding task: %s, id: %s, conclusion: %s", run.GetName(), taskID, run.GetConclusion()) 182 183 // Reconstruct Taskcluster TaskInfo from the check run without calling Taskcluster API. 184 state := run.GetConclusion() 185 if state == "success" { 186 // Checked in ExtractArtifactURLs. 187 state = completedState 188 } 189 190 group.Tasks = append(group.Tasks, TaskInfo{ 191 Name: run.GetName(), 192 TaskID: taskID, 193 State: state, 194 }) 195 } 196 197 event := EventInfo{ 198 Sha: checkSuite.GetCheckSuite().GetHeadSHA(), 199 RootURL: rootURL, 200 // The TaskID is a filter for a specific task. For check_suite events we 201 // only ever receieve events for an entire suite, so there is no TaskID. 202 TaskID: "", 203 Master: checkSuite.GetCheckSuite().GetHeadBranch() == "master", 204 Sender: checkSuite.GetSender().GetLogin(), 205 Group: &group, 206 } 207 208 return event, nil 209 } 210 211 // tcStatusWebhookHandler reacts to GitHub status webhook events. 212 func tcStatusWebhookHandler(w http.ResponseWriter, r *http.Request) { 213 eventName := r.Header.Get("X-GitHub-Event") 214 if r.Header.Get("Content-Type") != "application/json" || (eventName != "status" && eventName != "check_suite") { 215 w.WriteHeader(http.StatusBadRequest) 216 217 return 218 } 219 220 ctx := r.Context() 221 ds := shared.NewAppEngineDatastore(ctx, false) 222 secret, err := shared.GetSecret(ds, "github-tc-webhook-secret") 223 if err != nil { 224 http.Error(w, "Unable to verify request: secret not found", http.StatusInternalServerError) 225 226 return 227 } 228 229 log := shared.GetLogger(ctx) 230 log.Debugf("Retrieved GitHub secret from datastore") 231 232 payload, err := github.ValidatePayload(r, []byte(secret)) 233 if err != nil { 234 http.Error(w, err.Error(), http.StatusUnauthorized) 235 236 return 237 } 238 log.Debugf("Payload validated against secret") 239 240 log.Debugf("GitHub Delivery: %s", r.Header.Get("X-GitHub-Delivery")) 241 242 aeAPI := shared.NewAppEngineAPI(ctx) 243 244 ghClient, err := aeAPI.GetGitHubClient() 245 if err != nil { 246 log.Errorf("Failed to get GitHub client: %s", err.Error()) 247 http.Error(w, err.Error(), http.StatusInternalServerError) 248 249 return 250 } 251 api := apiImpl{ctx: aeAPI.Context(), ghClient: ghClient} 252 253 var event EventInfo 254 // nolint:nestif // TODO: Fix nestif lint error 255 if eventName == "status" { 256 var status StatusEventPayload 257 if err := json.Unmarshal(payload, &status); err != nil { 258 log.Errorf("%v", err) 259 http.Error(w, err.Error(), http.StatusBadRequest) 260 261 return 262 } 263 264 if !ShouldProcessStatus(log, &status) { 265 w.WriteHeader(http.StatusNoContent) 266 fmt.Fprintln(w, "Status was ignored") 267 268 return 269 } 270 271 event, err = GetStatusEventInfo(status, log, api) 272 } else { 273 var checkSuite github.CheckSuiteEvent 274 if err := json.Unmarshal(payload, &checkSuite); err != nil { 275 log.Errorf("%v", err) 276 http.Error(w, err.Error(), http.StatusBadRequest) 277 278 return 279 } 280 281 if checkSuite.GetCheckSuite().GetApp().GetID() != AppID { 282 log.Debugf("Ignoring non-Taskcluster app: %s (%s)", 283 checkSuite.GetCheckSuite().GetApp().GetName(), 284 checkSuite.GetCheckSuite().GetApp().GetID()) 285 w.WriteHeader(http.StatusNoContent) 286 fmt.Fprintln(w, "Status was ignored") 287 288 return 289 } 290 291 // As a webhook we should only receive completed check_suite events, as per 292 // https://developer.github.com/webhooks/event-payloads/#check_suite 293 if checkSuite.GetAction() != completedState || checkSuite.GetCheckSuite().GetStatus() != completedState { 294 log.Errorf("Received non-completed check_suite event (action: %s, status: %s)", 295 checkSuite.GetAction(), checkSuite.GetCheckSuite().GetStatus()) 296 http.Error(w, "Non-completed check_suite event", http.StatusBadRequest) 297 298 return 299 } 300 301 event, err = GetCheckSuiteEventInfo(checkSuite, log, api) 302 } 303 if err != nil { 304 log.Errorf("%v", err) 305 http.Error(w, err.Error(), http.StatusInternalServerError) 306 307 return 308 } 309 310 labels := mapset.NewSet() 311 if event.Master { 312 labels.Add(shared.MasterLabel) 313 } 314 if event.Sender != "" { 315 labels.Add(shared.GetUserLabel(event.Sender)) 316 } 317 318 processed, err := processTaskclusterBuild(aeAPI, event, shared.ToStringSlice(labels)...) 319 320 if errors.Is(err, errNoResults) { 321 log.Infof("%v", err) 322 http.Error(w, err.Error(), http.StatusNoContent) 323 324 return 325 } 326 if err != nil { 327 log.Errorf("%v", err) 328 http.Error(w, err.Error(), http.StatusInternalServerError) 329 330 return 331 } 332 if processed { 333 w.WriteHeader(http.StatusOK) 334 fmt.Fprintln(w, "Taskcluster tasks were sent to results receiver") 335 } else { 336 w.WriteHeader(http.StatusNoContent) 337 fmt.Fprintln(w, "Status was ignored") 338 } 339 } 340 341 // StatusEventPayload wraps a github.StatusEvent so we can declare methods on it 342 // https://developer.github.com/v3/activity/events/types/#statusevent 343 type StatusEventPayload struct { 344 github.StatusEvent 345 } 346 347 // IsCompleted checks if a github.StatusEvent has completed. 348 func (s StatusEventPayload) IsCompleted() bool { 349 return s.GetState() == "success" || s.GetState() == "failure" 350 } 351 352 // IsTaskcluster checks if a github.StatusEvent is from Taskcluster. 353 func (s StatusEventPayload) IsTaskcluster() bool { 354 return s.Context != nil && (strings.HasPrefix(*s.Context, "Taskcluster") || 355 strings.HasPrefix(*s.Context, "Community-TC")) 356 } 357 358 // IsOnMaster checks if a github.StatusEvent affects the master branch. 359 func (s StatusEventPayload) IsOnMaster() bool { 360 for _, branch := range s.Branches { 361 if branch.Name != nil && *branch.Name == "master" { 362 return true 363 } 364 } 365 366 return false 367 } 368 369 func processTaskclusterBuild(aeAPI shared.AppEngineAPI, event EventInfo, labels ...string) (bool, error) { 370 ctx := aeAPI.Context() 371 log := shared.GetLogger(ctx) 372 373 if event.TaskID != "" { 374 log.Debugf("Taskcluster task %s", event.TaskID) 375 } 376 377 urlsByProduct, err := ExtractArtifactURLs(event.RootURL, log, event.Group, event.TaskID) 378 if err != nil { 379 return false, err 380 } 381 382 uploader, err := aeAPI.GetUploader(uploaderName) 383 if err != nil { 384 log.Errorf("Failed to get uploader creds from Datastore") 385 386 return false, err 387 } 388 389 err = CreateAllRuns( 390 log, 391 shared.NewAppEngineAPI(ctx), 392 event.Sha, 393 uploader.Username, 394 uploader.Password, 395 urlsByProduct, 396 labels) 397 if err != nil { 398 return false, err 399 } 400 401 return true, nil 402 } 403 404 // ShouldProcessStatus determines whether we are interested in processing a 405 // given StatusEventPayload or not. 406 func ShouldProcessStatus(log shared.Logger, status *StatusEventPayload) bool { 407 if !status.IsCompleted() { 408 log.Debugf("Ignoring status: %s", status.GetState()) 409 410 return false 411 } else if !status.IsTaskcluster() { 412 log.Debugf("Ignoring non-Taskcluster context: %s", status.GetContext()) 413 414 return false 415 } 416 417 return true 418 } 419 420 // ParseTaskclusterURL splits a given URL into its root URL, the Taskcluster 421 // group id, and an optional specific task ID. 422 func ParseTaskclusterURL(targetURL string) (rootURL, taskGroupID, taskID string) { 423 if matches := inspectorURLRegex.FindStringSubmatch(targetURL); matches != nil { 424 rootURL = matches[1] 425 taskGroupID = matches[2] 426 } else if matches := taskURLRegex.FindStringSubmatch(targetURL); matches != nil { 427 rootURL = matches[1] 428 taskGroupID = matches[2] 429 // matches[3] may be an empty string -- the capturing group is optional. 430 taskID = matches[3] 431 } 432 // Special case for old Taskcluster instance, which uses subdomains for 433 // different services and we need to strip the subdomain away. 434 if strings.HasSuffix(rootURL, "taskcluster.net") { 435 rootURL = "https://taskcluster.net" 436 } 437 438 return rootURL, taskGroupID, taskID 439 } 440 441 func (api apiImpl) GetTaskGroupInfo(rootURL string, groupID string) (*TaskGroupInfo, error) { 442 queue := tcqueue.New(nil, rootURL) 443 444 group := TaskGroupInfo{ // nolint:exhaustruct // TODO: Fix exhaustruct lint error 445 TaskGroupID: groupID, 446 } 447 continuationToken := "" 448 449 for { 450 ltgr, err := queue.ListTaskGroup(groupID, continuationToken, "1000") 451 if err != nil { 452 return nil, err 453 } 454 455 for _, task := range ltgr.Tasks { 456 group.Tasks = append(group.Tasks, TaskInfo{ 457 Name: task.Task.Metadata.Name, 458 TaskID: task.Status.TaskID, 459 State: task.Status.State, 460 }) 461 } 462 463 continuationToken = ltgr.ContinuationToken 464 if continuationToken == "" { 465 break 466 } 467 } 468 469 return &group, nil 470 } 471 472 func (api apiImpl) ListCheckRuns(owner string, repo string, checkSuiteID int64) ([]*github.CheckRun, error) { 473 var runs []*github.CheckRun 474 options := github.ListCheckRunsOptions{ 475 ListOptions: github.ListOptions{ 476 // 100 is the maximum allowed items per page[0], but due to 477 // https://github.com/web-platform-tests/wpt/issues/27243 we 478 // request only 25 at a time. 479 // 480 // [0]: https://developer.github.com/v3/guides/traversing-with-pagination/#changing-the-number-of-items-received 481 PerPage: 25, 482 }, 483 } 484 485 // As a safety-check, we will not do more than 20 iterations (at 25 486 // check runs per page, this gives us a 500 run upper limit). 487 for i := 0; i < 20; i++ { 488 result, response, err := api.ghClient.Checks.ListCheckRunsCheckSuite(api.ctx, owner, repo, checkSuiteID, &options) 489 if err != nil { 490 return runs, err 491 } 492 493 runs = append(runs, result.CheckRuns...) 494 495 // GitHub APIs indicate being on the last page by not returning any 496 // value for NextPage, which go-github translates into zero. 497 // See https://gowalker.org/github.com/google/go-github/github#Response 498 if response.NextPage == 0 { 499 return runs, nil 500 } 501 502 // Setup for the next call. 503 options.ListOptions.Page = response.NextPage 504 } 505 506 return runs, errors.New("More than 500 CheckRuns returned for CheckSuite") 507 } 508 509 // ArtifactURLs holds the results and screenshot URLs for a Taskcluster run. 510 type ArtifactURLs struct { 511 Results []string 512 Screenshots []string 513 } 514 515 // ExtractArtifactURLs extracts the results and screenshot URLs for a set of 516 // tasks in a TaskGroupInfo. 517 func ExtractArtifactURLs(rootURL string, log shared.Logger, group *TaskGroupInfo, taskID string) ( 518 urlsByProduct map[string]ArtifactURLs, err error) { 519 urlsByProduct = make(map[string]ArtifactURLs) 520 failures := mapset.NewSet() 521 log.Debugf("Extracting artifact URLs for %d tasks", len(group.Tasks)) 522 for _, task := range group.Tasks { 523 id := task.TaskID 524 if id == "" { 525 return nil, fmt.Errorf("task group %s has a task without taskId", group.TaskGroupID) 526 } else if taskID != "" && taskID != id { 527 log.Debugf("Skipping task %s", id) 528 529 continue 530 } 531 532 matches := TaskNameRegex.FindStringSubmatch(task.Name) 533 if len(matches) != 3 { // full match, browser-channel, test type 534 log.Infof("Ignoring unrecognized task: %s", task.Name) 535 536 continue 537 } 538 product := matches[1] 539 switch matches[2] { 540 case "stability": 541 // Skip stability checks. 542 continue 543 case "results": 544 product += "-" + shared.PRHeadLabel 545 case "results-without-changes": 546 product += "-" + shared.PRBaseLabel 547 } 548 549 if task.State != completedState { 550 log.Infof("Task group %s has a non-successful task: %s; %s will be ignored in this group.", 551 group.TaskGroupID, id, product) 552 failures.Add(product) 553 554 continue 555 } 556 557 urls := urlsByProduct[product] 558 // Generate some URLs that point directly to 559 // https://docs.taskcluster.net/docs/reference/platform/queue/api#getLatestArtifact 560 urls.Results = append(urls.Results, 561 tcurls.API( 562 rootURL, "queue", "v1", 563 fmt.Sprintf("/task/%s/artifacts/public/results/wpt_report.json.gz", id))) 564 // wpt_screenshot.txt.gz might not exist, which is NOT a fatal error in the receiver. 565 urls.Screenshots = append(urls.Screenshots, 566 tcurls.API( 567 rootURL, "queue", "v1", 568 fmt.Sprintf("/task/%s/artifacts/public/results/wpt_screenshot.txt.gz", id))) 569 // urls is a *copy* of the value so we must store it back to the map. 570 urlsByProduct[product] = urls 571 } 572 573 for failure := range failures.Iter() { 574 delete(urlsByProduct, failure.(string)) 575 } 576 577 if len(urlsByProduct) == 0 { 578 return nil, errNoResults 579 } 580 581 return urlsByProduct, nil 582 } 583 584 // CreateAllRuns creates run entries in wpt.fyi for a set of products coming 585 // from Taskcluster. 586 func CreateAllRuns( 587 log shared.Logger, 588 aeAPI shared.AppEngineAPI, 589 sha, 590 username, 591 password string, 592 urlsByProduct map[string]ArtifactURLs, 593 labels []string) error { 594 errors := make(chan error, len(urlsByProduct)) 595 var wg sync.WaitGroup 596 wg.Add(len(urlsByProduct)) 597 for product, urls := range urlsByProduct { 598 go func(product string, urls ArtifactURLs) { 599 defer wg.Done() 600 log.Infof("Reports for %s: %v", product, urls) 601 602 // chrome-dev-pr_head => [chrome, dev, pr_head] 603 bits := strings.Split(product, "-") 604 labelsForRun := labels 605 switch lastBit := bits[len(bits)-1]; lastBit { 606 case shared.PRBaseLabel, shared.PRHeadLabel: 607 // We have seen cases where Community-TC triggers a pull request 608 // for merged commits. To guard against that, we strip the 609 // master label here. 610 for i, label := range labelsForRun { 611 if label == shared.MasterLabel { 612 labelsForRun = append(labelsForRun[:i], labelsForRun[i+1:]...) 613 614 break 615 } 616 } 617 labelsForRun = append(labelsForRun, lastBit) 618 } 619 620 uploadClient := uc.NewClient(aeAPI) 621 err := uploadClient.CreateRun(sha, username, password, urls.Results, urls.Screenshots, labelsForRun) 622 if err != nil { 623 errors <- err 624 625 return 626 } 627 }(product, urls) 628 } 629 wg.Wait() 630 close(errors) 631 632 return shared.NewMultiErrorFromChan(errors, "sending Taskcluster runs to results receiver") 633 }