go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/bisection/culpritaction/revertculprit/revertculprit.go (about) 1 // Copyright 2022 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 // Package revertculprit contains the logic to revert culprits 16 package revertculprit 17 18 import ( 19 "context" 20 "fmt" 21 "strings" 22 23 "google.golang.org/grpc/codes" 24 "google.golang.org/grpc/status" 25 "google.golang.org/protobuf/proto" 26 27 "go.chromium.org/luci/bisection/internal/config" 28 "go.chromium.org/luci/bisection/internal/gerrit" 29 "go.chromium.org/luci/bisection/internal/lucianalysis" 30 "go.chromium.org/luci/bisection/model" 31 configpb "go.chromium.org/luci/bisection/proto/config" 32 pb "go.chromium.org/luci/bisection/proto/v1" 33 taskpb "go.chromium.org/luci/bisection/task/proto" 34 "go.chromium.org/luci/bisection/util" 35 "go.chromium.org/luci/bisection/util/datastoreutil" 36 "go.chromium.org/luci/bisection/util/loggingutil" 37 "go.chromium.org/luci/server" 38 39 "go.chromium.org/luci/common/clock" 40 "go.chromium.org/luci/common/errors" 41 "go.chromium.org/luci/common/logging" 42 gerritpb "go.chromium.org/luci/common/proto/gerrit" 43 "go.chromium.org/luci/common/retry/transient" 44 "go.chromium.org/luci/gae/service/datastore" 45 "go.chromium.org/luci/server/tq" 46 ) 47 48 var CompileFailureTasks = tq.RegisterTaskClass(tq.TaskClass{ 49 ID: "revert-culprit-action", 50 Prototype: (*taskpb.RevertCulpritTask)(nil), 51 Queue: "revert-culprit-action", 52 Kind: tq.NonTransactional, 53 }) 54 55 var TestFailureTasks = tq.RegisterTaskClass(tq.TaskClass{ 56 ID: "test-failure-culprit-action", 57 Prototype: (*taskpb.TestFailureCulpritActionTask)(nil), 58 Queue: "test-failure-culprit-action", 59 Kind: tq.NonTransactional, 60 }) 61 62 // RegisterTaskClass registers the task class for tq dispatcher 63 func RegisterTaskClass(srv *server.Server, luciAnalysisProjectFunc func(luciProject string) string) error { 64 client, err := lucianalysis.NewClient(srv.Context, srv.Options.CloudProject, luciAnalysisProjectFunc) 65 if err != nil { 66 return err 67 } 68 srv.RegisterCleanup(func(context.Context) { 69 client.Close() 70 }) 71 72 CompileFailureTasks.AttachHandler(processRevertCulpritTask) 73 TestFailureTasks.AttachHandler(func(ctx context.Context, payload proto.Message) error { 74 task := payload.(*taskpb.TestFailureCulpritActionTask) 75 if err := processTestFailureCulpritTask(ctx, task.AnalysisId, client); err != nil { 76 err := errors.Annotate(err, "run test failure culprit action").Err() 77 logging.Errorf(ctx, err.Error()) 78 79 // Return nil so the task will not be retried. 80 // If in the future, task retry is required, remember to update HasTakenActions 81 // field to false before retry. 82 return nil 83 } 84 return nil 85 }) 86 return nil 87 } 88 89 func processRevertCulpritTask(ctx context.Context, payload proto.Message) error { 90 task := payload.(*taskpb.RevertCulpritTask) 91 92 analysisID := task.GetAnalysisId() 93 culpritID := task.GetCulpritId() 94 95 ctx, err := loggingutil.UpdateLoggingWithAnalysisID(ctx, analysisID) 96 if err != nil { 97 // not critical, just log 98 err := errors.Annotate(err, "failed UpdateLoggingWithAnalysisID %d", analysisID) 99 logging.Errorf(ctx, "%v", err) 100 } 101 102 logging.Infof(ctx, 103 "Processing revert culprit task for analysis ID=%d, culprit ID=%d", 104 analysisID, culpritID) 105 106 cfa, err := datastoreutil.GetCompileFailureAnalysis(ctx, analysisID) 107 if err != nil { 108 // failed getting the CompileFailureAnalysis, so no point retrying 109 err = errors.Annotate(err, 110 "failed getting CompileFailureAnalysis when processing culprit revert task").Err() 111 logging.Errorf(ctx, err.Error()) 112 return nil 113 } 114 115 var culprit *model.Suspect 116 for _, verifiedCulprit := range cfa.VerifiedCulprits { 117 if verifiedCulprit.IntID() == culpritID { 118 culprit, err = datastoreutil.GetSuspect(ctx, culpritID, verifiedCulprit.Parent()) 119 if err != nil { 120 // failed getting the Suspect, so no point retrying 121 err = errors.Annotate(err, 122 "failed getting Suspect when processing culprit revert task").Err() 123 logging.Errorf(ctx, err.Error()) 124 return nil 125 } 126 break 127 } 128 } 129 130 if culprit == nil { 131 // culprit is not within the analysis' verified culprits, so no point retrying 132 logging.Errorf(ctx, "failed to find the culprit within the analysis' verified culprits") 133 return nil 134 } 135 136 // The analysis should be canceled. We should not do any gerrit actions. 137 if cfa.ShouldCancel { 138 logging.Infof(ctx, "Analysis %d was canceled. No gerrit action required.", analysisID) 139 saveInactionReason(ctx, culprit, pb.CulpritInactionReason_ANALYSIS_CANCELED) 140 return nil 141 } 142 143 // Revert culprit 144 err = TakeCulpritAction(ctx, culprit) 145 if err != nil { 146 // If the error is transient, return err to retry 147 if transient.Tag.In(err) { 148 return err 149 } 150 151 // non-transient error, so do not retry 152 logging.Errorf(ctx, err.Error()) 153 return nil 154 } 155 return nil 156 } 157 158 func isSuspectGerritActionReady(ctx context.Context, culpritModel *model.Suspect, gerritConfig *configpb.GerritConfig) (bool, error) { 159 // We only proceed with heuristic culprit if it is a confirmed culprit 160 if culpritModel.Type == model.SuspectType_Heuristic { 161 if culpritModel.VerificationStatus == model.SuspectVerificationStatus_ConfirmedCulprit { 162 return true, nil 163 } 164 return false, fmt.Errorf("suspect (commit %s) has verification status %s and should not be reverted", 165 culpritModel.GitilesCommit.Id, culpritModel.VerificationStatus) 166 } 167 // Nthsection 168 if culpritModel.Type == model.SuspectType_NthSection { 169 settings := gerritConfig.NthsectionSettings 170 if !settings.Enabled { 171 logging.Infof(ctx, "Nthsection settings is disabled") 172 return false, nil 173 } 174 if culpritModel.VerificationStatus == model.SuspectVerificationStatus_ConfirmedCulprit || (settings.ActionWhenVerificationError && culpritModel.VerificationStatus == model.SuspectVerificationStatus_VerificationError) { 175 return true, nil 176 } 177 return false, fmt.Errorf("suspect (commit %s) has verification status %s and should not be reverted", 178 culpritModel.GitilesCommit.Id, culpritModel.VerificationStatus) 179 } 180 return false, fmt.Errorf("unsupported suspect type: %s", culpritModel.Type) 181 } 182 183 // TakeCulpritAction attempts to comment culprit, comment revert, create revert and commit revert for a culprit 184 // when the culprit satisfies the critieria of the action. 185 // A culprit is identified as a result of a heuristic analysis or an nthsection analysis. 186 func TakeCulpritAction(ctx context.Context, culpritModel *model.Suspect) error { 187 project, err := datastoreutil.GetProjectForSuspect(ctx, culpritModel) 188 if err != nil { 189 return errors.Annotate(err, "get project for suspect").Err() 190 } 191 // Get gerrit config. 192 gerritConfig, err := config.GetGerritCfgForSuspect(ctx, culpritModel, project) 193 if err != nil { 194 return errors.Annotate(err, "get gerrit config for suspect").Err() 195 } 196 // Check if Gerrit actions are disabled 197 if !gerritConfig.ActionsEnabled { 198 logging.Infof(ctx, "Gerrit actions have been disabled") 199 saveInactionReason(ctx, culpritModel, pb.CulpritInactionReason_ACTIONS_DISABLED) 200 return nil 201 } 202 203 // Check the culprit verification status 204 shouldTakeAction, err := isSuspectGerritActionReady(ctx, culpritModel, gerritConfig) 205 if err != nil { 206 return err 207 } 208 if !shouldTakeAction { 209 return nil 210 } 211 212 // Make Gerrit client 213 gerritHost, err := gerrit.GetHost(ctx, culpritModel.ReviewUrl) 214 if err != nil { 215 logging.Errorf(ctx, err.Error()) 216 return err 217 } 218 gerritClient, err := gerrit.NewClient(ctx, gerritHost) 219 if err != nil { 220 logging.Errorf(ctx, err.Error()) 221 return err 222 } 223 224 // Get the culprit's Gerrit change 225 culprit, err := gerritClient.GetChange(ctx, 226 culpritModel.GitilesCommit.Project, culpritModel.GitilesCommit.Id) 227 if err != nil { 228 logging.Errorf(ctx, err.Error()) 229 return err 230 } 231 232 // Check for existing reverts 233 existingRevert, err := getMostRelevantRevert(ctx, gerritClient, culprit) 234 if err != nil { 235 logging.Errorf(ctx, err.Error()) 236 return err 237 } 238 if existingRevert != nil { 239 err = saveRevertURL(ctx, gerritClient, culpritModel, existingRevert) 240 if err != nil { 241 // not critical - just log the error 242 logging.Errorf(ctx, err.Error()) 243 } 244 245 switch existingRevert.Status { 246 case gerritpb.ChangeStatus_MERGED: 247 // There is a merged revert - no further action required. 248 249 // Update the inaction reason based on whether the revert was created by 250 // LUCI Bisection. 251 lbOwned, err := gerrit.IsOwnedByLUCIBisection(ctx, existingRevert) 252 if err != nil { 253 // Not critical - just log the error and skip updating the 254 // inaction reason. 255 err = errors.Annotate(err, 256 "no action required but failed to find owner of existing revert").Err() 257 logging.Errorf(ctx, err.Error()) 258 } else { 259 reason := pb.CulpritInactionReason_REVERTED_MANUALLY 260 if lbOwned { 261 reason = pb.CulpritInactionReason_REVERTED_BY_BISECTION 262 } 263 saveInactionReason(ctx, culpritModel, reason) 264 } 265 266 return nil 267 case gerritpb.ChangeStatus_NEW: 268 // add a supporting comment to the first revert 269 err = commentSupportOnExistingRevert(ctx, gerritClient, culpritModel, existingRevert) 270 if err != nil { 271 logging.Errorf(ctx, err.Error()) 272 return err 273 } 274 return nil 275 case gerritpb.ChangeStatus_ABANDONED: 276 // add a comment on the culprit since the revert has been abandoned 277 err = commentReasonOnCulprit(ctx, gerritClient, culpritModel, culprit, 278 "an abandoned revert already exists") 279 if err != nil { 280 logging.Errorf(ctx, err.Error()) 281 return err 282 } 283 return nil 284 default: 285 logging.Errorf(ctx, 286 "status was not recognized for existing revert %s~%d [status='%v']", 287 existingRevert.Project, existingRevert.Number, existingRevert.Status) 288 } 289 290 return nil 291 } 292 293 shouldCreateRevert, reason, err := isCulpritRevertible(ctx, gerritClient, culprit, culpritModel, project) 294 if err != nil { 295 logging.Errorf(ctx, err.Error()) 296 return err 297 } 298 if !shouldCreateRevert { 299 // Add a comment on the culprit CL to explain why a revert was not created 300 err = commentReasonOnCulprit(ctx, gerritClient, culpritModel, culprit, 301 reason) 302 if err != nil { 303 logging.Errorf(ctx, err.Error()) 304 return err 305 } 306 307 return nil 308 } 309 310 // Create revert 311 revert, err := createRevert(ctx, gerritClient, culpritModel, culprit) 312 if err != nil { 313 logging.Errorf(ctx, err.Error()) 314 315 if status.Convert(errors.Unwrap(err)).Code() == codes.DeadlineExceeded { 316 // Workaround for Gerrit performance issue with revert creations 317 // (see b/261896675). The request may have timed out but the revert may 318 // have been successfully created, so look for the newly created revert 319 createdRevert, searchErr := searchForCreatedRevert(ctx, gerritClient, 320 culpritModel, culprit) 321 if searchErr != nil { 322 logging.Errorf(ctx, searchErr.Error()) 323 return searchErr 324 } 325 326 if createdRevert != nil { 327 logging.Debugf(ctx, "continuing revert process; found created revert") 328 revert = createdRevert 329 } else { 330 logging.Debugf(ctx, "could not find the revert created by LUCI Bisection") 331 return err 332 } 333 } else { 334 return err 335 } 336 } 337 338 err = saveCreationDetails(ctx, gerritClient, culpritModel, revert) 339 if err != nil { 340 logging.Errorf(ctx, err.Error()) 341 342 // a revert was created by LUCI Bisection - add reviewers to it 343 shouldReview, reviewErr := isRevertActive(ctx, gerritClient, revert) 344 if reviewErr != nil { 345 logging.Errorf(ctx, reviewErr.Error()) 346 return reviewErr 347 } 348 if shouldReview { 349 reviewErr = sendRevertForReview(ctx, gerritClient, culpritModel, revert, 350 "an unexpected error occurred after LUCI Bisection created this revert") 351 if reviewErr != nil { 352 logging.Errorf(ctx, reviewErr.Error()) 353 return reviewErr 354 } 355 } 356 357 return err 358 } 359 360 // Check again for merged reverts for the culprit, in case 361 // another revert was manually created and merged while waiting for Gerrit 362 // to finish creating the revert. 363 existingReverts, err := gerritClient.GetReverts(ctx, culprit) 364 if err != nil { 365 logging.Errorf(ctx, err.Error()) 366 return err 367 } 368 for _, existingRevert := range existingReverts { 369 if existingRevert.Status == gerritpb.ChangeStatus_MERGED { 370 // A revert has already been merged, so there is no need to commit the 371 // revert created by LUCI Bisection 372 logging.Debugf(ctx, "existing revert %s~%d already merged for culprit %s~%d", 373 existingRevert.Project, existingRevert.Number, 374 culprit.Project, culprit.Number) 375 376 // TODO (nqmtuan): Automatically abandon the revert created by 377 // LUCI Bisection if this merged revert is different. Currently, the 378 // created revert will be left open until manually abandoned. 379 380 return nil 381 } 382 } 383 384 // Check the revert is still active, as creation can take a long time so 385 // it may have been manually updated 386 isActive, err := isRevertActive(ctx, gerritClient, revert) 387 if err != nil { 388 logging.Errorf(ctx, err.Error()) 389 return err 390 } 391 if !isActive { 392 // revert has been manually updated, so no further action is required 393 return nil 394 } 395 396 shouldCommit, reason, err := canCommit(ctx, culprit, culpritModel, project) 397 if err != nil { 398 logging.Errorf(ctx, err.Error()) 399 return err 400 } 401 if !shouldCommit { 402 // Send the revert for manual review and add a comment to explain why the 403 // revert was not automatically submitted 404 err = sendRevertForReview(ctx, gerritClient, culpritModel, revert, 405 reason) 406 if err != nil { 407 logging.Errorf(ctx, err.Error()) 408 return err 409 } 410 411 return nil 412 } 413 414 // Commit revert 415 err = commitRevert(ctx, gerritClient, culpritModel, revert) 416 if err != nil { 417 logging.Errorf(ctx, err.Error()) 418 419 // Send the revert to be manually reviewed 420 reviewErr := sendRevertForReview(ctx, gerritClient, culpritModel, revert, 421 "an error occurred when attempting to submit it") 422 if reviewErr != nil { 423 logging.Errorf(ctx, reviewErr.Error()) 424 return reviewErr 425 } 426 427 return err 428 } 429 err = saveCommitDetails(ctx, culpritModel) 430 if err != nil { 431 logging.Errorf(ctx, err.Error()) 432 return err 433 } 434 435 return nil 436 } 437 438 // getMostRelevantRevert returns the most relevant revert based on the 439 // revert change's status, in the order of merged > active > abandoned > nil. 440 func getMostRelevantRevert(ctx context.Context, gerritClient *gerrit.Client, 441 culprit *gerritpb.ChangeInfo) (*gerritpb.ChangeInfo, error) { 442 // Check for existing reverts 443 reverts, err := gerritClient.GetReverts(ctx, culprit) 444 if err != nil { 445 return nil, err 446 } 447 448 var activeRevert *gerritpb.ChangeInfo = nil 449 var abandonedRevert *gerritpb.ChangeInfo = nil 450 for _, revert := range reverts { 451 logging.Debugf(ctx, "Existing revert found for culprit %s~%d - revert is %s~%d", 452 culprit.Project, culprit.Number, revert.Project, revert.Number) 453 454 switch revert.Status { 455 case gerritpb.ChangeStatus_MERGED: 456 return revert, nil 457 case gerritpb.ChangeStatus_ABANDONED: 458 if abandonedRevert == nil { 459 abandonedRevert = revert 460 } 461 case gerritpb.ChangeStatus_NEW: 462 if activeRevert == nil { 463 activeRevert = revert 464 } 465 default: 466 logging.Debugf(ctx, "ignoring revert %s~%d due to its unrecognized status %v", 467 revert.Project, revert.Number, revert.Status) 468 } 469 } 470 471 if activeRevert != nil { 472 // there is an existing revert yet to be merged 473 return activeRevert, nil 474 } 475 476 if abandonedRevert != nil { 477 // there is an abandoned revert 478 return abandonedRevert, nil 479 } 480 481 return nil, nil 482 } 483 484 // searchForCreatedRevert returns the revert CL created by LUCI Bisection 485 // when processing the given Suspect, if it exists. 486 func searchForCreatedRevert(ctx context.Context, gerritClient *gerrit.Client, 487 culpritModel *model.Suspect, culprit *gerritpb.ChangeInfo) (*gerritpb.ChangeInfo, error) { 488 // Construct the revert description to use for comparison since different 489 // analyses can result in the same culprit CL. The revert description 490 // similarity can be used to ascertain whether a revert CL was created for 491 // this specific Suspect 492 generatedRevertDescription, err := generateRevertDescription(ctx, culpritModel, culprit) 493 if err != nil { 494 return nil, errors.Annotate(err, "failed generating revert description"+ 495 " for comparison when searching for created revert").Err() 496 } 497 // Drop the last paragraph for the comparison as Gerrit may have inserted 498 // its own values in the footer 499 paragraphs := strings.Split(generatedRevertDescription, "\n\n") 500 descriptionStart := strings.Join(paragraphs[:len(paragraphs)-1], "\n\n") 501 502 // Check for existing reverts 503 reverts, err := gerritClient.GetReverts(ctx, culprit) 504 if err != nil { 505 return nil, errors.Annotate(err, 506 "failed getting existing reverts when searching for created revert").Err() 507 } 508 509 var createdRevert *gerritpb.ChangeInfo = nil 510 for _, revert := range reverts { 511 lbOwned, err := gerrit.IsOwnedByLUCIBisection(ctx, revert) 512 if err != nil { 513 // non-critical - log the error and move on 514 err = errors.Annotate(err, 515 "error searching for created revert when checking owner").Err() 516 logging.Errorf(ctx, err.Error()) 517 continue 518 } 519 520 // Check if the revert was created by LUCI Bisection 521 if lbOwned { 522 revertDescription, err := gerrit.CommitMessage(ctx, revert) 523 if err != nil { 524 // non-critical - log the error and move on 525 err = errors.Annotate(err, 526 "error searching for created revert when getting commit message").Err() 527 logging.Errorf(ctx, err.Error()) 528 continue 529 } 530 531 // Check if the description starts as expected, to confirm this revert CL 532 // was the newly-created one for this specific Suspect and not from 533 // another analysis 534 if strings.HasPrefix(revertDescription, descriptionStart) { 535 createdRevert = revert 536 break 537 } 538 } 539 } 540 541 return createdRevert, nil 542 } 543 544 func isRevertActive(ctx context.Context, gerritClient *gerrit.Client, 545 revert *gerritpb.ChangeInfo) (bool, error) { 546 // Refetch the created revert to get its latest status 547 revert, err := gerritClient.RefetchChange(ctx, revert) 548 if err != nil { 549 return false, errors.Annotate(err, 550 "error refetching revert created by LUCI Bisection").Err() 551 } 552 553 if revert.Status == gerritpb.ChangeStatus_NEW { 554 return true, nil 555 } else { 556 // the revert created by LUCI Bisection has been manually updated 557 logging.Debugf(ctx, "revert %s~%d created by LUCI Bisection was updated"+ 558 " manually [status=%v]", revert.Project, revert.Number, revert.Status) 559 return false, nil 560 } 561 } 562 563 // saveRevertURL updates the revert URL for the given Suspect 564 func saveRevertURL(ctx context.Context, gerritClient *gerrit.Client, 565 culpritModel *model.Suspect, revert *gerritpb.ChangeInfo) error { 566 err := datastore.RunInTransaction(ctx, func(ctx context.Context) error { 567 e := datastore.Get(ctx, culpritModel) 568 if e != nil { 569 return e 570 } 571 // set the revert CL URL 572 culpritModel.RevertURL = util.ConstructGerritCodeReviewURL(ctx, gerritClient, revert) 573 return datastore.Put(ctx, culpritModel) 574 }, nil) 575 576 if err != nil { 577 err = errors.Annotate(err, 578 "couldn't update suspect details for culprit with existing revert").Err() 579 return err 580 } 581 582 return nil 583 } 584 585 func saveCreationDetails(ctx context.Context, gerritClient *gerrit.Client, 586 culpritModel *model.Suspect, revert *gerritpb.ChangeInfo) error { 587 // Update tsmon metrics 588 err := updateCulpritActionCounter(ctx, culpritModel, ActionTypeCreateRevert) 589 if err != nil { 590 logging.Errorf(ctx, errors.Annotate(err, "updateCulpritActionCounter").Err().Error()) 591 } 592 593 // Update revert details for creation 594 err = datastore.RunInTransaction(ctx, func(ctx context.Context) error { 595 e := datastore.Get(ctx, culpritModel) 596 if e != nil { 597 return e 598 } 599 600 culpritModel.RevertURL = util.ConstructGerritCodeReviewURL(ctx, gerritClient, revert) 601 culpritModel.IsRevertCreated = true 602 culpritModel.RevertCreateTime = clock.Now(ctx) 603 604 return datastore.Put(ctx, culpritModel) 605 }, nil) 606 if err != nil { 607 return errors.Annotate(err, 608 "couldn't update suspect revert creation details").Err() 609 } 610 return nil 611 } 612 613 func saveCommitDetails(ctx context.Context, culpritModel *model.Suspect) error { 614 // Update tsmon metrics 615 err := updateCulpritActionCounter(ctx, culpritModel, ActionTypeSubmitRevert) 616 if err != nil { 617 logging.Errorf(ctx, errors.Annotate(err, "updateCulpritActionCounter").Err().Error()) 618 } 619 620 // Update revert details for commit action 621 err = datastore.RunInTransaction(ctx, func(ctx context.Context) error { 622 e := datastore.Get(ctx, culpritModel) 623 if e != nil { 624 return e 625 } 626 627 culpritModel.IsRevertCommitted = true 628 culpritModel.RevertCommitTime = clock.Now(ctx) 629 630 return datastore.Put(ctx, culpritModel) 631 }, nil) 632 if err != nil { 633 return errors.Annotate(err, 634 "couldn't update suspect revert commit details").Err() 635 } 636 return nil 637 } 638 639 // saveInactionReason updates the inaction reason for the given Suspect. 640 func saveInactionReason(ctx context.Context, culpritModel *model.Suspect, 641 reason pb.CulpritInactionReason) { 642 err := datastore.RunInTransaction(ctx, func(ctx context.Context) error { 643 e := datastore.Get(ctx, culpritModel) 644 if e != nil { 645 return e 646 } 647 // Set the inaction reason 648 culpritModel.InactionReason = reason 649 return datastore.Put(ctx, culpritModel) 650 }, nil) 651 652 if err != nil { 653 // not critical - just log the error 654 err = errors.Annotate(err, 655 "couldn't update suspect inaction reason").Err() 656 logging.Errorf(ctx, err.Error()) 657 } 658 } 659 660 func ScheduleTestFailureTask(ctx context.Context, analysisID int64) error { 661 return tq.AddTask(ctx, &tq.Task{ 662 Payload: &taskpb.TestFailureCulpritActionTask{ 663 AnalysisId: analysisID, 664 }, 665 Title: fmt.Sprintf("test_failure_culprit_action_%d", analysisID), 666 }) 667 }