go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/prjmanager/state/state_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 state
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"sort"
    21  	"strings"
    22  	"testing"
    23  	"time"
    24  
    25  	"google.golang.org/protobuf/encoding/prototext"
    26  	"google.golang.org/protobuf/proto"
    27  	"google.golang.org/protobuf/types/known/timestamppb"
    28  
    29  	"go.chromium.org/luci/common/clock/testclock"
    30  	gerritpb "go.chromium.org/luci/common/proto/gerrit"
    31  	"go.chromium.org/luci/gae/service/datastore"
    32  
    33  	cfgpb "go.chromium.org/luci/cv/api/config/v2"
    34  	"go.chromium.org/luci/cv/internal/changelist"
    35  	"go.chromium.org/luci/cv/internal/common"
    36  	"go.chromium.org/luci/cv/internal/configs/prjcfg"
    37  	"go.chromium.org/luci/cv/internal/configs/prjcfg/prjcfgtest"
    38  	"go.chromium.org/luci/cv/internal/cvtesting"
    39  	"go.chromium.org/luci/cv/internal/gerrit/cfgmatcher"
    40  	gf "go.chromium.org/luci/cv/internal/gerrit/gerritfake"
    41  	"go.chromium.org/luci/cv/internal/gerrit/gobmap/gobmaptest"
    42  	"go.chromium.org/luci/cv/internal/gerrit/poller"
    43  	"go.chromium.org/luci/cv/internal/gerrit/trigger"
    44  	gerritupdater "go.chromium.org/luci/cv/internal/gerrit/updater"
    45  	"go.chromium.org/luci/cv/internal/prjmanager"
    46  	"go.chromium.org/luci/cv/internal/prjmanager/clpurger"
    47  	"go.chromium.org/luci/cv/internal/prjmanager/prjpb"
    48  	"go.chromium.org/luci/cv/internal/run"
    49  	"go.chromium.org/luci/cv/internal/tryjob"
    50  
    51  	. "github.com/smartystreets/goconvey/convey"
    52  	. "go.chromium.org/luci/common/testing/assertions"
    53  )
    54  
    55  type ctest struct {
    56  	cvtesting.Test
    57  
    58  	lProject  string
    59  	gHost     string
    60  	pm        *prjmanager.Notifier
    61  	clUpdater *changelist.Updater
    62  }
    63  
    64  func (ct *ctest) SetUp(testingT *testing.T) (context.Context, func()) {
    65  	ctx, cancel := ct.Test.SetUp(testingT)
    66  	ct.pm = prjmanager.NewNotifier(ct.TQDispatcher)
    67  	ct.clUpdater = changelist.NewUpdater(ct.TQDispatcher, changelist.NewMutator(ct.TQDispatcher, ct.pm, nil, tryjob.NewNotifier(ct.TQDispatcher)))
    68  	gerritupdater.RegisterUpdater(ct.clUpdater, ct.GFactory())
    69  	return ctx, cancel
    70  }
    71  
    72  func (ct ctest) runCLUpdater(ctx context.Context, change int64) *changelist.CL {
    73  	return ct.runCLUpdaterAs(ctx, change, ct.lProject)
    74  }
    75  
    76  func (ct ctest) runCLUpdaterAs(ctx context.Context, change int64, lProject string) *changelist.CL {
    77  	So(ct.clUpdater.TestingForceUpdate(ctx, &changelist.UpdateCLTask{
    78  		LuciProject: lProject,
    79  		ExternalId:  string(changelist.MustGobID(ct.gHost, change)),
    80  		Requester:   changelist.UpdateCLTask_RUN_POKE,
    81  	}), ShouldBeNil)
    82  	eid, err := changelist.GobID(ct.gHost, change)
    83  	So(err, ShouldBeNil)
    84  	cl, err := eid.Load(ctx)
    85  	So(err, ShouldBeNil)
    86  	So(cl, ShouldNotBeNil)
    87  	return cl
    88  }
    89  
    90  func (ct ctest) submitCL(ctx context.Context, change int64) *changelist.CL {
    91  	ct.GFake.MutateChange(ct.gHost, int(change), func(c *gf.Change) {
    92  		gf.Status(gerritpb.ChangeStatus_MERGED)(c.Info)
    93  		gf.Updated(ct.Clock.Now())(c.Info)
    94  	})
    95  	cl := ct.runCLUpdater(ctx, change)
    96  
    97  	// If this fails, you forgot to change fake time.
    98  	So(cl.Snapshot.GetGerrit().GetInfo().GetStatus(), ShouldEqual, gerritpb.ChangeStatus_MERGED)
    99  	return cl
   100  }
   101  
   102  const cfgText1 = `
   103    config_groups {
   104      name: "g0"
   105      gerrit {
   106        url: "https://c-review.example.com"  # Must match gHost.
   107        projects {
   108          name: "repo/a"
   109          ref_regexp: "refs/heads/main"
   110        }
   111      }
   112    }
   113    config_groups {
   114      name: "g1"
   115      fallback: YES
   116      gerrit {
   117        url: "https://c-review.example.com"  # Must match gHost.
   118        projects {
   119          name: "repo/a"
   120          ref_regexp: "refs/heads/.+"
   121        }
   122      }
   123    }
   124  `
   125  
   126  func updateConfigToNoFallabck(ctx context.Context, ct *ctest) prjcfg.Meta {
   127  	cfgText2 := strings.ReplaceAll(cfgText1, "fallback: YES", "fallback: NO")
   128  	cfg2 := &cfgpb.Config{}
   129  	So(prototext.Unmarshal([]byte(cfgText2), cfg2), ShouldBeNil)
   130  	prjcfgtest.Update(ctx, ct.lProject, cfg2)
   131  	gobmaptest.Update(ctx, ct.lProject)
   132  	return prjcfgtest.MustExist(ctx, ct.lProject)
   133  }
   134  
   135  func updateConfigRenameG1toG11(ctx context.Context, ct *ctest) prjcfg.Meta {
   136  	cfgText2 := strings.ReplaceAll(cfgText1, `"g1"`, `"g11"`)
   137  	cfg2 := &cfgpb.Config{}
   138  	So(prototext.Unmarshal([]byte(cfgText2), cfg2), ShouldBeNil)
   139  	prjcfgtest.Update(ctx, ct.lProject, cfg2)
   140  	gobmaptest.Update(ctx, ct.lProject)
   141  	return prjcfgtest.MustExist(ctx, ct.lProject)
   142  }
   143  
   144  func TestUpdateConfig(t *testing.T) {
   145  	t.Parallel()
   146  
   147  	Convey("updateConfig works", t, func() {
   148  		ct := ctest{
   149  			lProject: "test",
   150  			gHost:    "c-review.example.com",
   151  			Test:     cvtesting.Test{},
   152  		}
   153  		ctx, cancel := ct.SetUp(t)
   154  		defer cancel()
   155  
   156  		cfg1 := &cfgpb.Config{}
   157  		So(prototext.Unmarshal([]byte(cfgText1), cfg1), ShouldBeNil)
   158  
   159  		prjcfgtest.Create(ctx, ct.lProject, cfg1)
   160  		meta := prjcfgtest.MustExist(ctx, ct.lProject)
   161  		gobmaptest.Update(ctx, ct.lProject)
   162  
   163  		clPoller := poller.New(ct.TQDispatcher, nil, nil, nil)
   164  		h := Handler{CLPoller: clPoller}
   165  
   166  		Convey("initializes newly started project", func() {
   167  			// Newly started project doesn't have any CLs, yet, regardless of what CL
   168  			// snapshots are stored in Datastore.
   169  			s0 := &State{PB: &prjpb.PState{LuciProject: ct.lProject}}
   170  			pb0 := backupPB(s0)
   171  			s1, sideEffect, err := h.UpdateConfig(ctx, s0)
   172  			So(err, ShouldBeNil)
   173  			So(s0.PB, ShouldResembleProto, pb0) // s0 must not change.
   174  			So(sideEffect, ShouldResemble, &UpdateIncompleteRunsConfig{
   175  				Hash:     meta.Hash(),
   176  				EVersion: meta.EVersion,
   177  				RunIDs:   nil,
   178  			})
   179  			So(s1.PB, ShouldResembleProto, &prjpb.PState{
   180  				LuciProject:         ct.lProject,
   181  				Status:              prjpb.Status_STARTED,
   182  				ConfigHash:          meta.Hash(),
   183  				ConfigGroupNames:    []string{"g0", "g1"},
   184  				Components:          nil,
   185  				Pcls:                nil,
   186  				RepartitionRequired: false,
   187  			})
   188  			So(s1.LogReasons, ShouldResemble, []prjpb.LogReason{prjpb.LogReason_CONFIG_CHANGED, prjpb.LogReason_STATUS_CHANGED})
   189  		})
   190  
   191  		// Add 3 CLs: 101 standalone and 202<-203 as a stack.
   192  		triggerTS := timestamppb.New(ct.Clock.Now())
   193  		ci101 := gf.CI(
   194  			101, gf.PS(1), gf.Ref("refs/heads/main"), gf.Project("repo/a"),
   195  			gf.CQ(+2, ct.Clock.Now(), gf.U("user-1")), gf.Updated(ct.Clock.Now()),
   196  		)
   197  		ci202 := gf.CI(
   198  			202, gf.PS(3), gf.Ref("refs/heads/other"), gf.Project("repo/a"), gf.AllRevs(),
   199  			gf.CQ(+1, ct.Clock.Now(), gf.U("user-2")), gf.Updated(ct.Clock.Now()),
   200  		)
   201  		ci203 := gf.CI(
   202  			203, gf.PS(3), gf.Ref("refs/heads/other"), gf.Project("repo/a"), gf.AllRevs(),
   203  			gf.CQ(+1, ct.Clock.Now(), gf.U("user-2")), gf.Updated(ct.Clock.Now()),
   204  		)
   205  		ct.GFake.CreateChange(&gf.Change{Host: ct.gHost, ACLs: gf.ACLPublic(), Info: ci101})
   206  		ct.GFake.CreateChange(&gf.Change{Host: ct.gHost, ACLs: gf.ACLPublic(), Info: ci202})
   207  		ct.GFake.CreateChange(&gf.Change{Host: ct.gHost, ACLs: gf.ACLPublic(), Info: ci203})
   208  		ct.GFake.SetDependsOn(ct.gHost, "203_3" /* child */, "202_2" /*parent*/)
   209  		cl101 := ct.runCLUpdater(ctx, 101)
   210  		cl202 := ct.runCLUpdater(ctx, 202)
   211  		cl203 := ct.runCLUpdater(ctx, 203)
   212  
   213  		s1 := &State{
   214  			PB: &prjpb.PState{
   215  				LuciProject:      ct.lProject,
   216  				Status:           prjpb.Status_STARTED,
   217  				ConfigHash:       meta.Hash(),
   218  				ConfigGroupNames: []string{"g0", "g1"},
   219  				Pcls: []*prjpb.PCL{
   220  					{
   221  						Clid:               int64(cl101.ID),
   222  						Eversion:           1,
   223  						ConfigGroupIndexes: []int32{0}, // g0
   224  						Status:             prjpb.PCL_OK,
   225  						Triggers: &run.Triggers{CqVoteTrigger: &run.Trigger{
   226  							Mode:            string(run.FullRun),
   227  							Time:            triggerTS,
   228  							Email:           gf.U("user-1").GetEmail(),
   229  							GerritAccountId: gf.U("user-1").GetAccountId(),
   230  						}},
   231  					},
   232  					{
   233  						Clid:               int64(cl202.ID),
   234  						Eversion:           1,
   235  						ConfigGroupIndexes: []int32{1}, // g1
   236  						Status:             prjpb.PCL_OK,
   237  						Triggers: &run.Triggers{CqVoteTrigger: &run.Trigger{
   238  							Mode:            string(run.DryRun),
   239  							Time:            triggerTS,
   240  							Email:           gf.U("user-2").GetEmail(),
   241  							GerritAccountId: gf.U("user-2").GetAccountId(),
   242  						}},
   243  					},
   244  					{
   245  						Clid:               int64(cl203.ID),
   246  						Eversion:           1,
   247  						ConfigGroupIndexes: []int32{1}, // g1
   248  						Status:             prjpb.PCL_OK,
   249  						Triggers: &run.Triggers{CqVoteTrigger: &run.Trigger{
   250  							Mode:            string(run.DryRun),
   251  							Time:            triggerTS,
   252  							Email:           gf.U("user-2").GetEmail(),
   253  							GerritAccountId: gf.U("user-2").GetAccountId(),
   254  						}},
   255  						Deps: []*changelist.Dep{{Clid: int64(cl202.ID), Kind: changelist.DepKind_HARD}},
   256  					},
   257  				},
   258  				Components: []*prjpb.Component{
   259  					{
   260  						Clids: []int64{int64(cl101.ID)},
   261  						Pruns: []*prjpb.PRun{
   262  							{
   263  								Id:    ct.lProject + "/" + "1111-v1-beef",
   264  								Clids: []int64{int64(cl101.ID)},
   265  							},
   266  						},
   267  					},
   268  					{
   269  						Clids: []int64{404},
   270  					},
   271  				},
   272  			},
   273  		}
   274  		pb1 := backupPB(s1)
   275  
   276  		Convey("noop update is quick", func() {
   277  			s2, sideEffect, err := h.UpdateConfig(ctx, s1)
   278  			So(err, ShouldBeNil)
   279  			So(s2, ShouldEqual, s1) // pointer comparison only.
   280  			So(sideEffect, ShouldBeNil)
   281  		})
   282  
   283  		Convey("existing project", func() {
   284  			Convey("updated without touching components", func() {
   285  				meta2 := updateConfigToNoFallabck(ctx, &ct)
   286  				s2, sideEffect, err := h.UpdateConfig(ctx, s1)
   287  				So(err, ShouldBeNil)
   288  				So(s1.PB, ShouldResembleProto, pb1) // s1 must not change.
   289  				So(sideEffect, ShouldResemble, &UpdateIncompleteRunsConfig{
   290  					Hash:     meta2.Hash(),
   291  					EVersion: meta2.EVersion,
   292  					RunIDs:   common.MakeRunIDs(ct.lProject + "/" + "1111-v1-beef"),
   293  				})
   294  				So(s2.PB, ShouldResembleProto, &prjpb.PState{
   295  					LuciProject:      ct.lProject,
   296  					Status:           prjpb.Status_STARTED,
   297  					ConfigHash:       meta2.Hash(), // changed
   298  					ConfigGroupNames: []string{"g0", "g1"},
   299  					Pcls: []*prjpb.PCL{
   300  						{
   301  							Clid:               int64(cl101.ID),
   302  							Eversion:           1,
   303  							ConfigGroupIndexes: []int32{0, 1}, // +g1, because g1 is no longer "fallback: YES"
   304  							Status:             prjpb.PCL_OK,
   305  							Triggers: &run.Triggers{CqVoteTrigger: &run.Trigger{
   306  								Mode:            string(run.FullRun),
   307  								Time:            triggerTS,
   308  								Email:           gf.U("user-1").GetEmail(),
   309  								GerritAccountId: gf.U("user-1").GetAccountId(),
   310  							}},
   311  						},
   312  						pb1.Pcls[1], // #202 didn't change.
   313  						pb1.Pcls[2], // #203 didn't change.
   314  					},
   315  					Components:          markForTriage(pb1.Components),
   316  					RepartitionRequired: true,
   317  				})
   318  				So(s2.LogReasons, ShouldResemble, []prjpb.LogReason{prjpb.LogReason_CONFIG_CHANGED})
   319  			})
   320  
   321  			Convey("If PCLs stay same, RepartitionRequired must be false", func() {
   322  				meta2 := updateConfigRenameG1toG11(ctx, &ct)
   323  				s2, sideEffect, err := h.UpdateConfig(ctx, s1)
   324  				So(err, ShouldBeNil)
   325  				So(s1.PB, ShouldResembleProto, pb1) // s1 must not change.
   326  				So(sideEffect, ShouldResemble, &UpdateIncompleteRunsConfig{
   327  					Hash:     meta2.Hash(),
   328  					EVersion: meta2.EVersion,
   329  					RunIDs:   common.MakeRunIDs(ct.lProject + "/" + "1111-v1-beef"),
   330  				})
   331  				So(s2.PB, ShouldResembleProto, &prjpb.PState{
   332  					LuciProject:         ct.lProject,
   333  					Status:              prjpb.Status_STARTED,
   334  					ConfigHash:          meta2.Hash(),
   335  					ConfigGroupNames:    []string{"g0", "g11"}, // g1 -> g11.
   336  					Pcls:                pb1.GetPcls(),
   337  					Components:          markForTriage(pb1.Components),
   338  					RepartitionRequired: false,
   339  				})
   340  			})
   341  		})
   342  
   343  		Convey("disabled project updated with long ago deleted CL", func() {
   344  			s1.PB.Status = prjpb.Status_STOPPED
   345  			for _, c := range s1.PB.GetComponents() {
   346  				c.Pruns = nil // disabled projects don't have incomplete runs.
   347  			}
   348  			pb1 = backupPB(s1)
   349  			changelist.Delete(ctx, cl101.ID)
   350  
   351  			meta2 := updateConfigToNoFallabck(ctx, &ct)
   352  			s2, sideEffect, err := h.UpdateConfig(ctx, s1)
   353  			So(err, ShouldBeNil)
   354  			So(s1.PB, ShouldResembleProto, pb1) // s1 must not change.
   355  			So(sideEffect, ShouldResemble, &UpdateIncompleteRunsConfig{
   356  				Hash:     meta2.Hash(),
   357  				EVersion: meta2.EVersion,
   358  				// No runs to notify.
   359  			})
   360  			So(s2.PB, ShouldResembleProto, &prjpb.PState{
   361  				LuciProject:      ct.lProject,
   362  				Status:           prjpb.Status_STARTED,
   363  				ConfigHash:       meta2.Hash(), // changed
   364  				ConfigGroupNames: []string{"g0", "g1"},
   365  				Pcls: []*prjpb.PCL{
   366  					{
   367  						Clid:     int64(cl101.ID),
   368  						Eversion: 1,
   369  						Status:   prjpb.PCL_DELETED,
   370  					},
   371  					pb1.Pcls[1], // #202 didn't change.
   372  					pb1.Pcls[2], // #203 didn't change.
   373  				},
   374  				Components:          markForTriage(pb1.Components),
   375  				RepartitionRequired: true,
   376  			})
   377  			So(s2.LogReasons, ShouldResemble, []prjpb.LogReason{prjpb.LogReason_CONFIG_CHANGED, prjpb.LogReason_STATUS_CHANGED})
   378  		})
   379  
   380  		Convey("disabled project waits for incomplete Runs", func() {
   381  			prjcfgtest.Disable(ctx, ct.lProject)
   382  			s2, sideEffect, err := h.UpdateConfig(ctx, s1)
   383  			So(err, ShouldBeNil)
   384  			pb := backupPB(s1)
   385  			pb.Status = prjpb.Status_STOPPING
   386  			So(s2.PB, ShouldResembleProto, pb)
   387  			So(sideEffect, ShouldResemble, &CancelIncompleteRuns{
   388  				RunIDs: common.MakeRunIDs(ct.lProject + "/" + "1111-v1-beef"),
   389  			})
   390  			So(s2.LogReasons, ShouldResemble, []prjpb.LogReason{prjpb.LogReason_STATUS_CHANGED})
   391  		})
   392  
   393  		Convey("disabled project stops iff there are no incomplete Runs", func() {
   394  			for _, c := range s1.PB.GetComponents() {
   395  				c.Pruns = nil
   396  			}
   397  			prjcfgtest.Disable(ctx, ct.lProject)
   398  			s2, sideEffect, err := h.UpdateConfig(ctx, s1)
   399  			So(err, ShouldBeNil)
   400  			So(sideEffect, ShouldBeNil)
   401  			pb := backupPB(s1)
   402  			pb.Status = prjpb.Status_STOPPED
   403  			So(s2.PB, ShouldResembleProto, pb)
   404  			So(prjpb.SortAndDedupeLogReasons(s2.LogReasons), ShouldResemble, []prjpb.LogReason{prjpb.LogReason_STATUS_CHANGED})
   405  		})
   406  
   407  		// The rest of the test coverage of UpdateConfig is achieved by testing code
   408  		// of makePCL.
   409  
   410  		Convey("makePCL with full snapshot works", func() {
   411  			var err error
   412  			s1.configGroups, err = meta.GetConfigGroups(ctx)
   413  			So(err, ShouldBeNil)
   414  			s1.cfgMatcher = cfgmatcher.LoadMatcherFromConfigGroups(ctx, s1.configGroups, &meta)
   415  
   416  			Convey("Status == OK", func() {
   417  				expected := &prjpb.PCL{
   418  					Clid:               int64(cl101.ID),
   419  					Eversion:           cl101.EVersion,
   420  					ConfigGroupIndexes: []int32{0}, // g0
   421  					Triggers: &run.Triggers{CqVoteTrigger: &run.Trigger{
   422  						Mode:            string(run.FullRun),
   423  						Time:            triggerTS,
   424  						Email:           gf.U("user-1").GetEmail(),
   425  						GerritAccountId: gf.U("user-1").GetAccountId(),
   426  					}},
   427  				}
   428  				Convey("CL snapshotted with current config", func() {
   429  					So(s1.makePCL(ctx, cl101), ShouldResembleProto, expected)
   430  				})
   431  				Convey("CL snapshotted with an older config", func() {
   432  					cl101.ApplicableConfig.GetProjects()[0].ConfigGroupIds = []string{"oldhash/g0"}
   433  					So(s1.makePCL(ctx, cl101), ShouldResembleProto, expected)
   434  				})
   435  				Convey("not triggered CL", func() {
   436  					delete(cl101.Snapshot.GetGerrit().GetInfo().GetLabels(), trigger.CQLabelName)
   437  					expected.Triggers = nil
   438  					So(s1.makePCL(ctx, cl101), ShouldResembleProto, expected)
   439  				})
   440  				Convey("abandoned CL is not triggered even if it has CQ vote", func() {
   441  					cl101.Snapshot.GetGerrit().GetInfo().Status = gerritpb.ChangeStatus_ABANDONED
   442  					expected.Triggers = nil
   443  					So(s1.makePCL(ctx, cl101), ShouldResembleProto, expected)
   444  				})
   445  				Convey("Submitted CL is also not triggered even if it has CQ vote", func() {
   446  					cl101.Snapshot.GetGerrit().GetInfo().Status = gerritpb.ChangeStatus_MERGED
   447  					expected.Triggers = nil
   448  					expected.Submitted = true
   449  					So(s1.makePCL(ctx, cl101), ShouldResembleProto, expected)
   450  				})
   451  			})
   452  
   453  			Convey("outdated snapshot requires waiting", func() {
   454  				cl101.Snapshot.Outdated = &changelist.Snapshot_Outdated{}
   455  				So(s1.makePCL(ctx, cl101), ShouldResembleProto, &prjpb.PCL{
   456  					Clid:     int64(cl101.ID),
   457  					Eversion: cl101.EVersion,
   458  					Status:   prjpb.PCL_UNKNOWN,
   459  					Outdated: &changelist.Snapshot_Outdated{},
   460  				})
   461  			})
   462  
   463  			Convey("snapshot from diff project requires waiting", func() {
   464  				cl101.Snapshot.LuciProject = "another"
   465  				So(s1.makePCL(ctx, cl101), ShouldResembleProto, &prjpb.PCL{
   466  					Clid:     int64(cl101.ID),
   467  					Eversion: cl101.EVersion,
   468  					Status:   prjpb.PCL_UNKNOWN,
   469  				})
   470  			})
   471  
   472  			Convey("CL from diff project is unwatched", func() {
   473  				s1.PB.LuciProject = "another"
   474  				So(s1.makePCL(ctx, cl101), ShouldResembleProto, &prjpb.PCL{
   475  					Clid:     int64(cl101.ID),
   476  					Eversion: cl101.EVersion,
   477  					Status:   prjpb.PCL_UNWATCHED,
   478  				})
   479  			})
   480  
   481  			Convey("CL watched by several projects is unwatched but with an error", func() {
   482  				cl101.ApplicableConfig.Projects = append(
   483  					cl101.ApplicableConfig.GetProjects(),
   484  					&changelist.ApplicableConfig_Project{
   485  						ConfigGroupIds: []string{"g"},
   486  						Name:           "another",
   487  					})
   488  				So(s1.makePCL(ctx, cl101), ShouldResembleProto, &prjpb.PCL{
   489  					Clid:               int64(cl101.ID),
   490  					Eversion:           cl101.EVersion,
   491  					Status:             prjpb.PCL_OK,
   492  					ConfigGroupIndexes: []int32{0}, // g0
   493  					Triggers: &run.Triggers{CqVoteTrigger: &run.Trigger{
   494  						Mode:            string(run.FullRun),
   495  						Time:            triggerTS,
   496  						Email:           gf.U("user-1").GetEmail(),
   497  						GerritAccountId: gf.U("user-1").GetAccountId(),
   498  					}},
   499  					PurgeReasons: []*prjpb.PurgeReason{{
   500  						ClError: &changelist.CLError{
   501  							Kind: &changelist.CLError_WatchedByManyProjects_{
   502  								WatchedByManyProjects: &changelist.CLError_WatchedByManyProjects{
   503  									Projects: []string{s1.PB.GetLuciProject(), "another"},
   504  								},
   505  							},
   506  						},
   507  						ApplyTo: &prjpb.PurgeReason_AllActiveTriggers{AllActiveTriggers: true},
   508  					}},
   509  					Errors: []*changelist.CLError{{Kind: &changelist.CLError_WatchedByManyProjects_{
   510  						WatchedByManyProjects: &changelist.CLError_WatchedByManyProjects{
   511  							Projects: []string{s1.PB.GetLuciProject(), "another"},
   512  						},
   513  					}}},
   514  				})
   515  			})
   516  
   517  			Convey("CL with Commit: false footer has an error", func() {
   518  				cl101.Snapshot.Metadata = []*changelist.StringPair{{Key: "Commit", Value: "false"}}
   519  				So(s1.makePCL(ctx, cl101).GetPurgeReasons(), ShouldResembleProto, []*prjpb.PurgeReason{
   520  					{
   521  						ClError: &changelist.CLError{
   522  							Kind: &changelist.CLError_CommitBlocked{CommitBlocked: true},
   523  						},
   524  						ApplyTo: &prjpb.PurgeReason_Triggers{Triggers: &run.Triggers{
   525  							CqVoteTrigger: &run.Trigger{
   526  								Mode:            string(run.FullRun),
   527  								Time:            triggerTS,
   528  								Email:           gf.U("user-1").GetEmail(),
   529  								GerritAccountId: gf.U("user-1").GetAccountId(),
   530  							},
   531  						}},
   532  					},
   533  				})
   534  			})
   535  
   536  			Convey("'Commit: false' footer works with different capitalization", func() {
   537  				cl101.Snapshot.Metadata = []*changelist.StringPair{{Key: "COMMIT", Value: "FALSE"}}
   538  				So(s1.makePCL(ctx, cl101).GetPurgeReasons(), ShouldResembleProto, []*prjpb.PurgeReason{{
   539  					ClError: &changelist.CLError{
   540  						Kind: &changelist.CLError_CommitBlocked{CommitBlocked: true},
   541  					},
   542  					ApplyTo: &prjpb.PurgeReason_Triggers{Triggers: &run.Triggers{
   543  						CqVoteTrigger: &run.Trigger{
   544  							Mode:            string(run.FullRun),
   545  							Time:            triggerTS,
   546  							Email:           gf.U("user-1").GetEmail(),
   547  							GerritAccountId: gf.U("user-1").GetAccountId(),
   548  						},
   549  					}},
   550  				}})
   551  			})
   552  
   553  			Convey("'Commit: false' has no effect for dry run CL", func() {
   554  				// cl202 is set up for dry run, unlike cl101.
   555  				cl202.Snapshot.Metadata = []*changelist.StringPair{{Key: "Commit", Value: "false"}}
   556  				So(s1.makePCL(ctx, cl202).GetPurgeReasons(), ShouldBeEmpty)
   557  			})
   558  		})
   559  	})
   560  }
   561  
   562  func TestOnCLsUpdated(t *testing.T) {
   563  	t.Parallel()
   564  
   565  	Convey("OnCLsUpdated works", t, func() {
   566  		ct := ctest{
   567  			lProject: "test",
   568  			gHost:    "c-review.example.com",
   569  		}
   570  		ctx, cancel := ct.SetUp(t)
   571  		defer cancel()
   572  
   573  		cfg1 := &cfgpb.Config{}
   574  		So(prototext.Unmarshal([]byte(cfgText1), cfg1), ShouldBeNil)
   575  
   576  		prjcfgtest.Create(ctx, ct.lProject, cfg1)
   577  		meta := prjcfgtest.MustExist(ctx, ct.lProject)
   578  		gobmaptest.Update(ctx, ct.lProject)
   579  
   580  		// Add 3 CLs: 101 standalone and 202<-203 as a stack.
   581  		triggerTS := timestamppb.New(ct.Clock.Now())
   582  		ci101 := gf.CI(
   583  			101, gf.PS(1), gf.Ref("refs/heads/main"), gf.Project("repo/a"),
   584  			gf.CQ(+2, ct.Clock.Now(), gf.U("user-1")), gf.Updated(ct.Clock.Now()),
   585  		)
   586  		ci202 := gf.CI(
   587  			202, gf.PS(3), gf.Ref("refs/heads/other"), gf.Project("repo/a"), gf.AllRevs(),
   588  			gf.CQ(+1, ct.Clock.Now(), gf.U("user-2")), gf.Updated(ct.Clock.Now()),
   589  		)
   590  		ci203 := gf.CI(
   591  			203, gf.PS(3), gf.Ref("refs/heads/other"), gf.Project("repo/a"), gf.AllRevs(),
   592  			gf.CQ(+1, ct.Clock.Now(), gf.U("user-2")), gf.Updated(ct.Clock.Now()),
   593  		)
   594  		ct.GFake.CreateChange(&gf.Change{Host: ct.gHost, ACLs: gf.ACLPublic(), Info: ci101})
   595  		ct.GFake.CreateChange(&gf.Change{Host: ct.gHost, ACLs: gf.ACLPublic(), Info: ci202})
   596  		ct.GFake.CreateChange(&gf.Change{Host: ct.gHost, ACLs: gf.ACLPublic(), Info: ci203})
   597  		ct.GFake.SetDependsOn(ct.gHost, "203_3" /* child */, "202_2" /*parent*/)
   598  		cl101 := ct.runCLUpdater(ctx, 101)
   599  		cl202 := ct.runCLUpdater(ctx, 202)
   600  		cl203 := ct.runCLUpdater(ctx, 203)
   601  
   602  		h := Handler{}
   603  		s0 := &State{PB: &prjpb.PState{
   604  			LuciProject:      ct.lProject,
   605  			Status:           prjpb.Status_STARTED,
   606  			ConfigHash:       meta.Hash(),
   607  			ConfigGroupNames: []string{"g0", "g1"},
   608  		}}
   609  		pb0 := backupPB(s0)
   610  
   611  		// NOTE: conversion of individual CL to PCL is in TestUpdateConfig.
   612  
   613  		Convey("One simple CL", func() {
   614  			s1, sideEffect, err := h.OnCLsUpdated(ctx, s0, map[int64]int64{
   615  				int64(cl101.ID): cl101.EVersion,
   616  			})
   617  			So(err, ShouldBeNil)
   618  			So(s0.PB, ShouldResembleProto, pb0)
   619  			So(sideEffect, ShouldBeNil)
   620  			So(s1.PB.Pcls, ShouldResembleProto, []*prjpb.PCL{
   621  				{
   622  					Clid:               int64(cl101.ID),
   623  					Eversion:           1,
   624  					ConfigGroupIndexes: []int32{0}, // g0
   625  					Status:             prjpb.PCL_OK,
   626  					Triggers: &run.Triggers{CqVoteTrigger: &run.Trigger{
   627  						Mode:            string(run.FullRun),
   628  						Time:            triggerTS,
   629  						Email:           gf.U("user-1").GetEmail(),
   630  						GerritAccountId: gf.U("user-1").GetAccountId(),
   631  					}},
   632  				},
   633  			})
   634  			So(s1.PB.RepartitionRequired, ShouldBeTrue)
   635  
   636  			Convey("Noop based on EVersion", func() {
   637  				s2, sideEffect, err := h.OnCLsUpdated(ctx, s1, map[int64]int64{
   638  					int64(cl101.ID): 1, // already known
   639  				})
   640  				So(err, ShouldBeNil)
   641  				So(sideEffect, ShouldBeNil)
   642  				So(s1.PB.GetPcls(), ShouldEqual, s2.PB.GetPcls()) // pointer comparison only.
   643  			})
   644  
   645  			Convey("Marks affected components for triage", func() {
   646  				cl101.EVersion++
   647  				So(datastore.Put(ctx, cl101), ShouldBeNil)
   648  				// Add 2 components, one of which references cl101.
   649  				s1.PB.Components = []*prjpb.Component{
   650  					{Clids: []int64{int64(cl101.ID)}},
   651  					{Clids: []int64{int64(cl101.ID + 111111)}},
   652  				}
   653  				pb := backupPB(s1)
   654  				s2, sideEffect, err := h.OnCLsUpdated(ctx, s1, map[int64]int64{
   655  					int64(cl101.ID): cl101.EVersion,
   656  				})
   657  				So(s1.PB, ShouldResembleProto, pb)
   658  				So(err, ShouldBeNil)
   659  				So(sideEffect, ShouldBeNil)
   660  				// The only expected changes are:
   661  				pb.Components[0].TriageRequired = true
   662  				pb.Pcls[0].Eversion = cl101.EVersion
   663  				So(s2.PB, ShouldResembleProto, pb)
   664  			})
   665  		})
   666  
   667  		Convey("One CL with a yet unknown dep", func() {
   668  			s1, sideEffect, err := h.OnCLsUpdated(ctx, s0, map[int64]int64{
   669  				int64(cl203.ID): 1,
   670  			})
   671  			So(err, ShouldBeNil)
   672  			So(s0.PB, ShouldResembleProto, pb0)
   673  			So(sideEffect, ShouldBeNil)
   674  			So(s1.PB, ShouldResembleProto, &prjpb.PState{
   675  				LuciProject:      ct.lProject,
   676  				Status:           prjpb.Status_STARTED,
   677  				ConfigHash:       meta.Hash(),
   678  				ConfigGroupNames: []string{"g0", "g1"},
   679  				Pcls: []*prjpb.PCL{
   680  					{
   681  						Clid:               int64(cl203.ID),
   682  						Eversion:           1,
   683  						ConfigGroupIndexes: []int32{1}, // g1
   684  						Status:             prjpb.PCL_OK,
   685  						Triggers: &run.Triggers{CqVoteTrigger: &run.Trigger{
   686  							Mode:            string(run.DryRun),
   687  							Time:            triggerTS,
   688  							Email:           gf.U("user-2").GetEmail(),
   689  							GerritAccountId: gf.U("user-2").GetAccountId(),
   690  						}},
   691  						Deps: []*changelist.Dep{{Clid: int64(cl202.ID), Kind: changelist.DepKind_HARD}},
   692  					},
   693  				},
   694  				RepartitionRequired: true,
   695  			})
   696  			Convey("unknown dep becomes known and marks a component for triage", func() {
   697  				// Add a component which has only 203.
   698  				s1.PB.Components = []*prjpb.Component{
   699  					{Clids: []int64{int64(cl203.ID)}},
   700  				}
   701  				pb := backupPB(s1)
   702  				s2, sideEffect, err := h.OnCLsUpdated(ctx, s1, map[int64]int64{
   703  					int64(cl202.ID): cl202.EVersion,
   704  				})
   705  				So(s1.PB, ShouldResembleProto, pb)
   706  				So(err, ShouldBeNil)
   707  				So(sideEffect, ShouldBeNil)
   708  				So(s2.PB.Components[0].TriageRequired, ShouldBeTrue)
   709  			})
   710  		})
   711  
   712  		Convey("PCLs must remain sorted", func() {
   713  			pcl101 := &prjpb.PCL{
   714  				Clid:               int64(cl101.ID),
   715  				Eversion:           1,
   716  				ConfigGroupIndexes: []int32{0}, // g0
   717  				Status:             prjpb.PCL_OK,
   718  				Triggers: &run.Triggers{CqVoteTrigger: &run.Trigger{
   719  					Mode: string(run.FullRun),
   720  					Time: triggerTS,
   721  				}},
   722  			}
   723  			s1 := &State{PB: &prjpb.PState{
   724  				LuciProject:      ct.lProject,
   725  				Status:           prjpb.Status_STARTED,
   726  				ConfigHash:       meta.Hash(),
   727  				ConfigGroupNames: []string{"g0", "g1"},
   728  				Pcls: sortPCLs([]*prjpb.PCL{
   729  					pcl101,
   730  					{
   731  						Clid:               int64(cl203.ID),
   732  						Eversion:           1,
   733  						ConfigGroupIndexes: []int32{1}, // g1
   734  						Status:             prjpb.PCL_OK,
   735  						Triggers: &run.Triggers{CqVoteTrigger: &run.Trigger{
   736  							Mode: string(run.DryRun),
   737  							Time: triggerTS,
   738  						}},
   739  						Deps: []*changelist.Dep{{Clid: int64(cl202.ID), Kind: changelist.DepKind_HARD}},
   740  					},
   741  				}),
   742  			}}
   743  			pb1 := backupPB(s1)
   744  			bumpEVersion(ctx, cl203, 3)
   745  			s2, sideEffect, err := h.OnCLsUpdated(ctx, s1, map[int64]int64{
   746  				404:             404,            // doesn't even exist
   747  				int64(cl202.ID): cl202.EVersion, // new
   748  				int64(cl101.ID): cl101.EVersion, // unchanged
   749  				int64(cl203.ID): 3,              // updated
   750  			})
   751  			So(err, ShouldBeNil)
   752  			So(s1.PB, ShouldResembleProto, pb1)
   753  			So(sideEffect, ShouldBeNil)
   754  			So(s2.PB, ShouldResembleProto, &prjpb.PState{
   755  				LuciProject:      ct.lProject,
   756  				Status:           prjpb.Status_STARTED,
   757  				ConfigHash:       meta.Hash(),
   758  				ConfigGroupNames: []string{"g0", "g1"},
   759  				Pcls: sortPCLs([]*prjpb.PCL{
   760  					{
   761  						Clid:     404,
   762  						Eversion: 0,
   763  						Status:   prjpb.PCL_DELETED,
   764  					},
   765  					pcl101, // 101 is unchanged
   766  					{ // new
   767  						Clid:               int64(cl202.ID),
   768  						Eversion:           1,
   769  						ConfigGroupIndexes: []int32{1}, // g1
   770  						Status:             prjpb.PCL_OK,
   771  						Triggers: &run.Triggers{CqVoteTrigger: &run.Trigger{
   772  							Mode:            string(run.DryRun),
   773  							Time:            triggerTS,
   774  							Email:           gf.U("user-2").GetEmail(),
   775  							GerritAccountId: gf.U("user-2").GetAccountId(),
   776  						}},
   777  					},
   778  					{ // updated
   779  						Clid:               int64(cl203.ID),
   780  						Eversion:           3,
   781  						ConfigGroupIndexes: []int32{1}, // g1
   782  						Status:             prjpb.PCL_OK,
   783  						Triggers: &run.Triggers{CqVoteTrigger: &run.Trigger{
   784  							Mode:            string(run.DryRun),
   785  							Time:            triggerTS,
   786  							Email:           gf.U("user-2").GetEmail(),
   787  							GerritAccountId: gf.U("user-2").GetAccountId(),
   788  						}},
   789  						Deps: []*changelist.Dep{{Clid: int64(cl202.ID), Kind: changelist.DepKind_HARD}},
   790  					},
   791  				}),
   792  				RepartitionRequired: true,
   793  			})
   794  		})
   795  
   796  		Convey("Invalid dep of some other CL must be marked as unwatched", func() {
   797  			// For example, if user made a typo in `CQ-Depend`, e.g.:
   798  			//    `CQ-Depend: chromiAm:123`
   799  			// then CL Updater will create an entity for such CL anyway,
   800  			// but eventually fill it with DependentMeta stating that this LUCI
   801  			// project has no access to it.
   802  			// Note that such typos may be malicious, so PM must treat such CLs as not
   803  			// found regardless of whether they actually exist in Gerrit.
   804  			cl404 := ct.runCLUpdater(ctx, 404)
   805  			So(cl404.Snapshot, ShouldBeNil)
   806  			So(cl404.ApplicableConfig, ShouldBeNil)
   807  			So(cl404.Access.GetByProject(), ShouldContainKey, ct.lProject)
   808  			s1, sideEffect, err := h.OnCLsUpdated(ctx, s0, map[int64]int64{
   809  				int64(cl404.ID): 1,
   810  			})
   811  			So(err, ShouldBeNil)
   812  			So(s0.PB, ShouldResembleProto, pb0)
   813  			So(sideEffect, ShouldBeNil)
   814  			pb1 := proto.Clone(pb0).(*prjpb.PState)
   815  			pb1.Pcls = append(pb0.Pcls, &prjpb.PCL{
   816  				Clid:               int64(cl404.ID),
   817  				Eversion:           1,
   818  				ConfigGroupIndexes: []int32{},
   819  				Status:             prjpb.PCL_UNWATCHED,
   820  			})
   821  			pb1.RepartitionRequired = true
   822  			So(s1.PB, ShouldResembleProto, pb1)
   823  		})
   824  
   825  		Convey("non-STARTED project ignores all CL events", func() {
   826  			s0.PB.Status = prjpb.Status_STOPPING
   827  			s1, sideEffect, err := h.OnCLsUpdated(ctx, s0, map[int64]int64{
   828  				int64(cl101.ID): cl101.EVersion,
   829  			})
   830  			So(err, ShouldBeNil)
   831  			So(sideEffect, ShouldBeNil)
   832  			So(s0, ShouldEqual, s1) // pointer comparison only.
   833  		})
   834  	})
   835  }
   836  
   837  func TestRunsCreatedAndFinished(t *testing.T) {
   838  	t.Parallel()
   839  
   840  	Convey("OnRunsCreated and OnRunsFinished works", t, func() {
   841  		ct := ctest{
   842  			lProject: "test",
   843  			gHost:    "c-review.example.com",
   844  		}
   845  		ctx, cancel := ct.SetUp(t)
   846  		defer cancel()
   847  
   848  		cfg1 := &cfgpb.Config{}
   849  		So(prototext.Unmarshal([]byte(cfgText1), cfg1), ShouldBeNil)
   850  		prjcfgtest.Create(ctx, ct.lProject, cfg1)
   851  		meta := prjcfgtest.MustExist(ctx, ct.lProject)
   852  
   853  		run1 := &run.Run{ID: common.RunID(ct.lProject + "/101-new"), CLs: common.CLIDs{101}}
   854  		run789 := &run.Run{ID: common.RunID(ct.lProject + "/789-efg"), CLs: common.CLIDs{709, 707, 708}}
   855  		run1finished := &run.Run{ID: common.RunID(ct.lProject + "/101-done"), CLs: common.CLIDs{101}, Status: run.Status_FAILED}
   856  		So(datastore.Put(ctx, run1finished, run1, run789), ShouldBeNil)
   857  		So(run.IsEnded(run1finished.Status), ShouldBeTrue)
   858  
   859  		h := Handler{}
   860  		s1 := &State{PB: &prjpb.PState{
   861  			LuciProject:      ct.lProject,
   862  			Status:           prjpb.Status_STARTED,
   863  			ConfigHash:       meta.Hash(),
   864  			ConfigGroupNames: []string{"g0", "g1"},
   865  			// For OnRunsFinished / OnRunsCreated PCLs don't matter, so omit them from
   866  			// the test for brevity, even though valid State must have PCLs covering
   867  			// all components.
   868  			Pcls: nil,
   869  			Components: []*prjpb.Component{
   870  				{
   871  					Clids: []int64{101},
   872  					Pruns: []*prjpb.PRun{{Id: ct.lProject + "/101-aaa", Clids: []int64{101}}},
   873  				},
   874  				{
   875  					Clids: []int64{202, 203, 204},
   876  				},
   877  			},
   878  			CreatedPruns: []*prjpb.PRun{
   879  				{Id: ct.lProject + "/789-efg", Clids: []int64{707, 708, 709}},
   880  			},
   881  		}}
   882  		var err error
   883  		s1.configGroups, err = meta.GetConfigGroups(ctx)
   884  		So(err, ShouldBeNil)
   885  		pb1 := backupPB(s1)
   886  
   887  		Convey("Noops", func() {
   888  			finished := make(map[common.RunID]run.Status)
   889  			Convey("OnRunsFinished on not tracked Run", func() {
   890  				finished[run1finished.ID] = run.Status_SUCCEEDED
   891  				s2, sideEffect, err := h.OnRunsFinished(ctx, s1, finished)
   892  				So(err, ShouldBeNil)
   893  				So(sideEffect, ShouldBeNil)
   894  				// although s2 is cloned, it must be exact same as s1.
   895  				So(s2.PB, ShouldResembleProto, pb1)
   896  			})
   897  			Convey("OnRunsCreated on already finished run", func() {
   898  				s2, sideEffect, err := h.OnRunsCreated(ctx, s1, common.RunIDs{run1finished.ID})
   899  				So(err, ShouldBeNil)
   900  				So(sideEffect, ShouldBeNil)
   901  				// although s2 is cloned, it must be exact same as s1.
   902  				So(s2.PB, ShouldResembleProto, pb1)
   903  			})
   904  			Convey("OnRunsCreated on already tracked Run", func() {
   905  				s2, sideEffect, err := h.OnRunsCreated(ctx, s1, common.MakeRunIDs(ct.lProject+"/101-aaa"))
   906  				So(err, ShouldBeNil)
   907  				So(sideEffect, ShouldBeNil)
   908  				So(s2, ShouldEqual, s1)
   909  				So(pb1, ShouldResembleProto, s1.PB)
   910  			})
   911  			Convey("OnRunsCreated on somehow already deleted run", func() {
   912  				s2, sideEffect, err := h.OnRunsCreated(ctx, s1, common.MakeRunIDs(ct.lProject+"/404-nnn"))
   913  				So(err, ShouldBeNil)
   914  				So(sideEffect, ShouldBeNil)
   915  				// although s2 is cloned, it must be exact same as s1.
   916  				So(s2.PB, ShouldResembleProto, pb1)
   917  			})
   918  		})
   919  
   920  		Convey("OnRunsCreated", func() {
   921  			Convey("when PM is started", func() {
   922  				runX := &run.Run{ // Run involving all of CLs and more.
   923  					ID: common.RunID(ct.lProject + "/000-xxx"),
   924  					// The order doesn't have to and is intentionally not sorted here.
   925  					CLs: common.CLIDs{404, 101, 202, 204, 203},
   926  				}
   927  				run2 := &run.Run{ID: common.RunID(ct.lProject + "/202-bbb"), CLs: common.CLIDs{202}}
   928  				run3 := &run.Run{ID: common.RunID(ct.lProject + "/203-ccc"), CLs: common.CLIDs{203}}
   929  				run23 := &run.Run{ID: common.RunID(ct.lProject + "/232-bcb"), CLs: common.CLIDs{203, 202}}
   930  				run234 := &run.Run{ID: common.RunID(ct.lProject + "/234-bcd"), CLs: common.CLIDs{203, 204, 202}}
   931  				So(datastore.Put(ctx, run2, run3, run23, run234, runX), ShouldBeNil)
   932  
   933  				s2, sideEffect, err := h.OnRunsCreated(ctx, s1, common.RunIDs{
   934  					run2.ID, run3.ID, run23.ID, run234.ID, runX.ID,
   935  					// non-existing Run shouldn't derail others.
   936  					common.RunID(ct.lProject + "/404-nnn"),
   937  				})
   938  				So(err, ShouldBeNil)
   939  				So(pb1, ShouldResembleProto, s1.PB)
   940  				So(sideEffect, ShouldBeNil)
   941  				So(s2.PB, ShouldResembleProto, &prjpb.PState{
   942  					LuciProject:      ct.lProject,
   943  					Status:           prjpb.Status_STARTED,
   944  					ConfigHash:       meta.Hash(),
   945  					ConfigGroupNames: []string{"g0", "g1"},
   946  					Components: []*prjpb.Component{
   947  						s1.PB.GetComponents()[0], // 101 is unchanged
   948  						{
   949  							Clids: []int64{202, 203, 204},
   950  							Pruns: []*prjpb.PRun{
   951  								// Runs & CLs must be sorted by their respective IDs.
   952  								{Id: string(run2.ID), Clids: []int64{202}},
   953  								{Id: string(run3.ID), Clids: []int64{203}},
   954  								{Id: string(run23.ID), Clids: []int64{202, 203}},
   955  								{Id: string(run234.ID), Clids: []int64{202, 203, 204}},
   956  							},
   957  							TriageRequired: true,
   958  						},
   959  					},
   960  					RepartitionRequired: true,
   961  					CreatedPruns: []*prjpb.PRun{
   962  						{Id: string(runX.ID), Clids: []int64{101, 202, 203, 204, 404}},
   963  						{Id: ct.lProject + "/789-efg", Clids: []int64{707, 708, 709}}, // unchanged
   964  					},
   965  				})
   966  			})
   967  			Convey("when PM is stopping", func() {
   968  				s1.PB.Status = prjpb.Status_STOPPING
   969  				pb1 := backupPB(s1)
   970  				Convey("cancels incomplete Runs", func() {
   971  					s2, sideEffect, err := h.OnRunsCreated(ctx, s1, common.RunIDs{run1.ID, run1finished.ID})
   972  					So(err, ShouldBeNil)
   973  					So(pb1, ShouldResembleProto, s1.PB)
   974  					So(sideEffect, ShouldResemble, &CancelIncompleteRuns{
   975  						RunIDs: common.RunIDs{run1.ID},
   976  					})
   977  					So(s2, ShouldEqual, s1)
   978  				})
   979  			})
   980  		})
   981  
   982  		Convey("OnRunsFinished", func() {
   983  			s1.PB.Status = prjpb.Status_STOPPING
   984  			pb1 := backupPB(s1)
   985  			finished := make(map[common.RunID]run.Status)
   986  
   987  			Convey("deletes from Components", func() {
   988  				pb1 := backupPB(s1)
   989  				runIDs := common.MakeRunIDs(ct.lProject + "/101-aaa")
   990  				finished[runIDs[0]] = run.Status_CANCELLED
   991  				s2, sideEffect, err := h.OnRunsFinished(ctx, s1, finished)
   992  				So(err, ShouldBeNil)
   993  				So(pb1, ShouldResembleProto, s1.PB)
   994  				So(sideEffect, ShouldBeNil)
   995  				So(s2.PB, ShouldResembleProto, &prjpb.PState{
   996  					LuciProject:      ct.lProject,
   997  					Status:           prjpb.Status_STOPPING,
   998  					ConfigHash:       meta.Hash(),
   999  					ConfigGroupNames: []string{"g0", "g1"},
  1000  					Components: []*prjpb.Component{
  1001  						{
  1002  							Clids:          []int64{101},
  1003  							Pruns:          nil, // removed
  1004  							TriageRequired: true,
  1005  						},
  1006  						s1.PB.GetComponents()[1], // unchanged
  1007  					},
  1008  					CreatedPruns:        s1.PB.GetCreatedPruns(), // unchanged
  1009  					RepartitionRequired: true,
  1010  				})
  1011  			})
  1012  
  1013  			Convey("deletes from CreatedPruns", func() {
  1014  				runIDs := common.MakeRunIDs(ct.lProject + "/789-efg")
  1015  				finished[runIDs[0]] = run.Status_CANCELLED
  1016  				s2, sideEffect, err := h.OnRunsFinished(ctx, s1, finished)
  1017  				So(err, ShouldBeNil)
  1018  				So(pb1, ShouldResembleProto, s1.PB)
  1019  				So(sideEffect, ShouldBeNil)
  1020  				So(s2.PB, ShouldResembleProto, &prjpb.PState{
  1021  					LuciProject:      ct.lProject,
  1022  					Status:           prjpb.Status_STOPPING,
  1023  					ConfigHash:       meta.Hash(),
  1024  					ConfigGroupNames: []string{"g0", "g1"},
  1025  					Components:       s1.PB.Components, // unchanged
  1026  					CreatedPruns:     nil,              // removed
  1027  				})
  1028  			})
  1029  
  1030  			Convey("stops PM iff all runs finished", func() {
  1031  				runIDs := common.MakeRunIDs(
  1032  					ct.lProject+"/101-aaa",
  1033  					ct.lProject+"/789-efg",
  1034  				)
  1035  				finished[runIDs[0]] = run.Status_SUCCEEDED
  1036  				finished[runIDs[1]] = run.Status_SUCCEEDED
  1037  				s2, sideEffect, err := h.OnRunsFinished(ctx, s1, finished)
  1038  				So(err, ShouldBeNil)
  1039  				So(pb1, ShouldResembleProto, s1.PB)
  1040  				So(sideEffect, ShouldBeNil)
  1041  				So(s2.PB, ShouldResembleProto, &prjpb.PState{
  1042  					LuciProject:      ct.lProject,
  1043  					Status:           prjpb.Status_STOPPED,
  1044  					ConfigHash:       meta.Hash(),
  1045  					ConfigGroupNames: []string{"g0", "g1"},
  1046  					Pcls:             s1.PB.GetPcls(),
  1047  					Components: []*prjpb.Component{
  1048  						{Clids: []int64{101}, TriageRequired: true},
  1049  						s1.PB.GetComponents()[1], // unchanged.
  1050  					},
  1051  					CreatedPruns:        nil, // removed
  1052  					RepartitionRequired: true,
  1053  				})
  1054  				So(s2.LogReasons, ShouldResemble, []prjpb.LogReason{prjpb.LogReason_STATUS_CHANGED})
  1055  			})
  1056  
  1057  			Convey("purges triggers of the child CLs", func() {
  1058  				// Emulate an MCE run.
  1059  				now := testclock.TestRecentTimeUTC
  1060  				mceRun := &prjpb.PRun{
  1061  					Id:    "202-deef",
  1062  					Mode:  string(run.FullRun),
  1063  					Clids: []int64{202},
  1064  				}
  1065  				s1.PB.Components = []*prjpb.Component{
  1066  					{
  1067  						Clids: []int64{202, 203, 204},
  1068  						Pruns: []*prjpb.PRun{mceRun},
  1069  					},
  1070  				}
  1071  				s1.PB.Pcls = []*prjpb.PCL{
  1072  					{
  1073  						Clid:               int64(202),
  1074  						Eversion:           1,
  1075  						Status:             prjpb.PCL_OK,
  1076  						ConfigGroupIndexes: []int32{0},
  1077  					},
  1078  					{
  1079  						Clid:     int64(203),
  1080  						Eversion: 1,
  1081  						Status:   prjpb.PCL_OK,
  1082  						Deps: []*changelist.Dep{
  1083  							{Clid: 202, Kind: changelist.DepKind_HARD},
  1084  						},
  1085  						ConfigGroupIndexes: []int32{0},
  1086  						Triggers: &run.Triggers{CqVoteTrigger: &run.Trigger{
  1087  							Mode: string(run.FullRun),
  1088  							Time: timestamppb.New(now.Add(-10 * time.Minute)),
  1089  						}},
  1090  					},
  1091  					{
  1092  						Clid:     int64(204),
  1093  						Eversion: 1,
  1094  						Status:   prjpb.PCL_OK,
  1095  						Deps: []*changelist.Dep{
  1096  							{Clid: 202, Kind: changelist.DepKind_HARD},
  1097  							{Clid: 203, Kind: changelist.DepKind_HARD},
  1098  						},
  1099  						ConfigGroupIndexes: []int32{0},
  1100  						Triggers: &run.Triggers{CqVoteTrigger: &run.Trigger{
  1101  							Mode: string(run.FullRun),
  1102  							Time: timestamppb.New(now.Add(-10 * time.Minute)),
  1103  						}},
  1104  					},
  1105  				}
  1106  				checkPurgeTask := func(task *prjpb.PurgeCLTask, clToPurge, depRunCL int64) {
  1107  					So(task.PurgingCl.Clid, ShouldEqual, clToPurge)
  1108  					So(task.PurgeReasons, ShouldResembleProto, []*prjpb.PurgeReason{
  1109  						{
  1110  							ClError: &changelist.CLError{
  1111  								Kind: &changelist.CLError_DepRunFailed{
  1112  									DepRunFailed: depRunCL,
  1113  								},
  1114  							},
  1115  							ApplyTo: &prjpb.PurgeReason_Triggers{
  1116  								Triggers: &run.Triggers{
  1117  									CqVoteTrigger: &run.Trigger{
  1118  										Mode: string(run.FullRun),
  1119  										Time: s1.PB.GetPCL(clToPurge).GetTriggers().GetCqVoteTrigger().GetTime(),
  1120  									},
  1121  								},
  1122  							},
  1123  						},
  1124  					})
  1125  				}
  1126  
  1127  				Convey("if they have CQ votes", func() {
  1128  					finished[common.RunID(mceRun.Id)] = run.Status_FAILED
  1129  					_, sideEffect, err := h.OnRunsFinished(ctx, s1, finished)
  1130  					So(err, ShouldBeNil)
  1131  					So(sideEffect, ShouldNotBeNil)
  1132  					tasks := sideEffect.(*TriggerPurgeCLTasks)
  1133  
  1134  					// Should purge the vote on both 203 and 204.
  1135  					So(tasks.payloads, ShouldHaveLength, 2)
  1136  					checkPurgeTask(tasks.payloads[0], 203, 202)
  1137  					checkPurgeTask(tasks.payloads[1], 204, 202)
  1138  
  1139  					// Only the top CL should be configured to send an email.
  1140  					So(tasks.payloads[0].PurgingCl.Notification, ShouldResembleProto, clpurger.NoNotification)
  1141  					So(tasks.payloads[1].PurgingCl.Notification, ShouldBeNil)
  1142  				})
  1143  				Convey("unless the finished Run is failed", func() {
  1144  					finished[common.RunID(mceRun.Id)] = run.Status_SUCCEEDED
  1145  					_, sideEffect, err := h.OnRunsFinished(ctx, s1, finished)
  1146  					So(err, ShouldBeNil)
  1147  					So(sideEffect, ShouldBeNil)
  1148  				})
  1149  				Convey("unless they have ongoing Runs", func() {
  1150  					finished[common.RunID(mceRun.Id)] = run.Status_FAILED
  1151  					// create a run for the middle CL, not the top CL.
  1152  					middleRun := &prjpb.PRun{
  1153  						Id:    "203-deef",
  1154  						Mode:  string(run.FullRun),
  1155  						Clids: []int64{203},
  1156  					}
  1157  					s1.PB.Components[0].Pruns = append(s1.PB.Components[0].Pruns, middleRun)
  1158  					_, sideEffect, err := h.OnRunsFinished(ctx, s1, finished)
  1159  					So(err, ShouldBeNil)
  1160  					So(sideEffect, ShouldNotBeNil)
  1161  					tasks := sideEffect.(*TriggerPurgeCLTasks)
  1162  
  1163  					// Should purge the vote on 204 only
  1164  					So(tasks.payloads, ShouldHaveLength, 1)
  1165  					checkPurgeTask(tasks.payloads[0], 204, 202)
  1166  					So(tasks.payloads[0].PurgingCl.Notification, ShouldBeNil)
  1167  				})
  1168  			})
  1169  		})
  1170  	})
  1171  }
  1172  
  1173  func TestOnPurgesCompleted(t *testing.T) {
  1174  	t.Parallel()
  1175  
  1176  	Convey("OnPurgesCompleted works", t, func() {
  1177  		ct := ctest{
  1178  			lProject: "test",
  1179  			gHost:    "c-review.example.com",
  1180  			Test:     cvtesting.Test{},
  1181  		}
  1182  		ctx, cancel := ct.SetUp(t)
  1183  		defer cancel()
  1184  
  1185  		cfg1 := &cfgpb.Config{}
  1186  		So(prototext.Unmarshal([]byte(cfgText1), cfg1), ShouldBeNil)
  1187  
  1188  		prjcfgtest.Create(ctx, ct.lProject, cfg1)
  1189  		meta := prjcfgtest.MustExist(ctx, ct.lProject)
  1190  		gobmaptest.Update(ctx, ct.lProject)
  1191  
  1192  		h := Handler{}
  1193  		triggerTS := timestamppb.New(ct.Clock.Now())
  1194  		ci101 := gf.CI(
  1195  			101, gf.PS(1), gf.Ref("refs/heads/main"), gf.Project("repo/a"),
  1196  			gf.CQ(+2, ct.Clock.Now(), gf.U("user-1")), gf.Updated(ct.Clock.Now()),
  1197  		)
  1198  		ci202 := gf.CI(
  1199  			202, gf.PS(3), gf.Ref("refs/heads/other"), gf.Project("repo/a"), gf.AllRevs(),
  1200  			gf.CQ(+1, ct.Clock.Now(), gf.U("user-2")), gf.Updated(ct.Clock.Now()),
  1201  		)
  1202  		ci203 := gf.CI(
  1203  			203, gf.PS(3), gf.Ref("refs/heads/other"), gf.Project("repo/a"), gf.AllRevs(),
  1204  			gf.CQ(+1, ct.Clock.Now(), gf.U("user-2")), gf.Updated(ct.Clock.Now()),
  1205  		)
  1206  		ci209 := gf.CI(
  1207  			209, gf.PS(3), gf.Ref("refs/heads/other"), gf.Project("repo/a"), gf.AllRevs(),
  1208  			gf.CQ(+1, ct.Clock.Now(), gf.U("user-2")), gf.Updated(ct.Clock.Now()),
  1209  		)
  1210  
  1211  		ct.GFake.CreateChange(&gf.Change{Host: ct.gHost, ACLs: gf.ACLPublic(), Info: ci101})
  1212  		ct.GFake.CreateChange(&gf.Change{Host: ct.gHost, ACLs: gf.ACLPublic(), Info: ci202})
  1213  		ct.GFake.CreateChange(&gf.Change{Host: ct.gHost, ACLs: gf.ACLPublic(), Info: ci203})
  1214  		ct.GFake.CreateChange(&gf.Change{Host: ct.gHost, ACLs: gf.ACLPublic(), Info: ci209})
  1215  		cl101 := ct.runCLUpdater(ctx, 101)
  1216  		cl202 := ct.runCLUpdater(ctx, 202)
  1217  		cl203 := ct.runCLUpdater(ctx, 203)
  1218  		cl209 := ct.runCLUpdater(ctx, 209)
  1219  
  1220  		Convey("Empty", func() {
  1221  			s1 := &State{PB: &prjpb.PState{}}
  1222  			s2, sideEffect, evsToConsume, err := h.OnPurgesCompleted(ctx, s1, nil)
  1223  			So(err, ShouldBeNil)
  1224  			So(sideEffect, ShouldBeNil)
  1225  			So(s1, ShouldEqual, s2)
  1226  			So(evsToConsume, ShouldHaveLength, 0)
  1227  		})
  1228  
  1229  		Convey("With existing", func() {
  1230  			now := testclock.TestRecentTimeUTC
  1231  			ctx, _ := testclock.UseTime(ctx, now)
  1232  			s1 := &State{PB: &prjpb.PState{
  1233  				LuciProject: ct.lProject,
  1234  				PurgingCls: []*prjpb.PurgingCL{
  1235  					// expires later
  1236  					{
  1237  						Clid:        int64(cl101.ID),
  1238  						OperationId: "1",
  1239  						Deadline:    timestamppb.New(now.Add(time.Minute)),
  1240  						ApplyTo:     &prjpb.PurgingCL_AllActiveTriggers{AllActiveTriggers: true},
  1241  					},
  1242  					// expires now, but due to grace period it'll stay here.
  1243  					{
  1244  						Clid:        int64(cl202.ID),
  1245  						OperationId: "2",
  1246  						Deadline:    timestamppb.New(now),
  1247  						ApplyTo:     &prjpb.PurgingCL_AllActiveTriggers{AllActiveTriggers: true},
  1248  					},
  1249  					// definitely expired.
  1250  					{
  1251  						Clid:        int64(cl203.ID),
  1252  						OperationId: "3",
  1253  						Deadline:    timestamppb.New(now.Add(-time.Hour)),
  1254  						ApplyTo:     &prjpb.PurgingCL_AllActiveTriggers{AllActiveTriggers: true},
  1255  					},
  1256  				},
  1257  				// Components require PCLs, but in this test it doesn't matter.
  1258  				Components: []*prjpb.Component{
  1259  					{Clids: []int64{int64(cl209.ID)}}, // for unconfusing indexes below.
  1260  					{Clids: []int64{int64(cl101.ID)}},
  1261  					{Clids: []int64{int64(cl202.ID)}, TriageRequired: true},
  1262  					{Clids: []int64{int64(cl203.ID)}},
  1263  				},
  1264  				// PCLs are supposed to be sorted.
  1265  				Pcls: []*prjpb.PCL{
  1266  					{
  1267  						Clid:               int64(cl101.ID),
  1268  						Eversion:           cl101.EVersion,
  1269  						Status:             prjpb.PCL_OK,
  1270  						ConfigGroupIndexes: []int32{0},
  1271  						Triggers: &run.Triggers{CqVoteTrigger: &run.Trigger{
  1272  							Mode:            string(run.FullRun),
  1273  							Time:            triggerTS,
  1274  							Email:           gf.U("user-1").GetEmail(),
  1275  							GerritAccountId: gf.U("user-1").GetAccountId(),
  1276  						}},
  1277  					},
  1278  					{
  1279  						Clid:               int64(cl202.ID),
  1280  						Eversion:           1,
  1281  						Status:             prjpb.PCL_OK,
  1282  						ConfigGroupIndexes: []int32{1},
  1283  						Triggers: &run.Triggers{CqVoteTrigger: &run.Trigger{
  1284  							Mode:            string(run.DryRun),
  1285  							Time:            triggerTS,
  1286  							Email:           gf.U("user-2").GetEmail(),
  1287  							GerritAccountId: gf.U("user-2").GetAccountId(),
  1288  						}},
  1289  					},
  1290  					{
  1291  						Clid:               int64(cl203.ID),
  1292  						Eversion:           1,
  1293  						Status:             prjpb.PCL_OK,
  1294  						ConfigGroupIndexes: []int32{1},
  1295  						Triggers: &run.Triggers{CqVoteTrigger: &run.Trigger{
  1296  							Mode:            string(run.DryRun),
  1297  							Time:            triggerTS,
  1298  							Email:           gf.U("user-2").GetEmail(),
  1299  							GerritAccountId: gf.U("user-2").GetAccountId(),
  1300  						}},
  1301  					},
  1302  					{
  1303  						Clid:               int64(cl209.ID),
  1304  						Eversion:           1,
  1305  						Status:             prjpb.PCL_OK,
  1306  						ConfigGroupIndexes: []int32{1},
  1307  						Triggers: &run.Triggers{CqVoteTrigger: &run.Trigger{
  1308  							Mode:            string(run.DryRun),
  1309  							Time:            triggerTS,
  1310  							Email:           gf.U("user-2").GetEmail(),
  1311  							GerritAccountId: gf.U("user-2").GetAccountId(),
  1312  						}},
  1313  					},
  1314  				},
  1315  				ConfigGroupNames: []string{"g0", "g1"},
  1316  				ConfigHash:       meta.Hash(),
  1317  			}}
  1318  			pb := backupPB(s1)
  1319  
  1320  			Convey("Expires and removed", func() {
  1321  				s2, sideEffect, evsToConsume, err := h.OnPurgesCompleted(ctx, s1, []*prjpb.PurgeCompleted{{OperationId: "1", Clid: int64(cl101.ID)}})
  1322  				So(err, ShouldBeNil)
  1323  				So(sideEffect, ShouldBeNil)
  1324  				So(s1.PB, ShouldResembleProto, pb)
  1325  				So(evsToConsume, ShouldEqual, []int{0})
  1326  
  1327  				pb.PurgingCls = []*prjpb.PurgingCL{
  1328  					{
  1329  						Clid: int64(cl202.ID), OperationId: "2", Deadline: timestamppb.New(now),
  1330  						ApplyTo: &prjpb.PurgingCL_AllActiveTriggers{AllActiveTriggers: true},
  1331  					},
  1332  				}
  1333  				pb.Components = []*prjpb.Component{
  1334  					pb.Components[0],
  1335  					{Clids: []int64{int64(cl101.ID)}, TriageRequired: true},
  1336  					pb.Components[2],
  1337  					{Clids: []int64{int64(cl203.ID)}, TriageRequired: true},
  1338  				}
  1339  				So(s2.PB, ShouldResembleProto, pb)
  1340  			})
  1341  
  1342  			Convey("All removed", func() {
  1343  				s2, sideEffect, evsToConsume, err := h.OnPurgesCompleted(ctx, s1, []*prjpb.PurgeCompleted{
  1344  					{OperationId: "3", Clid: int64(cl203.ID)},
  1345  					{OperationId: "1", Clid: int64(cl101.ID)},
  1346  					{OperationId: "5", Clid: int64(cl209.ID)},
  1347  					{OperationId: "2", Clid: int64(cl202.ID)},
  1348  				})
  1349  				So(err, ShouldBeNil)
  1350  				So(sideEffect, ShouldBeNil)
  1351  				So(s1.PB, ShouldResembleProto, pb)
  1352  				So(evsToConsume, ShouldEqual, []int{0, 1, 2, 3})
  1353  				pb.PurgingCls = nil
  1354  				pb.Components = []*prjpb.Component{
  1355  					pb.Components[0],
  1356  					{Clids: []int64{int64(cl101.ID)}, TriageRequired: true},
  1357  					pb.Components[2], // it was waiting for triage already
  1358  					{Clids: []int64{int64(cl203.ID)}, TriageRequired: true},
  1359  				}
  1360  				So(s2.PB, ShouldResembleProto, pb)
  1361  			})
  1362  
  1363  			Convey("Outdated", func() {
  1364  				cl101.Snapshot.Outdated = &changelist.Snapshot_Outdated{}
  1365  				So(datastore.Put(ctx, cl101), ShouldBeNil)
  1366  				s2, sideEffect, evsToConsume, err := h.OnPurgesCompleted(ctx, s1, []*prjpb.PurgeCompleted{
  1367  					{OperationId: "1", Clid: int64(cl101.ID)},
  1368  				})
  1369  				So(err, ShouldBeNil)
  1370  				So(sideEffect, ShouldBeNil)
  1371  				So(s2.PB.GetPurgingCL(int64(cl101.ID)), ShouldNotBeNil)
  1372  				So(evsToConsume, ShouldBeNil)
  1373  			})
  1374  
  1375  			Convey("Doesn't modify components if they are due re-repartition anyway", func() {
  1376  				s1.PB.RepartitionRequired = true
  1377  				pb := backupPB(s1)
  1378  				s2, sideEffect, evsToConsume, err := h.OnPurgesCompleted(ctx, s1, []*prjpb.PurgeCompleted{
  1379  					{OperationId: "1", Clid: int64(cl101.ID)},
  1380  					{OperationId: "2", Clid: int64(cl202.ID)},
  1381  					{OperationId: "3", Clid: int64(cl203.ID)},
  1382  				})
  1383  				So(err, ShouldBeNil)
  1384  				So(sideEffect, ShouldBeNil)
  1385  				So(s1.PB, ShouldResembleProto, pb)
  1386  
  1387  				pb.PurgingCls = nil
  1388  				So(s2.PB, ShouldResembleProto, pb)
  1389  				So(evsToConsume, ShouldEqual, []int{0, 1, 2})
  1390  			})
  1391  		})
  1392  	})
  1393  }
  1394  
  1395  func TestOnTriggeringCLDepsCompleted(t *testing.T) {
  1396  	t.Parallel()
  1397  
  1398  	Convey("OnTriggeringCLDepsCompleted", t, func() {
  1399  		ct := ctest{
  1400  			lProject: "test",
  1401  			gHost:    "c-review.example.com",
  1402  			Test:     cvtesting.Test{},
  1403  		}
  1404  		ctx, cancel := ct.SetUp(t)
  1405  		defer cancel()
  1406  
  1407  		cfg1 := &cfgpb.Config{}
  1408  		So(prototext.Unmarshal([]byte(cfgText1), cfg1), ShouldBeNil)
  1409  
  1410  		prjcfgtest.Create(ctx, ct.lProject, cfg1)
  1411  		meta := prjcfgtest.MustExist(ctx, ct.lProject)
  1412  		gobmaptest.Update(ctx, ct.lProject)
  1413  
  1414  		clPoller := poller.New(ct.TQDispatcher, nil, nil, nil)
  1415  		h := Handler{CLPoller: clPoller}
  1416  
  1417  		// mock CLs
  1418  		now := ct.Clock.Now()
  1419  		ci101 := gf.CI(
  1420  			101, gf.PS(1), gf.Ref("refs/heads/main"), gf.Project("repo/a"),
  1421  			gf.CQ(+2, now, gf.U("user-1")), gf.Updated(now),
  1422  		)
  1423  		ci102 := gf.CI(
  1424  			102, gf.PS(3), gf.Ref("refs/heads/main"), gf.Project("repo/a"), gf.AllRevs(),
  1425  			gf.CQ(+1, now, gf.U("user-1")), gf.Updated(now),
  1426  		)
  1427  		ci103 := gf.CI(
  1428  			103, gf.PS(3), gf.Ref("refs/heads/main"), gf.Project("repo/a"), gf.AllRevs(),
  1429  			gf.CQ(+1, now, gf.U("user-1")), gf.Updated(now),
  1430  		)
  1431  		ct.GFake.CreateChange(&gf.Change{Host: ct.gHost, ACLs: gf.ACLPublic(), Info: ci101})
  1432  		ct.GFake.CreateChange(&gf.Change{Host: ct.gHost, ACLs: gf.ACLPublic(), Info: ci102})
  1433  		ct.GFake.CreateChange(&gf.Change{Host: ct.gHost, ACLs: gf.ACLPublic(), Info: ci103})
  1434  		ct.GFake.SetDependsOn(ct.gHost, "103_3", "102_3", "101_1")
  1435  		cl101 := ct.runCLUpdater(ctx, 101)
  1436  		cl102 := ct.runCLUpdater(ctx, 102)
  1437  		cl103 := ct.runCLUpdater(ctx, 103)
  1438  
  1439  		s1 := &State{PB: &prjpb.PState{
  1440  			LuciProject: ct.lProject,
  1441  			Pcls: []*prjpb.PCL{
  1442  				{
  1443  					Clid:     int64(cl101.ID),
  1444  					Eversion: 1,
  1445  					Status:   prjpb.PCL_OK,
  1446  					Triggers: &run.Triggers{CqVoteTrigger: &run.Trigger{
  1447  						Mode: string(run.FullRun),
  1448  						Time: timestamppb.New(now.Add(-10 * time.Minute)),
  1449  					}},
  1450  					ConfigGroupIndexes: []int32{0},
  1451  				},
  1452  				{
  1453  					Clid:     int64(cl102.ID),
  1454  					Eversion: 1,
  1455  					Status:   prjpb.PCL_OK,
  1456  					Triggers: &run.Triggers{CqVoteTrigger: &run.Trigger{
  1457  						Mode: string(run.FullRun),
  1458  						Time: timestamppb.New(now.Add(-10 * time.Minute)),
  1459  					}},
  1460  					ConfigGroupIndexes: []int32{0},
  1461  				},
  1462  				{
  1463  					Clid:               int64(cl103.ID),
  1464  					Eversion:           1,
  1465  					Status:             prjpb.PCL_OK,
  1466  					ConfigGroupIndexes: []int32{0},
  1467  				},
  1468  			},
  1469  			// Components require PCLs, but in this test it doesn't matter.
  1470  			Components: []*prjpb.Component{
  1471  				{Clids: []int64{int64(cl101.ID)}}, // for unconfusing indexes below.
  1472  				{Clids: []int64{int64(cl102.ID)}},
  1473  				{Clids: []int64{int64(cl103.ID)}},
  1474  			},
  1475  			ConfigGroupNames: []string{"g0"},
  1476  			ConfigHash:       meta.Hash(),
  1477  		}}
  1478  		addTriggeringCLDeps := func(s *State, deadline time.Time, origin *changelist.CL, deps ...*changelist.CL) *prjpb.TriggeringCLDeps {
  1479  			var clids []int64
  1480  			for _, dep := range deps {
  1481  				clids = append(clids, int64(dep.ID))
  1482  			}
  1483  			op := &prjpb.TriggeringCLDeps{
  1484  				OriginClid:  int64(origin.ID),
  1485  				DepClids:    clids,
  1486  				OperationId: fmt.Sprintf("op-%d", origin.ID),
  1487  				Deadline:    timestamppb.New(deadline),
  1488  				Trigger:     &run.Trigger{Mode: string(run.FullRun)},
  1489  			}
  1490  			s.PB.TriggeringClDeps, _ = s.PB.COWTriggeringCLDeps(nil, []*prjpb.TriggeringCLDeps{op})
  1491  			return op
  1492  		}
  1493  		TriggeringCLDeps := func(s *State, cl *changelist.CL) *prjpb.TriggeringCLDeps {
  1494  			return s.PB.GetTriggeringCLDeps(int64(cl.ID))
  1495  		}
  1496  
  1497  		Convey("effectively noop if empty", func() {
  1498  			s2, se, evIndexes, err := h.OnTriggeringCLDepsCompleted(ctx, s1, nil)
  1499  			So(err, ShouldBeNil)
  1500  			So(se, ShouldBeNil)
  1501  			So(evIndexes, ShouldBeNil)
  1502  			// OnTriggeringCLDepsCompleted() always makes a shallow clone for
  1503  			// PCL evaluations. There shouldn't be any changes other than that.
  1504  			s2.alreadyCloned = true
  1505  			So(s1, ShouldEqual, s2)
  1506  		})
  1507  		Convey("removes an expired op", func() {
  1508  			addTriggeringCLDeps(s1, now.Add(-time.Hour), cl103, cl101, cl102)
  1509  			s2, se, evIndexes, err := h.OnTriggeringCLDepsCompleted(ctx, s1, nil)
  1510  			So(err, ShouldBeNil)
  1511  			So(TriggeringCLDeps(s2, cl103), ShouldBeNil)
  1512  			So(se, ShouldBeNil)
  1513  			So(evIndexes, ShouldBeNil)
  1514  		})
  1515  		Convey("with succeeeded ops", func() {
  1516  			op := addTriggeringCLDeps(s1, now.Add(time.Minute), cl103, cl101, cl102)
  1517  			events := []*prjpb.TriggeringCLDepsCompleted{
  1518  				{
  1519  					OperationId: op.GetOperationId(),
  1520  					Origin:      int64(cl103.ID),
  1521  					Succeeded:   []int64{int64(cl101.ID), int64(cl102.ID)},
  1522  				},
  1523  			}
  1524  			Convey("removes the op", func() {
  1525  				s2, se, evIndexes, err := h.OnTriggeringCLDepsCompleted(ctx, s1, events)
  1526  				So(err, ShouldBeNil)
  1527  				So(TriggeringCLDeps(s2, cl103), ShouldBeNil)
  1528  				So(se, ShouldBeNil)
  1529  				So(evIndexes, ShouldEqual, []int{0})
  1530  			})
  1531  			Convey("keeps the op, if any dep PCL is outdated", func() {
  1532  				cl102.Snapshot.Outdated = &changelist.Snapshot_Outdated{}
  1533  				So(datastore.Put(ctx, cl102 /* dep */), ShouldBeNil)
  1534  				s2, se, evIndexes, err := h.OnTriggeringCLDepsCompleted(ctx, s1, events)
  1535  				So(err, ShouldBeNil)
  1536  				So(TriggeringCLDeps(s2, cl103 /* origin */), ShouldNotBeNil)
  1537  				So(se, ShouldBeNil)
  1538  				So(evIndexes, ShouldBeNil)
  1539  			})
  1540  		})
  1541  		Convey("enqueues PurgeCLTasks for the origin and dep CLs, if an Op has fails", func() {
  1542  			op := addTriggeringCLDeps(s1, now.Add(time.Minute), cl103, cl101, cl102)
  1543  			events := []*prjpb.TriggeringCLDepsCompleted{
  1544  				{
  1545  					OperationId: op.GetOperationId(),
  1546  					Origin:      int64(cl103.ID),
  1547  					Succeeded:   []int64{int64(cl101.ID)},
  1548  					Failed: []*changelist.CLError_TriggerDeps{{
  1549  						PermissionDenied: []*changelist.CLError_TriggerDeps_PermissionDenied{{
  1550  							Clid:  int64(cl102.ID),
  1551  							Email: "foo@example.org",
  1552  						}},
  1553  					}},
  1554  				},
  1555  			}
  1556  			s2, se, evIndexes, err := h.OnTriggeringCLDepsCompleted(ctx, s1, events)
  1557  			So(err, ShouldBeNil)
  1558  			So(evIndexes, ShouldEqual, []int{0})
  1559  
  1560  			// remove the TriggeringCLDeps, but schedule PurgingCL(s).
  1561  			So(TriggeringCLDeps(s2, cl103), ShouldBeNil)
  1562  			So(s2.PB.GetPurgingCL(int64(cl101.ID)), ShouldNotBeNil)
  1563  			So(s2.PB.GetPurgingCL(int64(cl102.ID)), ShouldBeNil)
  1564  
  1565  			// verify the PurginCL payload.
  1566  			tasks := se.(*TriggerPurgeCLTasks)
  1567  			dl := timestamppb.New(now.Add(maxPurgingCLDuration))
  1568  			opID := dl.AsTime().Unix()
  1569  			So(tasks.payloads, ShouldHaveLength, 2)
  1570  			tr := &run.Triggers{
  1571  				CqVoteTrigger: &run.Trigger{
  1572  					Mode: string(run.FullRun),
  1573  				},
  1574  			}
  1575  			oriPT, depPT := tasks.payloads[0], tasks.payloads[1]
  1576  			if oriPT.GetPurgingCl().GetClid() != int64(cl103.ID) {
  1577  				oriPT, depPT = depPT, oriPT
  1578  			}
  1579  			expectedPurgeReasons := []*prjpb.PurgeReason{
  1580  				{
  1581  					ClError: &changelist.CLError{
  1582  						Kind: &changelist.CLError_TriggerDeps_{
  1583  							TriggerDeps: &changelist.CLError_TriggerDeps{
  1584  								PermissionDenied: []*changelist.CLError_TriggerDeps_PermissionDenied{{
  1585  									Clid:  int64(cl102.ID),
  1586  									Email: "foo@example.org",
  1587  								}},
  1588  							},
  1589  						},
  1590  					},
  1591  					ApplyTo: &prjpb.PurgeReason_Triggers{Triggers: tr},
  1592  				},
  1593  			}
  1594  			So(oriPT.GetPurgeReasons(), ShouldResembleProto, expectedPurgeReasons)
  1595  			So(oriPT.GetPurgingCl(), ShouldResembleProto, &prjpb.PurgingCL{
  1596  				Clid:     int64(cl103.ID),
  1597  				Deadline: dl,
  1598  				// Must be nil for the default notifications.
  1599  				Notification: nil,
  1600  				OperationId:  fmt.Sprintf("%d-%d", opID, cl103.ID),
  1601  				ApplyTo:      &prjpb.PurgingCL_Triggers{Triggers: tr},
  1602  			})
  1603  			So(depPT.GetPurgeReasons(), ShouldResembleProto, expectedPurgeReasons)
  1604  			So(depPT.GetPurgingCl(), ShouldResembleProto, &prjpb.PurgingCL{
  1605  				Clid:         int64(cl101.ID),
  1606  				Deadline:     dl,
  1607  				Notification: clpurger.NoNotification,
  1608  				OperationId:  fmt.Sprintf("%d-%d", opID, cl101.ID),
  1609  				ApplyTo:      &prjpb.PurgingCL_Triggers{Triggers: tr},
  1610  			})
  1611  		})
  1612  	})
  1613  }
  1614  
  1615  // backupPB returns a deep copy of State.PB for future assertion that State
  1616  // wasn't modified.
  1617  func backupPB(s *State) *prjpb.PState {
  1618  	ret := &prjpb.PState{}
  1619  	proto.Merge(ret, s.PB)
  1620  	return ret
  1621  }
  1622  
  1623  func bumpEVersion(ctx context.Context, cl *changelist.CL, desired int64) {
  1624  	if cl.EVersion >= desired {
  1625  		panic(fmt.Errorf("can't go %d to %d", cl.EVersion, desired))
  1626  	}
  1627  	cl.EVersion = desired
  1628  	So(datastore.Put(ctx, cl), ShouldBeNil)
  1629  }
  1630  
  1631  func defaultPCL(cl *changelist.CL) *prjpb.PCL {
  1632  	p := &prjpb.PCL{
  1633  		Clid:               int64(cl.ID),
  1634  		Eversion:           cl.EVersion,
  1635  		ConfigGroupIndexes: []int32{0},
  1636  		Status:             prjpb.PCL_OK,
  1637  		Deps:               cl.Snapshot.GetDeps(),
  1638  	}
  1639  	ci := cl.Snapshot.GetGerrit().GetInfo()
  1640  	if ci != nil {
  1641  		p.Triggers = trigger.Find(&trigger.FindInput{ChangeInfo: ci, ConfigGroup: &cfgpb.ConfigGroup{}})
  1642  	}
  1643  	return p
  1644  }
  1645  
  1646  func i64s(vs ...any) []int64 {
  1647  	res := make([]int64, len(vs))
  1648  	for i, v := range vs {
  1649  		switch x := v.(type) {
  1650  		case int64:
  1651  			res[i] = x
  1652  		case common.CLID:
  1653  			res[i] = int64(x)
  1654  		case int:
  1655  			res[i] = int64(x)
  1656  		default:
  1657  			panic(fmt.Errorf("unknown type: %T %v", v, v))
  1658  		}
  1659  	}
  1660  	return res
  1661  }
  1662  
  1663  func i64sorted(vs ...any) []int64 {
  1664  	res := i64s(vs...)
  1665  	sort.Slice(res, func(i, j int) bool { return res[i] < res[j] })
  1666  	return res
  1667  }
  1668  
  1669  func sortPCLs(vs []*prjpb.PCL) []*prjpb.PCL {
  1670  	sort.Slice(vs, func(i, j int) bool { return vs[i].GetClid() < vs[j].GetClid() })
  1671  	return vs
  1672  }
  1673  
  1674  func mkClidsSet(cls map[int]*changelist.CL, ids ...int) common.CLIDsSet {
  1675  	res := make(common.CLIDsSet, len(ids))
  1676  	for _, id := range ids {
  1677  		res[cls[id].ID] = struct{}{}
  1678  	}
  1679  	return res
  1680  }
  1681  
  1682  func sortByFirstCL(cs []*prjpb.Component) []*prjpb.Component {
  1683  	sort.Slice(cs, func(i, j int) bool { return cs[i].GetClids()[0] < cs[j].GetClids()[0] })
  1684  	return cs
  1685  }