go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/prjmanager/clpurger/clpurger_test.go (about)

     1  // Copyright 2021 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 clpurger
    16  
    17  import (
    18  	"context"
    19  	"testing"
    20  	"time"
    21  
    22  	"google.golang.org/protobuf/types/known/timestamppb"
    23  
    24  	gerritpb "go.chromium.org/luci/common/proto/gerrit"
    25  	"go.chromium.org/luci/gae/service/datastore"
    26  	"go.chromium.org/luci/server/tq/tqtesting"
    27  
    28  	cfgpb "go.chromium.org/luci/cv/api/config/v2"
    29  	"go.chromium.org/luci/cv/internal/changelist"
    30  	"go.chromium.org/luci/cv/internal/configs/prjcfg/prjcfgtest"
    31  	"go.chromium.org/luci/cv/internal/cvtesting"
    32  	gf "go.chromium.org/luci/cv/internal/gerrit/gerritfake"
    33  	"go.chromium.org/luci/cv/internal/gerrit/gobmap/gobmaptest"
    34  	"go.chromium.org/luci/cv/internal/gerrit/trigger"
    35  	gerritupdater "go.chromium.org/luci/cv/internal/gerrit/updater"
    36  	"go.chromium.org/luci/cv/internal/prjmanager"
    37  	"go.chromium.org/luci/cv/internal/prjmanager/pmtest"
    38  	"go.chromium.org/luci/cv/internal/prjmanager/prjpb"
    39  	"go.chromium.org/luci/cv/internal/run"
    40  	"go.chromium.org/luci/cv/internal/tryjob"
    41  	"go.chromium.org/luci/cv/internal/tryjob/tjcancel"
    42  
    43  	. "github.com/smartystreets/goconvey/convey"
    44  	. "go.chromium.org/luci/common/testing/assertions"
    45  )
    46  
    47  func TestPurgeCL(t *testing.T) {
    48  	t.Parallel()
    49  
    50  	Convey("PurgeCL works", t, func() {
    51  		ct := cvtesting.Test{}
    52  		ctx, cancel := ct.SetUp(t)
    53  		defer cancel()
    54  		ctx, pmDispatcher := pmtest.MockDispatch(ctx)
    55  
    56  		pmNotifier := prjmanager.NewNotifier(ct.TQDispatcher)
    57  		tjNotifier := tryjob.NewNotifier(ct.TQDispatcher)
    58  		_ = tjcancel.NewCancellator(tjNotifier)
    59  		clMutator := changelist.NewMutator(ct.TQDispatcher, pmNotifier, nil, tjNotifier)
    60  		fakeCLUpdater := clUpdaterMock{}
    61  		purger := New(pmNotifier, ct.GFactory(), &fakeCLUpdater, clMutator)
    62  
    63  		const lProject = "lprj"
    64  		const gHost = "x-review"
    65  		const gRepo = "repo"
    66  		const change = 43
    67  
    68  		cfg := makeConfig(gHost, gRepo)
    69  		prjcfgtest.Create(ctx, lProject, cfg)
    70  		cfgMeta := prjcfgtest.MustExist(ctx, lProject)
    71  		gobmaptest.Update(ctx, lProject)
    72  
    73  		// Fake 1 CL in gerrit & import it to Datastore.
    74  		ci := gf.CI(
    75  			change, gf.PS(2), gf.Project(gRepo), gf.Ref("refs/heads/main"),
    76  			gf.CQ(+2, ct.Clock.Now().Add(-2*time.Minute), gf.U("user-1")),
    77  			gf.Updated(ct.Clock.Now().Add(-1*time.Minute)),
    78  		)
    79  		ct.GFake.AddFrom(gf.WithCIs(gHost, gf.ACLRestricted(lProject), ci))
    80  
    81  		// The real CL Updater for realistic CL Snapshot in datastore.
    82  		clUpdater := changelist.NewUpdater(ct.TQDispatcher, clMutator)
    83  		gerritupdater.RegisterUpdater(clUpdater, ct.GFactory())
    84  		refreshCL := func() {
    85  			So(clUpdater.TestingForceUpdate(ctx, &changelist.UpdateCLTask{
    86  				LuciProject: lProject,
    87  				ExternalId:  string(changelist.MustGobID(gHost, change)),
    88  			}), ShouldBeNil)
    89  		}
    90  		refreshCL()
    91  
    92  		loadCL := func() *changelist.CL {
    93  			cl, err := changelist.MustGobID(gHost, change).Load(ctx)
    94  			So(err, ShouldBeNil)
    95  			So(cl, ShouldNotBeNil)
    96  			return cl
    97  		}
    98  		clBefore := loadCL()
    99  
   100  		assertPMNotified := func(purgingCL *prjpb.PurgingCL) {
   101  			pmtest.AssertInEventbox(ctx, lProject, &prjpb.Event{Event: &prjpb.Event_PurgeCompleted{
   102  				PurgeCompleted: &prjpb.PurgeCompleted{
   103  					OperationId: purgingCL.GetOperationId(),
   104  					Clid:        purgingCL.GetClid(),
   105  				},
   106  			}})
   107  		}
   108  
   109  		// Basic task.
   110  		task := &prjpb.PurgeCLTask{
   111  			LuciProject: lProject,
   112  			PurgingCl: &prjpb.PurgingCL{
   113  				OperationId: "op",
   114  				Clid:        int64(clBefore.ID),
   115  				Deadline:    timestamppb.New(ct.Clock.Now().Add(10 * time.Minute)),
   116  				ApplyTo:     &prjpb.PurgingCL_AllActiveTriggers{AllActiveTriggers: true},
   117  			},
   118  			PurgeReasons: []*prjpb.PurgeReason{{
   119  				ClError: &changelist.CLError{
   120  					Kind: &changelist.CLError_OwnerLacksEmail{OwnerLacksEmail: true},
   121  				},
   122  				ApplyTo: &prjpb.PurgeReason_AllActiveTriggers{AllActiveTriggers: true},
   123  			}},
   124  			ConfigGroups: []string{string(cfgMeta.ConfigGroupIDs[0])},
   125  		}
   126  
   127  		schedule := func() error {
   128  			return datastore.RunInTransaction(ctx, func(tCtx context.Context) error {
   129  				return purger.Schedule(tCtx, task)
   130  			}, nil)
   131  		}
   132  
   133  		ct.Clock.Add(time.Minute)
   134  
   135  		Convey("Purge one trigger, then the other", func() {
   136  			task.PurgeReasons = []*prjpb.PurgeReason{
   137  				{
   138  					ClError: &changelist.CLError{
   139  						Kind: &changelist.CLError_InvalidDeps_{
   140  							InvalidDeps: &changelist.CLError_InvalidDeps{
   141  								Unwatched: []*changelist.Dep{
   142  									{Clid: 1, Kind: changelist.DepKind_HARD},
   143  								},
   144  							},
   145  						},
   146  					},
   147  					ApplyTo: &prjpb.PurgeReason_Triggers{
   148  						Triggers: trigger.Find(&trigger.FindInput{ChangeInfo: ci, ConfigGroup: &cfgpb.ConfigGroup{}}),
   149  					},
   150  				},
   151  			}
   152  			So(schedule(), ShouldBeNil)
   153  			ct.TQ.Run(ctx, tqtesting.StopAfterTask(prjpb.PurgeProjectCLTaskClass))
   154  
   155  			ciAfter := ct.GFake.GetChange(gHost, change).Info
   156  			triggersAfter := trigger.Find(&trigger.FindInput{
   157  				ChangeInfo:                   ciAfter,
   158  				ConfigGroup:                  cfg.GetConfigGroups()[0],
   159  				TriggerNewPatchsetRunAfterPS: loadCL().TriggerNewPatchsetRunAfterPS,
   160  			})
   161  			So(triggersAfter, ShouldNotBeNil)
   162  			So(triggersAfter.CqVoteTrigger, ShouldBeNil)
   163  			So(triggersAfter.NewPatchsetRunTrigger, ShouldNotBeNil)
   164  			So(ciAfter, gf.ShouldLastMessageContain, "its deps are not watched")
   165  			So(fakeCLUpdater.scheduledTasks, ShouldHaveLength, 1)
   166  			assertPMNotified(task.PurgingCl)
   167  
   168  			task.PurgeReasons = []*prjpb.PurgeReason{
   169  				{
   170  					ClError: &changelist.CLError{
   171  						Kind: &changelist.CLError_UnsupportedMode{
   172  							UnsupportedMode: string(run.NewPatchsetRun)},
   173  					},
   174  					ApplyTo: &prjpb.PurgeReason_Triggers{
   175  						Triggers: &run.Triggers{
   176  							NewPatchsetRunTrigger: triggersAfter.GetNewPatchsetRunTrigger(),
   177  						},
   178  					},
   179  				},
   180  			}
   181  			So(schedule(), ShouldBeNil)
   182  			ct.TQ.Run(ctx, tqtesting.StopAfterTask(prjpb.PurgeProjectCLTaskClass))
   183  
   184  			ciAfter = ct.GFake.GetChange(gHost, change).Info
   185  			triggersAfter = trigger.Find(&trigger.FindInput{
   186  				ChangeInfo:                   ciAfter,
   187  				ConfigGroup:                  cfg.GetConfigGroups()[0],
   188  				TriggerNewPatchsetRunAfterPS: loadCL().TriggerNewPatchsetRunAfterPS,
   189  			})
   190  			So(triggersAfter, ShouldBeNil)
   191  			So(ciAfter, gf.ShouldLastMessageContain, "is not supported")
   192  			So(fakeCLUpdater.scheduledTasks, ShouldHaveLength, 2)
   193  			assertPMNotified(task.PurgingCl)
   194  		})
   195  		Convey("Happy path: reset both triggers, schedule CL refresh, and notify PM", func() {
   196  			So(schedule(), ShouldBeNil)
   197  			ct.TQ.Run(ctx, tqtesting.StopAfterTask(prjpb.PurgeProjectCLTaskClass))
   198  
   199  			ciAfter := ct.GFake.GetChange(gHost, change).Info
   200  			So(trigger.Find(&trigger.FindInput{
   201  				ChangeInfo:                   ciAfter,
   202  				ConfigGroup:                  cfg.GetConfigGroups()[0],
   203  				TriggerNewPatchsetRunAfterPS: loadCL().TriggerNewPatchsetRunAfterPS,
   204  			}), ShouldBeNil)
   205  			So(ciAfter, gf.ShouldLastMessageContain, "owner doesn't have a preferred email")
   206  
   207  			So(fakeCLUpdater.scheduledTasks, ShouldHaveLength, 1)
   208  			assertPMNotified(task.PurgingCl)
   209  			So(loadCL().Snapshot.GetOutdated(), ShouldNotBeNil)
   210  
   211  			Convey("Idempotent: if TQ task is retried, just notify PM", func() {
   212  				verifyIdempotency := func() {
   213  					// Use different Operation ID s.t. we can easily assert PM was notified
   214  					// the 2nd time.
   215  					task.PurgingCl.OperationId = "op-2"
   216  					So(schedule(), ShouldBeNil)
   217  					ct.TQ.Run(ctx, tqtesting.StopAfterTask(prjpb.PurgeProjectCLTaskClass))
   218  					// CL in Gerrit shouldn't be changed.
   219  					ciAfter2 := ct.GFake.GetChange(gHost, change).Info
   220  					So(ciAfter2, ShouldResembleProto, ciAfter)
   221  					// But PM must be notified.
   222  					assertPMNotified(task.PurgingCl)
   223  					So(pmDispatcher.LatestETAof(lProject), ShouldHappenBefore, ct.Clock.Now().Add(2*time.Second))
   224  				}
   225  				// Idempotency must not rely on CL being updated between retries.
   226  				Convey("CL updated between retries", func() {
   227  					verifyIdempotency()
   228  					// should remain with the same value in Outdated.
   229  					So(loadCL().Snapshot.GetOutdated(), ShouldNotBeNil)
   230  				})
   231  				Convey("CL not updated between retries", func() {
   232  					refreshCL()
   233  					// Outdated should be nil after refresh().
   234  					So(loadCL().Snapshot.GetOutdated(), ShouldBeNil)
   235  					verifyIdempotency()
   236  					// Idempotency should not make it outdated, because
   237  					// no purge is performed actually.
   238  					So(loadCL().Snapshot.GetOutdated(), ShouldBeNil)
   239  				})
   240  			})
   241  		})
   242  
   243  		Convey("Even if no purging is done, PM is always notified", func() {
   244  			Convey("Task arrives after the deadline", func() {
   245  				task.PurgingCl.Deadline = timestamppb.New(ct.Clock.Now().Add(-time.Minute))
   246  				So(schedule(), ShouldBeNil)
   247  				ct.TQ.Run(ctx, tqtesting.StopAfterTask(prjpb.PurgeProjectCLTaskClass))
   248  				So(loadCL().EVersion, ShouldEqual, clBefore.EVersion) // no changes.
   249  				assertPMNotified(task.PurgingCl)
   250  				So(pmDispatcher.LatestETAof(lProject), ShouldHappenBefore, ct.Clock.Now().Add(2*time.Second))
   251  			})
   252  
   253  			Convey("Trigger is no longer matching latest CL Snapshot", func() {
   254  				// Simulate old trigger for CQ+1, while snapshot contains CQ+2.
   255  				gf.CQ(+1, ct.Clock.Now().Add(-time.Hour), gf.U("user-1"))(ci)
   256  				// Simulate NPR finished earlier.
   257  				cl := loadCL()
   258  				cl.TriggerNewPatchsetRunAfterPS = 2
   259  				So(datastore.Put(ctx, cl), ShouldBeNil)
   260  
   261  				So(schedule(), ShouldBeNil)
   262  				ct.TQ.Run(ctx, tqtesting.StopAfterTask(prjpb.PurgeProjectCLTaskClass))
   263  				So(loadCL().EVersion, ShouldEqual, clBefore.EVersion+1) // +1 for setting Outdated{}
   264  				assertPMNotified(task.PurgingCl)
   265  				// The PM task should be ASAP.
   266  				So(pmDispatcher.LatestETAof(lProject), ShouldHappenBefore, ct.Clock.Now().Add(2*time.Second))
   267  			})
   268  		})
   269  
   270  		Convey("Sets Notify and AddToAttentionSet", func() {
   271  			var reqs []*gerritpb.SetReviewRequest
   272  			findSetReviewReqs := func() {
   273  				for _, req := range ct.GFake.Requests() {
   274  					if r, ok := req.(*gerritpb.SetReviewRequest); ok {
   275  						reqs = append(reqs, r)
   276  					}
   277  				}
   278  			}
   279  
   280  			Convey("with the default NotifyTarget", func() {
   281  				task.PurgingCl.Notification = nil
   282  				So(schedule(), ShouldBeNil)
   283  				ct.TQ.Run(ctx, tqtesting.StopAfterTask(prjpb.PurgeProjectCLTaskClass))
   284  				findSetReviewReqs()
   285  				So(reqs, ShouldHaveLength, 2)
   286  				postReq := reqs[0]
   287  				voteReq := reqs[1]
   288  				if reqs[1].GetMessage() != "" {
   289  					postReq, voteReq = reqs[1], reqs[0]
   290  				}
   291  				So(postReq.Notify, ShouldEqual, gerritpb.Notify_NOTIFY_NONE)
   292  				So(voteReq.Notify, ShouldEqual, gerritpb.Notify_NOTIFY_NONE)
   293  				So(postReq.GetNotifyDetails(), ShouldNotBeNil)
   294  				So(voteReq.GetNotifyDetails(), ShouldBeNil)
   295  				So(postReq.GetAddToAttentionSet(), ShouldNotBeNil)
   296  				So(voteReq.GetAddToAttentionSet(), ShouldBeNil)
   297  			})
   298  			Convey("with a custom Notification target", func() {
   299  				// 0 implies nobody
   300  				task.PurgingCl.Notification = NoNotification
   301  				So(schedule(), ShouldBeNil)
   302  				ct.TQ.Run(ctx, tqtesting.StopAfterTask(prjpb.PurgeProjectCLTaskClass))
   303  				findSetReviewReqs()
   304  				So(reqs, ShouldHaveLength, 2)
   305  				postReq := reqs[0]
   306  				voteReq := reqs[1]
   307  				if reqs[1].GetMessage() != "" {
   308  					postReq, voteReq = reqs[1], reqs[0]
   309  				}
   310  				So(postReq.Notify, ShouldEqual, gerritpb.Notify_NOTIFY_NONE)
   311  				So(voteReq.Notify, ShouldEqual, gerritpb.Notify_NOTIFY_NONE)
   312  				So(postReq.GetNotifyDetails(), ShouldBeNil)
   313  				So(voteReq.GetNotifyDetails(), ShouldBeNil)
   314  				So(postReq.GetAddToAttentionSet(), ShouldBeNil)
   315  				So(voteReq.GetAddToAttentionSet(), ShouldBeNil)
   316  			})
   317  		})
   318  	})
   319  }
   320  
   321  func makeConfig(gHost string, gRepo string) *cfgpb.Config {
   322  	return &cfgpb.Config{
   323  		ConfigGroups: []*cfgpb.ConfigGroup{
   324  			{
   325  				Name: "main",
   326  				Gerrit: []*cfgpb.ConfigGroup_Gerrit{
   327  					{
   328  						Url: "https://" + gHost + "/",
   329  						Projects: []*cfgpb.ConfigGroup_Gerrit_Project{
   330  							{
   331  								Name:      gRepo,
   332  								RefRegexp: []string{"refs/heads/main"},
   333  							},
   334  						},
   335  					},
   336  				},
   337  				Verifiers: &cfgpb.Verifiers{
   338  					Tryjob: &cfgpb.Verifiers_Tryjob{
   339  						Builders: []*cfgpb.Verifiers_Tryjob_Builder{
   340  							{
   341  								Name:          "linter",
   342  								ModeAllowlist: []string{string(run.NewPatchsetRun)},
   343  							},
   344  						},
   345  					},
   346  				},
   347  			},
   348  		},
   349  	}
   350  }
   351  
   352  type clUpdaterMock struct {
   353  	scheduledTasks []*changelist.UpdateCLTask
   354  }
   355  
   356  func (c *clUpdaterMock) Schedule(_ context.Context, task *changelist.UpdateCLTask) error {
   357  	c.scheduledTasks = append(c.scheduledTasks, task)
   358  	return nil
   359  }