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  }