go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/gerrit/trigger/reset.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  	"context"
    19  	"fmt"
    20  	"sort"
    21  	"strconv"
    22  	"strings"
    23  	"time"
    24  
    25  	"google.golang.org/grpc"
    26  	"google.golang.org/grpc/codes"
    27  
    28  	"go.chromium.org/luci/common/data/stringset"
    29  	"go.chromium.org/luci/common/errors"
    30  	"go.chromium.org/luci/common/logging"
    31  	gerritpb "go.chromium.org/luci/common/proto/gerrit"
    32  	"go.chromium.org/luci/common/retry/transient"
    33  	"go.chromium.org/luci/gae/service/datastore"
    34  	"go.chromium.org/luci/grpc/grpcutil"
    35  
    36  	"go.chromium.org/luci/cv/internal/changelist"
    37  	"go.chromium.org/luci/cv/internal/common"
    38  	"go.chromium.org/luci/cv/internal/common/lease"
    39  	"go.chromium.org/luci/cv/internal/configs/prjcfg"
    40  	"go.chromium.org/luci/cv/internal/gerrit"
    41  	"go.chromium.org/luci/cv/internal/gerrit/botdata"
    42  	"go.chromium.org/luci/cv/internal/run"
    43  	"go.chromium.org/luci/cv/internal/usertext"
    44  )
    45  
    46  // ErrResetPreconditionFailedTag is an error tag indicating that the
    47  // precondition of resetting a trigger has not been met,
    48  var ErrResetPreconditionFailedTag = errors.BoolTag{
    49  	Key: errors.NewTagKey("reset precondition not met"),
    50  }
    51  
    52  // ErrResetPermanentTag is an error tag indicating that error occurs during the
    53  // reset is permanent (e.g. lack of vote permission).
    54  var ErrResetPermanentTag = errors.BoolTag{
    55  	Key: errors.NewTagKey("permanent error while resetting triggers"),
    56  }
    57  
    58  var errGerritTagKey = errors.NewTagKey("this is a Gerrit error")
    59  
    60  func applyGerritErrTag(err error, grpcCode codes.Code) error {
    61  	return errors.TagValue{Key: errGerritTagKey, Value: grpcCode}.Apply(err)
    62  }
    63  
    64  // IsResetErrFromGerrit returns gerrit grpc error code if the `trigger.Reset`
    65  // fails because of Gerrit.
    66  func IsResetErrFromGerrit(err error) (codes.Code, bool) {
    67  	switch v, ok := errors.TagValueIn(errGerritTagKey, err); {
    68  	case err == nil:
    69  		return codes.OK, false
    70  	case !ok:
    71  		return codes.Unknown, false
    72  	default:
    73  		return v.(codes.Code), true
    74  	}
    75  }
    76  
    77  // ResetInput contains info to reset triggers of Run on a CL.
    78  type ResetInput struct {
    79  	// CL is a Gerrit CL entity.
    80  	//
    81  	// Must have CL.Snapshot set.
    82  	CL *changelist.CL
    83  	// Trigger identifies the triggering vote. Required.
    84  	//
    85  	// Removed only after all other votes on CQ label are removed.
    86  	Triggers *run.Triggers
    87  	// LUCIProject is the project that initiates this reset.
    88  	//
    89  	// The project scoped account of this LUCI project SHOULD have the permission
    90  	// to set the CQ label on behalf of other users in Gerrit.
    91  	LUCIProject string
    92  	// Message to be posted along with the triggering vote removal
    93  	Message string
    94  	// Requester describes the caller (e.g. Project Manager, Run Manager).
    95  	Requester string
    96  	// Notify describes whom to notify regarding the reset.
    97  	//
    98  	// If empty, notifies no one.
    99  	Notify gerrit.Whoms
   100  	// AddToAttentionSet describes whom to add in the attention set.
   101  	//
   102  	// If empty, no change will be made to attention set.
   103  	AddToAttentionSet gerrit.Whoms
   104  	// AttentionReason describes the reason of the attention change.
   105  	//
   106  	// It is attached to the attention set change, and rendered in UI to explain
   107  	// the reason of the attention to users.
   108  	//
   109  	// This is noop, if AddAttentionSet is empty.
   110  	AttentionReason string
   111  	// LeaseDuration is how long a lease will be held for this reset.
   112  	//
   113  	// If the passed context has a closer deadline, uses that deadline as lease
   114  	// `ExpireTime`.
   115  	LeaseDuration time.Duration
   116  	// ConfigGroups are the ConfigGroups that are watching this CL.
   117  	//
   118  	// They are used to remove votes for additional modes. Normally, there is
   119  	// just 1 ConfigGroup.
   120  	ConfigGroups []*prjcfg.ConfigGroup
   121  	// GFactory is used to create the gerrit client needed to perform the reset.
   122  	GFactory gerrit.Factory
   123  	// CLMutator performs mutations to the CL entity and notifies relevant parts
   124  	// of CV when appropriate.
   125  	CLMutator *changelist.Mutator
   126  }
   127  
   128  func (in *ResetInput) panicIfInvalid() {
   129  	// These are the conditions that shouldn't be met, and likely require code
   130  	// changes for fixes.
   131  	var err error
   132  	switch {
   133  	case in.CL.Snapshot == nil:
   134  		err = fmt.Errorf("cl.Snapshot must be non-nil")
   135  	case in.Triggers == nil:
   136  		err = fmt.Errorf("trigger must be non-nil")
   137  	case in.Triggers.CqVoteTrigger == nil && in.Triggers.NewPatchsetRunTrigger == nil:
   138  		err = fmt.Errorf("at least one of {CqVoteTrigger, NewPatchsetRunTrigger} must be non-nil")
   139  	case in.LUCIProject != in.CL.Snapshot.GetLuciProject():
   140  		err = fmt.Errorf("mismatched LUCI Project: got %q in input and %q in CL snapshot", in.LUCIProject, in.CL.Snapshot.GetLuciProject())
   141  	case len(in.ConfigGroups) == 0:
   142  		err = fmt.Errorf("config_groups must be given")
   143  	case in.GFactory == nil:
   144  		err = fmt.Errorf("gerrit factory must be non-nil")
   145  	case in.CLMutator == nil:
   146  		err = fmt.Errorf("mutator must be non-nil")
   147  	case len(in.Notify) > 0:
   148  		for _, enum := range in.Notify {
   149  			if _, ok := gerrit.Whom_name[int32(enum)]; !ok {
   150  				err = fmt.Errorf("notify: unknown Whom value %d", enum)
   151  				break
   152  			}
   153  		}
   154  	case len(in.AddToAttentionSet) > 0:
   155  		for _, enum := range in.AddToAttentionSet {
   156  			if _, ok := gerrit.Whom_name[int32(enum)]; !ok {
   157  				err = fmt.Errorf("add_to_attention: unknown Whom value %d", enum)
   158  				break
   159  			}
   160  		}
   161  	}
   162  	if err != nil {
   163  		panic(err)
   164  	}
   165  }
   166  
   167  // Reset removes or "deactivates" the trigger that made CV start processing the
   168  // current run, whether by removing votes on a CL and posting the given message,
   169  // or by updating the datastore entity associated with the CL; this, depending
   170  // on the RunMode of the Run.
   171  //
   172  // For vote-removal-based reset:
   173  //
   174  // Returns error tagged with `ErrPreconditionFailedTag` if one of the
   175  // following conditions is matched.
   176  //   - The patchset of the provided CL is not the latest in Gerrit.
   177  //   - The provided CL gets `changelist.AccessDenied` or
   178  //     `changelist.AccessDeniedProbably` from Gerrit.
   179  //
   180  // Normally, the triggering vote(s) is removed last and all other votes
   181  // are removed in chronological order (latest to earliest).
   182  // After all votes are removed, the message is posted to Gerrit.
   183  //
   184  // Abnormally, e.g. lack of permission to remove votes, falls back to post a
   185  // special message which "deactivates" the triggering votes. This special
   186  // message is a combination of:
   187  //   - the original message in the input
   188  //   - reason for abnormality,
   189  //   - special `botdata.BotData` which ensures CV won't consider previously
   190  //     triggering votes as triggering in the future.
   191  //
   192  // Alternatively, in the case of a new patchset run:
   193  //
   194  // Updates the CLEntity to record that CV is not to create new patchset runs
   195  // with the current patchset or lower. This prevents trigger.Find() from
   196  // continuing to return a trigger for this patchset, analog to the effect of
   197  // removing a cq vote on gerrit.
   198  func Reset(ctx context.Context, in ResetInput) error {
   199  	in.panicIfInvalid()
   200  	if in.CL.AccessKindFromCodeReviewSite(ctx, in.LUCIProject) != changelist.AccessGranted {
   201  		return errors.New("failed to reset trigger because CV lost access to this CL", ErrResetPreconditionFailedTag)
   202  	}
   203  	if len(in.AddToAttentionSet) > 0 && in.AttentionReason == "" {
   204  		logging.Warningf(ctx, "FIXME reset was given empty in AttentionReason.")
   205  		in.AttentionReason = usertext.StoppedRun
   206  	}
   207  
   208  	client, err := in.GFactory.MakeClient(ctx, in.CL.Snapshot.GetGerrit().GetHost(), in.LUCIProject)
   209  	if err != nil {
   210  		return err
   211  	}
   212  	cl := in.CL
   213  	if in.Triggers.GetNewPatchsetRunTrigger() != nil {
   214  		cl, err = resetByUpdatingCLEntity(ctx, &in)
   215  		if err != nil {
   216  			return err
   217  		}
   218  	}
   219  	if in.Message == "" && in.Triggers.GetCqVoteTrigger() == nil {
   220  		return nil
   221  	}
   222  
   223  	leaseCtx, close, lErr := lease.ApplyOnCL(ctx, cl.ID, in.LeaseDuration, in.Requester)
   224  	if lErr != nil {
   225  		return lErr
   226  	}
   227  	defer close()
   228  
   229  	switch {
   230  	case in.Triggers.GetCqVoteTrigger() != nil:
   231  		if err := ensurePSLatestInCV(ctx, cl); err != nil {
   232  			return err
   233  		}
   234  		return resetLeased(leaseCtx, client, &in, cl)
   235  	case in.Message != "":
   236  		// If there is a CQ Vote trigger to purge, resetLeased() will have
   237  		// taken care of posting any appropriate message.
   238  		// If the Reset() call _only_ applies to an NPR trigger, _and_
   239  		// a message has been specified, then this function needs to post a
   240  		// message.
   241  		return makeChange(client, &in, cl).postGerritMsg(
   242  			leaseCtx, cl.Snapshot.GetGerrit().GetInfo(), in.Message, in.Triggers.NewPatchsetRunTrigger, in.Notify, in.AddToAttentionSet, in.AttentionReason)
   243  	default:
   244  		panic("unreachable")
   245  	}
   246  }
   247  
   248  func makeChange(client gerrit.Client, in *ResetInput, cl *changelist.CL) *change {
   249  	return &change{
   250  		Host:        cl.Snapshot.GetGerrit().GetHost(),
   251  		LUCIProject: in.LUCIProject,
   252  		Project:     cl.Snapshot.GetGerrit().GetInfo().GetProject(),
   253  		Number:      cl.Snapshot.GetGerrit().GetInfo().GetNumber(),
   254  		Revision:    cl.Snapshot.GetGerrit().GetInfo().GetCurrentRevision(),
   255  		gf:          in.GFactory,
   256  		gc:          client,
   257  	}
   258  }
   259  
   260  func resetByUpdatingCLEntity(ctx context.Context, in *ResetInput) (*changelist.CL, error) {
   261  	return in.CLMutator.Update(ctx, in.LUCIProject, in.CL.ID, func(cl *changelist.CL) error {
   262  		switch {
   263  		case cl.TriggerNewPatchsetRunAfterPS < in.CL.Snapshot.GetPatchset():
   264  			cl.TriggerNewPatchsetRunAfterPS = in.CL.Snapshot.GetPatchset()
   265  			return nil
   266  		default:
   267  			logging.Warningf(ctx, "cl.TriggerNewPatchsetRunAfterPS has already been updated, race?")
   268  			return changelist.ErrStopMutation
   269  		}
   270  	})
   271  }
   272  
   273  // TODO(tandrii): merge with prjmanager/purger's error messages.
   274  var failMessage = "CV failed to unset the " + CQLabelName +
   275  	" label on your behalf. Please unvote and revote on the " +
   276  	CQLabelName + " label to retry."
   277  
   278  func resetLeased(ctx context.Context, client gerrit.Client, in *ResetInput, cl *changelist.CL) error {
   279  	c := makeChange(client, in, cl)
   280  	logging.Infof(ctx, "Resetting triggers on %s/%d", c.Host, c.Number)
   281  	ci, err := c.getLatest(ctx, cl.Snapshot.GetGerrit().GetInfo().GetUpdated().AsTime())
   282  	switch {
   283  	case err != nil:
   284  		return err
   285  	case ci.GetCurrentRevision() != c.Revision:
   286  		return errors.Reason("failed to reset because ps %d is not current for %s/%d", cl.Snapshot.GetPatchset(), c.Host, c.Number).Tag(ErrResetPreconditionFailedTag).Err()
   287  	}
   288  
   289  	labelsToRemove := stringset.NewFromSlice(CQLabelName)
   290  	if modeDef := in.Triggers.GetCqVoteTrigger().GetModeDefinition(); modeDef != nil {
   291  		labelsToRemove.Add(modeDef.GetTriggeringLabel())
   292  	}
   293  	for _, cg := range in.ConfigGroups {
   294  		for _, am := range cg.Content.GetAdditionalModes() {
   295  			if l := am.GetTriggeringLabel(); l != "" {
   296  				labelsToRemove.Add(l)
   297  			}
   298  		}
   299  	}
   300  	labelsToRemove.Iter(func(label string) bool {
   301  		c.recordVotesToRemove(label, ci)
   302  		return true
   303  	})
   304  
   305  	removeErr := c.removeVotesAndPostMsg(ctx, ci, in.Triggers.GetCqVoteTrigger(), in.Message, in.Notify, in.AddToAttentionSet, in.AttentionReason)
   306  	switch {
   307  	case removeErr == nil:
   308  		_, outErr := in.CLMutator.Update(ctx, in.LUCIProject, in.CL.ID, func(cl *changelist.CL) error {
   309  			if cl.Snapshot == nil || cl.Snapshot.GetOutdated() != nil {
   310  				return changelist.ErrStopMutation // noop
   311  			}
   312  			cl.Snapshot.Outdated = &changelist.Snapshot_Outdated{}
   313  			return nil
   314  		})
   315  		if outErr != nil {
   316  			// Let's log and ignore the error.
   317  			logging.Errorf(ctx, "CLMutator.Update: ignoring %s", outErr)
   318  		}
   319  		return nil
   320  	case !ErrResetPermanentTag.In(removeErr):
   321  		return removeErr
   322  	}
   323  
   324  	// Received permanent error, try posting message.
   325  	logging.Warningf(ctx, "Falling back to resetting via botdata message %s/%d", c.Host, c.Number)
   326  	var msgBuilder strings.Builder
   327  	if in.Message != "" {
   328  		msgBuilder.WriteString(in.Message)
   329  		msgBuilder.WriteString("\n\n")
   330  	}
   331  	msgBuilder.WriteString(failMessage)
   332  	if err := c.postResetMessage(ctx, ci, msgBuilder.String(), in.Triggers.GetCqVoteTrigger(), in.Notify, in.AddToAttentionSet, in.AttentionReason); err != nil {
   333  		// Return the original error, but add details from just posting a message.
   334  		return errors.Annotate(removeErr, "even just posting message also failed: %s", err).Err()
   335  	}
   336  	return nil
   337  }
   338  
   339  func ensurePSLatestInCV(ctx context.Context, cl *changelist.CL) error {
   340  	curCLInCV := &changelist.CL{ID: cl.ID}
   341  	switch err := datastore.Get(ctx, curCLInCV); {
   342  	case err == datastore.ErrNoSuchEntity:
   343  		return errors.Reason("cl(id=%d) doesn't exist in datastore", cl.ID).Err()
   344  	case err != nil:
   345  		return errors.Annotate(err, "failed to load cl: %d", cl.ID).Tag(transient.Tag).Err()
   346  	case curCLInCV.Snapshot.GetPatchset() > cl.Snapshot.GetPatchset():
   347  		return errors.Reason("failed to reset because ps %d is not current for cl(%d)", cl.Snapshot.GetPatchset(), cl.ID).Tag(ErrResetPreconditionFailedTag).Err()
   348  	}
   349  	return nil
   350  }
   351  
   352  type change struct {
   353  	Host        string
   354  	LUCIProject string
   355  	Project     string
   356  	Number      int64
   357  	Revision    string
   358  
   359  	gf gerrit.Factory
   360  	gc gerrit.Client
   361  	// votesToRemove maps accountID to a set of labels.
   362  	//
   363  	// For ease of passing to SetReview API, each label maps to 0.
   364  	votesToRemove map[int64]map[string]int32
   365  }
   366  
   367  func (c *change) getLatest(ctx context.Context, knownUpdatedTime time.Time) (*gerritpb.ChangeInfo, error) {
   368  	var ci *gerritpb.ChangeInfo
   369  	var gerritErr error
   370  	outerErr := c.gf.MakeMirrorIterator(ctx).RetryIfStale(func(opt grpc.CallOption) error {
   371  		ci, gerritErr = c.gc.GetChange(ctx, &gerritpb.GetChangeRequest{
   372  			Number:  c.Number,
   373  			Project: c.Project,
   374  			Options: []gerritpb.QueryOption{
   375  				gerritpb.QueryOption_CURRENT_REVISION,
   376  				gerritpb.QueryOption_DETAILED_LABELS,
   377  				gerritpb.QueryOption_DETAILED_ACCOUNTS,
   378  				gerritpb.QueryOption_MESSAGES,
   379  			},
   380  		}, opt)
   381  		switch {
   382  		case grpcutil.Code(gerritErr) == codes.NotFound:
   383  			// If a Run fails right after this CL is uploaded, it is possible that
   384  			// CV receives NotFound when fetching this CL due to eventual consistency
   385  			// of Gerrit. Therefore, consider this error as stale data. It is also
   386  			// possible that user actually deleted this CL. In that case, it is also
   387  			// okay to retry here because theoretically, Gerrit should consistently
   388  			// return 404 and fail the task. When the task retries, CV should figure
   389  			// that it has lost its access to this CL at the beginning and give up
   390  			// resetting the trigger. But, even if Gerrit accidentally return
   391  			// the deleted CL, the subsequent SetReview call will also fail the task.
   392  			return gerrit.ErrStaleData
   393  		case gerritErr != nil:
   394  			return gerritErr
   395  		case ci.GetUpdated().AsTime().Before(knownUpdatedTime):
   396  			return gerrit.ErrStaleData
   397  		}
   398  		return nil
   399  	})
   400  
   401  	switch {
   402  	case gerritErr != nil:
   403  		return nil, c.annotateGerritErr(ctx, gerritErr, "get")
   404  	case outerErr != nil:
   405  		// Should never happen unless MirrorIterator itself errors out for some
   406  		// reason.
   407  		return nil, outerErr
   408  	default:
   409  		return ci, nil
   410  	}
   411  }
   412  
   413  func (c *change) recordVotesToRemove(label string, ci *gerritpb.ChangeInfo) {
   414  	for _, vote := range ci.GetLabels()[label].GetAll() {
   415  		if vote.GetValue() == 0 {
   416  			continue
   417  		}
   418  		if c.votesToRemove == nil {
   419  			c.votesToRemove = make(map[int64]map[string]int32, 1)
   420  		}
   421  		accountID := vote.GetUser().GetAccountId()
   422  		if labels, exists := c.votesToRemove[accountID]; exists {
   423  			labels[label] = 0
   424  		} else {
   425  			c.votesToRemove[accountID] = map[string]int32{label: 0}
   426  		}
   427  	}
   428  }
   429  
   430  func (c *change) sortedVoterAccountIDs() []int64 {
   431  	ids := make([]int64, 0, len(c.votesToRemove))
   432  	for id := range c.votesToRemove {
   433  		ids = append(ids, id)
   434  	}
   435  	sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
   436  	return ids
   437  }
   438  
   439  func (c *change) removeVotesAndPostMsg(ctx context.Context, ci *gerritpb.ChangeInfo, t *run.Trigger, msg string, notify, addAttn gerrit.Whoms, reason string) error {
   440  	var nonTriggeringVotesRemovalErrs errors.MultiError
   441  	needRemoveTriggerVote := false
   442  	for _, voter := range c.sortedVoterAccountIDs() {
   443  		if voter == t.GetGerritAccountId() {
   444  			needRemoveTriggerVote = true
   445  			continue
   446  		}
   447  		if err := c.removeVote(ctx, voter, run.Mode(t.GetMode())); err != nil {
   448  			nonTriggeringVotesRemovalErrs = append(nonTriggeringVotesRemovalErrs, err)
   449  		}
   450  	}
   451  	if err := common.MostSevereError(nonTriggeringVotesRemovalErrs); err != nil {
   452  		// Return if failure occurs during removal of non-triggering votes so that
   453  		// triggering votes will be kept. Otherwise, CV may create a new Run using
   454  		// the non-triggering votes that CV fails to remove.
   455  		return err
   456  	}
   457  
   458  	if needRemoveTriggerVote {
   459  		// Remove the triggering vote last.
   460  		if err := c.removeVote(ctx, t.GetGerritAccountId(), run.Mode(t.GetMode())); err != nil {
   461  			return err
   462  		}
   463  	}
   464  	return c.postGerritMsg(ctx, ci, msg, t, notify, addAttn, reason)
   465  }
   466  
   467  func (c *change) removeVote(ctx context.Context, accountID int64, mode run.Mode) error {
   468  	var gerritErr error
   469  
   470  	outerErr := c.gf.MakeMirrorIterator(ctx).RetryIfStale(func(opt grpc.CallOption) error {
   471  		_, gerritErr = c.gc.SetReview(ctx, &gerritpb.SetReviewRequest{
   472  			Number:     c.Number,
   473  			Project:    c.Project,
   474  			RevisionId: c.Revision,
   475  			Labels:     c.votesToRemove[accountID],
   476  			Tag:        mode.GerritMessageTag(),
   477  			Notify:     gerritpb.Notify_NOTIFY_NONE,
   478  			OnBehalfOf: accountID,
   479  		}, opt)
   480  		switch grpcutil.Code(gerritErr) {
   481  		case codes.NotFound:
   482  			// This is known to happen on new CLs or on recently created revisions.
   483  			return gerrit.ErrStaleData
   484  		default:
   485  			return gerritErr
   486  		}
   487  	})
   488  
   489  	switch {
   490  	case gerritErr != nil:
   491  		return c.annotateGerritErr(ctx, gerritErr, "remove vote")
   492  	case outerErr != nil:
   493  		// Should never happen unless MirrorIterator itself errors out for some
   494  		// reason.
   495  		return outerErr
   496  	default:
   497  		return nil
   498  	}
   499  }
   500  
   501  func (c *change) postResetMessage(ctx context.Context, ci *gerritpb.ChangeInfo, msg string, t *run.Trigger, notify, addAttn gerrit.Whoms, reason string) (err error) {
   502  	bd := botdata.BotData{
   503  		Action:      botdata.Cancel,
   504  		TriggeredAt: t.GetTime().AsTime(),
   505  		Revision:    c.Revision,
   506  	}
   507  	// TODO(crbug.com/1414898) - deprecate botdata
   508  	if msg, err = botdata.Append(msg, bd); err != nil {
   509  		return err
   510  	}
   511  	return c.postGerritMsg(ctx, ci, msg, t, notify, addAttn, reason)
   512  }
   513  
   514  // postGerritMsg posts the given message to Gerrit.
   515  //
   516  // Skips if duplicate message is found after triggering time.
   517  func (c *change) postGerritMsg(ctx context.Context, ci *gerritpb.ChangeInfo, msg string, t *run.Trigger, notify, addAttn gerrit.Whoms, reason string) (err error) {
   518  	for _, m := range ci.GetMessages() {
   519  		switch {
   520  		case m.GetDate().AsTime().Before(t.GetTime().AsTime()):
   521  		case strings.Contains(m.Message, strings.TrimSpace(msg)):
   522  			return nil
   523  		}
   524  	}
   525  	nd := makeGerritNotifyDetails(notify, ci)
   526  	reason = fmt.Sprintf("ps#%d: %s", ci.GetRevisions()[ci.GetCurrentRevision()].GetNumber(), reason)
   527  	attention := makeGerritAttentionSetInputs(addAttn, ci, reason)
   528  	msg = gerrit.TruncateMessage(msg)
   529  	// Post message with unique tag per Run so that Gerrit will always display
   530  	// these messages. The uniqueness is achieved by appending the Run triggering
   531  	// time. Otherwise, users may falsely believe LUCI CV is not doing anything
   532  	// to handle their CLs because Gerrit will hide old messages with the same
   533  	// tag (See: crbug.com/1359521). The message in trigger reset normally
   534  	// contains the result for the Run (e.g. passing or why the Run fails) so it
   535  	// is a good indication of LUCI CV is working fine without introducing too
   536  	// much noise.
   537  	tag := fmt.Sprintf("%s:%d", run.Mode(t.Mode).GerritMessageTag(), t.GetTime().AsTime().Unix())
   538  	var gerritErr error
   539  	outerErr := c.gf.MakeMirrorIterator(ctx).RetryIfStale(func(opt grpc.CallOption) error {
   540  		_, gerritErr = c.gc.SetReview(ctx, &gerritpb.SetReviewRequest{
   541  			Number:     c.Number,
   542  			Project:    c.Project,
   543  			RevisionId: ci.GetCurrentRevision(),
   544  			Message:    msg,
   545  			Tag:        tag,
   546  			// Set `Notify` to NONE because LUCI CV has the knowledge on all the
   547  			// accounts to notify. All of them are included through `NotifyDetails`.
   548  			// Therefore, there is no point using the special enum provided via
   549  			// `Notify`.
   550  			Notify:            gerritpb.Notify_NOTIFY_NONE,
   551  			NotifyDetails:     nd,
   552  			AddToAttentionSet: attention,
   553  		}, opt)
   554  		switch grpcutil.Code(gerritErr) {
   555  		case codes.NotFound:
   556  			// This is known to happen on new CLs or on recently created revisions.
   557  			return gerrit.ErrStaleData
   558  		default:
   559  			return gerritErr
   560  		}
   561  	})
   562  
   563  	switch {
   564  	case gerritErr != nil:
   565  		return c.annotateGerritErr(ctx, gerritErr, "post message")
   566  	case outerErr != nil:
   567  		// Should never happen unless MirrorIterator itself errors out for some
   568  		// reason.
   569  		return outerErr
   570  	default:
   571  		return nil
   572  	}
   573  }
   574  
   575  func makeGerritNotifyDetails(notify gerrit.Whoms, ci *gerritpb.ChangeInfo) *gerritpb.NotifyDetails {
   576  	accounts := notify.ToAccountIDsSorted(ci)
   577  	if len(accounts) == 0 {
   578  		return nil
   579  	}
   580  
   581  	return &gerritpb.NotifyDetails{
   582  		Recipients: []*gerritpb.NotifyDetails_Recipient{
   583  			{
   584  				RecipientType: gerritpb.NotifyDetails_RECIPIENT_TYPE_TO,
   585  				Info: &gerritpb.NotifyDetails_Info{
   586  					Accounts: accounts,
   587  				},
   588  			},
   589  		},
   590  	}
   591  }
   592  
   593  func makeGerritAttentionSetInputs(addAttn gerrit.Whoms, ci *gerritpb.ChangeInfo, reason string) []*gerritpb.AttentionSetInput {
   594  	accounts := addAttn.ToAccountIDsSorted(ci)
   595  	if len(accounts) == 0 {
   596  		return nil
   597  	}
   598  	ret := make([]*gerritpb.AttentionSetInput, len(accounts))
   599  	for i, acct := range accounts {
   600  		ret[i] = &gerritpb.AttentionSetInput{
   601  			// The accountID supports various formats, including a bare account ID,
   602  			// email, full-name, and others.
   603  			User:   strconv.Itoa(int(acct)),
   604  			Reason: reason,
   605  		}
   606  	}
   607  	return ret
   608  }
   609  
   610  func (c *change) annotateGerritErr(ctx context.Context, err error, action string) error {
   611  	if err == nil {
   612  		return nil
   613  	}
   614  	code := grpcutil.Code(err)
   615  	var retErr error
   616  	switch code {
   617  	case codes.OK:
   618  		return nil
   619  	case codes.PermissionDenied:
   620  		retErr = errors.Reason("no permission to %s %s/%d", action, c.Host, c.Number).Tag(ErrResetPermanentTag).Err()
   621  	case codes.NotFound:
   622  		retErr = errors.Reason("change %s/%d not found", c.Host, c.Number).Tag(ErrResetPermanentTag).Err()
   623  	case codes.FailedPrecondition:
   624  		retErr = errors.Reason("change %s/%d in an unexpected state for action %s: %s", c.Host, c.Number, action, err).Tag(ErrResetPermanentTag).Err()
   625  	case codes.DeadlineExceeded:
   626  		retErr = errors.Reason("timeout when calling Gerrit to %s %s/%d", action, c.Host, c.Number).Tag(transient.Tag).Err()
   627  	default:
   628  		retErr = gerrit.UnhandledError(ctx, err, "failed to %s %s/%d", action, c.Host, c.Number)
   629  	}
   630  	retErr = applyGerritErrTag(retErr, code)
   631  	return retErr
   632  }