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  }