go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/internal/bugs/monorail/manager_test.go (about)

     1  // Copyright 2022 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 monorail
    16  
    17  import (
    18  	"context"
    19  	"strings"
    20  	"testing"
    21  	"time"
    22  
    23  	"google.golang.org/genproto/protobuf/field_mask"
    24  	"google.golang.org/grpc/codes"
    25  	"google.golang.org/grpc/status"
    26  	"google.golang.org/protobuf/types/known/timestamppb"
    27  
    28  	"go.chromium.org/luci/common/clock/testclock"
    29  	"go.chromium.org/luci/common/errors"
    30  
    31  	"go.chromium.org/luci/analysis/internal/bugs"
    32  	mpb "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto"
    33  	bugspb "go.chromium.org/luci/analysis/internal/bugs/proto"
    34  	"go.chromium.org/luci/analysis/internal/clustering"
    35  	"go.chromium.org/luci/analysis/internal/config"
    36  	configpb "go.chromium.org/luci/analysis/proto/config"
    37  
    38  	. "github.com/smartystreets/goconvey/convey"
    39  	. "go.chromium.org/luci/common/testing/assertions"
    40  )
    41  
    42  func NewCreateRequest() bugs.BugCreateRequest {
    43  	cluster := bugs.BugCreateRequest{
    44  		Description: &clustering.ClusterDescription{
    45  			Title:       "ClusterID",
    46  			Description: "Tests are failing with reason: Some failure reason.",
    47  		},
    48  		MonorailComponents: []string{
    49  			"Blink>Layout",
    50  			"Blink>Network",
    51  			"Blink>Invalid",
    52  		},
    53  		RuleID: "new-rule-id",
    54  	}
    55  	return cluster
    56  }
    57  
    58  func TestManager(t *testing.T) {
    59  	t.Parallel()
    60  
    61  	Convey("With Bug Manager", t, func() {
    62  		ctx := context.Background()
    63  		f := &FakeIssuesStore{
    64  			NextID:            100,
    65  			PriorityFieldName: "projects/chromium/fieldDefs/11",
    66  			ComponentNames: []string{
    67  				"projects/chromium/componentDefs/Blink",
    68  				"projects/chromium/componentDefs/Blink>Layout",
    69  				"projects/chromium/componentDefs/Blink>Network",
    70  			},
    71  		}
    72  		user := AutomationUsers[0]
    73  		cl, err := NewClient(UseFakeIssuesClient(ctx, f, user), "myhost")
    74  		So(err, ShouldBeNil)
    75  		monorailCfgs := ChromiumTestConfig()
    76  
    77  		policyA := config.CreatePlaceholderBugManagementPolicy("policy-a")
    78  		policyA.HumanReadableName = "Problem A"
    79  		policyA.Priority = configpb.BuganizerPriority_P4
    80  		policyA.BugTemplate.Monorail.Labels = []string{"Policy-A-Label"}
    81  
    82  		policyB := config.CreatePlaceholderBugManagementPolicy("policy-b")
    83  		policyB.HumanReadableName = "Problem B"
    84  		policyB.Priority = configpb.BuganizerPriority_P0
    85  		policyB.BugTemplate.Monorail.Labels = []string{"Policy-B-Label"}
    86  
    87  		policyC := config.CreatePlaceholderBugManagementPolicy("policy-c")
    88  		policyC.HumanReadableName = "Problem C"
    89  		policyC.Priority = configpb.BuganizerPriority_P1
    90  		policyC.BugTemplate.Monorail.Labels = []string{"Policy-C-Label"}
    91  
    92  		projectCfg := &configpb.ProjectConfig{
    93  			BugManagement: &configpb.BugManagement{
    94  				DefaultBugSystem: configpb.BugSystem_MONORAIL,
    95  				Monorail:         monorailCfgs,
    96  				Policies: []*configpb.BugManagementPolicy{
    97  					policyA,
    98  					policyB,
    99  					policyC,
   100  				},
   101  			},
   102  		}
   103  
   104  		bm, err := NewBugManager(cl, "https://luci-analysis-test.appspot.com", "luciproject", projectCfg)
   105  		So(err, ShouldBeNil)
   106  		now := time.Date(2040, time.January, 1, 2, 3, 4, 5, time.UTC)
   107  		ctx, tc := testclock.UseTime(ctx, now)
   108  
   109  		Convey("Create", func() {
   110  			createRequest := NewCreateRequest()
   111  			createRequest.ActivePolicyIDs = map[bugs.PolicyID]struct{}{
   112  				"policy-a": {}, // P4
   113  			}
   114  
   115  			expectedIssue := &mpb.Issue{
   116  				Name:             "projects/chromium/issues/100",
   117  				Summary:          "Tests are failing: ClusterID",
   118  				Reporter:         AutomationUsers[0],
   119  				State:            mpb.IssueContentState_ACTIVE,
   120  				Status:           &mpb.Issue_StatusValue{Status: "Untriaged"},
   121  				StatusModifyTime: timestamppb.New(now),
   122  				FieldValues: []*mpb.FieldValue{
   123  					{
   124  						// Type field.
   125  						Field: "projects/chromium/fieldDefs/10",
   126  						Value: "Bug",
   127  					},
   128  					{
   129  						// Priority field.
   130  						Field: "projects/chromium/fieldDefs/11",
   131  						// Monorail projects do not support priority level P4,
   132  						// so we use P3 for policies that require P4.
   133  						Value: "3",
   134  					},
   135  				},
   136  				Components: []*mpb.Issue_ComponentValue{
   137  					{Component: "projects/chromium/componentDefs/Blink>Layout"},
   138  					{Component: "projects/chromium/componentDefs/Blink>Network"},
   139  				},
   140  				Labels: []*mpb.Issue_LabelValue{{
   141  					Label: "LUCI-Analysis-Auto-Filed",
   142  				}, {
   143  					Label: "Policy-A-Label",
   144  				}, {
   145  					Label: "Restrict-View-Google",
   146  				}},
   147  			}
   148  
   149  			Convey("With reason-based failure cluster", func() {
   150  				reason := `Expected equality of these values:
   151  					"Expected_Value"
   152  					my_expr.evaluate(123)
   153  						Which is: "Unexpected_Value"`
   154  				createRequest.Description.Title = reason
   155  				createRequest.Description.Description = "A cluster of failures has been found with reason: " + reason
   156  
   157  				expectedIssue.Summary = "Tests are failing: Expected equality of these values: \"Expected_Value\" my_expr.evaluate(123) Which is: \"Unexpected_Value\""
   158  				expectedComment := "A cluster of failures has been found with reason: Expected equality " +
   159  					"of these values:\n\t\t\t\t\t\"Expected_Value\"\n\t\t\t\t\tmy_expr.evaluate(123)\n\t\t\t\t\t\t" +
   160  					"Which is: \"Unexpected_Value\"\n" +
   161  					"\n" +
   162  					"These test failures are causing problem(s) which require your attention, including:\n" +
   163  					"- Problem A\n" +
   164  					"\n" +
   165  					"See current problems, failure examples and more in LUCI Analysis at: https://luci-analysis-test.appspot.com/p/luciproject/rules/new-rule-id\n" +
   166  					"\n" +
   167  					"How to action this bug: https://luci-analysis-test.appspot.com/help#new-bug-filed\n" +
   168  					"Provide feedback: https://luci-analysis-test.appspot.com/help#feedback\n" +
   169  					"Was this bug filed in the wrong component? See: https://luci-analysis-test.appspot.com/help#component-selection"
   170  
   171  				Convey("Base case", func() {
   172  					// Act
   173  					response := bm.Create(ctx, createRequest)
   174  
   175  					// Verify
   176  					So(response, ShouldResemble, bugs.BugCreateResponse{
   177  						ID: "chromium/100",
   178  						PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   179  							"policy-a": {},
   180  						},
   181  					})
   182  					So(len(f.Issues), ShouldEqual, 1)
   183  
   184  					issue := f.Issues[0]
   185  					So(issue.Issue, ShouldResembleProto, expectedIssue)
   186  					So(len(issue.Comments), ShouldEqual, 2)
   187  					So(issue.Comments[0].Content, ShouldEqual, expectedComment)
   188  				})
   189  				Convey("Policy with comment template", func() {
   190  					policyA.BugTemplate.CommentTemplate = "RuleURL:{{.RuleURL}},BugID:{{if .BugID.IsMonorail}}{{.BugID.MonorailProject}}/{{.BugID.MonorailBugID}}{{end}}"
   191  
   192  					bm, err := NewBugManager(cl, "https://luci-analysis-test.appspot.com", "luciproject", projectCfg)
   193  					So(err, ShouldBeNil)
   194  
   195  					// Act
   196  					response := bm.Create(ctx, createRequest)
   197  
   198  					// Verify
   199  					So(response, ShouldResemble, bugs.BugCreateResponse{
   200  						ID: "chromium/100",
   201  						PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   202  							"policy-a": {},
   203  						},
   204  					})
   205  					So(len(f.Issues), ShouldEqual, 1)
   206  
   207  					issue := f.Issues[0]
   208  					So(issue.Issue, ShouldResembleProto, expectedIssue)
   209  					So(len(issue.Comments), ShouldEqual, 2)
   210  					So(issue.Comments[0].Content, ShouldEqual, expectedComment)
   211  					So(issue.Comments[1].Content, ShouldEqual, "RuleURL:https://luci-analysis-test.appspot.com/p/luciproject/rules/new-rule-id,BugID:chromium/100\n\n"+
   212  						"Why LUCI Analysis posted this comment: https://luci-analysis-test.appspot.com/help#policy-activated (Policy ID: policy-a)")
   213  					So(issue.NotifyCount, ShouldEqual, 2)
   214  				})
   215  				Convey("Policy has no comment template", func() {
   216  					policyA.BugTemplate.CommentTemplate = ""
   217  
   218  					bm, err := NewBugManager(cl, "https://luci-analysis-test.appspot.com", "luciproject", projectCfg)
   219  					So(err, ShouldBeNil)
   220  
   221  					// Act
   222  					response := bm.Create(ctx, createRequest)
   223  
   224  					// Verify
   225  					So(response, ShouldResemble, bugs.BugCreateResponse{
   226  						ID: "chromium/100",
   227  						PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   228  							"policy-a": {},
   229  						},
   230  					})
   231  					So(len(f.Issues), ShouldEqual, 1)
   232  
   233  					issue := f.Issues[0]
   234  					So(issue.Issue, ShouldResembleProto, expectedIssue)
   235  					So(len(issue.Comments), ShouldEqual, 2)
   236  					So(issue.Comments[0].Content, ShouldEqual, expectedComment)
   237  					So(issue.Comments[1].Content, ShouldBeEmpty)
   238  					So(issue.NotifyCount, ShouldEqual, 1)
   239  				})
   240  				Convey("Policy has no comment template or labels", func() {
   241  					policyA.BugTemplate.CommentTemplate = ""
   242  					policyA.BugTemplate.Monorail = nil
   243  
   244  					bm, err := NewBugManager(cl, "https://luci-analysis-test.appspot.com", "luciproject", projectCfg)
   245  					So(err, ShouldBeNil)
   246  
   247  					expectedIssue.Labels = removeLabel(expectedIssue.Labels, "Policy-A-Label")
   248  
   249  					// Act
   250  					response := bm.Create(ctx, createRequest)
   251  
   252  					// Verify
   253  					So(response, ShouldResemble, bugs.BugCreateResponse{
   254  						ID: "chromium/100",
   255  						PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   256  							"policy-a": {},
   257  						},
   258  					})
   259  					So(len(f.Issues), ShouldEqual, 1)
   260  
   261  					issue := f.Issues[0]
   262  					So(issue.Issue, ShouldResembleProto, expectedIssue)
   263  					So(len(issue.Comments), ShouldEqual, 1)
   264  					So(issue.Comments[0].Content, ShouldEqual, expectedComment)
   265  					So(issue.NotifyCount, ShouldEqual, 1)
   266  				})
   267  				Convey("Policy has no monorail template", func() {
   268  					policyA.BugTemplate.Monorail = nil
   269  
   270  					bm, err := NewBugManager(cl, "https://luci-analysis-test.appspot.com", "luciproject", projectCfg)
   271  					So(err, ShouldBeNil)
   272  
   273  					expectedIssue.Labels = removeLabel(expectedIssue.Labels, "Policy-A-Label")
   274  
   275  					// Act
   276  					response := bm.Create(ctx, createRequest)
   277  
   278  					// Verify
   279  					So(response, ShouldResemble, bugs.BugCreateResponse{
   280  						ID: "chromium/100",
   281  						PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   282  							"policy-a": {},
   283  						},
   284  					})
   285  					So(len(f.Issues), ShouldEqual, 1)
   286  
   287  					issue := f.Issues[0]
   288  					So(issue.Issue, ShouldResembleProto, expectedIssue)
   289  				})
   290  				Convey("Multiple policies activated", func() {
   291  					createRequest.ActivePolicyIDs = map[bugs.PolicyID]struct{}{
   292  						"policy-a": {}, // P4
   293  						"policy-b": {}, // P0
   294  						"policy-c": {}, // P1
   295  					}
   296  					expectedIssue.FieldValues[1].Value = "0"
   297  					expectedIssue.Labels = addLabel(expectedIssue.Labels, "Policy-B-Label")
   298  					expectedIssue.Labels = addLabel(expectedIssue.Labels, "Policy-C-Label")
   299  					expectedComment = strings.Replace(expectedComment, "- Problem A\n", "- Problem B\n- Problem C\n- Problem A\n", 1)
   300  
   301  					// Act
   302  					response := bm.Create(ctx, createRequest)
   303  
   304  					// Verify
   305  					So(response, ShouldResemble, bugs.BugCreateResponse{
   306  						ID: "chromium/100",
   307  						PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   308  							"policy-a": {},
   309  							"policy-b": {},
   310  							"policy-c": {},
   311  						},
   312  					})
   313  					So(len(f.Issues), ShouldEqual, 1)
   314  
   315  					issue := f.Issues[0]
   316  					So(issue.Issue, ShouldResembleProto, expectedIssue)
   317  					So(len(issue.Comments), ShouldEqual, 4)
   318  					So(issue.Comments[0].Content, ShouldEqual, expectedComment)
   319  					// Policy activation comments should appear in descending priority order.
   320  					So(issue.Comments[1].Content, ShouldStartWith, "Policy ID: policy-b")
   321  					So(issue.Comments[2].Content, ShouldStartWith, "Policy ID: policy-c")
   322  					So(issue.Comments[3].Content, ShouldStartWith, "Policy ID: policy-a")
   323  					So(issue.NotifyCount, ShouldEqual, 4)
   324  				})
   325  				Convey("Failed to post issue comment", func() {
   326  					f.UpdateError = status.Errorf(codes.Internal, "internal server error")
   327  
   328  					// Act
   329  					response := bm.Create(ctx, createRequest)
   330  
   331  					// Verify
   332  					// Both ID and Error set, reflecting partial success.
   333  					So(response.ID, ShouldEqual, "chromium/100")
   334  					So(response.Error, ShouldNotBeNil)
   335  					So(errors.Is(response.Error, f.UpdateError), ShouldBeTrue)
   336  					So(response.Simulated, ShouldBeFalse)
   337  					So(len(f.Issues), ShouldEqual, 1)
   338  
   339  					// Do not expect label to be populated as policy activation comment
   340  					// did not get a chance to post.
   341  					expectedIssue.Labels = removeLabel(expectedIssue.Labels, "Policy-A-Label")
   342  
   343  					issue := f.Issues[0]
   344  					So(issue.Issue, ShouldResembleProto, expectedIssue)
   345  					So(len(issue.Comments), ShouldEqual, 1)
   346  					So(issue.Comments[0].Content, ShouldEqual, expectedComment)
   347  					So(issue.NotifyCount, ShouldEqual, 1)
   348  				})
   349  			})
   350  			Convey("With test name failure cluster", func() {
   351  				createRequest.Description.Title = "ninja://:blink_web_tests/media/my-suite/my-test.html"
   352  				createRequest.Description.Description = "A test is failing " + createRequest.Description.Title
   353  
   354  				expectedIssue.Summary = "Tests are failing: ninja://:blink_web_tests/media/my-suite/my-test.html"
   355  				expectedComment := "A test is failing ninja://:blink_web_tests/media/my-suite/my-test.html\n" +
   356  					"\n" +
   357  					"These test failures are causing problem(s) which require your attention, including:\n" +
   358  					"- Problem A\n" +
   359  					"\n" +
   360  					"See current problems, failure examples and more in LUCI Analysis at: https://luci-analysis-test.appspot.com/p/luciproject/rules/new-rule-id\n" +
   361  					"\n" +
   362  					"How to action this bug: https://luci-analysis-test.appspot.com/help#new-bug-filed\n" +
   363  					"Provide feedback: https://luci-analysis-test.appspot.com/help#feedback\n" +
   364  					"Was this bug filed in the wrong component? See: https://luci-analysis-test.appspot.com/help#component-selection"
   365  
   366  				// Act
   367  				response := bm.Create(ctx, createRequest)
   368  
   369  				// Verify
   370  				So(response, ShouldResemble, bugs.BugCreateResponse{
   371  					ID: "chromium/100",
   372  					PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   373  						"policy-a": {},
   374  					},
   375  				})
   376  				So(len(f.Issues), ShouldEqual, 1)
   377  
   378  				issue := f.Issues[0]
   379  				So(issue.Issue, ShouldResembleProto, expectedIssue)
   380  				So(len(issue.Comments), ShouldEqual, 2)
   381  				So(issue.Comments[0].Content, ShouldEqual, expectedComment)
   382  				So(issue.Comments[1].Content, ShouldStartWith, "Policy ID: policy-a")
   383  				So(issue.NotifyCount, ShouldEqual, 2)
   384  			})
   385  			Convey("Without Restrict-View-Google", func() {
   386  				monorailCfgs.FileWithoutRestrictViewGoogle = true
   387  
   388  				response := bm.Create(ctx, createRequest)
   389  				So(response, ShouldResemble, bugs.BugCreateResponse{
   390  					ID: "chromium/100",
   391  					PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   392  						"policy-a": {},
   393  					},
   394  				})
   395  				So(len(f.Issues), ShouldEqual, 1)
   396  				issue := f.Issues[0]
   397  
   398  				expectedIssue.Labels = removeLabel(expectedIssue.Labels, "Restrict-View-Google")
   399  				So(issue.Issue, ShouldResembleProto, expectedIssue)
   400  			})
   401  			Convey("Does nothing if in simulation mode", func() {
   402  				bm.Simulate = true
   403  
   404  				response := bm.Create(ctx, createRequest)
   405  				So(response, ShouldResemble, bugs.BugCreateResponse{
   406  					Simulated: true,
   407  					ID:        "chromium/12345678",
   408  					PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   409  						"policy-a": {},
   410  					},
   411  				})
   412  				So(len(f.Issues), ShouldEqual, 0)
   413  			})
   414  		})
   415  		Convey("Update", func() {
   416  			c := NewCreateRequest()
   417  			c.ActivePolicyIDs = map[bugs.PolicyID]struct{}{
   418  				"policy-a": {}, // P4
   419  				"policy-c": {}, // P1
   420  			}
   421  			response := bm.Create(ctx, c)
   422  			So(response, ShouldResemble, bugs.BugCreateResponse{
   423  				ID: "chromium/100",
   424  				PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   425  					"policy-a": {},
   426  					"policy-c": {},
   427  				},
   428  			})
   429  			So(len(f.Issues), ShouldEqual, 1)
   430  			So(ChromiumTestIssuePriority(f.Issues[0].Issue), ShouldEqual, "1")
   431  			So(f.Issues[0].Comments, ShouldHaveLength, 3)
   432  
   433  			originalCommentCount := len(f.Issues[0].Comments)
   434  
   435  			activationTime := time.Date(2025, 1, 1, 1, 0, 0, 0, time.UTC)
   436  			state := &bugspb.BugManagementState{
   437  				RuleAssociationNotified: true,
   438  				PolicyState: map[string]*bugspb.BugManagementState_PolicyState{
   439  					"policy-a": { // P4
   440  						IsActive:           true,
   441  						LastActivationTime: timestamppb.New(activationTime),
   442  						ActivationNotified: true,
   443  					},
   444  					"policy-b": { // P0
   445  						IsActive:             false,
   446  						LastActivationTime:   timestamppb.New(activationTime.Add(-time.Hour)),
   447  						LastDeactivationTime: timestamppb.New(activationTime),
   448  						ActivationNotified:   false,
   449  					},
   450  					"policy-c": { // P1
   451  						IsActive:           true,
   452  						LastActivationTime: timestamppb.New(activationTime),
   453  						ActivationNotified: true,
   454  					},
   455  				},
   456  			}
   457  
   458  			bugsToUpdate := []bugs.BugUpdateRequest{
   459  				{
   460  					Bug:                              bugs.BugID{System: bugs.MonorailSystem, ID: response.ID},
   461  					BugManagementState:               state,
   462  					IsManagingBug:                    true,
   463  					RuleID:                           "rule-id",
   464  					IsManagingBugPriority:            true,
   465  					IsManagingBugPriorityLastUpdated: tc.Now(),
   466  				},
   467  			}
   468  			expectedResponse := []bugs.BugUpdateResponse{
   469  				{
   470  					IsDuplicate:               false,
   471  					PolicyActivationsNotified: map[bugs.PolicyID]struct{}{},
   472  				},
   473  			}
   474  			verifyUpdateDoesNothing := func() error {
   475  				originalIssues := CopyIssuesStore(f)
   476  				response, err := bm.Update(ctx, bugsToUpdate)
   477  				if err != nil {
   478  					return errors.Annotate(err, "update bugs").Err()
   479  				}
   480  				if diff := ShouldResemble(response, expectedResponse); diff != "" {
   481  					return errors.Reason("response: %s", diff).Err()
   482  				}
   483  				if diff := ShouldResembleProto(f, originalIssues); diff != "" {
   484  					return errors.Reason("issues store: %s", diff).Err()
   485  				}
   486  				return nil
   487  			}
   488  			// Create a monorail client that interacts with monorail
   489  			// as an end-user. This is needed as we distinguish user
   490  			// updates to the bug from system updates.
   491  			user := "users/100"
   492  			usercl, err := NewClient(UseFakeIssuesClient(ctx, f, user), "myhost")
   493  			So(err, ShouldBeNil)
   494  
   495  			Convey("If active policies unchanged and rule association is not pending, does nothing", func() {
   496  				So(verifyUpdateDoesNothing(), ShouldBeNil)
   497  			})
   498  			Convey("Notifies association between bug and rule", func() {
   499  				// Setup
   500  				// When RuleAssociationNotified is false.
   501  				bugsToUpdate[0].BugManagementState.RuleAssociationNotified = false
   502  				// Even if ManagingBug is also false.
   503  				bugsToUpdate[0].IsManagingBug = false
   504  
   505  				// Act
   506  				response, err := bm.Update(ctx, bugsToUpdate)
   507  
   508  				// Verify
   509  				So(err, ShouldBeNil)
   510  
   511  				// RuleAssociationNotified is set.
   512  				expectedResponse[0].RuleAssociationNotified = true
   513  				So(response, ShouldResemble, expectedResponse)
   514  
   515  				// Expect a comment on the bug notifying us about the association.
   516  				So(f.Issues[0].Comments, ShouldHaveLength, originalCommentCount+1)
   517  				So(f.Issues[0].Comments[originalCommentCount].Content, ShouldEqual,
   518  					"This bug has been associated with failures in LUCI Analysis. "+
   519  						"To view failure examples or update the association, go to LUCI Analysis at: https://luci-analysis-test.appspot.com/p/luciproject/rules/rule-id")
   520  			})
   521  			Convey("If active policies changed", func() {
   522  				// De-activates policy-c (P1), leaving only policy-a (P4) active.
   523  				bugsToUpdate[0].BugManagementState.PolicyState["policy-c"].IsActive = false
   524  				bugsToUpdate[0].BugManagementState.PolicyState["policy-c"].LastDeactivationTime = timestamppb.New(activationTime.Add(time.Hour))
   525  
   526  				Convey("Does not update bug priority/verified if IsManagingBug false", func() {
   527  					bugsToUpdate[0].IsManagingBug = false
   528  
   529  					So(verifyUpdateDoesNothing(), ShouldBeNil)
   530  				})
   531  				Convey("Notifies policy activation, even if IsManagingBug false", func() {
   532  					bugsToUpdate[0].IsManagingBug = false
   533  
   534  					// Activates policy B (P0) for the first time.
   535  					bugsToUpdate[0].BugManagementState.PolicyState["policy-b"].IsActive = true
   536  					bugsToUpdate[0].BugManagementState.PolicyState["policy-b"].LastActivationTime = timestamppb.New(activationTime.Add(time.Hour))
   537  
   538  					expectedResponse[0].PolicyActivationsNotified = map[bugs.PolicyID]struct{}{
   539  						"policy-b": {},
   540  					}
   541  
   542  					originalNotifyCount := f.Issues[0].NotifyCount
   543  
   544  					// Act
   545  					response, err := bm.Update(ctx, bugsToUpdate)
   546  
   547  					// Verify
   548  					So(err, ShouldBeNil)
   549  					So(response, ShouldResemble, expectedResponse)
   550  
   551  					// Priority was NOT raised to P0, because IsManagingBug is false.
   552  					So(ChromiumTestIssuePriority(f.Issues[0].Issue), ShouldNotEqual, "0")
   553  
   554  					// Expect the policy B activation comment to appear.
   555  					So(f.Issues[0].Comments, ShouldHaveLength, originalCommentCount+1)
   556  					So(f.Issues[0].Comments[originalCommentCount].Content, ShouldStartWith,
   557  						"Policy ID: policy-b")
   558  
   559  					// Expect the policy B label to appear.
   560  					So(containsLabel(f.Issues[0].Issue.Labels, "Policy-B-Label"), ShouldBeTrue)
   561  
   562  					// The policy activation was notified.
   563  					So(f.Issues[0].NotifyCount, ShouldEqual, originalNotifyCount+1)
   564  
   565  					// Verify repeated update has no effect.
   566  					bugsToUpdate[0].BugManagementState.PolicyState["policy-b"].ActivationNotified = true
   567  					expectedResponse[0].PolicyActivationsNotified = map[bugs.PolicyID]struct{}{}
   568  					So(verifyUpdateDoesNothing(), ShouldBeNil)
   569  				})
   570  				Convey("Reduces priority in response to policies de-activating", func() {
   571  					originalNotifyCount := f.Issues[0].NotifyCount
   572  
   573  					// Act
   574  					response, err := bm.Update(ctx, bugsToUpdate)
   575  
   576  					// Verify
   577  					So(err, ShouldBeNil)
   578  					So(response, ShouldResemble, expectedResponse)
   579  					So(ChromiumTestIssuePriority(f.Issues[0].Issue), ShouldEqual, "3")
   580  					So(f.Issues[0].Comments, ShouldHaveLength, originalCommentCount+1)
   581  					So(f.Issues[0].Comments[originalCommentCount].Content, ShouldContainSubstring,
   582  						"Because the following problem(s) have stopped:\n"+
   583  							"- Problem C (P1)\n"+
   584  							"The bug priority has been decreased from P1 to P3.")
   585  					So(f.Issues[0].Comments[originalCommentCount].Content, ShouldContainSubstring,
   586  						"https://luci-analysis-test.appspot.com/help#priority-update")
   587  					So(f.Issues[0].Comments[originalCommentCount].Content, ShouldContainSubstring,
   588  						"https://luci-analysis-test.appspot.com/p/luciproject/rules/rule-id")
   589  
   590  					// Does not notify.
   591  					So(f.Issues[0].NotifyCount, ShouldEqual, originalNotifyCount)
   592  
   593  					// Verify repeated update has no effect.
   594  					So(verifyUpdateDoesNothing(), ShouldBeNil)
   595  				})
   596  				Convey("Increases priority in response to priority policies activating", func() {
   597  					// Activates policy B (P0).
   598  					bugsToUpdate[0].BugManagementState.PolicyState["policy-b"].IsActive = true
   599  					bugsToUpdate[0].BugManagementState.PolicyState["policy-b"].LastActivationTime = timestamppb.New(activationTime.Add(time.Hour))
   600  
   601  					expectedResponse[0].PolicyActivationsNotified = map[bugs.PolicyID]struct{}{
   602  						"policy-b": {},
   603  					}
   604  
   605  					originalNotifyCount := f.Issues[0].NotifyCount
   606  
   607  					// Act
   608  					response, err := bm.Update(ctx, bugsToUpdate)
   609  
   610  					// Verify
   611  					So(err, ShouldBeNil)
   612  					So(response, ShouldResemble, expectedResponse)
   613  					So(ChromiumTestIssuePriority(f.Issues[0].Issue), ShouldEqual, "0")
   614  					So(f.Issues[0].Comments, ShouldHaveLength, originalCommentCount+2)
   615  					// Expect the policy B activation comment to appear, followed by the priority update comment.
   616  					So(f.Issues[0].Comments[originalCommentCount].Content, ShouldStartWith,
   617  						"Policy ID: policy-b")
   618  					So(f.Issues[0].Comments[originalCommentCount+1].Content, ShouldContainSubstring,
   619  						"Because the following problem(s) have started:\n"+
   620  							"- Problem B (P0)\n"+
   621  							"The bug priority has been increased from P1 to P0.")
   622  					So(f.Issues[0].Comments[originalCommentCount+1].Content, ShouldContainSubstring,
   623  						"https://luci-analysis-test.appspot.com/help#priority-update")
   624  					So(f.Issues[0].Comments[originalCommentCount+1].Content, ShouldContainSubstring,
   625  						"https://luci-analysis-test.appspot.com/p/luciproject/rules/rule-id")
   626  
   627  					// Notified the policy activation, and the priority increase.
   628  					So(f.Issues[0].NotifyCount, ShouldEqual, originalNotifyCount+2)
   629  
   630  					// Verify repeated update has no effect.
   631  					bugsToUpdate[0].BugManagementState.PolicyState["policy-b"].ActivationNotified = true
   632  					expectedResponse[0].PolicyActivationsNotified = map[bugs.PolicyID]struct{}{}
   633  					So(verifyUpdateDoesNothing(), ShouldBeNil)
   634  				})
   635  				Convey("Does not adjust priority if priority manually set", func() {
   636  					updateReq := updateBugPriorityRequest(f.Issues[0].Issue.Name, "0")
   637  					err = usercl.ModifyIssues(ctx, updateReq)
   638  					So(err, ShouldBeNil)
   639  					So(ChromiumTestIssuePriority(f.Issues[0].Issue), ShouldEqual, "0")
   640  
   641  					expectedIssue := CopyIssue(f.Issues[0].Issue)
   642  					originalNotifyCount := f.Issues[0].NotifyCount
   643  					originalCommentCount = len(f.Issues[0].Comments)
   644  
   645  					// Act
   646  					response, err := bm.Update(ctx, bugsToUpdate)
   647  
   648  					// Verify
   649  					So(err, ShouldBeNil)
   650  					expectedResponse[0].DisableRulePriorityUpdates = true
   651  					So(response, ShouldResemble, expectedResponse)
   652  					So(f.Issues[0].Issue, ShouldResembleProto, expectedIssue)
   653  					So(f.Issues[0].Comments, ShouldHaveLength, originalCommentCount+1)
   654  					So(f.Issues[0].Comments[originalCommentCount].Content, ShouldEqual,
   655  						"The bug priority has been manually set. To re-enable automatic priority updates by LUCI Analysis,"+
   656  							" enable the update priority flag on the rule.\n\nSee failure impact and configure the failure"+
   657  							" association rule for this bug at: https://luci-analysis-test.appspot.com/p/luciproject/rules/rule-id")
   658  					// Does not notify.
   659  					So(f.Issues[0].NotifyCount, ShouldEqual, originalNotifyCount)
   660  
   661  					// Normally, the caller would update IsManagingBugPriority to false
   662  					// now, but as this is a test, we have to do it manually.
   663  					// As priority updates are now off, DisableRulePriorityUpdates
   664  					// should henceforth also return false (as they are already
   665  					// disabled).
   666  					bugsToUpdate[0].IsManagingBugPriority = false
   667  					bugsToUpdate[0].IsManagingBugPriorityLastUpdated = tc.Now().Add(1 * time.Minute)
   668  					expectedResponse[0].DisableRulePriorityUpdates = false
   669  
   670  					// Check repeated update does nothing more.
   671  					So(verifyUpdateDoesNothing(), ShouldBeNil)
   672  
   673  					Convey("Unless IsManagingBugPriority manually updated", func() {
   674  						bugsToUpdate[0].IsManagingBugPriority = true
   675  						bugsToUpdate[0].IsManagingBugPriorityLastUpdated = tc.Now().Add(3 * time.Minute)
   676  
   677  						response, err := bm.Update(ctx, bugsToUpdate)
   678  						So(response, ShouldResemble, expectedResponse)
   679  						So(err, ShouldBeNil)
   680  						So(ChromiumTestIssuePriority(f.Issues[0].Issue), ShouldEqual, "3")
   681  						So(f.Issues[0].Comments, ShouldHaveLength, originalCommentCount+2)
   682  						So(f.Issues[0].Comments[originalCommentCount+1].Content, ShouldContainSubstring,
   683  							"Because the following problem(s) are active:\n"+
   684  								"- Problem A (P3)\n"+
   685  								"\n"+
   686  								"The bug priority has been set to P3.")
   687  						So(f.Issues[0].Comments[originalCommentCount+1].Content, ShouldContainSubstring,
   688  							"https://luci-analysis-test.appspot.com/help#priority-update")
   689  						So(f.Issues[0].Comments[originalCommentCount+1].Content, ShouldContainSubstring,
   690  							"https://luci-analysis-test.appspot.com/p/luciproject/rules/rule-id")
   691  
   692  						// Verify repeated update has no effect.
   693  						So(verifyUpdateDoesNothing(), ShouldBeNil)
   694  					})
   695  				})
   696  				Convey("Does nothing if in simulation mode", func() {
   697  					// In simulation mode, changes should be logged but not made.
   698  					bm.Simulate = true
   699  					So(verifyUpdateDoesNothing(), ShouldBeNil)
   700  				})
   701  			})
   702  			Convey("If all policies deactivate", func() {
   703  				// De-activate all policies, so the bug would normally be marked verified.
   704  				for _, policyState := range bugsToUpdate[0].BugManagementState.PolicyState {
   705  					if policyState.IsActive {
   706  						policyState.IsActive = false
   707  						policyState.LastDeactivationTime = timestamppb.New(activationTime.Add(time.Hour))
   708  					}
   709  				}
   710  
   711  				Convey("Does not update bug if IsManagingBug false", func() {
   712  					bugsToUpdate[0].IsManagingBug = false
   713  
   714  					So(verifyUpdateDoesNothing(), ShouldBeNil)
   715  				})
   716  				Convey("Update closes bug", func() {
   717  					// Act
   718  					response, err := bm.Update(ctx, bugsToUpdate)
   719  
   720  					// Verify
   721  					So(err, ShouldBeNil)
   722  					So(response, ShouldResemble, expectedResponse)
   723  					So(f.Issues[0].Issue.Status.Status, ShouldEqual, VerifiedStatus)
   724  
   725  					expectedComment := "Because the following problem(s) have stopped:\n" +
   726  						"- Problem C (P1)\n" +
   727  						"- Problem A (P3)\n" +
   728  						"The bug has been verified."
   729  					So(f.Issues[0].Comments, ShouldHaveLength, originalCommentCount+1)
   730  					So(f.Issues[0].Comments[originalCommentCount].Content, ShouldContainSubstring, expectedComment)
   731  					So(f.Issues[0].Comments[originalCommentCount].Content, ShouldContainSubstring,
   732  						"https://luci-analysis-test.appspot.com/help#bug-verified")
   733  					So(f.Issues[0].Comments[originalCommentCount].Content, ShouldContainSubstring,
   734  						"https://luci-analysis-test.appspot.com/p/luciproject/rules/rule-id")
   735  
   736  					// Verify repeated update has no effect.
   737  					So(verifyUpdateDoesNothing(), ShouldBeNil)
   738  
   739  					Convey("Rules for verified bugs archived after 30 days", func() {
   740  						tc.Add(time.Hour * 24 * 30)
   741  
   742  						expectedResponse := []bugs.BugUpdateResponse{
   743  							{
   744  								ShouldArchive:             true,
   745  								PolicyActivationsNotified: map[bugs.PolicyID]struct{}{},
   746  							},
   747  						}
   748  						originalIssues := CopyIssuesStore(f)
   749  
   750  						// Act
   751  						response, err := bm.Update(ctx, bugsToUpdate)
   752  
   753  						// Verify
   754  						So(err, ShouldBeNil)
   755  						So(response, ShouldResemble, expectedResponse)
   756  						So(f, ShouldResembleProto, originalIssues)
   757  					})
   758  
   759  					Convey("If impact increases, bug is re-opened with correct priority", func() {
   760  						// policy-b has priority P0.
   761  						bugsToUpdate[0].BugManagementState.PolicyState["policy-b"].IsActive = true
   762  						bugsToUpdate[0].BugManagementState.PolicyState["policy-b"].LastActivationTime = timestamppb.New(activationTime.Add(2 * time.Hour))
   763  						bugsToUpdate[0].BugManagementState.PolicyState["policy-b"].ActivationNotified = true
   764  
   765  						Convey("Issue has owner", func() {
   766  							// Update issue owner.
   767  							updateReq := updateOwnerRequest(f.Issues[0].Issue.Name, "users/100")
   768  							err = usercl.ModifyIssues(ctx, updateReq)
   769  							So(err, ShouldBeNil)
   770  							So(f.Issues[0].Issue.Owner.GetUser(), ShouldEqual, "users/100")
   771  							originalCommentCount = len(f.Issues[0].Comments)
   772  
   773  							// Issue should return to "Assigned" status.
   774  							response, err := bm.Update(ctx, bugsToUpdate)
   775  							So(err, ShouldBeNil)
   776  							So(response, ShouldResemble, expectedResponse)
   777  							So(f.Issues[0].Issue.Status.Status, ShouldEqual, AssignedStatus)
   778  							So(ChromiumTestIssuePriority(f.Issues[0].Issue), ShouldEqual, "0")
   779  
   780  							expectedComment := "Because the following problem(s) have started:\n" +
   781  								"- Problem B (P0)\n" +
   782  								"The bug has been re-opened as P0."
   783  							So(f.Issues[0].Comments, ShouldHaveLength, originalCommentCount+1)
   784  							So(f.Issues[0].Comments[originalCommentCount].Content, ShouldContainSubstring, expectedComment)
   785  							So(f.Issues[0].Comments[originalCommentCount].Content, ShouldContainSubstring,
   786  								"https://luci-analysis-test.appspot.com/help#bug-reopened")
   787  							So(f.Issues[0].Comments[originalCommentCount].Content, ShouldContainSubstring,
   788  								"https://luci-analysis-test.appspot.com/p/luciproject/rules/rule-id")
   789  
   790  							// Verify repeated update has no effect.
   791  							So(verifyUpdateDoesNothing(), ShouldBeNil)
   792  						})
   793  						Convey("Issue has no owner", func() {
   794  							// Remove owner.
   795  							updateReq := updateOwnerRequest(f.Issues[0].Issue.Name, "")
   796  							err = usercl.ModifyIssues(ctx, updateReq)
   797  							So(err, ShouldBeNil)
   798  							So(f.Issues[0].Issue.Owner.GetUser(), ShouldEqual, "")
   799  							originalCommentCount = len(f.Issues[0].Comments)
   800  
   801  							// Issue should return to "Untriaged" status.
   802  							response, err := bm.Update(ctx, bugsToUpdate)
   803  							So(err, ShouldBeNil)
   804  							So(response, ShouldResemble, expectedResponse)
   805  							So(f.Issues[0].Issue.Status.Status, ShouldEqual, UntriagedStatus)
   806  							So(ChromiumTestIssuePriority(f.Issues[0].Issue), ShouldEqual, "0")
   807  
   808  							expectedComment := "Because the following problem(s) have started:\n" +
   809  								"- Problem B (P0)\n" +
   810  								"The bug has been re-opened as P0."
   811  							So(f.Issues[0].Comments, ShouldHaveLength, originalCommentCount+1)
   812  							So(f.Issues[0].Comments[originalCommentCount].Content, ShouldContainSubstring, expectedComment)
   813  							So(f.Issues[0].Comments[originalCommentCount].Content, ShouldContainSubstring,
   814  								"https://luci-analysis-test.appspot.com/help#priority-update")
   815  							So(f.Issues[0].Comments[originalCommentCount].Content, ShouldContainSubstring,
   816  								"https://luci-analysis-test.appspot.com/help#bug-reopened")
   817  							So(f.Issues[0].Comments[originalCommentCount].Content, ShouldContainSubstring,
   818  								"https://luci-analysis-test.appspot.com/p/luciproject/rules/rule-id")
   819  
   820  							// Verify repeated update has no effect.
   821  							So(verifyUpdateDoesNothing(), ShouldBeNil)
   822  						})
   823  					})
   824  				})
   825  			})
   826  			Convey("If bug duplicate", func() {
   827  				f.Issues[0].Issue.Status.Status = DuplicateStatus
   828  				expectedResponse := []bugs.BugUpdateResponse{
   829  					{
   830  						IsDuplicate:               true,
   831  						PolicyActivationsNotified: map[bugs.PolicyID]struct{}{},
   832  					},
   833  				}
   834  				Convey("Issue has no assignee", func() {
   835  					f.Issues[0].Issue.Owner = nil
   836  
   837  					// As there is no assignee.
   838  					expectedResponse[0].IsDuplicateAndAssigned = false
   839  
   840  					originalIssues := CopyIssuesStore(f)
   841  
   842  					// Act
   843  					response, err := bm.Update(ctx, bugsToUpdate)
   844  
   845  					// Verify
   846  					So(err, ShouldBeNil)
   847  					So(response, ShouldResemble, expectedResponse)
   848  					So(f, ShouldResembleProto, originalIssues)
   849  				})
   850  				Convey("Issue has owner", func() {
   851  					f.Issues[0].Issue.Owner = &mpb.Issue_UserValue{
   852  						User: "users/100",
   853  					}
   854  
   855  					// As we have an assignee.
   856  					expectedResponse[0].IsDuplicateAndAssigned = true
   857  
   858  					originalIssues := CopyIssuesStore(f)
   859  
   860  					// Act
   861  					response, err := bm.Update(ctx, bugsToUpdate)
   862  
   863  					// Verify
   864  					So(err, ShouldBeNil)
   865  					So(response, ShouldResemble, expectedResponse)
   866  					So(f, ShouldResembleProto, originalIssues)
   867  				})
   868  			})
   869  			Convey("Rule not managing a bug archived after 30 days of the bug being in any closed state", func() {
   870  				tc.Add(time.Hour * 24 * 30)
   871  
   872  				bugsToUpdate[0].IsManagingBug = false
   873  				f.Issues[0].Issue.Status.Status = FixedStatus
   874  
   875  				expectedResponse := []bugs.BugUpdateResponse{
   876  					{
   877  						ShouldArchive:             true,
   878  						PolicyActivationsNotified: map[bugs.PolicyID]struct{}{},
   879  					},
   880  				}
   881  				originalIssues := CopyIssuesStore(f)
   882  
   883  				// Act
   884  				response, err := bm.Update(ctx, bugsToUpdate)
   885  
   886  				// Verify
   887  				So(err, ShouldBeNil)
   888  				So(response, ShouldResemble, expectedResponse)
   889  				So(f, ShouldResembleProto, originalIssues)
   890  			})
   891  			Convey("Rule managing a bug not archived after 30 days of the bug being in fixed state", func() {
   892  				tc.Add(time.Hour * 24 * 30)
   893  
   894  				// If LUCI Analysis is mangaging the bug state, the fixed state
   895  				// means the bug is still not verified. Do not archive the
   896  				// rule.
   897  				bugsToUpdate[0].IsManagingBug = true
   898  				f.Issues[0].Issue.Status.Status = FixedStatus
   899  
   900  				So(verifyUpdateDoesNothing(), ShouldBeNil)
   901  			})
   902  			Convey("Rules archived immediately if bug archived", func() {
   903  				f.Issues[0].Issue.Status.Status = "Archived"
   904  
   905  				expectedResponse := []bugs.BugUpdateResponse{
   906  					{
   907  						ShouldArchive:             true,
   908  						PolicyActivationsNotified: map[bugs.PolicyID]struct{}{},
   909  					},
   910  				}
   911  				originalIssues := CopyIssuesStore(f)
   912  
   913  				// Act
   914  				response, err := bm.Update(ctx, bugsToUpdate)
   915  
   916  				// Verify
   917  				So(err, ShouldBeNil)
   918  				So(response, ShouldResemble, expectedResponse)
   919  				So(f, ShouldResembleProto, originalIssues)
   920  			})
   921  			Convey("If issue does not exist, does nothing", func() {
   922  				f.Issues = nil
   923  				So(verifyUpdateDoesNothing(), ShouldBeNil)
   924  			})
   925  		})
   926  		Convey("GetMergedInto", func() {
   927  			c := NewCreateRequest()
   928  			c.ActivePolicyIDs = map[bugs.PolicyID]struct{}{
   929  				"policy-a": {},
   930  			}
   931  			response := bm.Create(ctx, c)
   932  			So(response, ShouldResemble, bugs.BugCreateResponse{
   933  				ID: "chromium/100",
   934  				PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   935  					"policy-a": {},
   936  				},
   937  			})
   938  			So(len(f.Issues), ShouldEqual, 1)
   939  
   940  			bugID := bugs.BugID{System: bugs.MonorailSystem, ID: "chromium/100"}
   941  			Convey("Merged into monorail bug", func() {
   942  				f.Issues[0].Issue.Status.Status = DuplicateStatus
   943  				f.Issues[0].Issue.MergedIntoIssueRef = &mpb.IssueRef{
   944  					Issue: "projects/testproject/issues/99",
   945  				}
   946  
   947  				result, err := bm.GetMergedInto(ctx, bugID)
   948  				So(err, ShouldEqual, nil)
   949  				So(result, ShouldResemble, &bugs.BugID{
   950  					System: bugs.MonorailSystem,
   951  					ID:     "testproject/99",
   952  				})
   953  			})
   954  			Convey("Merged into buganizer bug", func() {
   955  				f.Issues[0].Issue.Status.Status = DuplicateStatus
   956  				f.Issues[0].Issue.MergedIntoIssueRef = &mpb.IssueRef{
   957  					ExtIdentifier: "b/1234",
   958  				}
   959  
   960  				result, err := bm.GetMergedInto(ctx, bugID)
   961  				So(err, ShouldEqual, nil)
   962  				So(result, ShouldResemble, &bugs.BugID{
   963  					System: bugs.BuganizerSystem,
   964  					ID:     "1234",
   965  				})
   966  			})
   967  			Convey("Not merged into any bug", func() {
   968  				// While MergedIntoIssueRef is set, the bug status is not
   969  				// set to "Duplicate", so this value should be ignored.
   970  				f.Issues[0].Issue.Status.Status = UntriagedStatus
   971  				f.Issues[0].Issue.MergedIntoIssueRef = &mpb.IssueRef{
   972  					ExtIdentifier: "b/1234",
   973  				}
   974  
   975  				result, err := bm.GetMergedInto(ctx, bugID)
   976  				So(err, ShouldEqual, nil)
   977  				So(result, ShouldBeNil)
   978  			})
   979  		})
   980  		Convey("UpdateDuplicateSource", func() {
   981  			c := NewCreateRequest()
   982  			c.ActivePolicyIDs = map[bugs.PolicyID]struct{}{
   983  				"policy-a": {},
   984  			}
   985  			response := bm.Create(ctx, c)
   986  			So(response, ShouldResemble, bugs.BugCreateResponse{
   987  				ID: "chromium/100",
   988  				PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   989  					"policy-a": {},
   990  				},
   991  			})
   992  			So(f.Issues, ShouldHaveLength, 1)
   993  			So(f.Issues[0].Comments, ShouldHaveLength, 2)
   994  			originalCommentCount := len(f.Issues[0].Comments)
   995  
   996  			f.Issues[0].Issue.Status.Status = DuplicateStatus
   997  			f.Issues[0].Issue.MergedIntoIssueRef = &mpb.IssueRef{
   998  				Issue: "projects/testproject/issues/99",
   999  			}
  1000  
  1001  			Convey("With ErrorMessage", func() {
  1002  				request := bugs.UpdateDuplicateSourceRequest{
  1003  					BugDetails: bugs.DuplicateBugDetails{
  1004  						RuleID: "source-rule-id",
  1005  						Bug:    bugs.BugID{System: bugs.MonorailSystem, ID: "chromium/100"},
  1006  					},
  1007  					ErrorMessage: "Some error.",
  1008  				}
  1009  				err = bm.UpdateDuplicateSource(ctx, request)
  1010  				So(err, ShouldBeNil)
  1011  
  1012  				So(f.Issues[0].Issue.Status.Status, ShouldNotEqual, DuplicateStatus)
  1013  				So(f.Issues[0].Comments, ShouldHaveLength, originalCommentCount+1)
  1014  				So(f.Issues[0].Comments[originalCommentCount].Content, ShouldContainSubstring, "Some error.")
  1015  				So(f.Issues[0].Comments[originalCommentCount].Content, ShouldContainSubstring,
  1016  					"https://luci-analysis-test.appspot.com/p/luciproject/rules/source-rule-id")
  1017  			})
  1018  			Convey("Without ErrorMessage", func() {
  1019  				request := bugs.UpdateDuplicateSourceRequest{
  1020  					BugDetails: bugs.DuplicateBugDetails{
  1021  						RuleID: "source-rule-id",
  1022  						Bug:    bugs.BugID{System: bugs.MonorailSystem, ID: "chromium/100"},
  1023  					},
  1024  					DestinationRuleID: "12345abcdef",
  1025  				}
  1026  				err = bm.UpdateDuplicateSource(ctx, request)
  1027  				So(err, ShouldBeNil)
  1028  
  1029  				So(f.Issues[0].Issue.Status.Status, ShouldEqual, DuplicateStatus)
  1030  				So(f.Issues[0].Comments, ShouldHaveLength, originalCommentCount+1)
  1031  				So(f.Issues[0].Comments[originalCommentCount].Content, ShouldContainSubstring, "merged the failure association rule for this bug into the rule for the canonical bug.")
  1032  				So(f.Issues[0].Comments[originalCommentCount].Content, ShouldContainSubstring,
  1033  					"https://luci-analysis-test.appspot.com/p/luciproject/rules/12345abcdef")
  1034  			})
  1035  		})
  1036  	})
  1037  }
  1038  
  1039  func updateOwnerRequest(name string, owner string) *mpb.ModifyIssuesRequest {
  1040  	return &mpb.ModifyIssuesRequest{
  1041  		Deltas: []*mpb.IssueDelta{
  1042  			{
  1043  				Issue: &mpb.Issue{
  1044  					Name: name,
  1045  					Owner: &mpb.Issue_UserValue{
  1046  						User: owner,
  1047  					},
  1048  				},
  1049  				UpdateMask: &field_mask.FieldMask{
  1050  					Paths: []string{"owner"},
  1051  				},
  1052  			},
  1053  		},
  1054  		CommentContent: "User comment.",
  1055  	}
  1056  }
  1057  
  1058  func updateBugPriorityRequest(name string, priority string) *mpb.ModifyIssuesRequest {
  1059  	return &mpb.ModifyIssuesRequest{
  1060  		Deltas: []*mpb.IssueDelta{
  1061  			{
  1062  				Issue: &mpb.Issue{
  1063  					Name: name,
  1064  					FieldValues: []*mpb.FieldValue{
  1065  						{
  1066  							Field: "projects/chromium/fieldDefs/11",
  1067  							Value: priority,
  1068  						},
  1069  					},
  1070  				},
  1071  				UpdateMask: &field_mask.FieldMask{
  1072  					Paths: []string{"field_values"},
  1073  				},
  1074  			},
  1075  		},
  1076  		CommentContent: "User comment.",
  1077  	}
  1078  }
  1079  
  1080  func addLabel(labels []*mpb.Issue_LabelValue, label string) []*mpb.Issue_LabelValue {
  1081  	result := append(labels, &mpb.Issue_LabelValue{Label: label})
  1082  	SortLabels(result)
  1083  	return result
  1084  }
  1085  
  1086  func removeLabel(labels []*mpb.Issue_LabelValue, label string) []*mpb.Issue_LabelValue {
  1087  	var result []*mpb.Issue_LabelValue
  1088  	for _, item := range labels {
  1089  		if item.Label != label {
  1090  			result = append(result, item)
  1091  		}
  1092  	}
  1093  	SortLabels(result)
  1094  	return result
  1095  }
  1096  
  1097  func containsLabel(labels []*mpb.Issue_LabelValue, label string) bool {
  1098  	for _, item := range labels {
  1099  		if item.Label == label {
  1100  			return true
  1101  		}
  1102  	}
  1103  	return false
  1104  }