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 }