go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/run/impl/handler/tryjobs_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 handler
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"testing"
    21  	"time"
    22  
    23  	bbpb "go.chromium.org/luci/buildbucket/proto"
    24  	"google.golang.org/protobuf/proto"
    25  	"google.golang.org/protobuf/types/known/timestamppb"
    26  
    27  	gerritpb "go.chromium.org/luci/common/proto/gerrit"
    28  	"go.chromium.org/luci/gae/service/datastore"
    29  
    30  	cfgpb "go.chromium.org/luci/cv/api/config/v2"
    31  	"go.chromium.org/luci/cv/internal/changelist"
    32  	"go.chromium.org/luci/cv/internal/common"
    33  	"go.chromium.org/luci/cv/internal/configs/prjcfg/prjcfgtest"
    34  	"go.chromium.org/luci/cv/internal/cvtesting"
    35  	"go.chromium.org/luci/cv/internal/gerrit"
    36  	gf "go.chromium.org/luci/cv/internal/gerrit/gerritfake"
    37  	"go.chromium.org/luci/cv/internal/run"
    38  	"go.chromium.org/luci/cv/internal/run/eventpb"
    39  	"go.chromium.org/luci/cv/internal/run/impl/state"
    40  	"go.chromium.org/luci/cv/internal/run/impl/submit"
    41  	"go.chromium.org/luci/cv/internal/tryjob"
    42  
    43  	. "github.com/smartystreets/goconvey/convey"
    44  	. "go.chromium.org/luci/common/testing/assertions"
    45  )
    46  
    47  func TestOnTryjobsUpdated(t *testing.T) {
    48  	t.Parallel()
    49  
    50  	Convey("OnTryjobsUpdated", t, func() {
    51  		ct := cvtesting.Test{}
    52  		ctx, cancel := ct.SetUp(t)
    53  		defer cancel()
    54  
    55  		const lProject = "infra"
    56  
    57  		now := ct.Clock.Now().UTC()
    58  		rid := common.MakeRunID(lProject, now, 1, []byte("deadbeef"))
    59  		rs := &state.RunState{
    60  			Run: run.Run{
    61  				ID:         rid,
    62  				Status:     run.Status_RUNNING,
    63  				CreateTime: now.Add(-2 * time.Minute),
    64  				StartTime:  now.Add(-1 * time.Minute),
    65  				CLs:        common.CLIDs{1},
    66  			},
    67  		}
    68  		h, _ := makeTestHandler(&ct)
    69  
    70  		Convey("Enqueue longop", func() {
    71  			res, err := h.OnTryjobsUpdated(ctx, rs, common.MakeTryjobIDs(456, 789, 456))
    72  			So(err, ShouldBeNil)
    73  			So(res.SideEffectFn, ShouldBeNil)
    74  			So(res.PreserveEvents, ShouldBeFalse)
    75  			So(res.State.OngoingLongOps.GetOps(), ShouldHaveLength, 1)
    76  			for _, op := range res.State.OngoingLongOps.GetOps() {
    77  				So(op, ShouldResembleProto, &run.OngoingLongOps_Op{
    78  					Deadline: timestamppb.New(ct.Clock.Now().UTC().Add(maxTryjobExecutorDuration)),
    79  					Work: &run.OngoingLongOps_Op_ExecuteTryjobs{
    80  						ExecuteTryjobs: &tryjob.ExecuteTryjobsPayload{
    81  							TryjobsUpdated: []int64{456, 789}, // Also deduped 456
    82  						},
    83  					},
    84  				})
    85  			}
    86  		})
    87  
    88  		Convey("Defer if an tryjob execute task is ongoing", func() {
    89  			enqueueTryjobsUpdatedTask(ctx, rs, common.TryjobIDs{123})
    90  			res, err := h.OnTryjobsUpdated(ctx, rs, common.MakeTryjobIDs(456, 789, 456))
    91  			So(err, ShouldBeNil)
    92  			So(res.SideEffectFn, ShouldBeNil)
    93  			So(res.PreserveEvents, ShouldBeTrue)
    94  		})
    95  
    96  		statuses := []run.Status{
    97  			run.Status_SUCCEEDED,
    98  			run.Status_FAILED,
    99  			run.Status_CANCELLED,
   100  			run.Status_WAITING_FOR_SUBMISSION,
   101  			run.Status_SUBMITTING,
   102  		}
   103  		for _, status := range statuses {
   104  			Convey(fmt.Sprintf("Noop when Run is %s", status), func() {
   105  				rs.Status = status
   106  				res, err := h.OnTryjobsUpdated(ctx, rs, common.TryjobIDs{123})
   107  				So(err, ShouldBeNil)
   108  				So(res.State, ShouldEqual, rs)
   109  				So(res.SideEffectFn, ShouldBeNil)
   110  				So(res.PreserveEvents, ShouldBeFalse)
   111  			})
   112  		}
   113  	})
   114  }
   115  
   116  func TestOnCompletedExecuteTryjobs(t *testing.T) {
   117  	t.Parallel()
   118  
   119  	Convey("OnCompletedExecuteTryjobs works", t, func() {
   120  		ct := cvtesting.Test{}
   121  		ctx, cancel := ct.SetUp(t)
   122  		defer cancel()
   123  
   124  		const (
   125  			lProject = "chromium"
   126  			gHost    = "example-review.googlesource.com"
   127  			opID     = "1-1"
   128  		)
   129  		now := ct.Clock.Now().UTC()
   130  
   131  		prjcfgtest.Create(ctx, lProject, &cfgpb.Config{ConfigGroups: []*cfgpb.ConfigGroup{{Name: "single"}}})
   132  		rs := &state.RunState{
   133  			Run: run.Run{
   134  				ID:            common.MakeRunID(lProject, now, 1, []byte("deadbeef")),
   135  				Status:        run.Status_RUNNING,
   136  				CreateTime:    now.Add(-2 * time.Minute),
   137  				StartTime:     now.Add(-1 * time.Minute),
   138  				Mode:          run.DryRun,
   139  				ConfigGroupID: prjcfgtest.MustExist(ctx, lProject).ConfigGroupIDs[0],
   140  				CLs:           common.CLIDs{1},
   141  				OngoingLongOps: &run.OngoingLongOps{
   142  					Ops: map[string]*run.OngoingLongOps_Op{
   143  						opID: {
   144  							Work: &run.OngoingLongOps_Op_ExecuteTryjobs{
   145  								ExecuteTryjobs: &tryjob.ExecuteTryjobsPayload{
   146  									TryjobsUpdated: []int64{123},
   147  								},
   148  							},
   149  						},
   150  					},
   151  				},
   152  			},
   153  		}
   154  
   155  		for _, clid := range rs.CLs {
   156  			ci := gf.CI(100 + int(clid))
   157  			So(datastore.Put(ctx,
   158  				&run.RunCL{
   159  					ID:         clid,
   160  					Run:        datastore.MakeKey(ctx, common.RunKind, string(rs.ID)),
   161  					ExternalID: changelist.MustGobID(gHost, ci.GetNumber()),
   162  					Detail: &changelist.Snapshot{
   163  						LuciProject: lProject,
   164  						Kind: &changelist.Snapshot_Gerrit{
   165  							Gerrit: &changelist.Gerrit{
   166  								Host: gHost,
   167  								Info: proto.Clone(ci).(*gerritpb.ChangeInfo),
   168  							},
   169  						},
   170  						Patchset: 2,
   171  					},
   172  				},
   173  			), ShouldBeNil)
   174  		}
   175  
   176  		result := &eventpb.LongOpCompleted{
   177  			OperationId: opID,
   178  		}
   179  		h, _ := makeTestHandler(&ct)
   180  
   181  		for _, longOpStatus := range []eventpb.LongOpCompleted_Status{
   182  			eventpb.LongOpCompleted_EXPIRED,
   183  			eventpb.LongOpCompleted_FAILED,
   184  		} {
   185  			Convey(fmt.Sprintf("on long op %s", longOpStatus), func() {
   186  				result.Status = longOpStatus
   187  				res, err := h.OnLongOpCompleted(ctx, rs, result)
   188  				So(err, ShouldBeNil)
   189  				So(res.State.Status, ShouldEqual, run.Status_RUNNING)
   190  				So(res.State.OngoingLongOps.GetOps(), ShouldHaveLength, 1)
   191  				for _, op := range res.State.OngoingLongOps.GetOps() {
   192  					So(op.GetResetTriggers(), ShouldNotBeNil)
   193  					So(op.GetResetTriggers().GetRunStatusIfSucceeded(), ShouldEqual, run.Status_FAILED)
   194  				}
   195  				So(res.SideEffectFn, ShouldBeNil)
   196  				So(res.PreserveEvents, ShouldBeFalse)
   197  			})
   198  		}
   199  
   200  		Convey("on long op success", func() {
   201  			result.Status = eventpb.LongOpCompleted_SUCCEEDED
   202  
   203  			Convey("Tryjob execution still running", func() {
   204  				err := datastore.RunInTransaction(ctx, func(ctx context.Context) error {
   205  					return tryjob.SaveExecutionState(ctx, rs.ID, &tryjob.ExecutionState{
   206  						Status: tryjob.ExecutionState_RUNNING,
   207  					}, 0, nil)
   208  				}, nil)
   209  				So(err, ShouldBeNil)
   210  				res, err := h.OnLongOpCompleted(ctx, rs, result)
   211  				So(err, ShouldBeNil)
   212  				So(res.State.Status, ShouldEqual, run.Status_RUNNING)
   213  				So(res.State.Tryjobs, ShouldResembleProto, &run.Tryjobs{
   214  					State: &tryjob.ExecutionState{
   215  						Status: tryjob.ExecutionState_RUNNING,
   216  					},
   217  				})
   218  				So(res.State.OngoingLongOps, ShouldBeNil)
   219  				So(res.SideEffectFn, ShouldBeNil)
   220  				So(res.PreserveEvents, ShouldBeFalse)
   221  			})
   222  
   223  			Convey("Tryjob execution succeeds", func() {
   224  				err := datastore.RunInTransaction(ctx, func(ctx context.Context) error {
   225  					return tryjob.SaveExecutionState(ctx, rs.ID, &tryjob.ExecutionState{
   226  						Status: tryjob.ExecutionState_SUCCEEDED,
   227  					}, 0, nil)
   228  				}, nil)
   229  				So(err, ShouldBeNil)
   230  				Convey("Full Run", func() {
   231  					rs.Mode = run.FullRun
   232  					ctx = context.WithValue(ctx, &fakeTaskIDKey, "task-foo")
   233  					res, err := h.OnLongOpCompleted(ctx, rs, result)
   234  					So(err, ShouldBeNil)
   235  					So(res.State.Status, ShouldEqual, run.Status_SUBMITTING)
   236  					So(res.State.Tryjobs, ShouldResembleProto, &run.Tryjobs{
   237  						State: &tryjob.ExecutionState{
   238  							Status: tryjob.ExecutionState_SUCCEEDED,
   239  						},
   240  					})
   241  					So(res.State.OngoingLongOps, ShouldBeNil)
   242  					So(res.State.Submission, ShouldNotBeNil)
   243  					So(submit.MustCurrentRun(ctx, lProject), ShouldEqual, rs.ID)
   244  					So(res.SideEffectFn, ShouldBeNil)
   245  					So(res.PreserveEvents, ShouldBeFalse)
   246  				})
   247  				Convey("Dry Run", func() {
   248  					rs.Mode = run.DryRun
   249  					res, err := h.OnLongOpCompleted(ctx, rs, result)
   250  					So(err, ShouldBeNil)
   251  					So(res.State.Status, ShouldEqual, run.Status_RUNNING)
   252  					So(res.State.Tryjobs, ShouldResembleProto, &run.Tryjobs{
   253  						State: &tryjob.ExecutionState{
   254  							Status: tryjob.ExecutionState_SUCCEEDED,
   255  						},
   256  					})
   257  					So(res.State.OngoingLongOps.GetOps(), ShouldHaveLength, 1)
   258  					for _, op := range res.State.OngoingLongOps.GetOps() {
   259  						So(op.GetResetTriggers(), ShouldNotBeNil)
   260  						So(op.GetResetTriggers().GetRequests(), ShouldResembleProto, []*run.OngoingLongOps_Op_ResetTriggers_Request{
   261  							{
   262  								Clid:    int64(rs.CLs[0]),
   263  								Message: "This CL has passed the run",
   264  								Notify: gerrit.Whoms{
   265  									gerrit.Whom_OWNER,
   266  									gerrit.Whom_CQ_VOTERS,
   267  								},
   268  							},
   269  						})
   270  						So(op.GetResetTriggers().GetRunStatusIfSucceeded(), ShouldEqual, run.Status_SUCCEEDED)
   271  					}
   272  					So(res.SideEffectFn, ShouldBeNil)
   273  					So(res.PreserveEvents, ShouldBeFalse)
   274  				})
   275  				Convey("New Patchset Run, no message is posted", func() {
   276  					rs.Mode = run.NewPatchsetRun
   277  					res, err := h.OnLongOpCompleted(ctx, rs, result)
   278  					So(err, ShouldBeNil)
   279  					So(res.State.Status, ShouldEqual, run.Status_RUNNING)
   280  					So(res.State.Tryjobs, ShouldResembleProto, &run.Tryjobs{
   281  						State: &tryjob.ExecutionState{
   282  							Status: tryjob.ExecutionState_SUCCEEDED,
   283  						},
   284  					})
   285  					So(res.State.OngoingLongOps.GetOps(), ShouldHaveLength, 1)
   286  					for _, op := range res.State.OngoingLongOps.GetOps() {
   287  						So(op.GetResetTriggers(), ShouldNotBeNil)
   288  						So(op.GetResetTriggers().GetRequests(), ShouldResembleProto, []*run.OngoingLongOps_Op_ResetTriggers_Request{
   289  							{Clid: int64(rs.CLs[0])},
   290  						})
   291  						So(op.GetResetTriggers().GetRunStatusIfSucceeded(), ShouldEqual, run.Status_SUCCEEDED)
   292  					}
   293  					So(res.SideEffectFn, ShouldBeNil)
   294  					So(res.PreserveEvents, ShouldBeFalse)
   295  				})
   296  			})
   297  
   298  			Convey("Tryjob execution failed", func() {
   299  				Convey("tryjobs result failure", func() {
   300  					tj := tryjob.MustBuildbucketID("example.com", 12345).MustCreateIfNotExists(ctx)
   301  					tj.Result = &tryjob.Result{
   302  						Backend: &tryjob.Result_Buildbucket_{
   303  							Buildbucket: &tryjob.Result_Buildbucket{
   304  								Builder: &bbpb.BuilderID{
   305  									Project: lProject,
   306  									Bucket:  "test",
   307  									Builder: "foo",
   308  								},
   309  								SummaryMarkdown: "this is the summary",
   310  							},
   311  						},
   312  					}
   313  					So(datastore.Put(ctx, tj), ShouldBeNil)
   314  
   315  					err := datastore.RunInTransaction(ctx, func(ctx context.Context) error {
   316  						return tryjob.SaveExecutionState(ctx, rs.ID, &tryjob.ExecutionState{
   317  							Status: tryjob.ExecutionState_FAILED,
   318  							Failures: &tryjob.ExecutionState_Failures{
   319  								UnsuccessfulResults: []*tryjob.ExecutionState_Failures_UnsuccessfulResult{
   320  									{TryjobId: int64(tj.ID)},
   321  								},
   322  							},
   323  						}, 0, nil)
   324  					}, nil)
   325  					So(err, ShouldBeNil)
   326  					res, err := h.OnLongOpCompleted(ctx, rs, result)
   327  					So(err, ShouldBeNil)
   328  					So(res.State.Status, ShouldEqual, run.Status_RUNNING)
   329  					So(res.State.Tryjobs, ShouldResembleProto, &run.Tryjobs{
   330  						State: &tryjob.ExecutionState{
   331  							Status: tryjob.ExecutionState_FAILED,
   332  							Failures: &tryjob.ExecutionState_Failures{
   333  								UnsuccessfulResults: []*tryjob.ExecutionState_Failures_UnsuccessfulResult{
   334  									{TryjobId: int64(tj.ID)},
   335  								},
   336  							},
   337  						},
   338  					})
   339  					So(res.State.OngoingLongOps.GetOps(), ShouldHaveLength, 1)
   340  					for _, op := range res.State.OngoingLongOps.GetOps() {
   341  						So(op.GetResetTriggers(), ShouldNotBeNil)
   342  						So(op.GetResetTriggers().GetRequests(), ShouldResembleProto, []*run.OngoingLongOps_Op_ResetTriggers_Request{
   343  							{
   344  								Clid:    int64(rs.CLs[0]),
   345  								Message: "This CL has failed the run. Reason:\n\nTryjob [chromium/test/foo](https://example.com/build/12345) has failed with summary ([view all results](https://example-review.googlesource.com/c/101?checksPatchset=2&tab=checks)):\n\n---\nthis is the summary",
   346  								Notify: gerrit.Whoms{
   347  									gerrit.Whom_OWNER,
   348  									gerrit.Whom_CQ_VOTERS,
   349  								},
   350  								AddToAttention: gerrit.Whoms{
   351  									gerrit.Whom_OWNER,
   352  									gerrit.Whom_CQ_VOTERS,
   353  								},
   354  								AddToAttentionReason: "Tryjobs failed",
   355  							},
   356  						})
   357  						So(op.GetResetTriggers().GetRunStatusIfSucceeded(), ShouldEqual, run.Status_FAILED)
   358  					}
   359  					So(res.SideEffectFn, ShouldBeNil)
   360  					So(res.PreserveEvents, ShouldBeFalse)
   361  				})
   362  
   363  				Convey("failed to launch tryjobs", func() {
   364  					def := &tryjob.Definition{
   365  						Backend: &tryjob.Definition_Buildbucket_{
   366  							Buildbucket: &tryjob.Definition_Buildbucket{
   367  								Host: "example.com",
   368  								Builder: &bbpb.BuilderID{
   369  									Project: lProject,
   370  									Bucket:  "test",
   371  									Builder: "foo",
   372  								},
   373  							},
   374  						},
   375  					}
   376  					err := datastore.RunInTransaction(ctx, func(ctx context.Context) error {
   377  						return tryjob.SaveExecutionState(ctx, rs.ID, &tryjob.ExecutionState{
   378  							Status: tryjob.ExecutionState_FAILED,
   379  							Failures: &tryjob.ExecutionState_Failures{
   380  								LaunchFailures: []*tryjob.ExecutionState_Failures_LaunchFailure{
   381  									{
   382  										Definition: def,
   383  										Reason:     "permission denied",
   384  									},
   385  								},
   386  							},
   387  						}, 0, nil)
   388  					}, nil)
   389  					So(err, ShouldBeNil)
   390  					res, err := h.OnLongOpCompleted(ctx, rs, result)
   391  					So(err, ShouldBeNil)
   392  					So(res.State.Status, ShouldEqual, run.Status_RUNNING)
   393  					So(res.State.Tryjobs, ShouldResembleProto, &run.Tryjobs{
   394  						State: &tryjob.ExecutionState{
   395  							Status: tryjob.ExecutionState_FAILED,
   396  							Failures: &tryjob.ExecutionState_Failures{
   397  								LaunchFailures: []*tryjob.ExecutionState_Failures_LaunchFailure{
   398  									{
   399  										Definition: def,
   400  										Reason:     "permission denied",
   401  									},
   402  								},
   403  							},
   404  						},
   405  					})
   406  					So(res.State.OngoingLongOps.GetOps(), ShouldHaveLength, 1)
   407  					for _, op := range res.State.OngoingLongOps.GetOps() {
   408  						So(op.GetResetTriggers(), ShouldNotBeNil)
   409  						So(op.GetResetTriggers().GetRequests(), ShouldResembleProto, []*run.OngoingLongOps_Op_ResetTriggers_Request{
   410  							{
   411  								Clid:    int64(rs.CLs[0]),
   412  								Message: "Failed to launch tryjob `chromium/test/foo`. Reason: permission denied",
   413  								Notify: gerrit.Whoms{
   414  									gerrit.Whom_OWNER,
   415  									gerrit.Whom_CQ_VOTERS,
   416  								},
   417  								AddToAttention: gerrit.Whoms{
   418  									gerrit.Whom_OWNER,
   419  									gerrit.Whom_CQ_VOTERS,
   420  								},
   421  								AddToAttentionReason: "Tryjobs failed",
   422  							},
   423  						})
   424  						So(op.GetResetTriggers().GetRunStatusIfSucceeded(), ShouldEqual, run.Status_FAILED)
   425  					}
   426  					So(res.SideEffectFn, ShouldBeNil)
   427  					So(res.PreserveEvents, ShouldBeFalse)
   428  				})
   429  
   430  				Convey("Unknown error", func() {
   431  					err := datastore.RunInTransaction(ctx, func(ctx context.Context) error {
   432  						return tryjob.SaveExecutionState(ctx, rs.ID, &tryjob.ExecutionState{
   433  							Status: tryjob.ExecutionState_FAILED,
   434  						}, 0, nil)
   435  					}, nil)
   436  					So(err, ShouldBeNil)
   437  					res, err := h.OnLongOpCompleted(ctx, rs, result)
   438  					So(err, ShouldBeNil)
   439  					So(res.State.Status, ShouldEqual, run.Status_RUNNING)
   440  					So(res.State.Tryjobs, ShouldResembleProto, &run.Tryjobs{
   441  						State: &tryjob.ExecutionState{
   442  							Status: tryjob.ExecutionState_FAILED,
   443  						},
   444  					})
   445  					So(res.State.OngoingLongOps.GetOps(), ShouldHaveLength, 1)
   446  					for _, op := range res.State.OngoingLongOps.GetOps() {
   447  						So(op.GetResetTriggers(), ShouldNotBeNil)
   448  						So(op.GetResetTriggers().GetRequests(), ShouldResembleProto, []*run.OngoingLongOps_Op_ResetTriggers_Request{
   449  							{
   450  								Clid:    int64(rs.CLs[0]),
   451  								Message: "Unexpected error when processing Tryjobs. Please retry. If retry continues to fail, please contact LUCI team.\n\n" + cvBugLink,
   452  								Notify: gerrit.Whoms{
   453  									gerrit.Whom_OWNER,
   454  									gerrit.Whom_CQ_VOTERS,
   455  								},
   456  								AddToAttention: gerrit.Whoms{
   457  									gerrit.Whom_OWNER,
   458  									gerrit.Whom_CQ_VOTERS,
   459  								},
   460  								AddToAttentionReason: "Run failed",
   461  							},
   462  						})
   463  						So(op.GetResetTriggers().GetRunStatusIfSucceeded(), ShouldEqual, run.Status_FAILED)
   464  					}
   465  					So(res.SideEffectFn, ShouldBeNil)
   466  					So(res.PreserveEvents, ShouldBeFalse)
   467  				})
   468  
   469  				Convey("Only reset trigger on root CL", func() {
   470  					err := datastore.RunInTransaction(ctx, func(ctx context.Context) error {
   471  						return tryjob.SaveExecutionState(ctx, rs.ID, &tryjob.ExecutionState{
   472  							Status: tryjob.ExecutionState_FAILED,
   473  						}, 0, nil)
   474  					}, nil)
   475  					So(err, ShouldBeNil)
   476  					anotherCL := &run.RunCL{
   477  						ID:         1002,
   478  						Run:        datastore.MakeKey(ctx, common.RunKind, string(rs.ID)),
   479  						ExternalID: changelist.MustGobID(gHost, 1002),
   480  						Detail: &changelist.Snapshot{
   481  							LuciProject: lProject,
   482  							Kind: &changelist.Snapshot_Gerrit{
   483  								Gerrit: &changelist.Gerrit{
   484  									Host: gHost,
   485  									Info: gf.CI(1002),
   486  								},
   487  							},
   488  							Patchset: 2,
   489  						},
   490  					}
   491  					So(datastore.Put(ctx, anotherCL), ShouldBeNil)
   492  					rs.CLs = append(rs.CLs, anotherCL.ID)
   493  
   494  					rs.RootCL = anotherCL.ID
   495  					res, err := h.OnLongOpCompleted(ctx, rs, result)
   496  					So(err, ShouldBeNil)
   497  					So(res.State.OngoingLongOps.GetOps(), ShouldHaveLength, 1)
   498  					for _, op := range res.State.OngoingLongOps.GetOps() {
   499  						So(op.GetResetTriggers(), ShouldNotBeNil)
   500  						So(op.GetResetTriggers().GetRequests(), ShouldResembleProto, []*run.OngoingLongOps_Op_ResetTriggers_Request{
   501  							{
   502  								Clid:    int64(anotherCL.ID),
   503  								Message: "Unexpected error when processing Tryjobs. Please retry. If retry continues to fail, please contact LUCI team.\n\n" + cvBugLink,
   504  								Notify: gerrit.Whoms{
   505  									gerrit.Whom_OWNER,
   506  									gerrit.Whom_CQ_VOTERS,
   507  								},
   508  								AddToAttention: gerrit.Whoms{
   509  									gerrit.Whom_OWNER,
   510  									gerrit.Whom_CQ_VOTERS,
   511  								},
   512  								AddToAttentionReason: "Run failed",
   513  							},
   514  						})
   515  						So(op.GetResetTriggers().GetRunStatusIfSucceeded(), ShouldEqual, run.Status_FAILED)
   516  					}
   517  				})
   518  			})
   519  		})
   520  	})
   521  }
   522  
   523  func TestComposeTryjobsFailureReason(t *testing.T) {
   524  	Convey("ComposeTryjobsFailureReason", t, func() {
   525  		cl := &run.RunCL{
   526  			ID:         1111,
   527  			ExternalID: changelist.MustGobID("example.review.com", 123),
   528  			Detail: &changelist.Snapshot{
   529  				LuciProject: "test",
   530  				Kind: &changelist.Snapshot_Gerrit{
   531  					Gerrit: &changelist.Gerrit{
   532  						Host: "example.review.com",
   533  					},
   534  				},
   535  				Patchset: 2,
   536  			},
   537  		}
   538  		Convey("panics", func() {
   539  			So(func() {
   540  				_ = composeTryjobsResultFailureReason(cl, nil)
   541  			}, ShouldPanicLike, "called without tryjobs")
   542  		})
   543  		const bbHost = "test.com"
   544  		builder := &bbpb.BuilderID{
   545  			Project: "test_proj",
   546  			Bucket:  "test_bucket",
   547  			Builder: "test_builder",
   548  		}
   549  		Convey("works", func() {
   550  			Convey("single", func() {
   551  				Convey("restricted", func() {
   552  					r := composeTryjobsResultFailureReason(cl, []*tryjob.Tryjob{
   553  						{
   554  							ExternalID: tryjob.MustBuildbucketID(bbHost, 123456790),
   555  							Definition: &tryjob.Definition{
   556  								Backend: &tryjob.Definition_Buildbucket_{
   557  									Buildbucket: &tryjob.Definition_Buildbucket{
   558  										Builder: builder,
   559  										Host:    bbHost,
   560  									},
   561  								},
   562  								ResultVisibility: cfgpb.CommentLevel_COMMENT_LEVEL_RESTRICTED,
   563  							},
   564  							Result: &tryjob.Result{
   565  								Backend: &tryjob.Result_Buildbucket_{
   566  									Buildbucket: &tryjob.Result_Buildbucket{
   567  										Builder:         builder,
   568  										SummaryMarkdown: "A couple\nof lines\nwith secret details",
   569  									},
   570  								},
   571  							},
   572  						},
   573  					})
   574  					So(r, ShouldEqual, "[Tryjob](https://test.com/build/123456790) has failed")
   575  				})
   576  				Convey("not restricted", func() {
   577  					r := composeTryjobsResultFailureReason(cl, []*tryjob.Tryjob{
   578  						{
   579  							ExternalID: tryjob.MustBuildbucketID(bbHost, 123456790),
   580  							Definition: &tryjob.Definition{
   581  								Backend: &tryjob.Definition_Buildbucket_{
   582  									Buildbucket: &tryjob.Definition_Buildbucket{
   583  										Builder: builder,
   584  										Host:    bbHost,
   585  									},
   586  								},
   587  								ResultVisibility: cfgpb.CommentLevel_COMMENT_LEVEL_FULL,
   588  							},
   589  							Result: &tryjob.Result{
   590  								Backend: &tryjob.Result_Buildbucket_{
   591  									Buildbucket: &tryjob.Result_Buildbucket{
   592  										Builder:         builder,
   593  										SummaryMarkdown: "A couple\nof lines\nwith public details",
   594  									},
   595  								},
   596  							},
   597  						},
   598  					})
   599  					So(r, ShouldEqual, "Tryjob [test_proj/test_bucket/test_builder](https://test.com/build/123456790) has failed with summary ([view all results](https://example.review.com/c/123?checksPatchset=2&tab=checks)):\n\n---\nA couple\nof lines\nwith public details")
   600  				})
   601  			})
   602  
   603  			Convey("multiple tryjobs", func() {
   604  				tjs := []*tryjob.Tryjob{
   605  					// restricted.
   606  					{
   607  						ExternalID: tryjob.MustBuildbucketID("test.com", 123456790),
   608  						Definition: &tryjob.Definition{
   609  							Backend: &tryjob.Definition_Buildbucket_{
   610  								Buildbucket: &tryjob.Definition_Buildbucket{
   611  									Builder: builder,
   612  									Host:    bbHost,
   613  								},
   614  							},
   615  							ResultVisibility: cfgpb.CommentLevel_COMMENT_LEVEL_RESTRICTED,
   616  						},
   617  						Result: &tryjob.Result{
   618  							Backend: &tryjob.Result_Buildbucket_{
   619  								Buildbucket: &tryjob.Result_Buildbucket{
   620  									Builder:         builder,
   621  									SummaryMarkdown: "A couple\nof lines\nwith secret details",
   622  								},
   623  							},
   624  						},
   625  					},
   626  					// un-restricted but empty summary markdown.
   627  					{
   628  						ExternalID: tryjob.MustBuildbucketID("test.com", 123456791),
   629  						Definition: &tryjob.Definition{
   630  							Backend: &tryjob.Definition_Buildbucket_{
   631  								Buildbucket: &tryjob.Definition_Buildbucket{
   632  									Builder: builder,
   633  									Host:    bbHost,
   634  								},
   635  							},
   636  							ResultVisibility: cfgpb.CommentLevel_COMMENT_LEVEL_FULL,
   637  						},
   638  						Result: &tryjob.Result{
   639  							Backend: &tryjob.Result_Buildbucket_{
   640  								Buildbucket: &tryjob.Result_Buildbucket{
   641  									Builder: builder,
   642  								},
   643  							},
   644  						},
   645  					},
   646  					// un-restricted.
   647  					{
   648  						ExternalID: tryjob.MustBuildbucketID("test.com", 123456792),
   649  						Definition: &tryjob.Definition{
   650  							Backend: &tryjob.Definition_Buildbucket_{
   651  								Buildbucket: &tryjob.Definition_Buildbucket{
   652  									Builder: builder,
   653  									Host:    bbHost,
   654  								},
   655  							},
   656  							ResultVisibility: cfgpb.CommentLevel_COMMENT_LEVEL_FULL,
   657  						},
   658  						Result: &tryjob.Result{
   659  							Backend: &tryjob.Result_Buildbucket_{
   660  								Buildbucket: &tryjob.Result_Buildbucket{
   661  									SummaryMarkdown: "A couple\nof lines\nwith public details",
   662  								},
   663  							},
   664  						},
   665  					},
   666  				}
   667  
   668  				Convey("all public visibility", func() {
   669  					r := composeTryjobsResultFailureReason(cl, tjs[1:])
   670  					So(r, ShouldEqual, "Failed Tryjobs:\n* [test_proj/test_bucket/test_builder](https://test.com/build/123456791)\n* [test_proj/test_bucket/test_builder](https://test.com/build/123456792). Summary ([view all results](https://example.review.com/c/123?checksPatchset=2&tab=checks)):\n\n---\nA couple\nof lines\nwith public details\n\n---")
   671  				})
   672  
   673  				Convey("with one restricted visibility", func() {
   674  					r := composeTryjobsResultFailureReason(cl, tjs)
   675  					So(r, ShouldEqual, "Failed Tryjobs:\n* https://test.com/build/123456790\n* https://test.com/build/123456791\n* https://test.com/build/123456792")
   676  				})
   677  			})
   678  		})
   679  	})
   680  }
   681  
   682  func TestComposeLaunchFailureReason(t *testing.T) {
   683  	Convey("Compose Launch Failure Reason", t, func() {
   684  		defFoo := &tryjob.Definition{
   685  			Backend: &tryjob.Definition_Buildbucket_{
   686  				Buildbucket: &tryjob.Definition_Buildbucket{
   687  					Host: "buildbucket.example.com",
   688  					Builder: &bbpb.BuilderID{
   689  						Project: "ProjectFoo",
   690  						Bucket:  "BucketFoo",
   691  						Builder: "BuilderFoo",
   692  					},
   693  				},
   694  			},
   695  		}
   696  		Convey("Single", func() {
   697  			Convey("restricted", func() {
   698  				defFoo.ResultVisibility = cfgpb.CommentLevel_COMMENT_LEVEL_RESTRICTED
   699  				reason := composeLaunchFailureReason(
   700  					[]*tryjob.ExecutionState_Failures_LaunchFailure{
   701  						{Definition: defFoo, Reason: "permission denied"},
   702  					})
   703  				So(reason, ShouldEqual, "Failed to launch one tryjob. The tryjob name can't be shown due to configuration. Please contact your Project admin for help.")
   704  			})
   705  			Convey("public", func() {
   706  				reason := composeLaunchFailureReason(
   707  					[]*tryjob.ExecutionState_Failures_LaunchFailure{
   708  						{Definition: defFoo, Reason: "permission denied"},
   709  					})
   710  				So(reason, ShouldEqual, "Failed to launch tryjob `ProjectFoo/BucketFoo/BuilderFoo`. Reason: permission denied")
   711  			})
   712  		})
   713  		defBar := &tryjob.Definition{
   714  			Backend: &tryjob.Definition_Buildbucket_{
   715  				Buildbucket: &tryjob.Definition_Buildbucket{
   716  					Host: "buildbucket.example.com",
   717  					Builder: &bbpb.BuilderID{
   718  						Project: "ProjectBar",
   719  						Bucket:  "BucketBar",
   720  						Builder: "BuilderBar",
   721  					},
   722  				},
   723  			},
   724  		}
   725  		Convey("Multiple", func() {
   726  			Convey("All restricted", func() {
   727  				defFoo.ResultVisibility = cfgpb.CommentLevel_COMMENT_LEVEL_RESTRICTED
   728  				defBar.ResultVisibility = cfgpb.CommentLevel_COMMENT_LEVEL_RESTRICTED
   729  				reason := composeLaunchFailureReason(
   730  					[]*tryjob.ExecutionState_Failures_LaunchFailure{
   731  						{Definition: defFoo, Reason: "permission denied"},
   732  						{Definition: defBar, Reason: "builder not found"},
   733  					})
   734  				So(reason, ShouldEqual, "Failed to launch 2 tryjobs. The tryjob names can't be shown due to configuration. Please contact your Project admin for help.")
   735  			})
   736  			Convey("Partial restricted", func() {
   737  				defBar.ResultVisibility = cfgpb.CommentLevel_COMMENT_LEVEL_RESTRICTED
   738  				reason := composeLaunchFailureReason(
   739  					[]*tryjob.ExecutionState_Failures_LaunchFailure{
   740  						{Definition: defFoo, Reason: "permission denied"},
   741  						{Definition: defBar, Reason: "builder not found"},
   742  					})
   743  				So(reason, ShouldEqual, "Failed to launch the following tryjobs:\n* `ProjectFoo/BucketFoo/BuilderFoo`; Failure reason: permission denied\n\nIn addition to the tryjobs above, failed to launch 1 tryjob. But the tryjob names can't be shown due to configuration. Please contact your Project admin for help.")
   744  			})
   745  			Convey("All public", func() {
   746  				reason := composeLaunchFailureReason(
   747  					[]*tryjob.ExecutionState_Failures_LaunchFailure{
   748  						{Definition: defFoo, Reason: "permission denied"},
   749  						{Definition: defBar, Reason: "builder not found"},
   750  					})
   751  				So(reason, ShouldEqual, "Failed to launch the following tryjobs:\n* `ProjectBar/BucketBar/BuilderBar`; Failure reason: builder not found\n* `ProjectFoo/BucketFoo/BuilderFoo`; Failure reason: permission denied")
   752  			})
   753  		})
   754  	})
   755  }