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

     1  // Copyright 2023 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 buganizer
    16  
    17  import (
    18  	"context"
    19  	"strconv"
    20  	"strings"
    21  	"testing"
    22  	"time"
    23  
    24  	"google.golang.org/protobuf/proto"
    25  	"google.golang.org/protobuf/types/known/timestamppb"
    26  
    27  	"go.chromium.org/luci/common/clock"
    28  	"go.chromium.org/luci/common/clock/testclock"
    29  	"go.chromium.org/luci/common/errors"
    30  	"go.chromium.org/luci/third_party/google.golang.org/genproto/googleapis/devtools/issuetracker/v1"
    31  
    32  	"go.chromium.org/luci/analysis/internal/bugs"
    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 TestBugManager(t *testing.T) {
    43  	t.Parallel()
    44  
    45  	Convey("With Bug Manager", t, func() {
    46  		ctx := context.Background()
    47  		fakeClient := NewFakeClient()
    48  		fakeStore := fakeClient.FakeStore
    49  		buganizerCfg := ChromeOSTestConfig()
    50  
    51  		policyA := config.CreatePlaceholderBugManagementPolicy("policy-a")
    52  		policyA.HumanReadableName = "Problem A"
    53  		policyA.Priority = configpb.BuganizerPriority_P4
    54  		policyA.BugTemplate.Buganizer.Hotlists = []int64{1001}
    55  
    56  		policyB := config.CreatePlaceholderBugManagementPolicy("policy-b")
    57  		policyB.HumanReadableName = "Problem B"
    58  		policyB.Priority = configpb.BuganizerPriority_P0
    59  		policyB.BugTemplate.Buganizer.Hotlists = []int64{1002}
    60  
    61  		policyC := config.CreatePlaceholderBugManagementPolicy("policy-c")
    62  		policyC.HumanReadableName = "Problem C"
    63  		policyC.Priority = configpb.BuganizerPriority_P1
    64  		policyC.BugTemplate.Buganizer.Hotlists = []int64{1003}
    65  
    66  		projectCfg := &configpb.ProjectConfig{
    67  			BugManagement: &configpb.BugManagement{
    68  				DefaultBugSystem: configpb.BugSystem_BUGANIZER,
    69  				Buganizer:        buganizerCfg,
    70  				Policies: []*configpb.BugManagementPolicy{
    71  					policyA,
    72  					policyB,
    73  					policyC,
    74  				},
    75  			},
    76  		}
    77  
    78  		bm, err := NewBugManager(fakeClient, "https://luci-analysis-test.appspot.com", "chromeos", "email@test.com", projectCfg, false)
    79  		So(err, ShouldBeNil)
    80  		now := time.Date(2044, time.April, 4, 4, 4, 4, 4, time.UTC)
    81  		ctx, tc := testclock.UseTime(ctx, now)
    82  
    83  		Convey("Create", func() {
    84  			createRequest := newCreateRequest()
    85  			createRequest.ActivePolicyIDs = map[bugs.PolicyID]struct{}{
    86  				"policy-a": {}, // P4
    87  			}
    88  			expectedIssue := &issuetracker.Issue{
    89  				IssueId: 1,
    90  				IssueState: &issuetracker.IssueState{
    91  					ComponentId: buganizerCfg.DefaultComponent.Id,
    92  					Type:        issuetracker.Issue_BUG,
    93  					Status:      issuetracker.Issue_NEW,
    94  					Severity:    issuetracker.Issue_S2,
    95  					Priority:    issuetracker.Issue_P4,
    96  					Title:       "Tests are failing: Expected equality of these values: \"Expected_Value\" my_expr.evaluate(123) Which is: \"Unexpected_Value\"",
    97  					Ccs: []*issuetracker.User{
    98  						{
    99  							EmailAddress: "testcc1@google.com",
   100  						},
   101  						{
   102  							EmailAddress: "testcc2@google.com",
   103  						},
   104  					},
   105  					HotlistIds: []int64{1001},
   106  					AccessLimit: &issuetracker.IssueAccessLimit{
   107  						AccessLevel: issuetracker.IssueAccessLimit_LIMIT_NONE,
   108  					},
   109  				},
   110  				CreatedTime:  timestamppb.New(clock.Now(ctx)),
   111  				ModifiedTime: timestamppb.New(clock.Now(ctx)),
   112  			}
   113  
   114  			Convey("With reason-based failure cluster", func() {
   115  				reason := `Expected equality of these values:
   116  					"Expected_Value"
   117  					my_expr.evaluate(123)
   118  						Which is: "Unexpected_Value"`
   119  				createRequest.Description.Title = reason
   120  				createRequest.Description.Description = "A cluster of failures has been found with reason: " + reason
   121  
   122  				expectedIssue.Description = &issuetracker.IssueComment{
   123  					CommentNumber: 1,
   124  					Comment: "A cluster of failures has been found with reason: Expected equality " +
   125  						"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" +
   126  						"Which is: \"Unexpected_Value\"\n" +
   127  						"\n" +
   128  						"These test failures are causing problem(s) which require your attention, including:\n" +
   129  						"- Problem A\n" +
   130  						"\n" +
   131  						"See current problems, failure examples and more in LUCI Analysis at: https://luci-analysis-test.appspot.com/p/chromeos/rules/new-rule-id\n" +
   132  						"\n" +
   133  						"How to action this bug: https://luci-analysis-test.appspot.com/help#new-bug-filed\n" +
   134  						"Provide feedback: https://luci-analysis-test.appspot.com/help#feedback\n" +
   135  						"Was this bug filed in the wrong component? See: https://luci-analysis-test.appspot.com/help#component-selection",
   136  				}
   137  
   138  				Convey("Base case", func() {
   139  					response := bm.Create(ctx, createRequest)
   140  					So(response, ShouldResemble, bugs.BugCreateResponse{
   141  						ID: "1",
   142  						PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   143  							"policy-a": {},
   144  						},
   145  					})
   146  					So(len(fakeStore.Issues), ShouldEqual, 1)
   147  
   148  					issueData := fakeStore.Issues[1]
   149  					So(issueData.Issue, ShouldResembleProto, expectedIssue)
   150  				})
   151  
   152  				Convey("Policy with comment template", func() {
   153  					policyA.BugTemplate.CommentTemplate = "RuleURL:{{.RuleURL}},BugID:{{if .BugID.IsBuganizer}}{{.BugID.BuganizerBugID}}{{end}}"
   154  
   155  					bm, err := NewBugManager(fakeClient, "https://luci-analysis-test.appspot.com", "chromeos", "email@test.com", projectCfg, false)
   156  					So(err, ShouldBeNil)
   157  
   158  					response := bm.Create(ctx, createRequest)
   159  					So(response, ShouldResemble, bugs.BugCreateResponse{
   160  						ID: "1",
   161  						PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   162  							"policy-a": {},
   163  						},
   164  					})
   165  					So(len(fakeStore.Issues), ShouldEqual, 1)
   166  
   167  					issueData := fakeStore.Issues[1]
   168  					So(issueData.Issue, ShouldResembleProto, expectedIssue)
   169  					// Expect no comment for policy-a's activation, just the initial issue description.
   170  					So(len(issueData.Comments), ShouldEqual, 2)
   171  					So(issueData.Comments[1].Comment, ShouldEqual, "RuleURL:https://luci-analysis-test.appspot.com/p/chromeos/rules/new-rule-id,BugID:1\n\n"+
   172  						"Why LUCI Analysis posted this comment: https://luci-analysis-test.appspot.com/help#policy-activated (Policy ID: policy-a)")
   173  				})
   174  				Convey("Policy has no comment template", func() {
   175  					policyA.BugTemplate.CommentTemplate = ""
   176  
   177  					bm, err := NewBugManager(fakeClient, "https://luci-analysis-test.appspot.com", "chromeos", "email@test.com", projectCfg, false)
   178  					So(err, ShouldBeNil)
   179  
   180  					response := bm.Create(ctx, createRequest)
   181  					So(response, ShouldResemble, bugs.BugCreateResponse{
   182  						ID: "1",
   183  						PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   184  							"policy-a": {},
   185  						},
   186  					})
   187  					So(len(fakeStore.Issues), ShouldEqual, 1)
   188  
   189  					issueData := fakeStore.Issues[1]
   190  					So(issueData.Issue, ShouldResembleProto, expectedIssue)
   191  					// Expect no comment for policy-a's activation, just the initial issue description.
   192  					So(len(issueData.Comments), ShouldEqual, 1)
   193  				})
   194  				Convey("Multiple policies activated", func() {
   195  					createRequest.ActivePolicyIDs = map[bugs.PolicyID]struct{}{
   196  						"policy-a": {}, // P4
   197  						"policy-b": {}, // P0
   198  						"policy-c": {}, // P1
   199  					}
   200  					expectedIssue.Description.Comment = strings.Replace(expectedIssue.Description.Comment, "- Problem A\n", "- Problem B\n- Problem C\n- Problem A\n", 1)
   201  					expectedIssue.IssueState.Priority = issuetracker.Issue_P0
   202  					expectedIssue.IssueState.HotlistIds = []int64{1001, 1002, 1003}
   203  
   204  					// Act
   205  					response := bm.Create(ctx, createRequest)
   206  
   207  					// Verify
   208  					So(response, ShouldResemble, bugs.BugCreateResponse{
   209  						ID: "1",
   210  						PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   211  							"policy-a": {},
   212  							"policy-b": {},
   213  							"policy-c": {},
   214  						},
   215  					})
   216  					So(len(fakeStore.Issues), ShouldEqual, 1)
   217  
   218  					issueData := fakeStore.Issues[1]
   219  					So(issueData.Issue, ShouldResembleProto, expectedIssue)
   220  					So(len(issueData.Comments), ShouldEqual, 4)
   221  					// Policy notifications should be in order of priority.
   222  					So(issueData.Comments[1].Comment, ShouldStartWith, "Policy ID: policy-b")
   223  					So(issueData.Comments[2].Comment, ShouldStartWith, "Policy ID: policy-c")
   224  					So(issueData.Comments[3].Comment, ShouldStartWith, "Policy ID: policy-a")
   225  				})
   226  			})
   227  			Convey("With test name failure cluster", func() {
   228  				createRequest.Description.Title = "ninja://:blink_web_tests/media/my-suite/my-test.html"
   229  				createRequest.Description.Description = "A test is failing " + createRequest.Description.Title
   230  				expectedIssue.Description = &issuetracker.IssueComment{
   231  					CommentNumber: 1,
   232  					Comment: "A test is failing ninja://:blink_web_tests/media/my-suite/my-test.html\n" +
   233  						"\n" +
   234  						"These test failures are causing problem(s) which require your attention, including:\n" +
   235  						"- Problem A\n" +
   236  						"\n" +
   237  						"See current problems, failure examples and more in LUCI Analysis at: https://luci-analysis-test.appspot.com/p/chromeos/rules/new-rule-id\n" +
   238  						"\n" +
   239  						"How to action this bug: https://luci-analysis-test.appspot.com/help#new-bug-filed\n" +
   240  						"Provide feedback: https://luci-analysis-test.appspot.com/help#feedback\n" +
   241  						"Was this bug filed in the wrong component? See: https://luci-analysis-test.appspot.com/help#component-selection",
   242  				}
   243  				expectedIssue.IssueState.Title = "Tests are failing: ninja://:blink_web_tests/media/my-suite/my-test.html"
   244  
   245  				response := bm.Create(ctx, createRequest)
   246  				So(response, ShouldResemble, bugs.BugCreateResponse{
   247  					ID: "1",
   248  					PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   249  						"policy-a": {},
   250  					},
   251  				})
   252  				So(len(fakeStore.Issues), ShouldEqual, 1)
   253  				issue := fakeStore.Issues[1]
   254  
   255  				So(issue.Issue, ShouldResembleProto, expectedIssue)
   256  				So(len(issue.Comments), ShouldEqual, 2)
   257  				So(issue.Comments[0].Comment, ShouldContainSubstring, "https://luci-analysis-test.appspot.com/p/chromeos/rules/new-rule-id")
   258  				So(issue.Comments[1].Comment, ShouldStartWith, "Policy ID: policy-a")
   259  			})
   260  
   261  			Convey("Does nothing if in simulation mode", func() {
   262  				bm.Simulate = true
   263  				response := bm.Create(ctx, createRequest)
   264  				So(response, ShouldResemble, bugs.BugCreateResponse{
   265  					Simulated: true,
   266  					ID:        "123456",
   267  					PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   268  						"policy-a": {},
   269  					},
   270  				})
   271  				So(len(fakeStore.Issues), ShouldEqual, 0)
   272  			})
   273  
   274  			Convey("With provided component id", func() {
   275  				createRequest.BuganizerComponent = 7890
   276  				response := bm.Create(ctx, createRequest)
   277  				So(response, ShouldResemble, bugs.BugCreateResponse{
   278  					ID: "1",
   279  					PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   280  						"policy-a": {},
   281  					},
   282  				})
   283  				So(len(fakeStore.Issues), ShouldEqual, 1)
   284  				issue := fakeStore.Issues[1]
   285  				So(issue.Issue.IssueState.ComponentId, ShouldEqual, 7890)
   286  			})
   287  
   288  			Convey("With provided component id without permission", func() {
   289  				createRequest.BuganizerComponent = ComponentWithNoAccess
   290  				// TODO: Mock permission call to fail.
   291  				response := bm.Create(ctx, createRequest)
   292  				So(response, ShouldResemble, bugs.BugCreateResponse{
   293  					ID: "1",
   294  					PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   295  						"policy-a": {},
   296  					},
   297  				})
   298  				So(len(fakeStore.Issues), ShouldEqual, 1)
   299  				issue := fakeStore.Issues[1]
   300  				// Should have fallback component ID because no permission to wanted component.
   301  				So(issue.Issue.IssueState.ComponentId, ShouldEqual, buganizerCfg.DefaultComponent.Id)
   302  				// No permission to component should appear in comments.
   303  				So(len(issue.Comments), ShouldEqual, 3)
   304  				So(issue.Comments[1].Comment, ShouldContainSubstring, strconv.Itoa(ComponentWithNoAccess))
   305  				So(issue.Comments[2].Comment, ShouldStartWith, "Policy ID: policy-a")
   306  			})
   307  
   308  			Convey("With Buganizer test mode", func() {
   309  				createRequest.BuganizerComponent = 1234
   310  				// TODO: Mock permission call to fail.
   311  				ctx = context.WithValue(ctx, &BuganizerTestModeKey, true)
   312  				response := bm.Create(ctx, createRequest)
   313  				So(response, ShouldResemble, bugs.BugCreateResponse{
   314  					ID: "1",
   315  					PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   316  						"policy-a": {},
   317  					},
   318  				})
   319  				So(len(fakeStore.Issues), ShouldEqual, 1)
   320  				issue := fakeStore.Issues[1]
   321  				// Should have fallback component ID because no permission to wanted component.
   322  				So(issue.Issue.IssueState.ComponentId, ShouldEqual, buganizerCfg.DefaultComponent.Id)
   323  				So(len(issue.Comments), ShouldEqual, 3)
   324  				So(issue.Comments[1].Comment, ShouldContainSubstring, "This bug was filed in the fallback component")
   325  				So(issue.Comments[2].Comment, ShouldStartWith, "Policy ID: policy-a")
   326  			})
   327  
   328  			Convey("With Limit View Trusted", func() {
   329  				// Check config is respected and we file with Limit View Trusted if the
   330  				// config option to file without it is not set.
   331  				buganizerCfg.FileWithoutLimitViewTrusted = false
   332  				bm, err := NewBugManager(fakeClient, "https://luci-analysis-test.appspot.com", "chromeos", "email@test.com", projectCfg, false)
   333  				So(err, ShouldBeNil)
   334  
   335  				response := bm.Create(ctx, createRequest)
   336  				So(response, ShouldResemble, bugs.BugCreateResponse{
   337  					ID: "1",
   338  					PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   339  						"policy-a": {},
   340  					},
   341  				})
   342  				So(len(fakeStore.Issues), ShouldEqual, 1)
   343  				issue := fakeStore.Issues[1]
   344  				So(issue.Issue.IssueState.AccessLimit.AccessLevel, ShouldEqual, issuetracker.IssueAccessLimit_LIMIT_VIEW_TRUSTED)
   345  			})
   346  		})
   347  		Convey("Update", func() {
   348  			c := newCreateRequest()
   349  			c.ActivePolicyIDs = map[bugs.PolicyID]struct{}{
   350  				"policy-a": {}, // P4
   351  				"policy-c": {}, // P1
   352  			}
   353  			response := bm.Create(ctx, c)
   354  			So(response, ShouldResemble, bugs.BugCreateResponse{
   355  				ID: "1",
   356  				PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   357  					"policy-a": {},
   358  					"policy-c": {},
   359  				},
   360  			})
   361  			So(len(fakeStore.Issues), ShouldEqual, 1)
   362  			So(fakeStore.Issues[1].Issue.IssueState.Priority, ShouldEqual, issuetracker.Issue_P1)
   363  			So(fakeStore.Issues[1].Comments, ShouldHaveLength, 3)
   364  
   365  			originalCommentCount := len(fakeStore.Issues[1].Comments)
   366  
   367  			activationTime := time.Date(2025, 1, 1, 1, 0, 0, 0, time.UTC)
   368  			state := &bugspb.BugManagementState{
   369  				RuleAssociationNotified: true,
   370  				PolicyState: map[string]*bugspb.BugManagementState_PolicyState{
   371  					"policy-a": { // P4
   372  						IsActive:           true,
   373  						LastActivationTime: timestamppb.New(activationTime),
   374  						ActivationNotified: true,
   375  					},
   376  					"policy-b": { // P0
   377  						IsActive:             false,
   378  						LastActivationTime:   timestamppb.New(activationTime.Add(-time.Hour)),
   379  						LastDeactivationTime: timestamppb.New(activationTime),
   380  						ActivationNotified:   false,
   381  					},
   382  					"policy-c": { // P1
   383  						IsActive:           true,
   384  						LastActivationTime: timestamppb.New(activationTime),
   385  						ActivationNotified: true,
   386  					},
   387  				},
   388  			}
   389  
   390  			bugsToUpdate := []bugs.BugUpdateRequest{
   391  				{
   392  					Bug:                              bugs.BugID{System: bugs.BuganizerSystem, ID: response.ID},
   393  					BugManagementState:               state,
   394  					IsManagingBug:                    true,
   395  					RuleID:                           "rule-id",
   396  					IsManagingBugPriority:            true,
   397  					IsManagingBugPriorityLastUpdated: clock.Now(ctx),
   398  				},
   399  			}
   400  			expectedResponse := []bugs.BugUpdateResponse{
   401  				{
   402  					IsDuplicate:               false,
   403  					ShouldArchive:             false,
   404  					PolicyActivationsNotified: map[bugs.PolicyID]struct{}{},
   405  				},
   406  			}
   407  			verifyUpdateDoesNothing := func() error {
   408  				originalIssue := proto.Clone(fakeStore.Issues[1].Issue).(*issuetracker.Issue)
   409  				response, err := bm.Update(ctx, bugsToUpdate)
   410  				if err != nil {
   411  					return errors.Annotate(err, "update bugs").Err()
   412  				}
   413  				if diff := ShouldResemble(response, expectedResponse); diff != "" {
   414  					return errors.Reason("response: %s", diff).Err()
   415  				}
   416  				if diff := ShouldResembleProto(fakeStore.Issues[1].Issue, originalIssue); diff != "" {
   417  					return errors.Reason("issue 1: %s", diff).Err()
   418  				}
   419  				return nil
   420  			}
   421  
   422  			Convey("If less than expected issues are returned, should not fail", func() {
   423  				fakeStore.Issues = map[int64]*IssueData{}
   424  
   425  				bugsToUpdate := []bugs.BugUpdateRequest{
   426  					{
   427  						Bug:                              bugs.BugID{System: bugs.BuganizerSystem, ID: response.ID},
   428  						RuleID:                           "rule-id",
   429  						IsManagingBug:                    true,
   430  						IsManagingBugPriority:            true,
   431  						IsManagingBugPriorityLastUpdated: clock.Now(ctx),
   432  					},
   433  				}
   434  				expectedResponse = []bugs.BugUpdateResponse{
   435  					{
   436  						IsDuplicate:               false,
   437  						ShouldArchive:             false,
   438  						PolicyActivationsNotified: make(map[bugs.PolicyID]struct{}),
   439  					},
   440  				}
   441  				response, err := bm.Update(ctx, bugsToUpdate)
   442  				So(err, ShouldBeNil)
   443  				So(response, ShouldResemble, expectedResponse)
   444  			})
   445  
   446  			Convey("If active policies unchanged and no rule notification pending, does nothing", func() {
   447  				So(verifyUpdateDoesNothing(), ShouldBeNil)
   448  			})
   449  			Convey("Notifies association between bug and rule", func() {
   450  				// Setup
   451  				// When RuleAssociationNotified is false.
   452  				bugsToUpdate[0].BugManagementState.RuleAssociationNotified = false
   453  				// Even if ManagingBug is also false.
   454  				bugsToUpdate[0].IsManagingBug = false
   455  
   456  				// Act
   457  				response, err := bm.Update(ctx, bugsToUpdate)
   458  
   459  				// Verify
   460  				So(err, ShouldBeNil)
   461  
   462  				// RuleAssociationNotified is set.
   463  				expectedResponse[0].RuleAssociationNotified = true
   464  				So(response, ShouldResemble, expectedResponse)
   465  
   466  				// Expect a comment on the bug notifying us about the association.
   467  				So(fakeStore.Issues[1].Comments, ShouldHaveLength, originalCommentCount+1)
   468  				So(fakeStore.Issues[1].Comments[originalCommentCount].Comment, ShouldEqual,
   469  					"This bug has been associated with failures in LUCI Analysis. "+
   470  						"To view failure examples or update the association, go to LUCI Analysis at: https://luci-analysis-test.appspot.com/p/chromeos/rules/rule-id")
   471  			})
   472  			Convey("If active policies changed", func() {
   473  				// De-activates policy-c (P1), leaving only policy-a (P4) active.
   474  				bugsToUpdate[0].BugManagementState.PolicyState["policy-c"].IsActive = false
   475  				bugsToUpdate[0].BugManagementState.PolicyState["policy-c"].LastDeactivationTime = timestamppb.New(activationTime.Add(time.Hour))
   476  
   477  				Convey("Does not update bug priority/verified if IsManagingBug false", func() {
   478  					bugsToUpdate[0].IsManagingBug = false
   479  
   480  					So(verifyUpdateDoesNothing(), ShouldBeNil)
   481  				})
   482  				Convey("Notifies policy activation, even if IsManagingBug false", func() {
   483  					bugsToUpdate[0].IsManagingBug = false
   484  
   485  					// Activate policy B (P0).
   486  					bugsToUpdate[0].BugManagementState.PolicyState["policy-b"].IsActive = true
   487  					bugsToUpdate[0].BugManagementState.PolicyState["policy-b"].LastActivationTime = timestamppb.New(activationTime.Add(time.Hour))
   488  
   489  					expectedResponse[0].PolicyActivationsNotified = map[bugs.PolicyID]struct{}{
   490  						"policy-b": {},
   491  					}
   492  
   493  					// Act
   494  					response, err := bm.Update(ctx, bugsToUpdate)
   495  
   496  					// Verify
   497  					So(err, ShouldBeNil)
   498  					So(response, ShouldResemble, expectedResponse)
   499  
   500  					// Bug priority should NOT be increased to P0, because IsManagingBug is false.
   501  					So(fakeStore.Issues[1].Issue.IssueState.Priority, ShouldNotEqual, issuetracker.Issue_P0)
   502  
   503  					// Expect the policy B activation comment to appear.
   504  					So(fakeStore.Issues[1].Comments, ShouldHaveLength, originalCommentCount+1)
   505  					So(fakeStore.Issues[1].Comments[originalCommentCount].Comment, ShouldContainSubstring,
   506  						"Policy ID: policy-b")
   507  
   508  					// Expect policy B's hotlist to be added to the bug.
   509  					So(fakeStore.Issues[1].Issue.IssueState.HotlistIds, ShouldResemble, []int64{1001, 1002, 1003})
   510  
   511  					// Verify repeated update has no effect.
   512  					bugsToUpdate[0].BugManagementState.PolicyState["policy-b"].ActivationNotified = true
   513  					expectedResponse[0].PolicyActivationsNotified = map[bugs.PolicyID]struct{}{}
   514  					So(verifyUpdateDoesNothing(), ShouldBeNil)
   515  				})
   516  				Convey("Reduces priority in response to policies de-activating", func() {
   517  					// Act
   518  					response, err := bm.Update(ctx, bugsToUpdate)
   519  
   520  					// Verify
   521  					So(err, ShouldBeNil)
   522  					So(response, ShouldResemble, expectedResponse)
   523  					So(fakeStore.Issues[1].Issue.IssueState.Priority, ShouldEqual, issuetracker.Issue_P4)
   524  					So(fakeStore.Issues[1].Comments, ShouldHaveLength, originalCommentCount+1)
   525  					So(fakeStore.Issues[1].Comments[originalCommentCount].Comment, ShouldContainSubstring,
   526  						"Because the following problem(s) have stopped:\n"+
   527  							"- Problem C (P1)\n"+
   528  							"The bug priority has been decreased from P1 to P4.")
   529  					So(fakeStore.Issues[1].Comments[originalCommentCount].Comment, ShouldContainSubstring,
   530  						"https://luci-analysis-test.appspot.com/help#priority-update")
   531  					So(fakeStore.Issues[1].Comments[originalCommentCount].Comment, ShouldContainSubstring,
   532  						"https://luci-analysis-test.appspot.com/p/chromeos/rules/rule-id")
   533  
   534  					// Verify repeated update has no effect.
   535  					So(verifyUpdateDoesNothing(), ShouldBeNil)
   536  				})
   537  				Convey("Increases priority in response to priority policies activating", func() {
   538  					// Activate policy B (P0).
   539  					bugsToUpdate[0].BugManagementState.PolicyState["policy-b"].IsActive = true
   540  					bugsToUpdate[0].BugManagementState.PolicyState["policy-b"].LastActivationTime = timestamppb.New(activationTime.Add(time.Hour))
   541  
   542  					expectedResponse[0].PolicyActivationsNotified = map[bugs.PolicyID]struct{}{
   543  						"policy-b": {},
   544  					}
   545  
   546  					// Act
   547  					response, err := bm.Update(ctx, bugsToUpdate)
   548  
   549  					// Verify
   550  					So(err, ShouldBeNil)
   551  					So(response, ShouldResemble, expectedResponse)
   552  					So(fakeStore.Issues[1].Issue.IssueState.Priority, ShouldEqual, issuetracker.Issue_P0)
   553  					So(fakeStore.Issues[1].Comments, ShouldHaveLength, originalCommentCount+2)
   554  					// Expect the policy B activation comment to appear, followed by the priority update comment.
   555  					So(fakeStore.Issues[1].Comments[originalCommentCount].Comment, ShouldContainSubstring,
   556  						"Policy ID: policy-b")
   557  					So(fakeStore.Issues[1].Comments[originalCommentCount+1].Comment, ShouldContainSubstring,
   558  						"Because the following problem(s) have started:\n"+
   559  							"- Problem B (P0)\n"+
   560  							"The bug priority has been increased from P1 to P0.")
   561  					So(fakeStore.Issues[1].Comments[originalCommentCount+1].Comment, ShouldContainSubstring,
   562  						"https://luci-analysis-test.appspot.com/help#priority-update")
   563  					So(fakeStore.Issues[1].Comments[originalCommentCount+1].Comment, ShouldContainSubstring,
   564  						"https://luci-analysis-test.appspot.com/p/chromeos/rules/rule-id")
   565  
   566  					// Verify repeated update has no effect.
   567  					So(verifyUpdateDoesNothing(), ShouldBeNil)
   568  				})
   569  				Convey("Does not adjust priority if priority manually set", func() {
   570  					ctx := context.WithValue(ctx, &BuganizerSelfEmailKey, "luci-analysis@prod.google.com")
   571  					fakeStore.Issues[1].Issue.IssueState.Priority = issuetracker.Issue_P0
   572  					fakeStore.Issues[1].IssueUpdates = append(fakeStore.Issues[1].IssueUpdates, &issuetracker.IssueUpdate{
   573  						Author: &issuetracker.User{
   574  							EmailAddress: "testuser@google.com",
   575  						},
   576  						Timestamp: timestamppb.New(clock.Now(ctx).Add(time.Minute * 4)),
   577  						FieldUpdates: []*issuetracker.FieldUpdate{
   578  							{
   579  								Field: "priority",
   580  							},
   581  						},
   582  					})
   583  					response, err := bm.Update(ctx, bugsToUpdate)
   584  					So(err, ShouldBeNil)
   585  					So(fakeStore.Issues[1].Issue.IssueState.Priority, ShouldEqual, issuetracker.Issue_P0)
   586  					expectedResponse[0].DisableRulePriorityUpdates = true
   587  					So(response[0].DisableRulePriorityUpdates, ShouldBeTrue)
   588  					So(fakeStore.Issues[1].Comments, ShouldHaveLength, originalCommentCount+1)
   589  					So(fakeStore.Issues[1].Comments[originalCommentCount].Comment, ShouldEqual,
   590  						"The bug priority has been manually set. To re-enable automatic priority updates by LUCI Analysis,"+
   591  							" enable the update priority flag on the rule.\n\nSee failure impact and configure the failure"+
   592  							" association rule for this bug at: https://luci-analysis-test.appspot.com/p/chromeos/rules/rule-id")
   593  
   594  					// Normally, the caller would update IsManagingBugPriority to false
   595  					// now, but as this is a test, we have to do it manually.
   596  					// As priority updates are now off, DisableRulePriorityUpdates
   597  					// should henceforth also return false (as they are already
   598  					// disabled).
   599  					expectedResponse[0].DisableRulePriorityUpdates = false
   600  					bugsToUpdate[0].IsManagingBugPriority = false
   601  					bugsToUpdate[0].IsManagingBugPriorityLastUpdated = tc.Now().Add(1 * time.Minute)
   602  
   603  					// Check repeated update does nothing more.
   604  					initialComments := len(fakeStore.Issues[1].Comments)
   605  					So(verifyUpdateDoesNothing(), ShouldBeNil)
   606  					So(len(fakeStore.Issues[1].Comments), ShouldEqual, initialComments)
   607  
   608  					Convey("Unless IsManagingBugPriority manually updated", func() {
   609  						bugsToUpdate[0].IsManagingBugPriority = true
   610  						bugsToUpdate[0].IsManagingBugPriorityLastUpdated = clock.Now(ctx).Add(time.Minute * 15)
   611  
   612  						response, err := bm.Update(ctx, bugsToUpdate)
   613  						So(response, ShouldResemble, expectedResponse)
   614  						So(err, ShouldBeNil)
   615  						So(fakeStore.Issues[1].Issue.IssueState.Priority, ShouldEqual, issuetracker.Issue_P4)
   616  						So(fakeStore.Issues[1].Comments, ShouldHaveLength, initialComments+1)
   617  						So(fakeStore.Issues[1].Comments[initialComments].Comment, ShouldContainSubstring,
   618  							"Because the following problem(s) are active:\n"+
   619  								"- Problem A (P4)\n"+
   620  								"\n"+
   621  								"The bug priority has been set to P4.")
   622  						So(fakeStore.Issues[1].Comments[initialComments].Comment, ShouldContainSubstring,
   623  							"https://luci-analysis-test.appspot.com/help#priority-update")
   624  						So(fakeStore.Issues[1].Comments[initialComments].Comment, ShouldContainSubstring,
   625  							"https://luci-analysis-test.appspot.com/p/chromeos/rules/rule-id")
   626  
   627  						// Verify repeated update has no effect.
   628  						So(verifyUpdateDoesNothing(), ShouldBeNil)
   629  					})
   630  				})
   631  				Convey("Does nothing if in simulation mode", func() {
   632  					bm.Simulate = true
   633  					So(verifyUpdateDoesNothing(), ShouldBeNil)
   634  				})
   635  			})
   636  			Convey("If all policies deactivate", func() {
   637  				// De-activate all policies, so the bug would normally be marked verified.
   638  				for _, policyState := range bugsToUpdate[0].BugManagementState.PolicyState {
   639  					if policyState.IsActive {
   640  						policyState.IsActive = false
   641  						policyState.LastDeactivationTime = timestamppb.New(activationTime.Add(time.Hour))
   642  					}
   643  				}
   644  
   645  				Convey("Does not update bug if IsManagingBug false", func() {
   646  					bugsToUpdate[0].IsManagingBug = false
   647  
   648  					So(verifyUpdateDoesNothing(), ShouldBeNil)
   649  				})
   650  				Convey("Sets verifier and assignee to luci analysis if assignee is nil", func() {
   651  					fakeStore.Issues[1].Issue.IssueState.Assignee = nil
   652  
   653  					response, err := bm.Update(ctx, bugsToUpdate)
   654  
   655  					So(err, ShouldBeNil)
   656  					So(response, ShouldResemble, expectedResponse)
   657  					So(fakeStore.Issues[1].Issue.IssueState.Status, ShouldEqual, issuetracker.Issue_VERIFIED)
   658  					So(fakeStore.Issues[1].Issue.IssueState.Verifier.EmailAddress, ShouldEqual, "email@test.com")
   659  					So(fakeStore.Issues[1].Issue.IssueState.Assignee.EmailAddress, ShouldEqual, "email@test.com")
   660  
   661  					Convey("If re-opening, LUCI Analysis assignee is removed", func() {
   662  						bugsToUpdate[0].BugManagementState.PolicyState["policy-a"].IsActive = true
   663  						bugsToUpdate[0].BugManagementState.PolicyState["policy-a"].LastActivationTime = timestamppb.New(activationTime.Add(2 * time.Hour))
   664  
   665  						response, err := bm.Update(ctx, bugsToUpdate)
   666  
   667  						So(err, ShouldBeNil)
   668  						So(response, ShouldResemble, expectedResponse)
   669  						So(fakeStore.Issues[1].Issue.IssueState.Status, ShouldEqual, issuetracker.Issue_NEW)
   670  						So(fakeStore.Issues[1].Issue.IssueState.Assignee, ShouldBeNil)
   671  					})
   672  				})
   673  
   674  				Convey("Update closes bug", func() {
   675  					fakeStore.Issues[1].Issue.IssueState.Assignee = &issuetracker.User{
   676  						EmailAddress: "user@google.com",
   677  					}
   678  
   679  					response, err := bm.Update(ctx, bugsToUpdate)
   680  					So(err, ShouldBeNil)
   681  					So(response, ShouldResemble, expectedResponse)
   682  					So(fakeStore.Issues[1].Issue.IssueState.Status, ShouldEqual, issuetracker.Issue_VERIFIED)
   683  
   684  					expectedComment := "Because the following problem(s) have stopped:\n" +
   685  						"- Problem C (P1)\n" +
   686  						"- Problem A (P4)\n" +
   687  						"The bug has been verified."
   688  					So(fakeStore.Issues[1].Comments, ShouldHaveLength, originalCommentCount+1)
   689  					So(fakeStore.Issues[1].Comments[originalCommentCount].Comment, ShouldContainSubstring, expectedComment)
   690  					So(fakeStore.Issues[1].Comments[originalCommentCount].Comment, ShouldContainSubstring,
   691  						"https://luci-analysis-test.appspot.com/help#bug-verified")
   692  					So(fakeStore.Issues[1].Comments[originalCommentCount].Comment, ShouldContainSubstring,
   693  						"https://luci-analysis-test.appspot.com/p/chromeos/rules/rule-id")
   694  
   695  					// Verify repeated update has no effect.
   696  					So(verifyUpdateDoesNothing(), ShouldBeNil)
   697  
   698  					Convey("Rules for verified bugs archived after 30 days", func() {
   699  						tc.Add(time.Hour * 24 * 30)
   700  
   701  						expectedResponse := []bugs.BugUpdateResponse{
   702  							{
   703  								ShouldArchive:             true,
   704  								PolicyActivationsNotified: map[bugs.PolicyID]struct{}{},
   705  							},
   706  						}
   707  						tc.Add(time.Minute * 2)
   708  						response, err := bm.Update(ctx, bugsToUpdate)
   709  						So(err, ShouldBeNil)
   710  						So(response, ShouldResemble, expectedResponse)
   711  						So(fakeStore.Issues[1].Issue.ModifiedTime, ShouldResembleProto, timestamppb.New(now))
   712  					})
   713  
   714  					Convey("If policies re-activate, bug is re-opened with correct priority", func() {
   715  						// policy-b has priority P0.
   716  						bugsToUpdate[0].BugManagementState.PolicyState["policy-b"].IsActive = true
   717  						bugsToUpdate[0].BugManagementState.PolicyState["policy-b"].LastActivationTime = timestamppb.New(activationTime.Add(2 * time.Hour))
   718  						bugsToUpdate[0].BugManagementState.PolicyState["policy-b"].ActivationNotified = true
   719  
   720  						originalCommentCount := len(fakeStore.Issues[1].Comments)
   721  
   722  						Convey("Issue has owner", func() {
   723  							fakeStore.Issues[1].Issue.IssueState.Assignee = &issuetracker.User{
   724  								EmailAddress: "testuser@google.com",
   725  							}
   726  
   727  							// Issue should return to "Assigned" status.
   728  							response, err := bm.Update(ctx, bugsToUpdate)
   729  							So(err, ShouldBeNil)
   730  							So(response, ShouldResemble, expectedResponse)
   731  							So(fakeStore.Issues[1].Issue.IssueState.Status, ShouldEqual, issuetracker.Issue_ASSIGNED)
   732  							So(fakeStore.Issues[1].Issue.IssueState.Priority, ShouldEqual, issuetracker.Issue_P0)
   733  
   734  							expectedComment := "Because the following problem(s) have started:\n" +
   735  								"- Problem B (P0)\n" +
   736  								"The bug has been re-opened as P0."
   737  							So(fakeStore.Issues[1].Comments, ShouldHaveLength, originalCommentCount+1)
   738  							So(fakeStore.Issues[1].Comments[originalCommentCount].Comment, ShouldContainSubstring, expectedComment)
   739  							So(fakeStore.Issues[1].Comments[originalCommentCount].Comment, ShouldContainSubstring,
   740  								"https://luci-analysis-test.appspot.com/help#bug-reopened")
   741  							So(fakeStore.Issues[1].Comments[originalCommentCount].Comment, ShouldContainSubstring,
   742  								"https://luci-analysis-test.appspot.com/p/chromeos/rules/rule-id")
   743  
   744  							// Verify repeated update has no effect.
   745  							So(verifyUpdateDoesNothing(), ShouldBeNil)
   746  						})
   747  						Convey("Issue has no assignee", func() {
   748  							fakeStore.Issues[1].Issue.IssueState.Assignee = nil
   749  
   750  							// Issue should return to "Untriaged" status.
   751  							response, err := bm.Update(ctx, bugsToUpdate)
   752  							So(err, ShouldBeNil)
   753  							So(response, ShouldResemble, expectedResponse)
   754  							So(fakeStore.Issues[1].Issue.IssueState.Status, ShouldEqual, issuetracker.Issue_NEW)
   755  							So(fakeStore.Issues[1].Issue.IssueState.Priority, ShouldEqual, issuetracker.Issue_P0)
   756  
   757  							expectedComment := "Because the following problem(s) have started:\n" +
   758  								"- Problem B (P0)\n" +
   759  								"The bug has been re-opened as P0."
   760  							So(fakeStore.Issues[1].Comments, ShouldHaveLength, originalCommentCount+1)
   761  							So(fakeStore.Issues[1].Comments[originalCommentCount].Comment, ShouldContainSubstring, expectedComment)
   762  							So(fakeStore.Issues[1].Comments[originalCommentCount].Comment, ShouldContainSubstring,
   763  								"https://luci-analysis-test.appspot.com/help#priority-update")
   764  							So(fakeStore.Issues[1].Comments[originalCommentCount].Comment, ShouldContainSubstring,
   765  								"https://luci-analysis-test.appspot.com/help#bug-reopened")
   766  							So(fakeStore.Issues[1].Comments[originalCommentCount].Comment, ShouldContainSubstring,
   767  								"https://luci-analysis-test.appspot.com/p/chromeos/rules/rule-id")
   768  
   769  							// Verify repeated update has no effect.
   770  							So(verifyUpdateDoesNothing(), ShouldBeNil)
   771  						})
   772  					})
   773  				})
   774  			})
   775  			Convey("If bug duplicate", func() {
   776  				fakeStore.Issues[1].Issue.IssueState.Status = issuetracker.Issue_DUPLICATE
   777  				expectedResponse := []bugs.BugUpdateResponse{
   778  					{
   779  						IsDuplicate:               true,
   780  						PolicyActivationsNotified: map[bugs.PolicyID]struct{}{},
   781  					},
   782  				}
   783  
   784  				// Act
   785  				response, err := bm.Update(ctx, bugsToUpdate)
   786  
   787  				// Verify
   788  				So(err, ShouldBeNil)
   789  				So(response, ShouldResemble, expectedResponse)
   790  				So(fakeStore.Issues[1].Issue.ModifiedTime, ShouldResembleProto, timestamppb.New(clock.Now(ctx)))
   791  			})
   792  			Convey("Rule not managing a bug archived after 30 days of the bug being in any closed state", func() {
   793  				bugsToUpdate[0].IsManagingBug = false
   794  				fakeStore.Issues[1].Issue.IssueState.Status = issuetracker.Issue_FIXED
   795  				fakeStore.Issues[1].Issue.ResolvedTime = timestamppb.New(tc.Now())
   796  
   797  				tc.Add(time.Hour * 24 * 30)
   798  
   799  				expectedResponse := []bugs.BugUpdateResponse{
   800  					{
   801  						ShouldArchive:             true,
   802  						PolicyActivationsNotified: map[bugs.PolicyID]struct{}{},
   803  					},
   804  				}
   805  				originalTime := timestamppb.New(fakeStore.Issues[1].Issue.ModifiedTime.AsTime())
   806  
   807  				// Act
   808  				response, err := bm.Update(ctx, bugsToUpdate)
   809  
   810  				// Verify
   811  				So(err, ShouldBeNil)
   812  				So(response, ShouldResemble, expectedResponse)
   813  				So(fakeStore.Issues[1].Issue.ModifiedTime, ShouldResembleProto, originalTime)
   814  			})
   815  			Convey("Rule managing a bug not archived after 30 days of the bug being in fixed state", func() {
   816  				// If LUCI Analysis is mangaging the bug state, the fixed state
   817  				// means the bug is still not verified. Do not archive the
   818  				// rule.
   819  				bugsToUpdate[0].IsManagingBug = true
   820  				fakeStore.Issues[1].Issue.IssueState.Status = issuetracker.Issue_FIXED
   821  				fakeStore.Issues[1].Issue.ResolvedTime = timestamppb.New(tc.Now())
   822  
   823  				tc.Add(time.Hour * 24 * 30)
   824  
   825  				So(verifyUpdateDoesNothing(), ShouldBeNil)
   826  			})
   827  
   828  			Convey("Rules archived immediately if bug archived", func() {
   829  				fakeStore.Issues[1].Issue.IsArchived = true
   830  
   831  				expectedResponse := []bugs.BugUpdateResponse{
   832  					{
   833  						ShouldArchive:             true,
   834  						PolicyActivationsNotified: map[bugs.PolicyID]struct{}{},
   835  					},
   836  				}
   837  
   838  				// Act
   839  				response, err := bm.Update(ctx, bugsToUpdate)
   840  
   841  				// Verify
   842  				So(err, ShouldBeNil)
   843  				So(response, ShouldResemble, expectedResponse)
   844  			})
   845  			Convey("If issue does not exist, does nothing", func() {
   846  				fakeStore.Issues = nil
   847  				response, err := bm.Update(ctx, bugsToUpdate)
   848  				So(err, ShouldBeNil)
   849  				So(len(response), ShouldEqual, len(bugsToUpdate))
   850  				So(fakeStore.Issues, ShouldBeNil)
   851  			})
   852  		})
   853  		Convey("GetMergedInto", func() {
   854  			c := newCreateRequest()
   855  			c.ActivePolicyIDs = map[bugs.PolicyID]struct{}{"policy-a": {}}
   856  			response := bm.Create(ctx, c)
   857  			So(response, ShouldResemble, bugs.BugCreateResponse{
   858  				ID: "1",
   859  				PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   860  					"policy-a": {},
   861  				},
   862  			})
   863  			So(len(fakeStore.Issues), ShouldEqual, 1)
   864  
   865  			bugID := bugs.BugID{System: bugs.BuganizerSystem, ID: "1"}
   866  			Convey("Merged into Buganizer bug", func() {
   867  				fakeStore.Issues[1].Issue.IssueState.Status = issuetracker.Issue_DUPLICATE
   868  				fakeStore.Issues[1].Issue.IssueState.CanonicalIssueId = 2
   869  
   870  				result, err := bm.GetMergedInto(ctx, bugID)
   871  				So(err, ShouldEqual, nil)
   872  				So(result, ShouldResemble, &bugs.BugID{
   873  					System: bugs.BuganizerSystem,
   874  					ID:     "2",
   875  				})
   876  			})
   877  			Convey("Not merged into any bug", func() {
   878  				// While MergedIntoIssueRef is set, the bug status is not
   879  				// set to "Duplicate", so this value should be ignored.
   880  				fakeStore.Issues[1].Issue.IssueState.Status = issuetracker.Issue_NEW
   881  				fakeStore.Issues[1].Issue.IssueState.CanonicalIssueId = 2
   882  
   883  				result, err := bm.GetMergedInto(ctx, bugID)
   884  				So(err, ShouldEqual, nil)
   885  				So(result, ShouldBeNil)
   886  			})
   887  		})
   888  		Convey("UpdateDuplicateSource", func() {
   889  			c := newCreateRequest()
   890  			c.ActivePolicyIDs = map[bugs.PolicyID]struct{}{"policy-a": {}}
   891  			response := bm.Create(ctx, c)
   892  			So(response, ShouldResemble, bugs.BugCreateResponse{
   893  				ID: "1",
   894  				PolicyActivationsNotified: map[bugs.PolicyID]struct{}{
   895  					"policy-a": {},
   896  				},
   897  			})
   898  			So(fakeStore.Issues, ShouldHaveLength, 1)
   899  			So(fakeStore.Issues[1].Comments, ShouldHaveLength, 2)
   900  			originalCommentCount := len(fakeStore.Issues[1].Comments)
   901  
   902  			fakeStore.Issues[1].Issue.IssueState.Status = issuetracker.Issue_DUPLICATE
   903  			fakeStore.Issues[1].Issue.IssueState.CanonicalIssueId = 2
   904  
   905  			Convey("With ErrorMessage", func() {
   906  				request := bugs.UpdateDuplicateSourceRequest{
   907  					BugDetails: bugs.DuplicateBugDetails{
   908  						RuleID: "source-rule-id",
   909  						Bug:    bugs.BugID{System: bugs.BuganizerSystem, ID: "1"},
   910  					},
   911  					ErrorMessage: "Some error.",
   912  				}
   913  				err := bm.UpdateDuplicateSource(ctx, request)
   914  				So(err, ShouldBeNil)
   915  
   916  				So(fakeStore.Issues[1].Issue.IssueState.Status, ShouldEqual, issuetracker.Issue_NEW)
   917  				So(fakeStore.Issues[1].Comments, ShouldHaveLength, originalCommentCount+1)
   918  				So(fakeStore.Issues[1].Comments[originalCommentCount].Comment, ShouldContainSubstring, "Some error.")
   919  				So(fakeStore.Issues[1].Comments[originalCommentCount].Comment, ShouldContainSubstring,
   920  					"https://luci-analysis-test.appspot.com/p/chromeos/rules/source-rule-id")
   921  			})
   922  			Convey("With ErrorMessage and IsAssigned is true", func() {
   923  				request := bugs.UpdateDuplicateSourceRequest{
   924  					BugDetails: bugs.DuplicateBugDetails{
   925  						RuleID:     "source-rule-id",
   926  						Bug:        bugs.BugID{System: bugs.BuganizerSystem, ID: "1"},
   927  						IsAssigned: true,
   928  					},
   929  					ErrorMessage: "Some error.",
   930  				}
   931  				err := bm.UpdateDuplicateSource(ctx, request)
   932  				So(err, ShouldBeNil)
   933  
   934  				So(fakeStore.Issues[1].Issue.IssueState.Status, ShouldEqual, issuetracker.Issue_ASSIGNED)
   935  				So(fakeStore.Issues[1].Comments, ShouldHaveLength, originalCommentCount+1)
   936  				So(fakeStore.Issues[1].Comments[originalCommentCount].Comment, ShouldContainSubstring, "Some error.")
   937  				So(fakeStore.Issues[1].Comments[originalCommentCount].Comment, ShouldContainSubstring,
   938  					"https://luci-analysis-test.appspot.com/p/chromeos/rules/source-rule-id")
   939  			})
   940  			Convey("Without ErrorMessage", func() {
   941  				request := bugs.UpdateDuplicateSourceRequest{
   942  					BugDetails: bugs.DuplicateBugDetails{
   943  						RuleID: "source-bug-rule-id",
   944  						Bug:    bugs.BugID{System: bugs.BuganizerSystem, ID: "1"},
   945  					},
   946  					DestinationRuleID: "12345abcdef",
   947  				}
   948  				err := bm.UpdateDuplicateSource(ctx, request)
   949  				So(err, ShouldBeNil)
   950  
   951  				So(fakeStore.Issues[1].Issue.IssueState.Status, ShouldEqual, issuetracker.Issue_DUPLICATE)
   952  				So(fakeStore.Issues[1].Comments, ShouldHaveLength, originalCommentCount+1)
   953  				So(fakeStore.Issues[1].Comments[originalCommentCount].Comment, ShouldContainSubstring, "merged the failure association rule for this bug into the rule for the canonical bug.")
   954  				So(fakeStore.Issues[1].Comments[originalCommentCount].Comment, ShouldContainSubstring,
   955  					"https://luci-analysis-test.appspot.com/p/chromeos/rules/12345abcdef")
   956  			})
   957  		})
   958  	})
   959  }
   960  
   961  func newCreateRequest() bugs.BugCreateRequest {
   962  	cluster := bugs.BugCreateRequest{
   963  		Description: &clustering.ClusterDescription{
   964  			Title:       "ClusterID",
   965  			Description: "Tests are failing with reason: Some failure reason.",
   966  		},
   967  		RuleID: "new-rule-id",
   968  	}
   969  	return cluster
   970  }