go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/bisection/culpritaction/revertculprit/revert_test_failure_culprit_test.go (about)

     1  // Copyright 2023 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  	"testing"
    22  	"time"
    23  
    24  	"github.com/golang/mock/gomock"
    25  	. "github.com/smartystreets/goconvey/convey"
    26  	"go.chromium.org/luci/bisection/internal/config"
    27  	"go.chromium.org/luci/bisection/internal/gerrit"
    28  	"go.chromium.org/luci/bisection/internal/lucianalysis"
    29  	"go.chromium.org/luci/bisection/model"
    30  	configpb "go.chromium.org/luci/bisection/proto/config"
    31  	pb "go.chromium.org/luci/bisection/proto/v1"
    32  	"go.chromium.org/luci/bisection/util"
    33  	"go.chromium.org/luci/bisection/util/testutil"
    34  	buildbucketpb "go.chromium.org/luci/buildbucket/proto"
    35  	"go.chromium.org/luci/common/clock"
    36  	"go.chromium.org/luci/common/clock/testclock"
    37  	"go.chromium.org/luci/common/proto"
    38  	gerritpb "go.chromium.org/luci/common/proto/gerrit"
    39  	"go.chromium.org/luci/common/tsmon"
    40  	"go.chromium.org/luci/gae/impl/memory"
    41  	"go.chromium.org/luci/gae/service/datastore"
    42  	"google.golang.org/protobuf/types/known/timestamppb"
    43  )
    44  
    45  func TestProcessTestFailureCulpritTask(t *testing.T) {
    46  	t.Parallel()
    47  	client := &fakeLUCIAnalysisClient{
    48  		FailedConsistently: true,
    49  	}
    50  
    51  	Convey("processTestFailureCulpritTask", t, func() {
    52  		ctx := memory.Use(context.Background())
    53  		testutil.UpdateIndices(ctx)
    54  
    55  		// Set test clock
    56  		cl := testclock.New(testclock.TestTimeUTC)
    57  		cl.Set(time.Unix(10000, 0).UTC())
    58  		ctx = clock.Set(ctx, cl)
    59  
    60  		// Setup tsmon
    61  		ctx, _ = tsmon.WithDummyInMemory(ctx)
    62  
    63  		// Set the project-level config for this test
    64  		gerritConfig := &configpb.GerritConfig{
    65  			ActionsEnabled: true,
    66  			NthsectionSettings: &configpb.GerritConfig_NthSectionSettings{
    67  				Enabled:                     true,
    68  				ActionWhenVerificationError: false,
    69  			},
    70  		}
    71  		projectCfg := config.CreatePlaceholderProjectConfig()
    72  		projectCfg.TestAnalysisConfig.GerritConfig = gerritConfig
    73  		cfg := map[string]*configpb.ProjectConfig{"chromium": projectCfg}
    74  		So(config.SetTestProjectConfig(ctx, cfg), ShouldBeNil)
    75  
    76  		// Setup datastore
    77  		tfa := testutil.CreateTestFailureAnalysis(ctx, &testutil.TestFailureAnalysisCreationOption{
    78  			Project:        "chromium",
    79  			TestFailureKey: datastore.NewKey(ctx, "TestFailure", "", 1, nil),
    80  			FailedBuildID:  123,
    81  		})
    82  		nsa := testutil.CreateTestNthSectionAnalysis(ctx, &testutil.TestNthSectionAnalysisCreationOption{
    83  			ParentAnalysisKey: datastore.KeyForObj(ctx, tfa),
    84  		})
    85  		suspect := &model.Suspect{
    86  			Type:           model.SuspectType_NthSection,
    87  			ParentAnalysis: datastore.KeyForObj(ctx, nsa),
    88  			GitilesCommit: buildbucketpb.GitilesCommit{
    89  				Host:    "test.googlesource.com",
    90  				Project: "chromium/src",
    91  				Id:      "12ab34cd56ef",
    92  			},
    93  			ReviewUrl:          "https://test-review.googlesource.com/c/chromium/test/+/876543",
    94  			AnalysisType:       pb.AnalysisType_TEST_FAILURE_ANALYSIS,
    95  			VerificationStatus: model.SuspectVerificationStatus_ConfirmedCulprit,
    96  		}
    97  		So(datastore.Put(ctx, suspect), ShouldBeNil)
    98  		tfa.VerifiedCulpritKey = datastore.KeyForObj(ctx, suspect)
    99  		So(datastore.Put(ctx, tfa), ShouldBeNil)
   100  		tf1 := testutil.CreateTestFailure(ctx, &testutil.TestFailureCreationOption{
   101  			ID:          1,
   102  			Project:     "chromium",
   103  			IsPrimary:   true,
   104  			Analysis:    tfa,
   105  			TestID:      "testID1",
   106  			VariantHash: "varianthash1",
   107  		})
   108  		tf2 := testutil.CreateTestFailure(ctx, &testutil.TestFailureCreationOption{
   109  			ID:          2,
   110  			Project:     "chromium",
   111  			IsPrimary:   false,
   112  			Analysis:    tfa,
   113  			TestID:      "testID2",
   114  			VariantHash: "varianthash2",
   115  		})
   116  
   117  		// Set up mock Gerrit client
   118  		ctl := gomock.NewController(t)
   119  		defer ctl.Finish()
   120  		mockClient := gerrit.NewMockedClient(ctx, ctl)
   121  		ctx = mockClient.Ctx
   122  		culpritRes := &gerritpb.ListChangesResponse{
   123  			Changes: []*gerritpb.ChangeInfo{{
   124  				Number:          876543,
   125  				Project:         "chromium/src",
   126  				Status:          gerritpb.ChangeStatus_MERGED,
   127  				Submitted:       timestamppb.New(clock.Now(ctx).Add(-time.Hour * 3)),
   128  				CurrentRevision: "deadbeef",
   129  				Revisions: map[string]*gerritpb.RevisionInfo{
   130  					"deadbeef": {
   131  						Commit: &gerritpb.CommitInfo{
   132  							Message: "Title.\n\nBody is here.\n\nChange-Id: I100deadbeef",
   133  							Author: &gerritpb.GitPersonInfo{
   134  								Name:  "John Doe",
   135  								Email: "jdoe@example.com",
   136  							},
   137  						},
   138  					},
   139  				},
   140  			}},
   141  		}
   142  		analysisURL := util.ConstructTestAnalysisURL("chromium", tfa.ID)
   143  		buildURL := util.ConstructBuildURL(ctx, tfa.FailedBuildID)
   144  		bugURL := util.ConstructBuganizerURLForAnalysis("https://test-review.googlesource.com/c/chromium/test/+/876543", analysisURL)
   145  		testLinks := fmt.Sprintf("[%s](%s)\n[%s](%s)",
   146  			tf1.TestID,
   147  			util.ConstructTestHistoryURL(tf1.Project, tf1.TestID, tf1.VariantHash),
   148  			tf2.TestID,
   149  			util.ConstructTestHistoryURL(tf2.Project, tf2.TestID, tf2.VariantHash))
   150  
   151  		Convey("test no longer unexpected", func() {
   152  			err := processTestFailureCulpritTask(ctx, tfa.ID, &fakeLUCIAnalysisClient{
   153  				FailedConsistently: false,
   154  			})
   155  			So(err, ShouldBeNil)
   156  			// Suspect action has been saved.
   157  			So(datastore.Get(ctx, suspect), ShouldBeNil)
   158  			So(suspect.InactionReason, ShouldEqual, pb.CulpritInactionReason_TEST_NO_LONGER_UNEXPECTED)
   159  			So(suspect.HasTakenActions, ShouldBeTrue)
   160  		})
   161  
   162  		Convey("gerrit action disabled", func() {
   163  			projectCfg.TestAnalysisConfig.GerritConfig.ActionsEnabled = false
   164  			cfg := map[string]*configpb.ProjectConfig{tfa.Project: projectCfg}
   165  			So(config.SetTestProjectConfig(ctx, cfg), ShouldBeNil)
   166  
   167  			err := processTestFailureCulpritTask(ctx, tfa.ID, client)
   168  			So(err, ShouldBeNil)
   169  			// Suspect action has been saved.
   170  			So(datastore.Get(ctx, suspect), ShouldBeNil)
   171  			So(suspect.InactionReason, ShouldEqual, pb.CulpritInactionReason_ACTIONS_DISABLED)
   172  			So(suspect.HasTakenActions, ShouldBeTrue)
   173  		})
   174  
   175  		Convey("has existing revert", func() {
   176  			Convey("has merged revert", func() {
   177  				revertRes := &gerritpb.ListChangesResponse{
   178  					Changes: []*gerritpb.ChangeInfo{
   179  						{
   180  							Number:  876549,
   181  							Project: "chromium/src",
   182  							Status:  gerritpb.ChangeStatus_MERGED,
   183  						},
   184  					},
   185  				}
   186  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   187  					Return(culpritRes, nil).Times(1)
   188  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   189  					Return(revertRes, nil).Times(1)
   190  
   191  				err := processTestFailureCulpritTask(ctx, tfa.ID, client)
   192  				So(err, ShouldBeNil)
   193  				// Suspect action has been saved.
   194  				So(datastore.Get(ctx, suspect), ShouldBeNil)
   195  				So(suspect.InactionReason, ShouldEqual, pb.CulpritInactionReason_REVERTED_MANUALLY)
   196  				So(suspect.HasTakenActions, ShouldBeTrue)
   197  			})
   198  
   199  			Convey("has new revert", func() {
   200  				revertRes := &gerritpb.ListChangesResponse{
   201  					Changes: []*gerritpb.ChangeInfo{
   202  						{
   203  							Number:  876549,
   204  							Project: "chromium/src",
   205  							Status:  gerritpb.ChangeStatus_NEW,
   206  						},
   207  					},
   208  				}
   209  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   210  					Return(culpritRes, nil).Times(1)
   211  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   212  					Return(revertRes, nil).Times(1)
   213  				mockClient.Client.EXPECT().SetReview(gomock.Any(), proto.MatcherEqual(
   214  					&gerritpb.SetReviewRequest{
   215  						Project:    revertRes.Changes[0].Project,
   216  						Number:     revertRes.Changes[0].Number,
   217  						RevisionId: "current",
   218  						Message: fmt.Sprintf("LUCI Bisection recommends submitting this"+
   219  							" revert because it has confirmed the target of this revert is"+
   220  							" the cause of a test failure. See the analysis: %s\n\n"+
   221  							"Sample build with failed test: %s\n"+
   222  							"Affected test(s):\n%s\n\n"+
   223  							"If this is a false positive, please report it at %s", analysisURL, buildURL, testLinks, bugURL),
   224  					},
   225  				)).Times(1)
   226  
   227  				err := processTestFailureCulpritTask(ctx, tfa.ID, client)
   228  				So(err, ShouldBeNil)
   229  				// Suspect action has been saved.
   230  				So(datastore.Get(ctx, suspect), ShouldBeNil)
   231  				So(suspect.HasSupportRevertComment, ShouldBeTrue)
   232  				So(suspect.SupportRevertCommentTime, ShouldEqual, time.Unix(10000, 0).UTC())
   233  				// Check counter incremented.
   234  				So(culpritActionCounter.Get(ctx, "chromium", "test", "comment_revert"), ShouldEqual, 1)
   235  				So(suspect.HasTakenActions, ShouldBeTrue)
   236  			})
   237  
   238  			Convey("only abandoned revert", func() {
   239  				revertRes := &gerritpb.ListChangesResponse{
   240  					Changes: []*gerritpb.ChangeInfo{
   241  						{
   242  							Number:  876549,
   243  							Project: "chromium/src",
   244  							Status:  gerritpb.ChangeStatus_ABANDONED,
   245  						},
   246  					},
   247  				}
   248  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   249  					Return(culpritRes, nil).Times(1)
   250  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   251  					Return(revertRes, nil).Times(1)
   252  				mockClient.Client.EXPECT().SetReview(gomock.Any(), proto.MatcherEqual(
   253  					&gerritpb.SetReviewRequest{
   254  						Project:    culpritRes.Changes[0].Project,
   255  						Number:     culpritRes.Changes[0].Number,
   256  						RevisionId: "current",
   257  						Message: fmt.Sprintf("LUCI Bisection has identified this"+
   258  							" change as the cause of a test failure. See the analysis: %s\n\n"+
   259  							"Sample build with failed test: %s\n"+
   260  							"Affected test(s):\n%s\n"+
   261  							"A revert for this change was not created because an abandoned revert already exists.\n\n"+
   262  							"If this is a false positive, please report it at %s", analysisURL, buildURL, testLinks, bugURL),
   263  					},
   264  				)).Times(1)
   265  
   266  				err := processTestFailureCulpritTask(ctx, tfa.ID, client)
   267  				So(err, ShouldBeNil)
   268  				// Suspect action has been saved.
   269  				So(datastore.Get(ctx, suspect), ShouldBeNil)
   270  				So(suspect.HasCulpritComment, ShouldBeTrue)
   271  				So(suspect.CulpritCommentTime, ShouldEqual, time.Unix(10000, 0).UTC())
   272  				// Check counter incremented.
   273  				So(culpritActionCounter.Get(ctx, "chromium", "test", "comment_culprit"), ShouldEqual, 1)
   274  				So(suspect.HasTakenActions, ShouldBeTrue)
   275  			})
   276  		})
   277  
   278  		Convey("no existing revert", func() {
   279  			Convey("has LUCI bisection comment", func() {
   280  				lbEmail, err := gerrit.ServiceAccountEmail(ctx)
   281  				So(err, ShouldBeNil)
   282  				culpritRes.Changes[0].Messages = []*gerritpb.ChangeMessageInfo{
   283  					{Author: &gerritpb.AccountInfo{Email: lbEmail}},
   284  				}
   285  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   286  					Return(culpritRes, nil).Times(1)
   287  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   288  					Return(&gerritpb.ListChangesResponse{Changes: []*gerritpb.ChangeInfo{}}, nil).Times(1)
   289  
   290  				err = processTestFailureCulpritTask(ctx, tfa.ID, client)
   291  				So(err, ShouldBeNil)
   292  				// Suspect action has been saved.
   293  				So(datastore.Get(ctx, suspect), ShouldBeNil)
   294  				So(suspect.InactionReason, ShouldEqual, pb.CulpritInactionReason_CULPRIT_HAS_COMMENT)
   295  				So(suspect.HasTakenActions, ShouldBeTrue)
   296  			})
   297  
   298  			Convey("comment culprit", func() {
   299  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   300  					Return(culpritRes, nil).Times(1)
   301  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   302  					Return(&gerritpb.ListChangesResponse{Changes: []*gerritpb.ChangeInfo{}}, nil).Times(1)
   303  				mockClient.Client.EXPECT().SetReview(gomock.Any(), proto.MatcherEqual(
   304  					&gerritpb.SetReviewRequest{
   305  						Project:    culpritRes.Changes[0].Project,
   306  						Number:     culpritRes.Changes[0].Number,
   307  						RevisionId: "current",
   308  						Message: fmt.Sprintf("LUCI Bisection has identified this"+
   309  							" change as the cause of a test failure. See the analysis: %s\n\n"+
   310  							"Sample build with failed test: %s\n"+
   311  							"Affected test(s):\n%s\n"+
   312  							"A revert for this change was not created because the builder of the failed test(s) is not being watched by gardeners.\n\n"+
   313  							"If this is a false positive, please report it at %s", analysisURL, buildURL, testLinks, bugURL),
   314  					},
   315  				)).Times(1)
   316  
   317  				err := processTestFailureCulpritTask(ctx, tfa.ID, client)
   318  				So(err, ShouldBeNil)
   319  				// Suspect action has been saved.
   320  				So(datastore.Get(ctx, suspect), ShouldBeNil)
   321  				So(suspect.HasCulpritComment, ShouldBeTrue)
   322  				So(suspect.CulpritCommentTime, ShouldEqual, time.Unix(10000, 0).UTC())
   323  				So(suspect.HasTakenActions, ShouldBeTrue)
   324  				// Check counter incremented.
   325  				So(culpritActionCounter.Get(ctx, "chromium", "test", "comment_culprit"), ShouldEqual, 1)
   326  			})
   327  
   328  			Convey("comment culprit with more than 5 test failures", func() {
   329  				for i := 1; i < 8; i++ {
   330  					testutil.CreateTestFailure(ctx, &testutil.TestFailureCreationOption{
   331  						ID:          int64(i),
   332  						Project:     "chromium",
   333  						IsPrimary:   i == 1,
   334  						Analysis:    tfa,
   335  						TestID:      fmt.Sprintf("testID%d", i),
   336  						VariantHash: fmt.Sprintf("varianthash%d", i),
   337  					})
   338  				}
   339  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   340  					Return(culpritRes, nil).Times(1)
   341  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   342  					Return(&gerritpb.ListChangesResponse{Changes: []*gerritpb.ChangeInfo{}}, nil).Times(1)
   343  				mockClient.Client.EXPECT().SetReview(gomock.Any(), proto.MatcherEqual(
   344  					&gerritpb.SetReviewRequest{
   345  						Project:    culpritRes.Changes[0].Project,
   346  						Number:     culpritRes.Changes[0].Number,
   347  						RevisionId: "current",
   348  						Message: fmt.Sprintf("LUCI Bisection has identified this"+
   349  							" change as the cause of a test failure. See the analysis: %s\n\n"+
   350  							"Sample build with failed test: %s\n"+
   351  							"Affected test(s):\n"+
   352  							"[testID1](https://ci.chromium.org/ui/test/chromium/testID1?q=VHash%%3Avarianthash1)\n"+
   353  							"[testID2](https://ci.chromium.org/ui/test/chromium/testID2?q=VHash%%3Avarianthash2)\n"+
   354  							"[testID3](https://ci.chromium.org/ui/test/chromium/testID3?q=VHash%%3Avarianthash3)\n"+
   355  							"[testID4](https://ci.chromium.org/ui/test/chromium/testID4?q=VHash%%3Avarianthash4)\n"+
   356  							"[testID5](https://ci.chromium.org/ui/test/chromium/testID5?q=VHash%%3Avarianthash5)\n"+
   357  							"and 2 more ...\n"+
   358  							"A revert for this change was not created because the builder of the failed test(s) is not being watched by gardeners.\n\n"+
   359  							"If this is a false positive, please report it at %s", analysisURL, buildURL, bugURL),
   360  					},
   361  				)).Times(1)
   362  
   363  				err := processTestFailureCulpritTask(ctx, tfa.ID, client)
   364  				So(err, ShouldBeNil)
   365  				// Suspect action has been saved.
   366  				So(datastore.Get(ctx, suspect), ShouldBeNil)
   367  				So(suspect.HasCulpritComment, ShouldBeTrue)
   368  				So(suspect.CulpritCommentTime, ShouldEqual, time.Unix(10000, 0).UTC())
   369  				So(suspect.HasTakenActions, ShouldBeTrue)
   370  				// Check counter incremented.
   371  				So(culpritActionCounter.Get(ctx, "chromium", "test", "comment_culprit"), ShouldEqual, 1)
   372  			})
   373  		})
   374  
   375  		Convey("revert creation", func() {
   376  			tfa.SheriffRotations = []string{"chromium"}
   377  			So(datastore.Put(ctx, tfa), ShouldBeNil)
   378  			datastore.GetTestable(ctx).CatchupIndexes()
   379  
   380  			Convey("revert has auto-revert off flag set", func() {
   381  				culpritRes.Changes[0].Revisions["deadbeef"].Commit.Message = "Title.\n\nBody is here.\n\nNOAUTOREVERT=true\n\nChange-Id: I100deadbeef"
   382  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   383  					Return(culpritRes, nil).Times(1)
   384  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   385  					Return(&gerritpb.ListChangesResponse{}, nil).Times(1)
   386  				mockClient.Client.EXPECT().SetReview(gomock.Any(), proto.MatcherEqual(
   387  					&gerritpb.SetReviewRequest{
   388  						Project:    culpritRes.Changes[0].Project,
   389  						Number:     culpritRes.Changes[0].Number,
   390  						RevisionId: "current",
   391  						Message: fmt.Sprintf("LUCI Bisection has identified this"+
   392  							" change as the cause of a test failure. See the analysis: %s\n\n"+
   393  							"Sample build with failed test: %s\n"+
   394  							"Affected test(s):\n%s\n"+
   395  							"A revert for this change was not created because auto-revert has been disabled for this CL by its description.\n\n"+
   396  							"If this is a false positive, please report it at %s", analysisURL, buildURL, testLinks, bugURL),
   397  					},
   398  				)).Times(1)
   399  
   400  				err := processTestFailureCulpritTask(ctx, tfa.ID, client)
   401  				So(err, ShouldBeNil)
   402  
   403  				datastore.GetTestable(ctx).CatchupIndexes()
   404  				// Suspect action has been saved.
   405  				So(datastore.Get(ctx, suspect), ShouldBeNil)
   406  				So(suspect.HasCulpritComment, ShouldBeTrue)
   407  				So(suspect.CulpritCommentTime, ShouldEqual, time.Unix(10000, 0).UTC())
   408  				So(suspect.HasTakenActions, ShouldBeTrue)
   409  				// Check counter incremented.
   410  				So(culpritActionCounter.Get(ctx, "chromium", "test", "comment_culprit"), ShouldEqual, 1)
   411  			})
   412  
   413  			Convey("revert was from an irrevertible author", func() {
   414  				culpritRes.Changes[0].Revisions["deadbeef"].Commit.Author = &gerritpb.GitPersonInfo{
   415  					Name:  "ChromeOS Commit Bot",
   416  					Email: "chromeos-commit-bot@chromium.org",
   417  				}
   418  
   419  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   420  					Return(culpritRes, nil).Times(1)
   421  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   422  					Return(&gerritpb.ListChangesResponse{}, nil).Times(1)
   423  				mockClient.Client.EXPECT().SetReview(gomock.Any(), proto.MatcherEqual(
   424  					&gerritpb.SetReviewRequest{
   425  						Project:    culpritRes.Changes[0].Project,
   426  						Number:     culpritRes.Changes[0].Number,
   427  						RevisionId: "current",
   428  						Message: fmt.Sprintf("LUCI Bisection has identified this"+
   429  							" change as the cause of a test failure. See the analysis: %s\n\n"+
   430  							"Sample build with failed test: %s\n"+
   431  							"Affected test(s):\n%s\n"+
   432  							"A revert for this change was not created because LUCI Bisection cannot revert changes from this CL's author.\n\n"+
   433  							"If this is a false positive, please report it at %s", analysisURL, buildURL, testLinks, bugURL),
   434  					},
   435  				)).Times(1)
   436  
   437  				err := processTestFailureCulpritTask(ctx, tfa.ID, client)
   438  				So(err, ShouldBeNil)
   439  
   440  				datastore.GetTestable(ctx).CatchupIndexes()
   441  				// Suspect action has been saved.
   442  				So(datastore.Get(ctx, suspect), ShouldBeNil)
   443  				So(suspect.HasCulpritComment, ShouldBeTrue)
   444  				So(suspect.CulpritCommentTime, ShouldEqual, time.Unix(10000, 0).UTC())
   445  				So(suspect.HasTakenActions, ShouldBeTrue)
   446  				// Check counter incremented.
   447  				So(culpritActionCounter.Get(ctx, "chromium", "test", "comment_culprit"), ShouldEqual, 1)
   448  			})
   449  
   450  			Convey("culprit has a downstream dependency", func() {
   451  				revertRes := &gerritpb.ListChangesResponse{
   452  					Changes: []*gerritpb.ChangeInfo{},
   453  				}
   454  				relatedChanges := &gerritpb.GetRelatedChangesResponse{
   455  					Changes: []*gerritpb.GetRelatedChangesResponse_ChangeAndCommit{
   456  						{
   457  							Project: "chromium/src",
   458  							Number:  876544,
   459  							Status:  gerritpb.ChangeStatus_MERGED,
   460  						},
   461  						{
   462  							Project: "chromium/src",
   463  							Number:  876543,
   464  							Status:  gerritpb.ChangeStatus_MERGED,
   465  						},
   466  					},
   467  				}
   468  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   469  					Return(culpritRes, nil).Times(1)
   470  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   471  					Return(revertRes, nil).Times(1)
   472  				mockClient.Client.EXPECT().GetRelatedChanges(gomock.Any(), gomock.Any()).
   473  					Return(relatedChanges, nil).Times(1)
   474  				mockClient.Client.EXPECT().SetReview(gomock.Any(), proto.MatcherEqual(
   475  					&gerritpb.SetReviewRequest{
   476  						Project:    culpritRes.Changes[0].Project,
   477  						Number:     culpritRes.Changes[0].Number,
   478  						RevisionId: "current",
   479  						Message: fmt.Sprintf("LUCI Bisection has identified this"+
   480  							" change as the cause of a test failure. See the analysis: %s\n\n"+
   481  							"Sample build with failed test: %s\n"+
   482  							"Affected test(s):\n%s\n"+
   483  							"A revert for this change was not created because there are merged changes depending on it.\n\n"+
   484  							"If this is a false positive, please report it at %s", analysisURL, buildURL, testLinks, bugURL),
   485  					},
   486  				)).Times(1)
   487  
   488  				err := processTestFailureCulpritTask(ctx, tfa.ID, client)
   489  				So(err, ShouldBeNil)
   490  
   491  				datastore.GetTestable(ctx).CatchupIndexes()
   492  				// Suspect action has been saved.
   493  				So(datastore.Get(ctx, suspect), ShouldBeNil)
   494  				So(suspect.HasCulpritComment, ShouldBeTrue)
   495  				So(suspect.CulpritCommentTime, ShouldEqual, time.Unix(10000, 0).UTC())
   496  				So(suspect.HasTakenActions, ShouldBeTrue)
   497  				// Check counter incremented.
   498  				So(culpritActionCounter.Get(ctx, "chromium", "test", "comment_culprit"), ShouldEqual, 1)
   499  			})
   500  
   501  			Convey("revert creation is disabled", func() {
   502  				projectCfg.TestAnalysisConfig.GerritConfig.CreateRevertSettings = &configpb.GerritConfig_RevertActionSettings{
   503  					Enabled: false,
   504  				}
   505  				cfg := map[string]*configpb.ProjectConfig{tfa.Project: projectCfg}
   506  				So(config.SetTestProjectConfig(ctx, cfg), ShouldBeNil)
   507  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   508  					Return(culpritRes, nil).Times(1)
   509  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   510  					Return(&gerritpb.ListChangesResponse{}, nil).Times(1)
   511  				mockClient.Client.EXPECT().GetRelatedChanges(gomock.Any(), gomock.Any()).
   512  					Return(&gerritpb.GetRelatedChangesResponse{}, nil).Times(1)
   513  				mockClient.Client.EXPECT().SetReview(gomock.Any(), proto.MatcherEqual(
   514  					&gerritpb.SetReviewRequest{
   515  						Project:    culpritRes.Changes[0].Project,
   516  						Number:     culpritRes.Changes[0].Number,
   517  						RevisionId: "current",
   518  						Message: fmt.Sprintf("LUCI Bisection has identified this"+
   519  							" change as the cause of a test failure. See the analysis: %s\n\n"+
   520  							"Sample build with failed test: %s\n"+
   521  							"Affected test(s):\n%s\n"+
   522  							"A revert for this change was not created because LUCI Bisection's revert creation has been disabled.\n\n"+
   523  							"If this is a false positive, please report it at %s", analysisURL, buildURL, testLinks, bugURL),
   524  					},
   525  				)).Times(1)
   526  
   527  				err := processTestFailureCulpritTask(ctx, tfa.ID, client)
   528  				So(err, ShouldBeNil)
   529  
   530  				datastore.GetTestable(ctx).CatchupIndexes()
   531  				// Suspect action has been saved.
   532  				So(datastore.Get(ctx, suspect), ShouldBeNil)
   533  				So(suspect.HasCulpritComment, ShouldBeTrue)
   534  				So(suspect.CulpritCommentTime, ShouldEqual, time.Unix(10000, 0).UTC())
   535  				So(suspect.HasTakenActions, ShouldBeTrue)
   536  				// Check counter incremented.
   537  				So(culpritActionCounter.Get(ctx, "chromium", "test", "comment_culprit"), ShouldEqual, 1)
   538  			})
   539  
   540  			Convey("exceed daily limit", func() {
   541  				// Set up config.
   542  				projectCfg.TestAnalysisConfig.GerritConfig.CreateRevertSettings = &configpb.GerritConfig_RevertActionSettings{
   543  					DailyLimit: 1,
   544  					Enabled:    true,
   545  				}
   546  				cfg := map[string]*configpb.ProjectConfig{tfa.Project: projectCfg}
   547  				So(config.SetTestProjectConfig(ctx, cfg), ShouldBeNil)
   548  
   549  				// Add existing revert.
   550  				testutil.CreateSuspect(ctx, &testutil.SuspectCreationOption{
   551  					AnalysisType: pb.AnalysisType_TEST_FAILURE_ANALYSIS,
   552  					ActionDetails: model.ActionDetails{
   553  						IsRevertCreated:  true,
   554  						RevertCreateTime: clock.Now(ctx).Add(-time.Hour),
   555  					},
   556  				})
   557  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   558  					Return(culpritRes, nil).Times(1)
   559  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   560  					Return(&gerritpb.ListChangesResponse{}, nil).Times(1)
   561  				mockClient.Client.EXPECT().GetRelatedChanges(gomock.Any(), gomock.Any()).
   562  					Return(&gerritpb.GetRelatedChangesResponse{}, nil).Times(1)
   563  				mockClient.Client.EXPECT().SetReview(gomock.Any(), proto.MatcherEqual(
   564  					&gerritpb.SetReviewRequest{
   565  						Project:    culpritRes.Changes[0].Project,
   566  						Number:     culpritRes.Changes[0].Number,
   567  						RevisionId: "current",
   568  						Message: fmt.Sprintf("LUCI Bisection has identified this"+
   569  							" change as the cause of a test failure. See the analysis: %s\n\n"+
   570  							"Sample build with failed test: %s\n"+
   571  							"Affected test(s):\n%s\n"+
   572  							"A revert for this change was not created because LUCI Bisection's daily limit for revert creation (1) has been reached; 1 reverts have already been created.\n\n"+
   573  							"If this is a false positive, please report it at %s", analysisURL, buildURL, testLinks, bugURL),
   574  					},
   575  				)).Times(1)
   576  
   577  				err := processTestFailureCulpritTask(ctx, tfa.ID, client)
   578  				So(err, ShouldBeNil)
   579  
   580  				datastore.GetTestable(ctx).CatchupIndexes()
   581  				// Suspect action has been saved.
   582  				So(datastore.Get(ctx, suspect), ShouldBeNil)
   583  				So(suspect.HasCulpritComment, ShouldBeTrue)
   584  				So(suspect.CulpritCommentTime, ShouldEqual, time.Unix(10000, 0).UTC())
   585  				So(suspect.HasTakenActions, ShouldBeTrue)
   586  				// Check counter incremented.
   587  				So(culpritActionCounter.Get(ctx, "chromium", "test", "comment_culprit"), ShouldEqual, 1)
   588  			})
   589  
   590  			Convey("revert created", func() {
   591  				// Set up config.
   592  				projectCfg.TestAnalysisConfig.GerritConfig.CreateRevertSettings = &configpb.GerritConfig_RevertActionSettings{
   593  					DailyLimit: 10,
   594  					Enabled:    true,
   595  				}
   596  				cfg := map[string]*configpb.ProjectConfig{tfa.Project: projectCfg}
   597  				So(config.SetTestProjectConfig(ctx, cfg), ShouldBeNil)
   598  
   599  				revertRes := &gerritpb.ChangeInfo{
   600  					Number:  876549,
   601  					Project: "chromium/src",
   602  					Status:  gerritpb.ChangeStatus_NEW,
   603  				}
   604  
   605  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   606  					Return(culpritRes, nil).Times(1)
   607  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   608  					Return(&gerritpb.ListChangesResponse{}, nil).Times(1)
   609  				mockClient.Client.EXPECT().GetRelatedChanges(gomock.Any(), gomock.Any()).
   610  					Return(&gerritpb.GetRelatedChangesResponse{}, nil).Times(1)
   611  				mockClient.Client.EXPECT().RevertChange(gomock.Any(), proto.MatcherEqual(
   612  					&gerritpb.RevertChangeRequest{
   613  						Project: culpritRes.Changes[0].Project,
   614  						Number:  culpritRes.Changes[0].Number,
   615  						Message: fmt.Sprintf("Revert \"chromium/src~876543\"\n\n"+
   616  							"This reverts commit 12ab34cd56ef.\n\n"+
   617  							"Reason for revert:\n"+
   618  							"LUCI Bisection has identified this"+
   619  							" change as the cause of a test failure. See the analysis: %s\n\n"+
   620  							"Sample build with failed test: %s\n"+
   621  							"Affected test(s):\n%s\n\n"+
   622  							"If this is a false positive, please report it at %s\n\n"+
   623  							"Original change's description:\n"+
   624  							"> Title.\n"+
   625  							">\n"+
   626  							"> Body is here.\n"+
   627  							">\n"+
   628  							"> Change-Id: I100deadbeef\n\n"+
   629  							"No-Presubmit: true\n"+
   630  							"No-Tree-Checks: true\n"+
   631  							"No-Try: true", analysisURL, buildURL, testLinks, bugURL),
   632  					},
   633  				)).
   634  					Return(revertRes, nil).Times(1)
   635  				mockClient.Client.EXPECT().ListChanges(gomock.Any(), gomock.Any()).
   636  					Return(&gerritpb.ListChangesResponse{
   637  						Changes: []*gerritpb.ChangeInfo{
   638  							{
   639  								Number:  876549,
   640  								Project: "chromium/src",
   641  								Status:  gerritpb.ChangeStatus_MERGED,
   642  							},
   643  						},
   644  					}, nil).Times(1)
   645  
   646  				err := processTestFailureCulpritTask(ctx, tfa.ID, client)
   647  				So(err, ShouldBeNil)
   648  
   649  				datastore.GetTestable(ctx).CatchupIndexes()
   650  				// Suspect action has been saved.
   651  				So(datastore.Get(ctx, suspect), ShouldBeNil)
   652  				So(suspect.IsRevertCreated, ShouldBeTrue)
   653  				So(suspect.RevertCreateTime, ShouldEqual, time.Unix(10000, 0).UTC())
   654  				So(suspect.HasTakenActions, ShouldBeTrue)
   655  				// Check counter incremented.
   656  				So(culpritActionCounter.Get(ctx, "chromium", "test", "create_revert"), ShouldEqual, 1)
   657  			})
   658  		})
   659  	})
   660  }
   661  
   662  type fakeLUCIAnalysisClient struct {
   663  	FailedConsistently bool
   664  }
   665  
   666  func (cl *fakeLUCIAnalysisClient) TestIsUnexpectedConsistently(ctx context.Context, project string, key lucianalysis.TestVerdictKey, sinceCommitPosition int64) (bool, error) {
   667  	return cl.FailedConsistently, nil
   668  }