go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/gerrit/trigger/reset_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 trigger
    16  
    17  import (
    18  	"fmt"
    19  	"strconv"
    20  	"testing"
    21  	"time"
    22  
    23  	"google.golang.org/grpc/codes"
    24  	"google.golang.org/grpc/status"
    25  	"google.golang.org/protobuf/proto"
    26  	"google.golang.org/protobuf/types/known/timestamppb"
    27  
    28  	"go.chromium.org/luci/common/clock"
    29  	gerritpb "go.chromium.org/luci/common/proto/gerrit"
    30  	"go.chromium.org/luci/common/retry/transient"
    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/configs/prjcfg"
    36  	"go.chromium.org/luci/cv/internal/cvtesting"
    37  	"go.chromium.org/luci/cv/internal/gerrit"
    38  	gf "go.chromium.org/luci/cv/internal/gerrit/gerritfake"
    39  	"go.chromium.org/luci/cv/internal/run"
    40  	"go.chromium.org/luci/cv/internal/usertext"
    41  
    42  	// Convey package also exports `Reset` method that conflicts with the function
    43  	// in this package. This is a workaround since we can not dot import the convey
    44  	// package.
    45  	//
    46  	// WARNING: importing the below packages as "." will make So() and
    47  	// assertions, like ShouldErrLike, silently ignore the assertion results.
    48  	// e.g., So(1, ShouldBeNil) will pass.
    49  	c "github.com/smartystreets/goconvey/convey"
    50  	la "go.chromium.org/luci/common/testing/assertions"
    51  )
    52  
    53  func TestReset(t *testing.T) {
    54  	t.Parallel()
    55  
    56  	c.Convey("Reset", t, func() {
    57  		ct := cvtesting.Test{}
    58  		ctx, cancel := ct.SetUp(t)
    59  		defer cancel()
    60  
    61  		const ownerID int64 = 5
    62  		const reviewerID int64 = 50
    63  		const triggererID int64 = 100
    64  		triggerer := gf.U(fmt.Sprintf("user-%d", triggererID))
    65  		const gHost = "x-review.example.com"
    66  		const lProject = "lProject"
    67  		const changeNum = 10001
    68  		triggerTime := ct.Clock.Now().Add(-2 * time.Minute)
    69  		ci := gf.CI(
    70  			10001, gf.PS(2),
    71  			gf.Owner(fmt.Sprintf("user-%d", ownerID)),
    72  			gf.CQ(2, triggerTime, triggerer),
    73  			gf.Updated(clock.Now(ctx).Add(-1*time.Minute)),
    74  			gf.Reviewer(gf.U(fmt.Sprintf("user-%d", reviewerID))),
    75  		)
    76  		triggers := Find(&FindInput{ChangeInfo: ci, ConfigGroup: &cfgpb.ConfigGroup{}})
    77  		c.So(triggers.GetCqVoteTrigger(), la.ShouldResembleProto, &run.Trigger{
    78  			Time:            timestamppb.New(triggerTime),
    79  			Mode:            string(run.FullRun),
    80  			Email:           fmt.Sprintf("user-%d@example.com", triggererID),
    81  			GerritAccountId: triggererID,
    82  		})
    83  		c.So(triggers.GetCqVoteTrigger().GerritAccountId, c.ShouldEqual, 100)
    84  		cl := &changelist.CL{
    85  			ID:         99999,
    86  			ExternalID: changelist.MustGobID(gHost, int64(changeNum)),
    87  			EVersion:   2,
    88  			Snapshot: &changelist.Snapshot{
    89  				ExternalUpdateTime:    timestamppb.New(clock.Now(ctx).Add(-3 * time.Minute)),
    90  				LuciProject:           lProject,
    91  				Patchset:              2,
    92  				MinEquivalentPatchset: 1,
    93  				Kind: &changelist.Snapshot_Gerrit{
    94  					Gerrit: &changelist.Gerrit{
    95  						Host: gHost,
    96  						Info: proto.Clone(ci).(*gerritpb.ChangeInfo),
    97  					},
    98  				},
    99  			},
   100  			TriggerNewPatchsetRunAfterPS: 1,
   101  		}
   102  		c.So(datastore.Put(ctx, cl), c.ShouldBeNil)
   103  		ct.GFake.CreateChange(&gf.Change{
   104  			Host: gHost,
   105  			Info: proto.Clone(ci).(*gerritpb.ChangeInfo),
   106  			ACLs: gf.ACLGrant(gf.OpRead, codes.PermissionDenied, lProject).Or(
   107  				gf.ACLGrant(gf.OpReview, codes.PermissionDenied, lProject),
   108  				gf.ACLGrant(gf.OpAlterVotesOfOthers, codes.PermissionDenied, lProject),
   109  			),
   110  		})
   111  
   112  		input := ResetInput{
   113  			CL: cl,
   114  			ConfigGroups: []*prjcfg.ConfigGroup{{
   115  				Content: &cfgpb.ConfigGroup{
   116  					Verifiers: &cfgpb.Verifiers{Tryjob: &cfgpb.Verifiers_Tryjob{
   117  						Builders: []*cfgpb.Verifiers_Tryjob_Builder{{
   118  							Name:          "new patchset upload builder",
   119  							ModeAllowlist: []string{string(run.NewPatchsetRun)},
   120  						}},
   121  					}},
   122  				},
   123  			}},
   124  			LUCIProject:       lProject,
   125  			Message:           "Full Run has passed",
   126  			Requester:         "test",
   127  			Notify:            gerrit.Whoms{gerrit.Whom_OWNER, gerrit.Whom_CQ_VOTERS},
   128  			AddToAttentionSet: gerrit.Whoms{gerrit.Whom_REVIEWERS},
   129  			AttentionReason:   usertext.StoppedRun,
   130  			LeaseDuration:     30 * time.Second,
   131  			CLMutator:         changelist.NewMutator(ct.TQDispatcher, nil, nil, nil),
   132  			GFactory:          ct.GFactory(),
   133  		}
   134  		findTriggers := func(resultCI *gerritpb.ChangeInfo) *run.Triggers {
   135  			for _, cg := range input.ConfigGroups {
   136  				if ts := Find(&FindInput{ChangeInfo: resultCI, ConfigGroup: cg.Content}); ts != nil {
   137  					return ts
   138  				}
   139  			}
   140  			return nil
   141  		}
   142  		ts := findTriggers(ci)
   143  		cqTrigger := ts.GetCqVoteTrigger()
   144  		nprTrigger := ts.GetNewPatchsetRunTrigger()
   145  		input.Triggers = &run.Triggers{}
   146  
   147  		c.Convey("Fails PreCondition if CL is AccessDenied from code review site", func() {
   148  			c.Convey("For CQ-Label trigger", func() {
   149  				input.Triggers.CqVoteTrigger = cqTrigger
   150  			})
   151  			c.Convey("For NewPatchset trigger", func() {
   152  				input.Triggers.NewPatchsetRunTrigger = nprTrigger
   153  			})
   154  			noAccessTime := ct.Clock.Now().UTC().Add(1 * time.Minute)
   155  			cl.Access = &changelist.Access{
   156  				ByProject: map[string]*changelist.Access_Project{
   157  					lProject: {
   158  						UpdateTime:   timestamppb.New(noAccessTime),
   159  						NoAccessTime: timestamppb.New(noAccessTime),
   160  					},
   161  				},
   162  			}
   163  			err := Reset(ctx, input)
   164  			c.So(err, la.ShouldErrLike, "failed to reset trigger because CV lost access to this CL")
   165  			c.So(ErrResetPreconditionFailedTag.In(err), c.ShouldBeTrue)
   166  		})
   167  		isOutdated := func(cl *changelist.CL) bool {
   168  			e := &changelist.CL{ID: cl.ID}
   169  			c.So(datastore.Get(ctx, e), c.ShouldBeNil)
   170  			return e.Snapshot.GetOutdated() != nil
   171  		}
   172  
   173  		c.Convey("Fails PreCondition if CL has newer PS in datastore", func() {
   174  			input.Triggers.CqVoteTrigger = cqTrigger
   175  			newCI := proto.Clone(ci).(*gerritpb.ChangeInfo)
   176  			gf.PS(3)(newCI)
   177  			newCL := &changelist.CL{
   178  				ID:         99999,
   179  				ExternalID: changelist.MustGobID(gHost, int64(changeNum)),
   180  				EVersion:   3,
   181  				Snapshot: &changelist.Snapshot{
   182  					ExternalUpdateTime:    timestamppb.New(clock.Now(ctx).Add(-1 * time.Minute)),
   183  					LuciProject:           lProject,
   184  					Patchset:              3,
   185  					MinEquivalentPatchset: 3,
   186  					Kind: &changelist.Snapshot_Gerrit{
   187  						Gerrit: &changelist.Gerrit{
   188  							Host: gHost,
   189  							Info: newCI,
   190  						},
   191  					},
   192  				},
   193  			}
   194  			c.So(datastore.Put(ctx, newCL), c.ShouldBeNil)
   195  			err := Reset(ctx, input)
   196  			c.So(err, la.ShouldErrLike, "failed to reset because ps 2 is not current for cl(99999)")
   197  			c.So(ErrResetPreconditionFailedTag.In(err), c.ShouldBeTrue)
   198  			c.So(isOutdated(cl), c.ShouldBeFalse)
   199  		})
   200  
   201  		c.Convey("Fails PreCondition if CL has newer PS in Gerrit", func() {
   202  			input.Triggers.CqVoteTrigger = cqTrigger
   203  			ct.GFake.MutateChange(gHost, int(ci.GetNumber()), func(c *gf.Change) {
   204  				gf.PS(3)(c.Info)
   205  			})
   206  			err := Reset(ctx, input)
   207  			c.So(err, la.ShouldErrLike, "failed to reset because ps 2 is not current for x-review.example.com/10001")
   208  			c.So(ErrResetPreconditionFailedTag.In(err), c.ShouldBeTrue)
   209  			c.So(isOutdated(cl), c.ShouldBeFalse)
   210  		})
   211  
   212  		c.Convey("Cancelling CQ Vote fails if receive stale data from gerrit", func() {
   213  			input.Triggers.CqVoteTrigger = cqTrigger
   214  			ct.GFake.MutateChange(gHost, int(ci.GetNumber()), func(c *gf.Change) {
   215  				gf.Updated(clock.Now(ctx).Add(-3 * time.Minute))(c.Info)
   216  			})
   217  			err := Reset(ctx, input)
   218  			c.So(err, la.ShouldErrLike, gerrit.ErrStaleData)
   219  			c.So(transient.Tag.In(err), c.ShouldBeTrue)
   220  			c.So(isOutdated(cl), c.ShouldBeFalse)
   221  		})
   222  
   223  		c.Convey("Cancelling NewPatchsetRun", func() {
   224  			input.Triggers.NewPatchsetRunTrigger = nprTrigger
   225  			input.Message = "reset new patchset run trigger"
   226  
   227  			cl := &changelist.CL{ID: input.CL.ID}
   228  			c.So(datastore.Get(ctx, cl), c.ShouldBeNil)
   229  			originalValue := cl.TriggerNewPatchsetRunAfterPS
   230  
   231  			c.So(Reset(ctx, input), c.ShouldBeNil)
   232  			// cancelling a new patchset run doesn't mark the snapshot
   233  			// as outdated.
   234  			c.So(isOutdated(cl), c.ShouldBeFalse)
   235  
   236  			cl = &changelist.CL{ID: input.CL.ID}
   237  			c.So(datastore.Get(ctx, cl), c.ShouldBeNil)
   238  			c.So(cl.TriggerNewPatchsetRunAfterPS, c.ShouldNotEqual, originalValue)
   239  			c.So(cl.TriggerNewPatchsetRunAfterPS, c.ShouldEqual, input.CL.Snapshot.Patchset)
   240  			change := ct.GFake.GetChange(input.CL.Snapshot.GetGerrit().GetHost(), int(input.CL.Snapshot.GetGerrit().GetInfo().GetNumber()))
   241  			c.So(change.Info.GetMessages()[len(change.Info.GetMessages())-1].Message, c.ShouldEqual, input.Message)
   242  		})
   243  
   244  		splitSetReviewRequests := func() (onBehalf, asSelf []*gerritpb.SetReviewRequest) {
   245  			for _, req := range ct.GFake.Requests() {
   246  				switch r, ok := req.(*gerritpb.SetReviewRequest); {
   247  				case !ok:
   248  				case r.GetOnBehalfOf() != 0:
   249  					// OnBehalfOf removes votes and must happen before any asSelf.
   250  					c.So(asSelf, c.ShouldBeEmpty)
   251  					onBehalf = append(onBehalf, r)
   252  				default:
   253  					asSelf = append(asSelf, r)
   254  				}
   255  			}
   256  			return onBehalf, asSelf
   257  		}
   258  		c.Convey("cancel new patchset run and cq vote run at the same time", func() {
   259  			input.Triggers.CqVoteTrigger = cqTrigger
   260  			input.Triggers.NewPatchsetRunTrigger = nprTrigger
   261  			cl := &changelist.CL{ID: input.CL.ID}
   262  			c.So(datastore.Get(ctx, cl), c.ShouldBeNil)
   263  			originalValue := cl.TriggerNewPatchsetRunAfterPS
   264  
   265  			err := Reset(ctx, input)
   266  			c.So(err, c.ShouldBeNil)
   267  			c.So(isOutdated(cl), c.ShouldBeTrue) // snapshot is outdated
   268  			resultCI := ct.GFake.GetChange(gHost, int(ci.GetNumber()))
   269  			c.So(resultCI.Info.GetMessages(), c.ShouldHaveLength, 1)
   270  			c.So(resultCI.Info.GetMessages()[0].GetMessage(), c.ShouldEqual, input.Message)
   271  			c.So(gf.NonZeroVotes(resultCI.Info, CQLabelName), c.ShouldBeEmpty)
   272  
   273  			onBehalfs, asSelf := splitSetReviewRequests()
   274  			c.So(onBehalfs, c.ShouldHaveLength, 1)
   275  			c.So(onBehalfs[0].GetOnBehalfOf(), c.ShouldEqual, triggererID)
   276  			c.So(onBehalfs[0].GetNotifyDetails(), c.ShouldBeNil)
   277  			c.So(asSelf, c.ShouldHaveLength, 1)
   278  			c.So(asSelf[0].GetNotify(), c.ShouldEqual, gerritpb.Notify_NOTIFY_NONE)
   279  			c.So(asSelf[0].GetNotifyDetails(), la.ShouldResembleProto,
   280  				&gerritpb.NotifyDetails{
   281  					Recipients: []*gerritpb.NotifyDetails_Recipient{
   282  						{
   283  							RecipientType: gerritpb.NotifyDetails_RECIPIENT_TYPE_TO,
   284  							Info: &gerritpb.NotifyDetails_Info{
   285  								Accounts: []int64{ownerID, triggererID},
   286  							},
   287  						},
   288  					},
   289  				})
   290  			c.So(asSelf[0].GetAddToAttentionSet(), la.ShouldResembleProto, []*gerritpb.AttentionSetInput{
   291  				{User: strconv.FormatInt(reviewerID, 10), Reason: "ps#2: " + usertext.StoppedRun},
   292  			})
   293  			cl = &changelist.CL{ID: input.CL.ID}
   294  			c.So(datastore.Get(ctx, cl), c.ShouldBeNil)
   295  			c.So(cl.TriggerNewPatchsetRunAfterPS, c.ShouldNotEqual, originalValue)
   296  			c.So(cl.TriggerNewPatchsetRunAfterPS, c.ShouldEqual, input.CL.Snapshot.Patchset)
   297  		})
   298  		c.Convey("Remove single vote", func() {
   299  			input.Triggers.CqVoteTrigger = cqTrigger
   300  			err := Reset(ctx, input)
   301  			c.So(err, c.ShouldBeNil)
   302  			c.So(isOutdated(cl), c.ShouldBeTrue) // snapshot is outdated
   303  			resultCI := ct.GFake.GetChange(gHost, int(ci.GetNumber()))
   304  			c.So(resultCI.Info.GetMessages(), c.ShouldHaveLength, 1)
   305  			c.So(resultCI.Info.GetMessages()[0].GetMessage(), c.ShouldEqual, input.Message)
   306  			c.So(gf.NonZeroVotes(resultCI.Info, CQLabelName), c.ShouldBeEmpty)
   307  
   308  			onBehalfs, asSelf := splitSetReviewRequests()
   309  			c.So(onBehalfs, c.ShouldHaveLength, 1)
   310  			c.So(onBehalfs[0].GetOnBehalfOf(), c.ShouldEqual, triggererID)
   311  			c.So(onBehalfs[0].GetNotifyDetails(), c.ShouldBeNil)
   312  			c.So(asSelf, c.ShouldHaveLength, 1)
   313  			c.So(asSelf[0].GetNotify(), c.ShouldEqual, gerritpb.Notify_NOTIFY_NONE)
   314  			c.So(asSelf[0].GetNotifyDetails(), la.ShouldResembleProto,
   315  				&gerritpb.NotifyDetails{
   316  					Recipients: []*gerritpb.NotifyDetails_Recipient{
   317  						{
   318  							RecipientType: gerritpb.NotifyDetails_RECIPIENT_TYPE_TO,
   319  							Info: &gerritpb.NotifyDetails_Info{
   320  								Accounts: []int64{ownerID, triggererID},
   321  							},
   322  						},
   323  					},
   324  				})
   325  			c.So(asSelf[0].GetAddToAttentionSet(), la.ShouldResembleProto, []*gerritpb.AttentionSetInput{
   326  				{User: strconv.FormatInt(reviewerID, 10), Reason: "ps#2: " + usertext.StoppedRun},
   327  			})
   328  			c.So(asSelf[0].GetTag(), c.ShouldResemble, fmt.Sprintf("autogenerated:cq:full-run:%d", triggerTime.Unix()))
   329  		})
   330  
   331  		c.Convey("Remove multiple votes", func() {
   332  			input.Triggers.CqVoteTrigger = cqTrigger
   333  			ct.GFake.MutateChange(gHost, int(ci.GetNumber()), func(c *gf.Change) {
   334  				gf.CQ(1, clock.Now(ctx).Add(-130*time.Second), gf.U("user-1"))(c.Info)
   335  				gf.CQ(2, clock.Now(ctx).Add(-110*time.Second), gf.U("user-70"))(c.Info)
   336  				gf.CQ(1, clock.Now(ctx).Add(-100*time.Second), gf.U("user-1000"))(c.Info)
   337  			})
   338  
   339  			c.Convey("Success", func() {
   340  				err := Reset(ctx, input)
   341  				c.So(err, c.ShouldBeNil)
   342  				c.So(isOutdated(cl), c.ShouldBeTrue) // snapshot is outdated
   343  				resultCI := ct.GFake.GetChange(gHost, int(ci.GetNumber()))
   344  				c.So(resultCI.Info.GetMessages(), c.ShouldHaveLength, 1)
   345  				c.So(resultCI.Info.GetMessages()[0].GetMessage(), c.ShouldEqual, input.Message)
   346  				c.So(gf.NonZeroVotes(resultCI.Info, CQLabelName), c.ShouldBeEmpty)
   347  
   348  				onBehalfs, asSelf := splitSetReviewRequests()
   349  				for _, r := range onBehalfs {
   350  					c.So(r.GetNotify(), c.ShouldEqual, gerritpb.Notify_NOTIFY_NONE)
   351  					c.So(r.GetNotifyDetails(), c.ShouldBeNil)
   352  				}
   353  				// The triggering vote(s) must have been removed last, the order of
   354  				// removals for the rest doesn't matter so long as it does the job.
   355  				c.So(onBehalfs[len(onBehalfs)-1].GetOnBehalfOf(), c.ShouldEqual, 100)
   356  				c.So(asSelf, c.ShouldHaveLength, 1)
   357  				c.So(asSelf[0].GetNotify(), c.ShouldEqual, gerritpb.Notify_NOTIFY_NONE)
   358  				c.So(asSelf[0].GetNotifyDetails(), la.ShouldResembleProto,
   359  					&gerritpb.NotifyDetails{
   360  						Recipients: []*gerritpb.NotifyDetails_Recipient{
   361  							{
   362  								RecipientType: gerritpb.NotifyDetails_RECIPIENT_TYPE_TO,
   363  								Info: &gerritpb.NotifyDetails_Info{
   364  									Accounts: []int64{1, ownerID, 70, triggererID, 1000},
   365  								},
   366  							},
   367  						},
   368  					})
   369  				c.So(asSelf[0].GetAddToAttentionSet(), la.ShouldResembleProto, []*gerritpb.AttentionSetInput{
   370  					{User: strconv.FormatInt(reviewerID, 10), Reason: "ps#2: " + usertext.StoppedRun},
   371  				})
   372  			})
   373  
   374  			c.Convey("Removing non-triggering votes fails", func() {
   375  				ct.GFake.MutateChange(gHost, int(ci.GetNumber()), func(c *gf.Change) {
   376  					c.ACLs = gf.ACLGrant(gf.OpRead, codes.PermissionDenied, lProject).Or(
   377  						gf.ACLGrant(gf.OpReview, codes.PermissionDenied, lProject),
   378  					) // no permission to vote on behalf of others
   379  				})
   380  				err := Reset(ctx, input)
   381  				c.So(err, c.ShouldBeNil)
   382  				c.So(isOutdated(cl), c.ShouldBeFalse)
   383  				onBehalfs, _ := splitSetReviewRequests()
   384  				c.So(onBehalfs, c.ShouldHaveLength, 3) // all non-triggering votes
   385  				for _, r := range onBehalfs {
   386  					switch r.GetOnBehalfOf() {
   387  					case triggererID:
   388  						// CV shouldn't remove triggering votes if removal of non-triggering
   389  						// votes fails.
   390  						c.So(r.GetOnBehalfOf(), c.ShouldNotEqual, triggererID)
   391  					case 1, 70, 1000:
   392  					default:
   393  						panic(fmt.Errorf("unknown on_behalf_of %d", r.GetOnBehalfOf()))
   394  					}
   395  				}
   396  			})
   397  		})
   398  
   399  		c.Convey("Removing votes from non-CQ labels used in additional modes", func() {
   400  			const uLabel = "Ultra-Quick-Label"
   401  			const qLabel = "Quick-Label"
   402  			input.Triggers.CqVoteTrigger = cqTrigger
   403  			input.ConfigGroups = []*prjcfg.ConfigGroup{
   404  				{
   405  					Content: &cfgpb.ConfigGroup{
   406  						AdditionalModes: []*cfgpb.Mode{
   407  							{
   408  								Name:            "ULTRA_QUICK_RUN",
   409  								CqLabelValue:    1,
   410  								TriggeringLabel: uLabel,
   411  								TriggeringValue: 1,
   412  							},
   413  							{
   414  								Name:            "QUICK_RUN",
   415  								CqLabelValue:    1,
   416  								TriggeringLabel: qLabel,
   417  								TriggeringValue: 1,
   418  							},
   419  						},
   420  					},
   421  				},
   422  			}
   423  
   424  			ultraQuick := func(value int, timeAndUser ...any) gf.CIModifier {
   425  				return gf.Vote(uLabel, value, timeAndUser...)
   426  			}
   427  			quick := func(value int, timeAndUser ...any) gf.CIModifier {
   428  				return gf.Vote(qLabel, value, timeAndUser...)
   429  			}
   430  			// Exact timestamps don't matter in this test, but in practice they affect
   431  			// computation of the triggering vote.
   432  			ct.GFake.MutateChange(gHost, int(ci.GetNumber()), func(c *gf.Change) {
   433  				// user-99 forgot to vote CQ+1.
   434  				quick(1, clock.Now(ctx).Add(-300*time.Second), gf.U("user-99"))(c.Info)
   435  				ultraQuick(1, clock.Now(ctx).Add(-200*time.Second), gf.U("user-99"))(c.Info)
   436  
   437  				// user-100 actually triggered an ULTRA_QUICK_RUN.
   438  				gf.CQ(1, clock.Now(ctx).Add(-150*time.Second), gf.U("user-100"))(c.Info)
   439  				ultraQuick(1, clock.Now(ctx).Add(-150*time.Second), gf.U("user-100"))(c.Info)
   440  				quick(1, clock.Now(ctx).Add(-150*time.Second), gf.U("user-100"))(c.Info)
   441  
   442  				// user-101 CQ+1 was a noop.
   443  				gf.CQ(1, clock.Now(ctx).Add(-120*time.Second), gf.U("user-101"))(c.Info)
   444  
   445  				// user-102 votes for a QUICK_RUN is a noop, but should be removed as
   446  				// as well.
   447  				gf.CQ(1, clock.Now(ctx).Add(-110*time.Second), gf.U("user-101"))(c.Info)
   448  				quick(1, clock.Now(ctx).Add(-110*time.Second), gf.U("user-102"))(c.Info)
   449  
   450  				// user-103 votes is a noop, though weird, yet still must be removed.
   451  				ultraQuick(3, clock.Now(ctx).Add(-100*time.Second), gf.U("user-104"))(c.Info)
   452  
   453  				// user-104 votes is 0, and doesn't need a reset.
   454  				ultraQuick(0, clock.Now(ctx).Add(-90*time.Second), gf.U("user-104"))(c.Info)
   455  			})
   456  			err := Reset(ctx, input)
   457  			c.So(err, c.ShouldBeNil)
   458  			c.So(isOutdated(cl), c.ShouldBeTrue) // snapshot is outdated
   459  
   460  			resultCI := ct.GFake.GetChange(gHost, int(ci.GetNumber()))
   461  			c.So(gf.NonZeroVotes(resultCI.Info, CQLabelName), c.ShouldBeEmpty)
   462  			c.So(gf.NonZeroVotes(resultCI.Info, qLabel), c.ShouldBeEmpty)
   463  			c.So(gf.NonZeroVotes(resultCI.Info, uLabel), c.ShouldBeEmpty)
   464  
   465  			onBehalfs, _ := splitSetReviewRequests()
   466  			// The last request must be for account 100.
   467  			c.So(onBehalfs[len(onBehalfs)-1].GetOnBehalfOf(), c.ShouldEqual, 100)
   468  			c.So(onBehalfs[len(onBehalfs)-1].GetLabels(), c.ShouldResemble, map[string]int32{
   469  				CQLabelName: 0,
   470  				qLabel:      0,
   471  				uLabel:      0,
   472  			})
   473  		})
   474  
   475  		c.Convey("Skips zero votes", func() {
   476  			input.Triggers.CqVoteTrigger = cqTrigger
   477  			ct.GFake.MutateChange(gHost, int(ci.GetNumber()), func(c *gf.Change) {
   478  				gf.CQ(0, clock.Now(ctx).Add(-90*time.Second), gf.U("user-101"))(c.Info)
   479  				gf.CQ(0, clock.Now(ctx).Add(-100*time.Second), gf.U("user-102"))(c.Info)
   480  				gf.CQ(0, clock.Now(ctx).Add(-110*time.Second), gf.U("user-103"))(c.Info)
   481  			})
   482  
   483  			err := Reset(ctx, input)
   484  			c.So(err, c.ShouldBeNil)
   485  			c.So(isOutdated(cl), c.ShouldBeTrue) // snapshot is outdated
   486  			resultCI := ct.GFake.GetChange(gHost, int(ci.GetNumber()))
   487  			c.So(resultCI.Info.GetMessages(), c.ShouldHaveLength, 1)
   488  			c.So(resultCI.Info.GetMessages()[0].GetMessage(), c.ShouldEqual, input.Message)
   489  			c.So(gf.NonZeroVotes(resultCI.Info, CQLabelName), c.ShouldBeEmpty)
   490  			onBehalfs, _ := splitSetReviewRequests()
   491  			c.So(onBehalfs, c.ShouldHaveLength, 1)
   492  			c.So(onBehalfs[0].GetOnBehalfOf(), c.ShouldEqual, triggererID)
   493  		})
   494  
   495  		c.Convey("Post Message even if triggering votes has been removed already", func() {
   496  			input.Triggers.CqVoteTrigger = cqTrigger
   497  			ct.GFake.MutateChange(gHost, int(ci.GetNumber()), func(c *gf.Change) {
   498  				gf.CQ(0, clock.Now(ctx), triggerer)(c.Info)
   499  			})
   500  			err := Reset(ctx, input)
   501  			c.So(err, c.ShouldBeNil)
   502  			c.So(isOutdated(cl), c.ShouldBeTrue) // snapshot is outdated
   503  			resultCI := ct.GFake.GetChange(gHost, int(ci.GetNumber()))
   504  			c.So(resultCI.Info.GetMessages(), c.ShouldHaveLength, 1)
   505  			c.So(resultCI.Info.GetMessages()[0].GetMessage(), c.ShouldEqual, input.Message)
   506  		})
   507  
   508  		c.Convey("Post Message if CV has no permission to vote", func() {
   509  			input.Triggers.CqVoteTrigger = cqTrigger
   510  			ct.GFake.MutateChange(gHost, int(ci.GetNumber()), func(c *gf.Change) {
   511  				c.ACLs = gf.ACLGrant(gf.OpRead, codes.PermissionDenied, lProject).Or(
   512  					// Needed to post comments
   513  					gf.ACLGrant(gf.OpReview, codes.PermissionDenied, lProject),
   514  				)
   515  			})
   516  			c.So(Reset(ctx, input), c.ShouldBeNil)
   517  			c.So(isOutdated(cl), c.ShouldBeFalse)
   518  			resultCI := ct.GFake.GetChange(gHost, int(ci.GetNumber())).Info
   519  			// CQ+2 vote remains.
   520  			c.So(gf.NonZeroVotes(resultCI, CQLabelName), la.ShouldResembleProto, []*gerritpb.ApprovalInfo{
   521  				{
   522  					User:  triggerer,
   523  					Value: 2,
   524  					Date:  timestamppb.New(triggerTime),
   525  				},
   526  			})
   527  			// But CL is no longer triggered.
   528  			c.So(findTriggers(resultCI).GetCqVoteTrigger(), c.ShouldBeNil)
   529  			// Still, user should know what happened.
   530  			expectedMsg := input.Message + `
   531  
   532  CV failed to unset the Commit-Queue label on your behalf. Please unvote and revote on the Commit-Queue label to retry.
   533  
   534  Bot data: {"action":"cancel","triggered_at":"2020-02-02T10:28:00Z","revision":"rev-010001-002"}`
   535  			c.So(resultCI.GetMessages()[0].GetMessage(), c.ShouldEqual, expectedMsg)
   536  		})
   537  
   538  		c.Convey("Post Message if change is in bad state", func() {
   539  			input.Triggers.CqVoteTrigger = cqTrigger
   540  			ct.GFake.MutateChange(gHost, int(ci.GetNumber()), func(c *gf.Change) {
   541  				gf.Status(gerritpb.ChangeStatus_ABANDONED)(c.Info)
   542  				c.ACLs = func(op gf.Operation, _ string) *status.Status {
   543  					if op == gf.OpAlterVotesOfOthers {
   544  						return status.New(codes.FailedPrecondition, "change abandoned, no vote removals allowed")
   545  					}
   546  					return status.New(codes.OK, "")
   547  				}
   548  			})
   549  			err := Reset(ctx, input)
   550  			c.So(err, c.ShouldBeNil)
   551  			c.So(isOutdated(cl), c.ShouldBeFalse)
   552  			resultCI := ct.GFake.GetChange(gHost, int(ci.GetNumber())).Info
   553  			// CQ+2 vote remains.
   554  			c.So(gf.NonZeroVotes(resultCI, CQLabelName), la.ShouldResembleProto, []*gerritpb.ApprovalInfo{
   555  				{
   556  					User:  triggerer,
   557  					Value: 2,
   558  					Date:  timestamppb.New(triggerTime),
   559  				},
   560  			})
   561  			// But CL is no longer triggered.
   562  			c.So(findTriggers(resultCI).GetCqVoteTrigger(), c.ShouldBeNil)
   563  			// Still, user should know what happened.
   564  			c.So(resultCI.GetMessages(), c.ShouldHaveLength, 1)
   565  			c.So(resultCI.GetMessages()[0].GetMessage(), c.ShouldContainSubstring, "CV failed to unset the Commit-Queue label on your behalf")
   566  		})
   567  
   568  		c.Convey("Post Message also fails", func() {
   569  			input.Triggers.CqVoteTrigger = cqTrigger
   570  			ct.GFake.MutateChange(gHost, int(ci.GetNumber()), func(c *gf.Change) {
   571  				c.ACLs = gf.ACLGrant(gf.OpRead, codes.PermissionDenied, lProject)
   572  			})
   573  			err := Reset(ctx, input)
   574  			c.So(err, la.ShouldErrLike, "no permission to remove vote x-review.example.com/10001")
   575  			c.So(isOutdated(cl), c.ShouldBeFalse)
   576  			c.So(ErrResetPermanentTag.In(err), c.ShouldBeTrue)
   577  			resultCI := ct.GFake.GetChange(gHost, int(ci.GetNumber())).Info
   578  			c.So(gf.NonZeroVotes(resultCI, CQLabelName), la.ShouldResembleProto, []*gerritpb.ApprovalInfo{
   579  				{
   580  					User:  triggerer,
   581  					Value: 2,
   582  					Date:  timestamppb.New(triggerTime),
   583  				},
   584  			})
   585  			c.So(resultCI.GetMessages(), c.ShouldBeEmpty)
   586  		})
   587  	})
   588  }