github.com/web-platform-tests/wpt.fyi@v0.0.0-20240530210107-70cf978996f1/api/checks/update.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  	"errors"
    10  	"fmt"
    11  	"net/http"
    12  	"sort"
    13  	"strings"
    14  	"time"
    15  
    16  	mapset "github.com/deckarep/golang-set"
    17  
    18  	"github.com/gorilla/mux"
    19  	"github.com/web-platform-tests/wpt.fyi/api/checks/summaries"
    20  	"github.com/web-platform-tests/wpt.fyi/shared"
    21  )
    22  
    23  // CheckProcessingQueue is the name of the TaskQueue that handles processing and
    24  // interpretation of TestRun results, in order to update the GitHub checks.
    25  const CheckProcessingQueue = "check-processing"
    26  
    27  const failChecksOnRegressionFeature = "failChecksOnRegression"
    28  const onlyChangesAsRegressionsFeature = "onlyChangesAsRegressions"
    29  
    30  // updateCheckHandler handles /api/checks/[commit] POST requests.
    31  func updateCheckHandler(w http.ResponseWriter, r *http.Request) {
    32  	ctx := r.Context()
    33  	log := shared.GetLogger(ctx)
    34  
    35  	vars := mux.Vars(r)
    36  	sha, err := shared.ParseSHA(vars["commit"])
    37  	if err != nil {
    38  		log.Warningf(err.Error())
    39  		http.Error(w, err.Error(), http.StatusBadRequest)
    40  
    41  		return
    42  	}
    43  
    44  	if err := r.ParseForm(); err != nil {
    45  		log.Warningf("Failed to parse form: %s", err.Error())
    46  		http.Error(w, err.Error(), http.StatusBadRequest)
    47  
    48  		return
    49  	}
    50  
    51  	filter, err := shared.ParseTestRunFilterParams(r.Form)
    52  	if err != nil {
    53  		log.Warningf("Failed to parse params: %s", err.Error())
    54  		http.Error(w, err.Error(), http.StatusBadRequest)
    55  
    56  		return
    57  	}
    58  
    59  	if len(filter.Products) != 1 {
    60  		msg := "product param is missing"
    61  		log.Warningf(msg)
    62  		http.Error(w, msg, http.StatusBadRequest)
    63  
    64  		return
    65  	}
    66  	filter.SHAs = shared.SHAs{sha}
    67  	headRun, baseRun, err := loadRunsToCompare(ctx, filter)
    68  	if err != nil {
    69  		msg := "Could not find runs to compare: " + err.Error()
    70  		log.Errorf(msg)
    71  		http.Error(w, msg, http.StatusNotFound)
    72  
    73  		return
    74  	}
    75  
    76  	sha = headRun.FullRevisionHash
    77  	aeAPI := shared.NewAppEngineAPI(ctx)
    78  	diffAPI := shared.NewDiffAPI(ctx)
    79  	suites, err := NewAPI(ctx).GetSuitesForSHA(sha)
    80  	if err != nil {
    81  		log.Warningf("Failed to load CheckSuites for %s: %s", sha, err.Error())
    82  		http.Error(w, err.Error(), http.StatusInternalServerError)
    83  
    84  		return
    85  	} else if len(suites) < 1 {
    86  		log.Debugf("No CheckSuites found for %s", sha)
    87  	}
    88  
    89  	updatedAny := false
    90  	for _, suite := range suites {
    91  		var summaryData summaries.Summary
    92  		summaryData, err = getDiffSummary(aeAPI, diffAPI, suite, *baseRun, *headRun)
    93  		if errors.Is(err, shared.ErrRunNotInSearchCache) {
    94  			http.Error(w, err.Error(), http.StatusUnprocessableEntity)
    95  
    96  			return
    97  		} else if err != nil {
    98  			http.Error(w, err.Error(), http.StatusInternalServerError)
    99  
   100  			return
   101  		}
   102  		updated, updateErr := updateCheckRunSummary(ctx, summaryData, suite)
   103  		if updateErr != nil {
   104  			err = updateErr
   105  		}
   106  		updatedAny = updatedAny || updated
   107  	}
   108  
   109  	if err != nil {
   110  		log.Errorf("Failed to update check_run(s): %s", err.Error())
   111  		http.Error(w, err.Error(), http.StatusInternalServerError)
   112  	} else if updatedAny {
   113  		_, err = w.Write([]byte("Check(s) updated"))
   114  	} else {
   115  		_, err = w.Write([]byte("No check(s) updated"))
   116  	}
   117  
   118  	if err != nil {
   119  		log.Warningf("Failed to write data in api/checks handler: %s", err.Error())
   120  	}
   121  }
   122  
   123  func loadRunsToCompare(ctx context.Context, filter shared.TestRunFilter) (
   124  	headRun,
   125  	baseRun *shared.TestRun,
   126  	err error,
   127  ) {
   128  	one := 1
   129  	store := shared.NewAppEngineDatastore(ctx, false)
   130  	runs, err := store.TestRunQuery().LoadTestRuns(
   131  		filter.Products,
   132  		filter.Labels,
   133  		filter.SHAs,
   134  		filter.From,
   135  		filter.To,
   136  		&one,
   137  		nil,
   138  	)
   139  	if err != nil {
   140  		return nil, nil, err
   141  	}
   142  	run := runs.First()
   143  	if run == nil {
   144  		return nil, nil, fmt.Errorf("no test run found for %s @ %s",
   145  			filter.Products[0].String(),
   146  			shared.CropString(filter.SHAs.FirstOrLatest(), 7))
   147  	}
   148  
   149  	labels := run.LabelsSet()
   150  	if labels.Contains(shared.MasterLabel) {
   151  		headRun = run
   152  		baseRun, err = loadMasterRunBefore(ctx, filter, headRun)
   153  	} else if labels.Contains(shared.PRBaseLabel) {
   154  		baseRun = run
   155  		headRun, err = loadPRRun(ctx, filter, shared.PRHeadLabel)
   156  	} else if labels.Contains(shared.PRHeadLabel) {
   157  		headRun = run
   158  		baseRun, err = loadPRRun(ctx, filter, shared.PRBaseLabel)
   159  	} else {
   160  		return nil, nil, fmt.Errorf("test run %d doesn't have pr_base, pr_head or master label", run.ID)
   161  	}
   162  
   163  	return headRun, baseRun, err
   164  }
   165  
   166  func loadPRRun(ctx context.Context, filter shared.TestRunFilter, extraLabel string) (*shared.TestRun, error) {
   167  	// Find the corresponding pr_base or pr_head run.
   168  	one := 1
   169  	store := shared.NewAppEngineDatastore(ctx, false)
   170  	labels := mapset.NewSetWith(extraLabel)
   171  	runs, err := store.TestRunQuery().LoadTestRuns(
   172  		filter.Products,
   173  		labels,
   174  		filter.SHAs,
   175  		nil,
   176  		nil,
   177  		&one,
   178  		nil,
   179  	)
   180  	run := runs.First()
   181  	if err != nil {
   182  		return nil, err
   183  	}
   184  	if run == nil {
   185  		err = fmt.Errorf("no test run found for %s @ %s with label %s",
   186  			filter.Products[0].String(), filter.SHAs.FirstOrLatest(), extraLabel)
   187  	}
   188  
   189  	return run, err
   190  }
   191  
   192  func loadMasterRunBefore(
   193  	ctx context.Context,
   194  	filter shared.TestRunFilter,
   195  	headRun *shared.TestRun,
   196  ) (*shared.TestRun, error) {
   197  	// Get the most recent, but still earlier, master run to compare.
   198  	store := shared.NewAppEngineDatastore(ctx, false)
   199  	one := 1
   200  	to := headRun.TimeStart.Add(-time.Millisecond)
   201  	labels := mapset.NewSetWith(headRun.Channel(), shared.MasterLabel)
   202  	runs, err := store.TestRunQuery().LoadTestRuns(filter.Products, labels, nil, nil, &to, &one, nil)
   203  	baseRun := runs.First()
   204  	if err != nil {
   205  		return nil, err
   206  	}
   207  	if baseRun == nil {
   208  		err = fmt.Errorf("no master run found for %s before %s",
   209  			filter.Products[0].String(), filter.SHAs.FirstOrLatest())
   210  	}
   211  
   212  	return baseRun, err
   213  }
   214  
   215  // nolint:ireturn // TODO: Fix ireturn lint error
   216  func getDiffSummary(
   217  	aeAPI shared.AppEngineAPI,
   218  	diffAPI shared.DiffAPI,
   219  	suite shared.CheckSuite,
   220  	baseRun,
   221  	headRun shared.TestRun,
   222  ) (summaries.Summary, error) { // nolint:ireturn // TODO: Fix ireturn lint error
   223  	// nolint:exhaustruct // TODO: Fix exhauststruct lint error
   224  	diffFilter := shared.DiffFilterParam{Added: true, Changed: true, Deleted: true}
   225  	diff, err := diffAPI.GetRunsDiff(baseRun, headRun, diffFilter, nil)
   226  	if err != nil {
   227  		return nil, err
   228  	}
   229  
   230  	checkProduct := shared.ProductSpec{
   231  		// [browser]@[sha] is plenty specific, and avoids bad version strings.
   232  		// nolint:exhaustruct // TODO: Fix exhaustruct lint error.
   233  		ProductAtRevision: shared.ProductAtRevision{
   234  			Product:  shared.Product{BrowserName: headRun.BrowserName},
   235  			Revision: headRun.Revision, // nolint:staticcheck // TODO: Fix staticcheck lint error (SA1019).
   236  		},
   237  		Labels: mapset.NewSetWith(baseRun.Channel()),
   238  	}
   239  
   240  	diffURL := diffAPI.GetDiffURL(baseRun, headRun, &diffFilter)
   241  	host := aeAPI.GetHostname()
   242  	// nolint:exhaustruct // TODO: Fix exhaustruct lint error.
   243  	checkState := summaries.CheckState{
   244  		HostName:   host,
   245  		TestRun:    &headRun,
   246  		Product:    checkProduct,
   247  		HeadSHA:    headRun.FullRevisionHash,
   248  		DetailsURL: diffURL,
   249  		Status:     "completed",
   250  		PRNumbers:  suite.PRNumbers,
   251  	}
   252  
   253  	var regressions mapset.Set
   254  	if aeAPI.IsFeatureEnabled(onlyChangesAsRegressionsFeature) {
   255  		// nolint:exhaustruct // TODO: Fix exhaustruct lint error.
   256  		regressionFilter := shared.DiffFilterParam{Changed: true} // Only changed items
   257  		changeOnlyDiff, err := diffAPI.GetRunsDiff(baseRun, headRun, regressionFilter, nil)
   258  		if err != nil {
   259  			return nil, err
   260  		}
   261  		regressions = changeOnlyDiff.Differences.Regressions()
   262  	} else {
   263  		regressions = diff.Differences.Regressions()
   264  	}
   265  	hasRegressions := regressions.Cardinality() > 0
   266  	neutral := "neutral"
   267  	checkState.Conclusion = &neutral
   268  	checksCanBeNonNeutral := aeAPI.IsFeatureEnabled(failChecksOnRegressionFeature)
   269  
   270  	// Set URL path to deepest shared dir.
   271  	var tests []string
   272  	if hasRegressions {
   273  		tests = shared.ToStringSlice(regressions)
   274  	} else {
   275  		tests, _ = shared.MapStringKeys(diff.AfterSummary)
   276  	}
   277  	sharedPath := "/results" + shared.GetSharedPath(tests...)
   278  	diffURL.Path = sharedPath
   279  
   280  	var summary summaries.Summary
   281  
   282  	// nolint:exhaustruct // TODO: Fix exhaustruct lint error.
   283  	resultsComparison := summaries.ResultsComparison{
   284  		BaseRun: baseRun,
   285  		HeadRun: headRun,
   286  		HostURL: fmt.Sprintf("https://%s/", host),
   287  		DiffURL: diffURL.String(),
   288  	}
   289  	if headRun.LabelsSet().Contains(shared.PRHeadLabel) {
   290  		// Deletions are meaningless and abundant comparing to master; ignore them.
   291  		// nolint:exhaustruct // TODO: Fix exhaustruct lint error.
   292  		masterDiffFilter := shared.DiffFilterParam{Added: true, Changed: true, Unchanged: true}
   293  		masterDiffURL := diffAPI.GetMasterDiffURL(headRun, &masterDiffFilter)
   294  		masterDiffURL.Path = sharedPath
   295  		resultsComparison.MasterDiffURL = masterDiffURL.String()
   296  	}
   297  
   298  	// nolint:nestif // TODO: Fix nestif lint error
   299  	if !hasRegressions {
   300  		collapsed := collapseSummary(diff, 10)
   301  		data := summaries.Completed{ // nolint:exhaustruct // TODO: Fix exhaustruct lint error.
   302  			CheckState:        checkState,
   303  			ResultsComparison: resultsComparison,
   304  			Results:           make(summaries.BeforeAndAfter),
   305  		}
   306  		tests, _ := shared.MapStringKeys(collapsed)
   307  		sort.Strings(tests)
   308  		for _, test := range tests {
   309  			if len(data.Results) < 10 {
   310  				data.Results[test] = collapsed[test]
   311  			} else {
   312  				data.More++
   313  			}
   314  		}
   315  		success := "success"
   316  		data.CheckState.Conclusion = &success
   317  		summary = data
   318  	} else {
   319  		// nolint:exhaustruct // TODO: Fix exhaustruct lint error
   320  		data := summaries.Regressed{
   321  			CheckState:        checkState,
   322  			ResultsComparison: resultsComparison,
   323  			Regressions:       make(summaries.BeforeAndAfter),
   324  		}
   325  		tests := shared.ToStringSlice(regressions)
   326  		sort.Strings(tests)
   327  		for _, path := range tests {
   328  			if len(data.Regressions) <= 10 {
   329  				data.Regressions.Add(path, diff.BeforeSummary[path], diff.AfterSummary[path])
   330  			} else {
   331  				data.More++
   332  			}
   333  		}
   334  		if checksCanBeNonNeutral {
   335  			actionRequired := "action_required"
   336  			data.CheckState.Conclusion = &actionRequired
   337  		}
   338  		summary = data
   339  	}
   340  
   341  	return summary, nil
   342  }
   343  
   344  type pathKeys []string
   345  
   346  func (e pathKeys) Len() int      { return len(e) }
   347  func (e pathKeys) Swap(i, j int) { e[i], e[j] = e[j], e[i] }
   348  func (e pathKeys) Less(i, j int) bool {
   349  	return len(strings.Split(e[i], "/")) > len(strings.Split(e[j], "/"))
   350  }
   351  
   352  // collapseSummary collapses a tree of file paths into a smaller tree of folders.
   353  func collapseSummary(diff shared.RunDiff, limit int) summaries.BeforeAndAfter {
   354  	beforeKeys, _ := shared.MapStringKeys(diff.BeforeSummary)
   355  	afterKeys, _ := shared.MapStringKeys(diff.AfterSummary)
   356  	keys := shared.ToStringSlice(
   357  		shared.NewSetFromStringSlice(beforeKeys).Union(shared.NewSetFromStringSlice(afterKeys)),
   358  	)
   359  	paths := shared.ToStringSlice(collapsePaths(keys, limit))
   360  	result := make(summaries.BeforeAndAfter)
   361  	for _, k := range keys {
   362  		for _, p := range paths {
   363  			if strings.HasPrefix(k, p) {
   364  				result.Add(p, diff.BeforeSummary[k], diff.AfterSummary[k])
   365  
   366  				break
   367  			}
   368  		}
   369  	}
   370  
   371  	return result
   372  }
   373  
   374  func collapsePaths(keys []string, limit int) mapset.Set { // nolint:ireturn // TODO: Fix ireturn lint error
   375  	result := shared.NewSetFromStringSlice(keys)
   376  	// 10 iterations to avoid edge-case infinite looping risk.
   377  	for i := 0; i < 10 && result.Cardinality() > limit; i++ {
   378  		sort.Sort(pathKeys(keys))
   379  		collapsed := mapset.NewSet()
   380  		depth := -1
   381  		for _, k := range keys {
   382  			// Something might have already collapsed down 1 dir into this one.
   383  			if collapsed.Contains(k) {
   384  				continue
   385  			}
   386  			parts := strings.Split(k, "/")
   387  			if parts[len(parts)-1] == "" {
   388  				parts = parts[:len(parts)-1]
   389  			}
   390  			if len(parts) < depth {
   391  				collapsed.Add(k)
   392  
   393  				continue
   394  			}
   395  
   396  			path := strings.Join(parts[:len(parts)-1], "/") + "/"
   397  			collapsed.Add(path)
   398  			depth = len(parts)
   399  		}
   400  		if i > 0 && depth < 3 {
   401  			break
   402  		}
   403  		keys = shared.ToStringSlice(collapsed)
   404  		result = collapsed
   405  	}
   406  
   407  	return result
   408  }