go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/internal/bugs/updater/updater_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 updater
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"sort"
    21  	"strings"
    22  	"testing"
    23  	"time"
    24  
    25  	"cloud.google.com/go/bigquery"
    26  	"google.golang.org/grpc/codes"
    27  	"google.golang.org/grpc/status"
    28  	"google.golang.org/protobuf/proto"
    29  	"google.golang.org/protobuf/types/known/fieldmaskpb"
    30  	"google.golang.org/protobuf/types/known/timestamppb"
    31  
    32  	"go.chromium.org/luci/common/clock"
    33  	"go.chromium.org/luci/common/clock/testclock"
    34  	"go.chromium.org/luci/common/errors"
    35  	"go.chromium.org/luci/config/validation"
    36  	"go.chromium.org/luci/gae/impl/memory"
    37  	"go.chromium.org/luci/server/span"
    38  	"go.chromium.org/luci/third_party/google.golang.org/genproto/googleapis/devtools/issuetracker/v1"
    39  
    40  	"go.chromium.org/luci/analysis/internal/analysis"
    41  	"go.chromium.org/luci/analysis/internal/analysis/metrics"
    42  	"go.chromium.org/luci/analysis/internal/bugs"
    43  	"go.chromium.org/luci/analysis/internal/bugs/buganizer"
    44  	"go.chromium.org/luci/analysis/internal/bugs/monorail"
    45  	mpb "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto"
    46  	bugspb "go.chromium.org/luci/analysis/internal/bugs/proto"
    47  	"go.chromium.org/luci/analysis/internal/clustering/algorithms"
    48  	"go.chromium.org/luci/analysis/internal/clustering/rules"
    49  	"go.chromium.org/luci/analysis/internal/clustering/runs"
    50  	"go.chromium.org/luci/analysis/internal/config"
    51  	"go.chromium.org/luci/analysis/internal/config/compiledcfg"
    52  	"go.chromium.org/luci/analysis/internal/testutil"
    53  	configpb "go.chromium.org/luci/analysis/proto/config"
    54  
    55  	. "github.com/smartystreets/goconvey/convey"
    56  	. "go.chromium.org/luci/common/testing/assertions"
    57  )
    58  
    59  func TestUpdate(t *testing.T) {
    60  	Convey("With bug updater", t, func() {
    61  		ctx := testutil.IntegrationTestContext(t)
    62  		ctx = memory.Use(ctx)
    63  		ctx = context.WithValue(ctx, &buganizer.BuganizerSelfEmailKey, "email@test.com")
    64  
    65  		const project = "chromeos"
    66  
    67  		// Has two policies:
    68  		// exoneration-policy (P2):
    69  		// - activation threshold: 100 in one day
    70  		// - deactivation threshold: 10 in one day
    71  		// cls-rejected-policy (P1):
    72  		// - activation threshold: 10 in one week
    73  		// - deactivation threshold: 1 in one week
    74  		projectCfg := createProjectConfig()
    75  		projectsCfg := map[string]*configpb.ProjectConfig{
    76  			project: projectCfg,
    77  		}
    78  		err := config.SetTestProjectConfig(ctx, projectsCfg)
    79  		So(err, ShouldBeNil)
    80  
    81  		compiledCfg, err := compiledcfg.NewConfig(projectCfg)
    82  		So(err, ShouldBeNil)
    83  
    84  		suggestedClusters := []*analysis.Cluster{
    85  			makeReasonCluster(compiledCfg, 0),
    86  			makeReasonCluster(compiledCfg, 1),
    87  			makeReasonCluster(compiledCfg, 2),
    88  			makeReasonCluster(compiledCfg, 3),
    89  			makeReasonCluster(compiledCfg, 4),
    90  		}
    91  		analysisClient := &fakeAnalysisClient{
    92  			clusters: suggestedClusters,
    93  		}
    94  
    95  		buganizerClient := buganizer.NewFakeClient()
    96  		buganizerStore := buganizerClient.FakeStore
    97  
    98  		monorailStore := &monorail.FakeIssuesStore{
    99  			NextID:            100,
   100  			PriorityFieldName: "projects/chromium/fieldDefs/11",
   101  			ComponentNames: []string{
   102  				"projects/chromium/componentDefs/Blink",
   103  				"projects/chromium/componentDefs/Blink>Layout",
   104  				"projects/chromium/componentDefs/Blink>Network",
   105  			},
   106  		}
   107  		user := monorail.AutomationUsers[0]
   108  		monorailClient, err := monorail.NewClient(monorail.UseFakeIssuesClient(ctx, monorailStore, user), "myhost")
   109  		So(err, ShouldBeNil)
   110  
   111  		// Unless otherwise specified, assume re-clustering has caught up to
   112  		// the latest version of algorithms and config.
   113  		err = runs.SetRunsForTesting(ctx, []*runs.ReclusteringRun{
   114  			runs.NewRun(0).
   115  				WithProject(project).
   116  				WithAlgorithmsVersion(algorithms.AlgorithmsVersion).
   117  				WithConfigVersion(projectCfg.LastUpdated.AsTime()).
   118  				WithRulesVersion(rules.StartingEpoch).
   119  				WithCompletedProgress().Build(),
   120  		})
   121  		So(err, ShouldBeNil)
   122  
   123  		progress, err := runs.ReadReclusteringProgress(ctx, project)
   124  		So(err, ShouldBeNil)
   125  
   126  		opts := UpdateOptions{
   127  			UIBaseURL:            "https://luci-analysis-test.appspot.com",
   128  			Project:              project,
   129  			AnalysisClient:       analysisClient,
   130  			BuganizerClient:      buganizerClient,
   131  			MonorailClient:       monorailClient,
   132  			MaxBugsFiledPerRun:   1,
   133  			ReclusteringProgress: progress,
   134  			RunTimestamp:         time.Date(2100, 2, 2, 2, 2, 2, 2, time.UTC),
   135  		}
   136  
   137  		// Mock current time. This is needed to control behaviours like
   138  		// automatic archiving of rules after 30 days of bug being marked
   139  		// Closed (Verified).
   140  		now := time.Date(2055, time.May, 5, 5, 5, 5, 5, time.UTC)
   141  		ctx, tc := testclock.UseTime(ctx, now)
   142  
   143  		Convey("configuration used for testing is valid", func() {
   144  			c := validation.Context{Context: context.Background()}
   145  			config.ValidateProjectConfig(&c, project, projectCfg)
   146  			So(c.Finalize(), ShouldBeNil)
   147  		})
   148  		Convey("with a suggested cluster", func() {
   149  			// Create a suggested cluster we should consider filing a bug for.
   150  			sourceClusterID := reasonClusterID(compiledCfg, "Failed to connect to 100.1.1.99.")
   151  			suggestedClusters[1].ClusterID = sourceClusterID
   152  			suggestedClusters[1].ExampleFailureReason = bigquery.NullString{StringVal: "Failed to connect to 100.1.1.105.", Valid: true}
   153  			suggestedClusters[1].TopTestIDs = []analysis.TopCount{
   154  				{Value: "network-test-1", Count: 10},
   155  				{Value: "network-test-2", Count: 10},
   156  			}
   157  			// Meets failure dispersion thresholds.
   158  			suggestedClusters[1].DistinctUserCLsWithFailures7d.Residual = 3
   159  
   160  			expectedRule := &rules.Entry{
   161  				Project:                 "chromeos",
   162  				RuleDefinition:          `reason LIKE "Failed to connect to %.%.%.%."`,
   163  				BugID:                   bugs.BugID{System: bugs.BuganizerSystem, ID: "1"},
   164  				IsActive:                true,
   165  				IsManagingBug:           true,
   166  				IsManagingBugPriority:   true,
   167  				SourceCluster:           sourceClusterID,
   168  				CreateUser:              rules.LUCIAnalysisSystem,
   169  				LastAuditableUpdateUser: rules.LUCIAnalysisSystem,
   170  				BugManagementState: &bugspb.BugManagementState{
   171  					RuleAssociationNotified: true,
   172  					PolicyState: map[string]*bugspb.BugManagementState_PolicyState{
   173  						"exoneration-policy": {
   174  							IsActive:           true,
   175  							LastActivationTime: timestamppb.New(opts.RunTimestamp),
   176  							ActivationNotified: true,
   177  						},
   178  						"cls-rejected-policy": {},
   179  					},
   180  				},
   181  			}
   182  			expectedRules := []*rules.Entry{expectedRule}
   183  
   184  			expectedBuganizerBug := buganizerBug{
   185  				ID:            1,
   186  				Component:     projectCfg.BugManagement.Buganizer.DefaultComponent.Id,
   187  				ExpectedTitle: "Failed to connect to 100.1.1.105.",
   188  				// Expect the bug description to contain the top tests.
   189  				ExpectedContent: []string{
   190  					"https://luci-analysis-test.appspot.com/p/chromeos/rules/", // Rule ID randomly generated.
   191  					"network-test-1",
   192  					"network-test-2",
   193  				},
   194  				ExpectedPolicyIDsActivated: []string{
   195  					"exoneration-policy",
   196  				},
   197  			}
   198  
   199  			issueCount := func() int {
   200  				return len(buganizerStore.Issues) + len(monorailStore.Issues)
   201  			}
   202  
   203  			// Bug-filing threshold met.
   204  			suggestedClusters[1].MetricValues[metrics.CriticalFailuresExonerated.ID] = metrics.TimewiseCounts{
   205  				OneDay: metrics.Counts{Residual: 100},
   206  			}
   207  
   208  			Convey("bug filing threshold must be met to file a new bug", func() {
   209  				Convey("Reason cluster", func() {
   210  					Convey("Above threshold", func() {
   211  						suggestedClusters[1].MetricValues[metrics.CriticalFailuresExonerated.ID] = metrics.TimewiseCounts{OneDay: metrics.Counts{Residual: 100}}
   212  
   213  						// Act
   214  						err = UpdateBugsForProject(ctx, opts)
   215  
   216  						// Verify
   217  						So(err, ShouldBeNil)
   218  
   219  						So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
   220  						So(expectBuganizerBug(buganizerStore, expectedBuganizerBug), ShouldBeNil)
   221  						So(issueCount(), ShouldEqual, 1)
   222  
   223  						// Further updates do nothing.
   224  						err = UpdateBugsForProject(ctx, opts)
   225  
   226  						// Verify
   227  						So(err, ShouldBeNil)
   228  
   229  						So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
   230  						So(expectBuganizerBug(buganizerStore, expectedBuganizerBug), ShouldBeNil)
   231  						So(issueCount(), ShouldEqual, 1)
   232  					})
   233  					Convey("Below threshold", func() {
   234  						suggestedClusters[1].MetricValues[metrics.CriticalFailuresExonerated.ID] = metrics.TimewiseCounts{OneDay: metrics.Counts{Residual: 99}}
   235  
   236  						// Act
   237  						err = UpdateBugsForProject(ctx, opts)
   238  
   239  						// Verify
   240  						So(err, ShouldBeNil)
   241  
   242  						// No bug should be created.
   243  						So(verifyRulesResemble(ctx, nil), ShouldBeNil)
   244  						So(issueCount(), ShouldEqual, 0)
   245  					})
   246  				})
   247  				Convey("Test name cluster", func() {
   248  					suggestedClusters[1].ClusterID = testIDClusterID(compiledCfg, "ui-test-1")
   249  					suggestedClusters[1].TopTestIDs = []analysis.TopCount{
   250  						{Value: "ui-test-1", Count: 10},
   251  					}
   252  					expectedRule.RuleDefinition = `test = "ui-test-1"`
   253  					expectedRule.SourceCluster = suggestedClusters[1].ClusterID
   254  					expectedBuganizerBug.ExpectedTitle = "ui-test-1"
   255  					expectedBuganizerBug.ExpectedContent = []string{"ui-test-1"}
   256  
   257  					// 34% more impact is required for a test name cluster to
   258  					// be filed, compared to a failure reason cluster.
   259  					Convey("Above threshold", func() {
   260  						suggestedClusters[1].MetricValues[metrics.CriticalFailuresExonerated.ID] = metrics.TimewiseCounts{OneDay: metrics.Counts{Residual: 134}}
   261  
   262  						// Act
   263  						err = UpdateBugsForProject(ctx, opts)
   264  
   265  						// Verify
   266  						So(err, ShouldBeNil)
   267  						So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
   268  						So(expectBuganizerBug(buganizerStore, expectedBuganizerBug), ShouldBeNil)
   269  						So(issueCount(), ShouldEqual, 1)
   270  
   271  						// Further updates do nothing.
   272  						err = UpdateBugsForProject(ctx, opts)
   273  
   274  						// Verify
   275  						So(err, ShouldBeNil)
   276  						So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
   277  						So(expectBuganizerBug(buganizerStore, expectedBuganizerBug), ShouldBeNil)
   278  						So(issueCount(), ShouldEqual, 1)
   279  					})
   280  					Convey("Below threshold", func() {
   281  						suggestedClusters[1].MetricValues[metrics.CriticalFailuresExonerated.ID] = metrics.TimewiseCounts{OneDay: metrics.Counts{Residual: 133}}
   282  
   283  						// Act
   284  						err = UpdateBugsForProject(ctx, opts)
   285  
   286  						// Verify
   287  						So(err, ShouldBeNil)
   288  
   289  						// No bug should be created.
   290  						So(verifyRulesResemble(ctx, nil), ShouldBeNil)
   291  						So(issueCount(), ShouldEqual, 0)
   292  					})
   293  				})
   294  			})
   295  			Convey("policies are correctly activated when new bugs are filed", func() {
   296  				Convey("other policy activation threshold not met", func() {
   297  					suggestedClusters[1].MetricValues[metrics.HumanClsFailedPresubmit.ID] = metrics.TimewiseCounts{SevenDay: metrics.Counts{Residual: 9}}
   298  
   299  					// Act
   300  					err = UpdateBugsForProject(ctx, opts)
   301  
   302  					// Verify
   303  					So(err, ShouldBeNil)
   304  					So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
   305  					So(expectBuganizerBug(buganizerStore, expectedBuganizerBug), ShouldBeNil)
   306  					So(issueCount(), ShouldEqual, 1)
   307  				})
   308  				Convey("other policy activation threshold met", func() {
   309  					suggestedClusters[1].MetricValues[metrics.HumanClsFailedPresubmit.ID] = metrics.TimewiseCounts{SevenDay: metrics.Counts{Residual: 10}}
   310  					expectedRule.BugManagementState.PolicyState["cls-rejected-policy"].IsActive = true
   311  					expectedRule.BugManagementState.PolicyState["cls-rejected-policy"].LastActivationTime = timestamppb.New(opts.RunTimestamp)
   312  					expectedRule.BugManagementState.PolicyState["cls-rejected-policy"].ActivationNotified = true
   313  					expectedBuganizerBug.ExpectedPolicyIDsActivated = []string{
   314  						"cls-rejected-policy",
   315  						"exoneration-policy",
   316  					}
   317  
   318  					// Act
   319  					err = UpdateBugsForProject(ctx, opts)
   320  
   321  					// Verify
   322  					So(err, ShouldBeNil)
   323  					So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
   324  					So(expectBuganizerBug(buganizerStore, expectedBuganizerBug), ShouldBeNil)
   325  					So(issueCount(), ShouldEqual, 1)
   326  				})
   327  			})
   328  			Convey("dispersion criteria must be met to file a new bug", func() {
   329  				Convey("met via User CLs with failures", func() {
   330  					suggestedClusters[1].DistinctUserCLsWithFailures7d.Residual = 3
   331  					suggestedClusters[1].PostsubmitBuildsWithFailures7d.Residual = 0
   332  
   333  					// Act
   334  					err = UpdateBugsForProject(ctx, opts)
   335  
   336  					// Verify
   337  					So(err, ShouldBeNil)
   338  					So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
   339  					So(expectBuganizerBug(buganizerStore, expectedBuganizerBug), ShouldBeNil)
   340  					So(issueCount(), ShouldEqual, 1)
   341  				})
   342  				Convey("met via Postsubmit builds with failures", func() {
   343  					suggestedClusters[1].DistinctUserCLsWithFailures7d.Residual = 0
   344  					suggestedClusters[1].PostsubmitBuildsWithFailures7d.Residual = 1
   345  
   346  					// Act
   347  					err = UpdateBugsForProject(ctx, opts)
   348  
   349  					// Verify
   350  					So(err, ShouldBeNil)
   351  					So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
   352  					So(expectBuganizerBug(buganizerStore, expectedBuganizerBug), ShouldBeNil)
   353  					So(issueCount(), ShouldEqual, 1)
   354  				})
   355  				Convey("not met", func() {
   356  					suggestedClusters[1].DistinctUserCLsWithFailures7d.Residual = 0
   357  					suggestedClusters[1].PostsubmitBuildsWithFailures7d.Residual = 0
   358  
   359  					// Act
   360  					err = UpdateBugsForProject(ctx, opts)
   361  
   362  					// Verify
   363  					So(err, ShouldBeNil)
   364  					// No bug should be created.
   365  					So(verifyRulesResemble(ctx, nil), ShouldBeNil)
   366  					So(issueCount(), ShouldEqual, 0)
   367  				})
   368  			})
   369  			Convey("duplicate bugs are suppressed", func() {
   370  				Convey("where a rule was recently filed for the same suggested cluster, and reclustering is pending", func() {
   371  					createTime := time.Date(2021, time.January, 5, 12, 30, 0, 0, time.UTC)
   372  					buganizerStore.StoreIssue(ctx, buganizer.NewFakeIssue(1))
   373  					existingRule := rules.NewRule(1).
   374  						WithBugSystem(bugs.BuganizerSystem).
   375  						WithProject(project).
   376  						WithCreateTime(createTime).
   377  						WithPredicateLastUpdateTime(createTime.Add(1 * time.Hour)).
   378  						WithLastAuditableUpdateTime(createTime.Add(2 * time.Hour)).
   379  						WithLastUpdateTime(createTime.Add(3 * time.Hour)).
   380  						WithBugPriorityManaged(true).
   381  						WithBugPriorityManagedLastUpdateTime(createTime.Add(1 * time.Hour)).
   382  						WithSourceCluster(sourceClusterID).Build()
   383  					err := rules.SetForTesting(ctx, []*rules.Entry{
   384  						existingRule,
   385  					})
   386  					So(err, ShouldBeNil)
   387  
   388  					// Initially do not expect a new bug to be filed.
   389  					err = UpdateBugsForProject(ctx, opts)
   390  
   391  					So(err, ShouldBeNil)
   392  					So(verifyRulesResemble(ctx, []*rules.Entry{existingRule}), ShouldBeNil)
   393  					So(issueCount(), ShouldEqual, 1)
   394  
   395  					// Once re-clustering has incorporated the version of rules
   396  					// that included this new rule, it is OK to file another bug
   397  					// for the suggested cluster if sufficient impact remains.
   398  					// This should only happen when the rule definition has been
   399  					// manually narrowed in some way from the originally filed bug.
   400  					err = runs.SetRunsForTesting(ctx, []*runs.ReclusteringRun{
   401  						runs.NewRun(0).
   402  							WithProject(project).
   403  							WithAlgorithmsVersion(algorithms.AlgorithmsVersion).
   404  							WithConfigVersion(projectCfg.LastUpdated.AsTime()).
   405  							WithRulesVersion(createTime).
   406  							WithCompletedProgress().Build(),
   407  					})
   408  					So(err, ShouldBeNil)
   409  					progress, err := runs.ReadReclusteringProgress(ctx, project)
   410  					So(err, ShouldBeNil)
   411  					opts.ReclusteringProgress = progress
   412  
   413  					// Act
   414  					err = UpdateBugsForProject(ctx, opts)
   415  
   416  					// Verify
   417  					So(err, ShouldBeNil)
   418  					expectedBuganizerBug.ID = 2 // Because we already created a bug with ID 1 above.
   419  					expectedRule.BugID.ID = "2"
   420  					So(verifyRulesResemble(ctx, []*rules.Entry{expectedRule, existingRule}), ShouldBeNil)
   421  					So(expectBuganizerBug(buganizerStore, expectedBuganizerBug), ShouldBeNil)
   422  					So(issueCount(), ShouldEqual, 2)
   423  				})
   424  				Convey("when re-clustering to new algorithms", func() {
   425  					err = runs.SetRunsForTesting(ctx, []*runs.ReclusteringRun{
   426  						runs.NewRun(0).
   427  							WithProject(project).
   428  							WithAlgorithmsVersion(algorithms.AlgorithmsVersion - 1).
   429  							WithConfigVersion(projectCfg.LastUpdated.AsTime()).
   430  							WithRulesVersion(rules.StartingEpoch).
   431  							WithCompletedProgress().Build(),
   432  					})
   433  					So(err, ShouldBeNil)
   434  					progress, err := runs.ReadReclusteringProgress(ctx, project)
   435  					So(err, ShouldBeNil)
   436  					opts.ReclusteringProgress = progress
   437  
   438  					// Act
   439  					err = UpdateBugsForProject(ctx, opts)
   440  
   441  					// Verify no bugs were filed.
   442  					So(err, ShouldBeNil)
   443  					So(verifyRulesResemble(ctx, nil), ShouldBeNil)
   444  					So(issueCount(), ShouldEqual, 0)
   445  				})
   446  				Convey("when re-clustering to new config", func() {
   447  					err = runs.SetRunsForTesting(ctx, []*runs.ReclusteringRun{
   448  						runs.NewRun(0).
   449  							WithProject(project).
   450  							WithAlgorithmsVersion(algorithms.AlgorithmsVersion).
   451  							WithConfigVersion(projectCfg.LastUpdated.AsTime().Add(-1 * time.Hour)).
   452  							WithRulesVersion(rules.StartingEpoch).
   453  							WithCompletedProgress().Build(),
   454  					})
   455  					So(err, ShouldBeNil)
   456  					progress, err := runs.ReadReclusteringProgress(ctx, project)
   457  					So(err, ShouldBeNil)
   458  					opts.ReclusteringProgress = progress
   459  
   460  					// Act
   461  					err = UpdateBugsForProject(ctx, opts)
   462  
   463  					// Verify no bugs were filed.
   464  					So(err, ShouldBeNil)
   465  					So(verifyRulesResemble(ctx, nil), ShouldBeNil)
   466  					So(issueCount(), ShouldEqual, 0)
   467  				})
   468  			})
   469  			Convey("bugs are routed to the correct issue tracker and component", func() {
   470  				suggestedClusters[1].TopBuganizerComponents = []analysis.TopCount{
   471  					{Value: "77777", Count: 20},
   472  				}
   473  				expectedBuganizerBug.Component = 77777
   474  
   475  				suggestedClusters[1].TopMonorailComponents = []analysis.TopCount{
   476  					{Value: "Blink>Layout", Count: 40},  // >30% of failures.
   477  					{Value: "Blink>Network", Count: 31}, // >30% of failures.
   478  					{Value: "Blink>Other", Count: 4},
   479  				}
   480  				expectedMonorailBug := monorailBug{
   481  					Project: "chromium",
   482  					ID:      100,
   483  					ExpectedComponents: []string{
   484  						"projects/chromium/componentDefs/Blink>Layout",
   485  						"projects/chromium/componentDefs/Blink>Network",
   486  					},
   487  					ExpectedTitle: "Failed to connect to 100.1.1.105.",
   488  					// Expect the bug description to contain the top tests.
   489  					ExpectedContent: []string{
   490  						"network-test-1",
   491  						"network-test-2",
   492  					},
   493  					ExpectedPolicyIDsActivated: []string{
   494  						"exoneration-policy",
   495  					},
   496  				}
   497  				expectedRule.BugID = bugs.BugID{
   498  					System: "monorail",
   499  					ID:     "chromium/100",
   500  				}
   501  
   502  				Convey("if Monorail component has greatest failure count, should create Monorail issue", func() {
   503  					suggestedClusters[1].TopBuganizerComponents = []analysis.TopCount{{
   504  						Value: "12345",
   505  						Count: 39,
   506  					}}
   507  
   508  					// Act
   509  					err = UpdateBugsForProject(ctx, opts)
   510  
   511  					// Verify
   512  					So(err, ShouldBeNil)
   513  					So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
   514  					So(expectMonorailBug(monorailStore, expectedMonorailBug), ShouldBeNil)
   515  					So(issueCount(), ShouldEqual, 1)
   516  				})
   517  				Convey("if Buganizer component has higher failure count, should creates Buganizer issue", func() {
   518  					suggestedClusters[1].TopBuganizerComponents = []analysis.TopCount{{
   519  						// Check that null values are ignored.
   520  						Value: "",
   521  						Count: 100,
   522  					}, {
   523  						Value: "681721",
   524  						Count: 41,
   525  					}}
   526  					expectedBuganizerBug.Component = 681721
   527  
   528  					// Act
   529  					err = UpdateBugsForProject(ctx, opts)
   530  
   531  					// Verify
   532  					So(err, ShouldBeNil)
   533  					expectedRule.BugID = bugs.BugID{
   534  						System: "buganizer",
   535  						ID:     "1",
   536  					}
   537  					So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
   538  					So(expectBuganizerBug(buganizerStore, expectedBuganizerBug), ShouldBeNil)
   539  					So(issueCount(), ShouldEqual, 1)
   540  				})
   541  				Convey("with no Buganizer configuration, should use Monorail as default system", func() {
   542  					// Ensure Buganizer component has highest failure impact.
   543  					suggestedClusters[1].TopBuganizerComponents = []analysis.TopCount{{
   544  						Value: "88888",
   545  						Count: 99999,
   546  					}}
   547  
   548  					// But buganizer is not configured, so we should file into monorail.
   549  					projectCfg.BugManagement.Buganizer = nil
   550  					err = config.SetTestProjectConfig(ctx, projectsCfg)
   551  					So(err, ShouldBeNil)
   552  
   553  					// Act
   554  					err = UpdateBugsForProject(ctx, opts)
   555  
   556  					// Verify
   557  					So(err, ShouldBeNil)
   558  					So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
   559  					So(expectMonorailBug(monorailStore, expectedMonorailBug), ShouldBeNil)
   560  					So(issueCount(), ShouldEqual, 1)
   561  				})
   562  				Convey("with no Monorail configuration, should use Buganizer as default system", func() {
   563  					// Ensure Monorail component has highest failure impact.
   564  					suggestedClusters[1].TopMonorailComponents = []analysis.TopCount{{
   565  						Value: "Infra",
   566  						Count: 99999,
   567  					}}
   568  
   569  					// But monorail is not configured, so we should file into Buganizer.
   570  					projectCfg.BugManagement.Monorail = nil
   571  					err = config.SetTestProjectConfig(ctx, projectsCfg)
   572  					So(err, ShouldBeNil)
   573  
   574  					// Act
   575  					err = UpdateBugsForProject(ctx, opts)
   576  
   577  					// Verify
   578  					So(err, ShouldBeNil)
   579  					expectedRule.BugID = bugs.BugID{
   580  						System: "buganizer",
   581  						ID:     "1",
   582  					}
   583  					So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
   584  					So(expectBuganizerBug(buganizerStore, expectedBuganizerBug), ShouldBeNil)
   585  					So(issueCount(), ShouldEqual, 1)
   586  				})
   587  				Convey("in case of tied failure count between monorail/buganizer, should use default bug system", func() {
   588  					// The default bug system is buganizer.
   589  					So(projectCfg.BugManagement.DefaultBugSystem, ShouldEqual, configpb.BugSystem_BUGANIZER)
   590  
   591  					suggestedClusters[1].TopBuganizerComponents = []analysis.TopCount{{
   592  						Value: "",
   593  						Count: 55,
   594  					}, {
   595  						Value: "681721",
   596  						Count: 40, // Tied with monorail.
   597  					}}
   598  					expectedRule.BugID = bugs.BugID{
   599  						System: "buganizer",
   600  						ID:     "1",
   601  					}
   602  					expectedBuganizerBug.Component = 681721
   603  
   604  					// Act
   605  					err = UpdateBugsForProject(ctx, opts)
   606  
   607  					// Verify we filed into Buganizer.
   608  					So(err, ShouldBeNil)
   609  					So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
   610  					So(expectBuganizerBug(buganizerStore, expectedBuganizerBug), ShouldBeNil)
   611  					So(issueCount(), ShouldEqual, 1)
   612  				})
   613  			})
   614  			Convey("partial success creating bugs is correctly handled", func() {
   615  				// Inject an error updating the bug after creation.
   616  				buganizerClient.CreateCommentError = status.Errorf(codes.Internal, "internal error creating comment")
   617  
   618  				// Act
   619  				err = UpdateBugsForProject(ctx, opts)
   620  
   621  				// Do not expect policy activations to have been notified.
   622  				expectedBuganizerBug.ExpectedPolicyIDsActivated = []string{}
   623  				expectedRule.BugManagementState.PolicyState["exoneration-policy"].ActivationNotified = false
   624  
   625  				// Verify the rule was still created.
   626  				So(err, ShouldErrLike, "internal error creating comment")
   627  				So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
   628  				So(expectBuganizerBug(buganizerStore, expectedBuganizerBug), ShouldBeNil)
   629  				So(issueCount(), ShouldEqual, 1)
   630  			})
   631  		})
   632  		Convey("With both failure reason and test name clusters above bug-filing threshold", func() {
   633  			// Reason cluster above the 1-day exoneration threshold.
   634  			suggestedClusters[2] = makeReasonCluster(compiledCfg, 2)
   635  			suggestedClusters[2].MetricValues[metrics.CriticalFailuresExonerated.ID] = metrics.TimewiseCounts{
   636  				OneDay:   metrics.Counts{Residual: 100},
   637  				ThreeDay: metrics.Counts{Residual: 100},
   638  				SevenDay: metrics.Counts{Residual: 100},
   639  			}
   640  			suggestedClusters[2].PostsubmitBuildsWithFailures7d.Residual = 1
   641  
   642  			// Test name cluster with 33% more impact.
   643  			suggestedClusters[1] = makeTestNameCluster(compiledCfg, 3)
   644  			suggestedClusters[1].MetricValues[metrics.CriticalFailuresExonerated.ID] = metrics.TimewiseCounts{
   645  				OneDay:   metrics.Counts{Residual: 133},
   646  				ThreeDay: metrics.Counts{Residual: 133},
   647  				SevenDay: metrics.Counts{Residual: 133},
   648  			}
   649  			suggestedClusters[1].PostsubmitBuildsWithFailures7d.Residual = 1
   650  
   651  			// Limit to one bug filed each time, so that
   652  			// we test change throttling.
   653  			opts.MaxBugsFiledPerRun = 1
   654  
   655  			Convey("reason clusters preferred over test name clusters", func() {
   656  				// Test name cluster has <34% more impact than the reason
   657  				// cluster.
   658  
   659  				// Act
   660  				err = UpdateBugsForProject(ctx, opts)
   661  
   662  				// Verify reason cluster filed.
   663  				rs, err := rules.ReadAllForTesting(span.Single(ctx))
   664  				So(err, ShouldBeNil)
   665  				So(len(rs), ShouldEqual, 1)
   666  				So(rs[0].SourceCluster, ShouldResemble, suggestedClusters[2].ClusterID)
   667  				So(rs[0].SourceCluster.IsFailureReasonCluster(), ShouldBeTrue)
   668  			})
   669  			Convey("test name clusters can be filed if significantly more impact", func() {
   670  				// Increase impact of the test name cluster so that the
   671  				// test name cluster has >34% more impact than the reason
   672  				// cluster.
   673  				suggestedClusters[1].MetricValues[metrics.CriticalFailuresExonerated.ID] = metrics.TimewiseCounts{
   674  					OneDay:   metrics.Counts{Residual: 135},
   675  					ThreeDay: metrics.Counts{Residual: 135},
   676  					SevenDay: metrics.Counts{Residual: 135},
   677  				}
   678  
   679  				// Act
   680  				err = UpdateBugsForProject(ctx, opts)
   681  
   682  				// Verify test name cluster filed.
   683  				rs, err := rules.ReadAllForTesting(span.Single(ctx))
   684  				So(err, ShouldBeNil)
   685  				So(len(rs), ShouldEqual, 1)
   686  				So(rs[0].SourceCluster, ShouldResemble, suggestedClusters[1].ClusterID)
   687  				So(rs[0].SourceCluster.IsTestNameCluster(), ShouldBeTrue)
   688  			})
   689  		})
   690  		Convey("With multiple rules / bugs on file", func() {
   691  			// Use a mix of test name and failure reason clusters for
   692  			// code path coverage.
   693  			suggestedClusters[0] = makeTestNameCluster(compiledCfg, 0)
   694  			suggestedClusters[0].MetricValues[metrics.CriticalFailuresExonerated.ID] = metrics.TimewiseCounts{
   695  				OneDay:   metrics.Counts{Residual: 940},
   696  				ThreeDay: metrics.Counts{Residual: 940},
   697  				SevenDay: metrics.Counts{Residual: 940},
   698  			}
   699  			suggestedClusters[0].PostsubmitBuildsWithFailures7d.Residual = 1
   700  
   701  			suggestedClusters[1] = makeReasonCluster(compiledCfg, 1)
   702  			suggestedClusters[1].MetricValues[metrics.CriticalFailuresExonerated.ID] = metrics.TimewiseCounts{
   703  				OneDay:   metrics.Counts{Residual: 300},
   704  				ThreeDay: metrics.Counts{Residual: 300},
   705  				SevenDay: metrics.Counts{Residual: 300},
   706  			}
   707  			suggestedClusters[1].PostsubmitBuildsWithFailures7d.Residual = 1
   708  
   709  			suggestedClusters[2] = makeReasonCluster(compiledCfg, 2)
   710  			suggestedClusters[2].MetricValues[metrics.CriticalFailuresExonerated.ID] = metrics.TimewiseCounts{
   711  				OneDay:   metrics.Counts{Residual: 250},
   712  				ThreeDay: metrics.Counts{Residual: 250},
   713  				SevenDay: metrics.Counts{Residual: 250},
   714  			}
   715  			suggestedClusters[2].PostsubmitBuildsWithFailures7d.Residual = 1
   716  			suggestedClusters[2].TopMonorailComponents = []analysis.TopCount{
   717  				{Value: "Monorail", Count: 250},
   718  			}
   719  
   720  			suggestedClusters[3] = makeReasonCluster(compiledCfg, 3)
   721  			suggestedClusters[3].MetricValues[metrics.CriticalFailuresExonerated.ID] = metrics.TimewiseCounts{
   722  				OneDay:   metrics.Counts{Residual: 200},
   723  				ThreeDay: metrics.Counts{Residual: 200},
   724  				SevenDay: metrics.Counts{Residual: 200},
   725  			}
   726  			suggestedClusters[3].PostsubmitBuildsWithFailures7d.Residual = 1
   727  			suggestedClusters[3].TopMonorailComponents = []analysis.TopCount{
   728  				{Value: "Monorail", Count: 200},
   729  			}
   730  
   731  			expectedRules := []*rules.Entry{
   732  				{
   733  					Project:                 "chromeos",
   734  					RuleDefinition:          `test = "testname-0"`,
   735  					BugID:                   bugs.BugID{System: bugs.BuganizerSystem, ID: "1"},
   736  					SourceCluster:           suggestedClusters[0].ClusterID,
   737  					IsActive:                true,
   738  					IsManagingBug:           true,
   739  					IsManagingBugPriority:   true,
   740  					CreateUser:              rules.LUCIAnalysisSystem,
   741  					LastAuditableUpdateUser: rules.LUCIAnalysisSystem,
   742  					BugManagementState: &bugspb.BugManagementState{
   743  						RuleAssociationNotified: true,
   744  						PolicyState: map[string]*bugspb.BugManagementState_PolicyState{
   745  							"exoneration-policy": {
   746  								IsActive:           true,
   747  								LastActivationTime: timestamppb.New(opts.RunTimestamp),
   748  								ActivationNotified: true,
   749  							},
   750  							"cls-rejected-policy": {},
   751  						},
   752  					},
   753  				},
   754  				{
   755  					Project:                 "chromeos",
   756  					RuleDefinition:          `reason LIKE "want foo, got bar"`,
   757  					BugID:                   bugs.BugID{System: bugs.BuganizerSystem, ID: "2"},
   758  					SourceCluster:           suggestedClusters[1].ClusterID,
   759  					IsActive:                true,
   760  					IsManagingBug:           true,
   761  					IsManagingBugPriority:   true,
   762  					CreateUser:              rules.LUCIAnalysisSystem,
   763  					LastAuditableUpdateUser: rules.LUCIAnalysisSystem,
   764  					BugManagementState: &bugspb.BugManagementState{
   765  						RuleAssociationNotified: true,
   766  						PolicyState: map[string]*bugspb.BugManagementState_PolicyState{
   767  							"exoneration-policy": {
   768  								IsActive:           true,
   769  								LastActivationTime: timestamppb.New(opts.RunTimestamp),
   770  								ActivationNotified: true,
   771  							},
   772  							"cls-rejected-policy": {},
   773  						},
   774  					},
   775  				},
   776  				{
   777  					Project:                 "chromeos",
   778  					RuleDefinition:          `reason LIKE "want foofoo, got bar"`,
   779  					BugID:                   bugs.BugID{System: bugs.MonorailSystem, ID: "chromium/100"},
   780  					SourceCluster:           suggestedClusters[2].ClusterID,
   781  					IsActive:                true,
   782  					IsManagingBug:           true,
   783  					IsManagingBugPriority:   true,
   784  					CreateUser:              rules.LUCIAnalysisSystem,
   785  					LastAuditableUpdateUser: rules.LUCIAnalysisSystem,
   786  					BugManagementState: &bugspb.BugManagementState{
   787  						RuleAssociationNotified: true,
   788  						PolicyState: map[string]*bugspb.BugManagementState_PolicyState{
   789  							"exoneration-policy": {
   790  								IsActive:           true,
   791  								LastActivationTime: timestamppb.New(opts.RunTimestamp),
   792  								ActivationNotified: true,
   793  							},
   794  							"cls-rejected-policy": {},
   795  						},
   796  					},
   797  				},
   798  				{
   799  					Project:                 "chromeos",
   800  					RuleDefinition:          `reason LIKE "want foofoofoo, got bar"`,
   801  					BugID:                   bugs.BugID{System: bugs.MonorailSystem, ID: "chromium/101"},
   802  					SourceCluster:           suggestedClusters[3].ClusterID,
   803  					IsActive:                true,
   804  					IsManagingBug:           true,
   805  					IsManagingBugPriority:   true,
   806  					CreateUser:              rules.LUCIAnalysisSystem,
   807  					LastAuditableUpdateUser: rules.LUCIAnalysisSystem,
   808  					BugManagementState: &bugspb.BugManagementState{
   809  						RuleAssociationNotified: true,
   810  						PolicyState: map[string]*bugspb.BugManagementState_PolicyState{
   811  							"exoneration-policy": {
   812  								IsActive:           true,
   813  								LastActivationTime: timestamppb.New(opts.RunTimestamp),
   814  								ActivationNotified: true,
   815  							},
   816  							"cls-rejected-policy": {},
   817  						},
   818  					},
   819  				},
   820  			}
   821  
   822  			// The offset of the first monorail rule in the rules slice.
   823  			// (Rules read by rules.Read...() are sorted by bug system and bug ID,
   824  			// so monorail always appears after Buganizer.)
   825  			const firstMonorailRuleIndex = 2
   826  
   827  			// Limit to one bug filed each time, so that
   828  			// we test change throttling.
   829  			opts.MaxBugsFiledPerRun = 1
   830  
   831  			// Verify one bug is filed at a time.
   832  			for i := 0; i < len(expectedRules); i++ {
   833  				// Act
   834  				err = UpdateBugsForProject(ctx, opts)
   835  
   836  				// Verify
   837  				So(err, ShouldBeNil)
   838  				So(verifyRulesResemble(ctx, expectedRules[:i+1]), ShouldBeNil)
   839  			}
   840  
   841  			// Further updates do nothing.
   842  			err = UpdateBugsForProject(ctx, opts)
   843  
   844  			So(err, ShouldBeNil)
   845  			So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
   846  
   847  			rs, err := rules.ReadAllForTesting(span.Single(ctx))
   848  			So(err, ShouldBeNil)
   849  
   850  			bugClusters := []*analysis.Cluster{
   851  				makeBugCluster(rs[0].RuleID),
   852  				makeBugCluster(rs[1].RuleID),
   853  				makeBugCluster(rs[2].RuleID),
   854  				makeBugCluster(rs[3].RuleID),
   855  			}
   856  
   857  			Convey("if re-clustering in progress", func() {
   858  				analysisClient.clusters = append(suggestedClusters, bugClusters...)
   859  
   860  				Convey("negligable cluster metrics does not affect issue priority, status or active policies", func() {
   861  					// The policy should already be active from previous setup.
   862  					So(expectedRules[0].BugManagementState.PolicyState["exoneration-policy"].IsActive, ShouldBeTrue)
   863  
   864  					issue := buganizerStore.Issues[1]
   865  					originalPriority := issue.Issue.IssueState.Priority
   866  					originalStatus := issue.Issue.IssueState.Status
   867  					So(originalStatus, ShouldNotEqual, issuetracker.Issue_VERIFIED)
   868  
   869  					SetResidualMetrics(bugClusters[1], bugs.ClusterMetrics{
   870  						metrics.CriticalFailuresExonerated.ID: bugs.MetricValues{},
   871  					})
   872  
   873  					// Act
   874  					err = UpdateBugsForProject(ctx, opts)
   875  
   876  					// Verify.
   877  					So(err, ShouldBeNil)
   878  					So(issue.Issue.IssueState.Priority, ShouldEqual, originalPriority)
   879  					So(issue.Issue.IssueState.Status, ShouldEqual, originalStatus)
   880  					So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
   881  				})
   882  			})
   883  			Convey("with re-clustering complete", func() {
   884  				analysisClient.clusters = append(suggestedClusters, bugClusters...)
   885  
   886  				// Move residual impact from suggested clusters to new bug clusters.
   887  				bugClusters[0].MetricValues = suggestedClusters[0].MetricValues
   888  				bugClusters[1].MetricValues = suggestedClusters[1].MetricValues
   889  				bugClusters[2].MetricValues = suggestedClusters[2].MetricValues
   890  				bugClusters[3].MetricValues = suggestedClusters[3].MetricValues
   891  
   892  				// Clear residual impact on suggested clusters to inhibit
   893  				// further bug filing.
   894  				suggestedClusters[0].MetricValues = emptyMetricValues()
   895  				suggestedClusters[1].MetricValues = emptyMetricValues()
   896  				suggestedClusters[2].MetricValues = emptyMetricValues()
   897  				suggestedClusters[3].MetricValues = emptyMetricValues()
   898  
   899  				// Mark reclustering complete.
   900  				err := runs.SetRunsForTesting(ctx, []*runs.ReclusteringRun{
   901  					runs.NewRun(0).
   902  						WithProject(project).
   903  						WithAlgorithmsVersion(algorithms.AlgorithmsVersion).
   904  						WithConfigVersion(projectCfg.LastUpdated.AsTime()).
   905  						WithRulesVersion(rs[3].PredicateLastUpdateTime).
   906  						WithCompletedProgress().Build(),
   907  				})
   908  				So(err, ShouldBeNil)
   909  
   910  				progress, err := runs.ReadReclusteringProgress(ctx, project)
   911  				So(err, ShouldBeNil)
   912  				opts.ReclusteringProgress = progress
   913  
   914  				opts.RunTimestamp = opts.RunTimestamp.Add(10 * time.Minute)
   915  
   916  				Convey("policy activation", func() {
   917  					// Verify updates work, even when rules are in later batches.
   918  					opts.UpdateRuleBatchSize = 1
   919  
   920  					Convey("policy remains inactive if activation threshold unmet", func() {
   921  						// The policy should be inactive from previous setup.
   922  						expectedPolicyState := expectedRules[1].BugManagementState.PolicyState["cls-rejected-policy"]
   923  						So(expectedPolicyState.IsActive, ShouldBeFalse)
   924  
   925  						// Set metrics just below the policy activation threshold.
   926  						bugClusters[1].MetricValues[metrics.HumanClsFailedPresubmit.ID] = metrics.TimewiseCounts{
   927  							OneDay:   metrics.Counts{Residual: 9},
   928  							ThreeDay: metrics.Counts{Residual: 9},
   929  							SevenDay: metrics.Counts{Residual: 9},
   930  						}
   931  
   932  						// Act
   933  						err = UpdateBugsForProject(ctx, opts)
   934  
   935  						// Verify policy activation unchanged.
   936  						So(err, ShouldBeNil)
   937  						So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
   938  					})
   939  					Convey("policy activates if activation threshold met", func() {
   940  						// The policy should be inactive from previous setup.
   941  						expectedPolicyState := expectedRules[1].BugManagementState.PolicyState["cls-rejected-policy"]
   942  						So(expectedPolicyState.IsActive, ShouldBeFalse)
   943  						So(expectedPolicyState.ActivationNotified, ShouldBeFalse)
   944  
   945  						// Update metrics so that policy should activate.
   946  						bugClusters[1].MetricValues[metrics.HumanClsFailedPresubmit.ID] = metrics.TimewiseCounts{
   947  							OneDay:   metrics.Counts{Residual: 0},
   948  							ThreeDay: metrics.Counts{Residual: 0},
   949  							SevenDay: metrics.Counts{Residual: 10},
   950  						}
   951  
   952  						issue := buganizerStore.Issues[2]
   953  						So(expectedRules[1].BugID, ShouldResemble, bugs.BugID{System: bugs.BuganizerSystem, ID: "2"})
   954  						existingCommentCount := len(issue.Comments)
   955  
   956  						// Act
   957  						err = UpdateBugsForProject(ctx, opts)
   958  
   959  						// Verify policy activates.
   960  						So(err, ShouldBeNil)
   961  						expectedPolicyState.IsActive = true
   962  						expectedPolicyState.LastActivationTime = timestamppb.New(opts.RunTimestamp)
   963  						expectedPolicyState.ActivationNotified = true
   964  						So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
   965  
   966  						// Expect comments to be posted.
   967  						So(issue.Comments, ShouldHaveLength, existingCommentCount+2)
   968  						So(issue.Comments[2].Comment, ShouldContainSubstring,
   969  							"Why LUCI Analysis posted this comment: https://luci-analysis-test.appspot.com/help#policy-activated (Policy ID: cls-rejected-policy)")
   970  						So(issue.Comments[3].Comment, ShouldContainSubstring,
   971  							"The bug priority has been increased from P2 to P1.")
   972  					})
   973  					Convey("policy remains active if deactivation threshold unmet", func() {
   974  						// The policy should already be active from previous setup.
   975  						expectedPolicyState := expectedRules[0].BugManagementState.PolicyState["exoneration-policy"]
   976  						So(expectedPolicyState.IsActive, ShouldBeTrue)
   977  
   978  						// Metrics still meet/exceed the deactivation threshold, so deactivation is inhibited.
   979  						bugClusters[0].MetricValues[metrics.CriticalFailuresExonerated.ID] = metrics.TimewiseCounts{
   980  							OneDay:   metrics.Counts{Residual: 10},
   981  							ThreeDay: metrics.Counts{Residual: 10},
   982  							SevenDay: metrics.Counts{Residual: 10},
   983  						}
   984  
   985  						// Act
   986  						err = UpdateBugsForProject(ctx, opts)
   987  
   988  						// Verify policy activation should be unchanged.
   989  						So(err, ShouldBeNil)
   990  						So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
   991  					})
   992  					Convey("policy deactivates if deactivation threshold met", func() {
   993  						// The policy should already be active from previous setup.
   994  						expectedPolicyState := expectedRules[0].BugManagementState.PolicyState["exoneration-policy"]
   995  						So(expectedPolicyState.IsActive, ShouldBeTrue)
   996  
   997  						// Update metrics so that policy should de-activate.
   998  						bugClusters[0].MetricValues[metrics.CriticalFailuresExonerated.ID] = metrics.TimewiseCounts{
   999  							OneDay:   metrics.Counts{Residual: 9},
  1000  							ThreeDay: metrics.Counts{Residual: 9},
  1001  							SevenDay: metrics.Counts{Residual: 9},
  1002  						}
  1003  
  1004  						// Act
  1005  						err = UpdateBugsForProject(ctx, opts)
  1006  
  1007  						// Verify policy deactivated.
  1008  						So(err, ShouldBeNil)
  1009  						expectedPolicyState.IsActive = false
  1010  						expectedPolicyState.LastDeactivationTime = timestamppb.New(opts.RunTimestamp)
  1011  						So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1012  					})
  1013  					Convey("policy configuration changes are handled", func() {
  1014  						// Delete the existing policy named "exoneration-policy", and replace it with a new policy,
  1015  						// "new-exoneration-policy". Activation and de-activation criteria remain the same.
  1016  						projectCfg.BugManagement.Policies[0].Id = "new-exoneration-policy"
  1017  
  1018  						// Act
  1019  						err = UpdateBugsForProject(ctx, opts)
  1020  
  1021  						// Verify state for the old policy is deleted, and state for the new policy is added.
  1022  						So(err, ShouldBeNil)
  1023  						expectedRules[0].BugManagementState.PolicyState = map[string]*bugspb.BugManagementState_PolicyState{
  1024  							"new-exoneration-policy": {
  1025  								// The new policy should activate, because the metrics justify its activation.
  1026  								IsActive:           true,
  1027  								LastActivationTime: timestamppb.New(opts.RunTimestamp),
  1028  							},
  1029  							"cls-rejected-policy": {},
  1030  						}
  1031  						expectedRules[1].BugManagementState.PolicyState = map[string]*bugspb.BugManagementState_PolicyState{
  1032  							"new-exoneration-policy": {},
  1033  							"cls-rejected-policy":    {},
  1034  						}
  1035  						expectedRules[2].BugManagementState.PolicyState = map[string]*bugspb.BugManagementState_PolicyState{
  1036  							"new-exoneration-policy": {},
  1037  							"cls-rejected-policy":    {},
  1038  						}
  1039  					})
  1040  				})
  1041  				Convey("rule associated notification", func() {
  1042  					Convey("buganizer", func() {
  1043  						// Select a Buganizer issue.
  1044  						issue := buganizerStore.Issues[1]
  1045  
  1046  						// Get the corresponding rule, confirming we got the right one.
  1047  						rule := rs[0]
  1048  						So(rule.BugID.ID, ShouldEqual, fmt.Sprintf("%v", issue.Issue.IssueId))
  1049  
  1050  						// Reset RuleAssociationNotified on the rule.
  1051  						rule.BugManagementState.RuleAssociationNotified = false
  1052  						So(rules.SetForTesting(ctx, rs), ShouldBeNil)
  1053  
  1054  						originalCommentCount := len(issue.Comments)
  1055  
  1056  						// Act
  1057  						err = UpdateBugsForProject(ctx, opts)
  1058  
  1059  						// Verify
  1060  						So(err, ShouldBeNil)
  1061  						So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1062  						So(issue.Comments, ShouldHaveLength, originalCommentCount+1)
  1063  						So(issue.Comments[originalCommentCount].Comment, ShouldEqual,
  1064  							"This bug has been associated with failures in LUCI Analysis."+
  1065  								" To view failure examples or update the association, go to LUCI Analysis at: https://luci-analysis-test.appspot.com/p/chromeos/rules/"+rule.RuleID)
  1066  
  1067  						// Further runs should not lead to repeated posting of the comment.
  1068  						err = UpdateBugsForProject(ctx, opts)
  1069  						So(err, ShouldBeNil)
  1070  						So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1071  						So(issue.Comments, ShouldHaveLength, originalCommentCount+1)
  1072  					})
  1073  					Convey("monorail", func() {
  1074  						// Select a Monorail issue.
  1075  						issue := monorailStore.Issues[0]
  1076  
  1077  						// Get the corresponding rule, and confirm we got the right one.
  1078  						const ruleIndex = firstMonorailRuleIndex
  1079  						rule := rs[ruleIndex]
  1080  						So(rule.BugID.ID, ShouldEqual, "chromium/100")
  1081  						So(issue.Issue.Name, ShouldEqual, "projects/chromium/issues/100")
  1082  
  1083  						// Reset RuleAssociationNotified on the rule.
  1084  						rule.BugManagementState.RuleAssociationNotified = false
  1085  						So(rules.SetForTesting(ctx, rs), ShouldBeNil)
  1086  
  1087  						originalCommentCount := len(issue.Comments)
  1088  
  1089  						// Act
  1090  						err = UpdateBugsForProject(ctx, opts)
  1091  
  1092  						// Verify
  1093  						So(err, ShouldBeNil)
  1094  						So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1095  						So(issue.Comments, ShouldHaveLength, originalCommentCount+1)
  1096  						So(issue.Comments[originalCommentCount].Content, ShouldContainSubstring,
  1097  							"This bug has been associated with failures in LUCI Analysis."+
  1098  								" To view failure examples or update the association, go to LUCI Analysis at: https://luci-analysis-test.appspot.com/p/chromeos/rules/"+rule.RuleID)
  1099  
  1100  						// Further runs should not lead to repeated posting of the comment.
  1101  						err = UpdateBugsForProject(ctx, opts)
  1102  						So(err, ShouldBeNil)
  1103  						So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1104  						So(issue.Comments, ShouldHaveLength, originalCommentCount+1)
  1105  					})
  1106  				})
  1107  				Convey("priority updates and auto-closure", func() {
  1108  					Convey("buganizer", func() {
  1109  						// Select a Buganizer issue.
  1110  						issue := buganizerStore.Issues[1]
  1111  						originalPriority := issue.Issue.IssueState.Priority
  1112  						originalStatus := issue.Issue.IssueState.Status
  1113  						So(originalStatus, ShouldEqual, issuetracker.Issue_NEW)
  1114  
  1115  						// Get the corresponding rule, confirming we got the right one.
  1116  						rule := rs[0]
  1117  						So(rule.BugID.ID, ShouldEqual, fmt.Sprintf("%v", issue.Issue.IssueId))
  1118  
  1119  						// Activate the cls-rejected-policy, which should raise the priority to P1.
  1120  						So(originalPriority, ShouldNotEqual, issuetracker.Issue_P1)
  1121  						SetResidualMetrics(bugClusters[0], bugs.ClusterMetrics{
  1122  							metrics.CriticalFailuresExonerated.ID: bugs.MetricValues{OneDay: 100},
  1123  							metrics.HumanClsFailedPresubmit.ID:    bugs.MetricValues{SevenDay: 10},
  1124  						})
  1125  
  1126  						Convey("priority updates to reflect active policies", func() {
  1127  							expectedRules[0].BugManagementState.PolicyState["cls-rejected-policy"].IsActive = true
  1128  							expectedRules[0].BugManagementState.PolicyState["cls-rejected-policy"].LastActivationTime = timestamppb.New(opts.RunTimestamp)
  1129  							expectedRules[0].BugManagementState.PolicyState["cls-rejected-policy"].ActivationNotified = true
  1130  							So(originalPriority, ShouldNotEqual, issuetracker.Issue_P1)
  1131  
  1132  							// Act
  1133  							err = UpdateBugsForProject(ctx, opts)
  1134  
  1135  							// Verify
  1136  							So(err, ShouldBeNil)
  1137  							So(issue.Issue.IssueState.Priority, ShouldEqual, issuetracker.Issue_P1)
  1138  							So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1139  						})
  1140  						Convey("disabling IsManagingBugPriority prevents priority updates", func() {
  1141  							expectedRules[0].BugManagementState.PolicyState["cls-rejected-policy"].IsActive = true
  1142  							expectedRules[0].BugManagementState.PolicyState["cls-rejected-policy"].LastActivationTime = timestamppb.New(opts.RunTimestamp)
  1143  							expectedRules[0].BugManagementState.PolicyState["cls-rejected-policy"].ActivationNotified = true
  1144  
  1145  							// Set IsManagingBugPriority to false on the rule.
  1146  							rule.IsManagingBugPriority = false
  1147  							So(rules.SetForTesting(ctx, rs), ShouldBeNil)
  1148  
  1149  							// Act
  1150  							err = UpdateBugsForProject(ctx, opts)
  1151  
  1152  							// Verify
  1153  							So(err, ShouldBeNil)
  1154  
  1155  							// Check that the bug priority and status has not changed.
  1156  							So(issue.Issue.IssueState.Status, ShouldEqual, originalStatus)
  1157  							So(issue.Issue.IssueState.Priority, ShouldEqual, originalPriority)
  1158  
  1159  							// Check the rules have not changed except for the IsManagingBugPriority change.
  1160  							expectedRules[0].IsManagingBugPriority = false
  1161  							So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1162  						})
  1163  						Convey("manually setting a priority prevents bug updates", func() {
  1164  							expectedRules[0].BugManagementState.PolicyState["cls-rejected-policy"].IsActive = true
  1165  							expectedRules[0].BugManagementState.PolicyState["cls-rejected-policy"].LastActivationTime = timestamppb.New(opts.RunTimestamp)
  1166  							expectedRules[0].BugManagementState.PolicyState["cls-rejected-policy"].ActivationNotified = true
  1167  
  1168  							issue.IssueUpdates = append(issue.IssueUpdates, &issuetracker.IssueUpdate{
  1169  								Author: &issuetracker.User{
  1170  									EmailAddress: "testuser@google.com",
  1171  								},
  1172  								Timestamp: timestamppb.New(clock.Now(ctx).Add(time.Minute * 4)),
  1173  								FieldUpdates: []*issuetracker.FieldUpdate{
  1174  									{
  1175  										Field: "priority",
  1176  									},
  1177  								},
  1178  							})
  1179  
  1180  							Convey("happy path", func() {
  1181  								// Act
  1182  								err = UpdateBugsForProject(ctx, opts)
  1183  
  1184  								// Verify
  1185  								So(err, ShouldBeNil)
  1186  								So(issue.Issue.IssueState.Status, ShouldEqual, originalStatus)
  1187  								So(issue.Issue.IssueState.Priority, ShouldEqual, originalPriority)
  1188  								expectedRules[0].IsManagingBugPriority = false
  1189  								So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1190  
  1191  								Convey("further updates leave no comments", func() {
  1192  									initialComments := len(issue.Comments)
  1193  
  1194  									// Act
  1195  									err = UpdateBugsForProject(ctx, opts)
  1196  
  1197  									// Verify
  1198  									So(err, ShouldBeNil)
  1199  									So(len(issue.Comments), ShouldEqual, initialComments)
  1200  									So(issue.Issue.IssueState.Status, ShouldEqual, originalStatus)
  1201  									So(issue.Issue.IssueState.Priority, ShouldEqual, originalPriority)
  1202  									So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1203  								})
  1204  							})
  1205  
  1206  							Convey("errors updating other bugs", func() {
  1207  								// Check we handle partial success correctly:
  1208  								// Even if there is an error updating another bug, if we comment on a bug
  1209  								// to say the user took manual priority control, we must commit
  1210  								// the rule update setting IsManagingBugPriority to false. Otherwise
  1211  								// we may get stuck in a loop where we comment on the bug every
  1212  								// time bug filing runs.
  1213  
  1214  								// Trigger a priority update for bug 2 in addition to the
  1215  								// manual priority update.
  1216  								SetResidualMetrics(bugClusters[1], bugs.ClusterMetrics{
  1217  									metrics.CriticalFailuresExonerated.ID: bugs.MetricValues{OneDay: 100},
  1218  									metrics.HumanClsFailedPresubmit.ID:    bugs.MetricValues{SevenDay: 10},
  1219  								})
  1220  
  1221  								// But prevent LUCI Analysis from applying that priority update, due to an error.
  1222  								modifyError := errors.New("this issue may not be modified")
  1223  								buganizerStore.Issues[2].UpdateError = modifyError
  1224  
  1225  								// Act
  1226  								err = UpdateBugsForProject(ctx, opts)
  1227  
  1228  								// Verify
  1229  
  1230  								// The error modifying bug 2 is bubbled up.
  1231  								So(err, ShouldNotBeNil)
  1232  								So(errors.Is(err, modifyError), ShouldBeTrue)
  1233  
  1234  								// The policy on the bug 2 was activated, and we notified
  1235  								// bug 2 of the policy activation, even if we did
  1236  								// not succeed then updating its priority.
  1237  								// Furthermore, we record that we notified the policy
  1238  								// activation, so repeated notifications do not occur.
  1239  								expectedRules[1].BugManagementState.PolicyState["cls-rejected-policy"].IsActive = true
  1240  								expectedRules[1].BugManagementState.PolicyState["cls-rejected-policy"].LastActivationTime = timestamppb.New(opts.RunTimestamp)
  1241  								expectedRules[1].BugManagementState.PolicyState["cls-rejected-policy"].ActivationNotified = true
  1242  
  1243  								otherIssue := buganizerStore.Issues[2]
  1244  								So(otherIssue.Comments[len(otherIssue.Comments)-1].Comment, ShouldContainSubstring,
  1245  									"Why LUCI Analysis posted this comment: https://luci-analysis-test.appspot.com/help#policy-activated (Policy ID: cls-rejected-policy)")
  1246  
  1247  								// Despite the issue with bug 2, bug 1 was commented on updated and
  1248  								// IsManagingBugPriority was set to false.
  1249  								expectedRules[0].IsManagingBugPriority = false
  1250  								So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1251  								So(issue.Comments[len(issue.Comments)-1].Comment, ShouldContainSubstring,
  1252  									"The bug priority has been manually set.")
  1253  								So(issue.Issue.IssueState.Status, ShouldEqual, originalStatus)
  1254  								So(issue.Issue.IssueState.Priority, ShouldEqual, originalPriority)
  1255  							})
  1256  						})
  1257  						Convey("if all policies de-activate, bug is auto-closed", func() {
  1258  							SetResidualMetrics(bugClusters[0], bugs.ClusterMetrics{
  1259  								metrics.CriticalFailuresExonerated.ID: bugs.MetricValues{OneDay: 9},
  1260  								metrics.HumanClsFailedPresubmit.ID:    bugs.MetricValues{},
  1261  							})
  1262  
  1263  							// Act
  1264  							err = UpdateBugsForProject(ctx, opts)
  1265  
  1266  							// Verify
  1267  							So(err, ShouldBeNil)
  1268  							expectedRules[0].BugManagementState.PolicyState["exoneration-policy"].IsActive = false
  1269  							expectedRules[0].BugManagementState.PolicyState["exoneration-policy"].LastDeactivationTime = timestamppb.New(opts.RunTimestamp)
  1270  							So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1271  							So(issue.Issue.IssueState.Status, ShouldEqual, issuetracker.Issue_VERIFIED)
  1272  							So(issue.Issue.IssueState.Priority, ShouldEqual, issuetracker.Issue_P2)
  1273  						})
  1274  						Convey("disabling IsManagingBug prevents bug closure", func() {
  1275  							SetResidualMetrics(bugClusters[0], bugs.ClusterMetrics{
  1276  								metrics.CriticalFailuresExonerated.ID: bugs.MetricValues{OneDay: 9},
  1277  								metrics.HumanClsFailedPresubmit.ID:    bugs.MetricValues{},
  1278  							})
  1279  							expectedRules[0].BugManagementState.PolicyState["exoneration-policy"].IsActive = false
  1280  							expectedRules[0].BugManagementState.PolicyState["exoneration-policy"].LastDeactivationTime = timestamppb.New(opts.RunTimestamp)
  1281  
  1282  							// Set IsManagingBug to false on the rule.
  1283  							rule.IsManagingBug = false
  1284  							So(rules.SetForTesting(ctx, rs), ShouldBeNil)
  1285  
  1286  							// Act
  1287  							err = UpdateBugsForProject(ctx, opts)
  1288  
  1289  							// Verify
  1290  							So(err, ShouldBeNil)
  1291  
  1292  							// Check the rules have not changed except for the IsManagingBug change.
  1293  							expectedRules[0].IsManagingBug = false
  1294  							So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1295  
  1296  							// Check that the bug priority and status has not changed.
  1297  							So(issue.Issue.IssueState.Status, ShouldEqual, originalStatus)
  1298  							So(issue.Issue.IssueState.Priority, ShouldEqual, originalPriority)
  1299  						})
  1300  						Convey("cluster disappearing closes issue", func() {
  1301  							// Drop the corresponding bug cluster. This is consistent with
  1302  							// no more failures in the cluster occuring.
  1303  							bugClusters = bugClusters[1:]
  1304  							analysisClient.clusters = append(suggestedClusters, bugClusters...)
  1305  
  1306  							// Act
  1307  							err = UpdateBugsForProject(ctx, opts)
  1308  
  1309  							// Verify
  1310  							So(err, ShouldBeNil)
  1311  							expectedRules[0].BugManagementState.PolicyState["exoneration-policy"].IsActive = false
  1312  							expectedRules[0].BugManagementState.PolicyState["exoneration-policy"].LastDeactivationTime = timestamppb.New(opts.RunTimestamp)
  1313  							So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1314  							So(issue.Issue.IssueState.Status, ShouldEqual, issuetracker.Issue_VERIFIED)
  1315  
  1316  							Convey("rule automatically archived after 30 days", func() {
  1317  								tc.Add(time.Hour * 24 * 30)
  1318  
  1319  								// Act
  1320  								err = UpdateBugsForProject(ctx, opts)
  1321  
  1322  								// Verify
  1323  								So(err, ShouldBeNil)
  1324  								expectedRules[0].IsActive = false
  1325  								So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1326  								So(issue.Issue.IssueState.Status, ShouldEqual, issuetracker.Issue_VERIFIED)
  1327  							})
  1328  						})
  1329  						Convey("if all policies are removed, bug is auto-closed", func() {
  1330  							projectCfg.BugManagement.Policies = nil
  1331  							err := config.SetTestProjectConfig(ctx, projectsCfg)
  1332  							So(err, ShouldBeNil)
  1333  
  1334  							for _, expectedRule := range expectedRules {
  1335  								expectedRule.BugManagementState.PolicyState = nil
  1336  							}
  1337  
  1338  							// Act
  1339  							err = UpdateBugsForProject(ctx, opts)
  1340  
  1341  							// Verify
  1342  							So(err, ShouldBeNil)
  1343  							So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1344  							So(issue.Issue.IssueState.Status, ShouldEqual, issuetracker.Issue_VERIFIED)
  1345  							So(issue.Issue.IssueState.Priority, ShouldEqual, issuetracker.Issue_P2)
  1346  						})
  1347  					})
  1348  					Convey("monorail", func() {
  1349  						// Select a Monorail issue.
  1350  						issue := monorailStore.Issues[0]
  1351  						originalPriority := monorail.ChromiumTestIssuePriority(issue.Issue)
  1352  						originalStatus := issue.Issue.Status.Status
  1353  						So(originalStatus, ShouldEqual, monorail.UntriagedStatus)
  1354  
  1355  						// Get the corresponding rule, and confirm we got the right one.
  1356  						const ruleIndex = firstMonorailRuleIndex
  1357  						rule := rs[ruleIndex]
  1358  						So(rule.BugID.ID, ShouldEqual, "chromium/100")
  1359  						So(issue.Issue.Name, ShouldEqual, "projects/chromium/issues/100")
  1360  
  1361  						// Activate the cls-rejected-policy, which should raise the priority to P1.
  1362  						So(originalPriority, ShouldNotEqual, issuetracker.Issue_P1)
  1363  						SetResidualMetrics(bugClusters[firstMonorailRuleIndex], bugs.ClusterMetrics{
  1364  							metrics.CriticalFailuresExonerated.ID: bugs.MetricValues{OneDay: 100},
  1365  							metrics.HumanClsFailedPresubmit.ID:    bugs.MetricValues{SevenDay: 10},
  1366  						})
  1367  
  1368  						Convey("priority updates to reflect active policies", func() {
  1369  							expectedRules[firstMonorailRuleIndex].BugManagementState.PolicyState["cls-rejected-policy"].IsActive = true
  1370  							expectedRules[firstMonorailRuleIndex].BugManagementState.PolicyState["cls-rejected-policy"].LastActivationTime = timestamppb.New(opts.RunTimestamp)
  1371  							expectedRules[firstMonorailRuleIndex].BugManagementState.PolicyState["cls-rejected-policy"].ActivationNotified = true
  1372  							So(originalPriority, ShouldNotEqual, "1")
  1373  
  1374  							// Act
  1375  							err = UpdateBugsForProject(ctx, opts)
  1376  
  1377  							// Verify
  1378  							So(err, ShouldBeNil)
  1379  							So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1380  							So(issue.Issue.Status.Status, ShouldEqual, originalStatus)
  1381  							So(monorail.ChromiumTestIssuePriority(issue.Issue), ShouldEqual, "1")
  1382  						})
  1383  						Convey("disabling IsManagingBugPriority prevents priority updates", func() {
  1384  							expectedRules[firstMonorailRuleIndex].BugManagementState.PolicyState["cls-rejected-policy"].IsActive = true
  1385  							expectedRules[firstMonorailRuleIndex].BugManagementState.PolicyState["cls-rejected-policy"].LastActivationTime = timestamppb.New(opts.RunTimestamp)
  1386  							expectedRules[firstMonorailRuleIndex].BugManagementState.PolicyState["cls-rejected-policy"].ActivationNotified = true
  1387  
  1388  							// Set IsManagingBugPriority to false on the rule.
  1389  							rule.IsManagingBugPriority = false
  1390  							So(rules.SetForTesting(ctx, rs), ShouldBeNil)
  1391  
  1392  							// Act
  1393  							err = UpdateBugsForProject(ctx, opts)
  1394  
  1395  							// Verify
  1396  							So(err, ShouldBeNil)
  1397  
  1398  							// Check the rules have not changed except for the IsManagingBugPriority change.
  1399  							expectedRules[firstMonorailRuleIndex].IsManagingBugPriority = false
  1400  							So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1401  
  1402  							// Check that the bug priority and status has not changed.
  1403  							So(issue.Issue.Status.Status, ShouldEqual, originalStatus)
  1404  							So(monorail.ChromiumTestIssuePriority(issue.Issue), ShouldEqual, originalPriority)
  1405  						})
  1406  						Convey("manually setting a priority prevents bug updates", func() {
  1407  							expectedRules[firstMonorailRuleIndex].BugManagementState.PolicyState["cls-rejected-policy"].IsActive = true
  1408  							expectedRules[firstMonorailRuleIndex].BugManagementState.PolicyState["cls-rejected-policy"].LastActivationTime = timestamppb.New(opts.RunTimestamp)
  1409  							expectedRules[firstMonorailRuleIndex].BugManagementState.PolicyState["cls-rejected-policy"].ActivationNotified = true
  1410  
  1411  							// Create a fake client to interact with monorail as a user.
  1412  							userClient, err := monorail.NewClient(monorail.UseFakeIssuesClient(ctx, monorailStore, "user@google.com"), "myhost")
  1413  							So(err, ShouldBeNil)
  1414  
  1415  							// Set priority to P0 manually.
  1416  							updateRequest := &mpb.ModifyIssuesRequest{
  1417  								Deltas: []*mpb.IssueDelta{
  1418  									{
  1419  										Issue: &mpb.Issue{
  1420  											Name: issue.Issue.Name,
  1421  											FieldValues: []*mpb.FieldValue{
  1422  												{
  1423  													Field: "projects/chromium/fieldDefs/11",
  1424  													Value: "0",
  1425  												},
  1426  											},
  1427  										},
  1428  										UpdateMask: &fieldmaskpb.FieldMask{
  1429  											Paths: []string{"field_values"},
  1430  										},
  1431  									},
  1432  								},
  1433  								CommentContent: "User comment.",
  1434  							}
  1435  							err = userClient.ModifyIssues(ctx, updateRequest)
  1436  							So(err, ShouldBeNil)
  1437  
  1438  							Convey("happy path", func() {
  1439  								// Act
  1440  								err = UpdateBugsForProject(ctx, opts)
  1441  
  1442  								// Verify
  1443  								So(err, ShouldBeNil)
  1444  
  1445  								// Expect IsManagingBugPriority to get set to false.
  1446  								expectedRules[firstMonorailRuleIndex].IsManagingBugPriority = false
  1447  								So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1448  
  1449  								// Expect a comment on the bug.
  1450  								So(issue.Comments[len(issue.Comments)-1].Content, ShouldContainSubstring,
  1451  									"The bug priority has been manually set.")
  1452  								So(issue.Issue.Status.Status, ShouldEqual, originalStatus)
  1453  								So(monorail.ChromiumTestIssuePriority(issue.Issue), ShouldEqual, "0")
  1454  
  1455  								Convey("further updates leave no comments", func() {
  1456  									initialComments := len(issue.Comments)
  1457  
  1458  									// Act
  1459  									err = UpdateBugsForProject(ctx, opts)
  1460  
  1461  									// Verify
  1462  									So(err, ShouldBeNil)
  1463  									So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1464  									So(len(issue.Comments), ShouldEqual, initialComments)
  1465  									So(issue.Issue.Status.Status, ShouldEqual, originalStatus)
  1466  									So(monorail.ChromiumTestIssuePriority(issue.Issue), ShouldEqual, "0")
  1467  								})
  1468  							})
  1469  							Convey("errors updating other bugs", func() {
  1470  								// Check we handle partial success correctly:
  1471  								// Even if there is an error updating another bug, if we comment on a bug
  1472  								// to say the user took manual priority control, we must commit
  1473  								// the rule update setting IsManagingBugPriority to false. Otherwise
  1474  								// we may get stuck in a loop where we comment on the bug every
  1475  								// time bug filing runs.
  1476  
  1477  								// Trigger a priority update for another monorail bug in addition to the
  1478  								// manual priority update.
  1479  								SetResidualMetrics(bugClusters[firstMonorailRuleIndex+1], bugs.ClusterMetrics{
  1480  									metrics.CriticalFailuresExonerated.ID: bugs.MetricValues{OneDay: 100},
  1481  									metrics.HumanClsFailedPresubmit.ID:    bugs.MetricValues{SevenDay: 10},
  1482  								})
  1483  								expectedRules[firstMonorailRuleIndex+1].BugManagementState.PolicyState["cls-rejected-policy"].IsActive = true
  1484  								expectedRules[firstMonorailRuleIndex+1].BugManagementState.PolicyState["cls-rejected-policy"].LastActivationTime = timestamppb.New(opts.RunTimestamp)
  1485  
  1486  								// But prevent LUCI Analysis from applying that priority update, due to an error.
  1487  								modifyError := errors.New("this issue may not be modified")
  1488  								monorailStore.Issues[1].UpdateError = modifyError
  1489  
  1490  								// Act
  1491  								err = UpdateBugsForProject(ctx, opts)
  1492  
  1493  								// Verify
  1494  								So(err, ShouldNotBeNil)
  1495  
  1496  								// The error modifying the other bug is bubbled up.
  1497  								So(errors.Is(err, modifyError), ShouldBeTrue)
  1498  
  1499  								// Nonetheless, our bug was commented on updated and
  1500  								// IsManagingBugPriority was set to false.
  1501  								expectedRules[firstMonorailRuleIndex].IsManagingBugPriority = false
  1502  								So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1503  								So(issue.Comments[len(issue.Comments)-1].Content, ShouldContainSubstring,
  1504  									"The bug priority has been manually set.")
  1505  								So(issue.Issue.Status.Status, ShouldEqual, originalStatus)
  1506  								So(monorail.ChromiumTestIssuePriority(issue.Issue), ShouldEqual, "0")
  1507  							})
  1508  						})
  1509  						Convey("if all policies de-activate, bug is auto-closed", func() {
  1510  							SetResidualMetrics(bugClusters[firstMonorailRuleIndex], bugs.ClusterMetrics{
  1511  								metrics.CriticalFailuresExonerated.ID: bugs.MetricValues{OneDay: 9},
  1512  								metrics.HumanClsFailedPresubmit.ID:    bugs.MetricValues{},
  1513  							})
  1514  
  1515  							// Act
  1516  							err = UpdateBugsForProject(ctx, opts)
  1517  
  1518  							// Verify
  1519  							So(err, ShouldBeNil)
  1520  							expectedRules[firstMonorailRuleIndex].BugManagementState.PolicyState["exoneration-policy"].IsActive = false
  1521  							expectedRules[firstMonorailRuleIndex].BugManagementState.PolicyState["exoneration-policy"].LastDeactivationTime = timestamppb.New(opts.RunTimestamp)
  1522  							So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1523  							So(issue.Issue.Status.Status, ShouldEqual, monorail.VerifiedStatus)
  1524  							So(monorail.ChromiumTestIssuePriority(issue.Issue), ShouldEqual, originalPriority)
  1525  						})
  1526  						Convey("disabling IsManagingBug prevents bug closure", func() {
  1527  							SetResidualMetrics(bugClusters[firstMonorailRuleIndex], bugs.ClusterMetrics{
  1528  								metrics.CriticalFailuresExonerated.ID: bugs.MetricValues{OneDay: 9},
  1529  								metrics.HumanClsFailedPresubmit.ID:    bugs.MetricValues{},
  1530  							})
  1531  							expectedRules[firstMonorailRuleIndex].BugManagementState.PolicyState["exoneration-policy"].IsActive = false
  1532  							expectedRules[firstMonorailRuleIndex].BugManagementState.PolicyState["exoneration-policy"].LastDeactivationTime = timestamppb.New(opts.RunTimestamp)
  1533  
  1534  							// Set IsManagingBug to false on the rule.
  1535  							rule.IsManagingBug = false
  1536  							So(rules.SetForTesting(ctx, rs), ShouldBeNil)
  1537  
  1538  							// Act
  1539  							err = UpdateBugsForProject(ctx, opts)
  1540  
  1541  							// Verify
  1542  							So(err, ShouldBeNil)
  1543  
  1544  							// Check the rules have not changed except for the IsManagingBug change.
  1545  							expectedRules[firstMonorailRuleIndex].IsManagingBug = false
  1546  							So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1547  
  1548  							// Check that the bug priority and status has not changed.
  1549  							So(issue.Issue.Status.Status, ShouldEqual, originalStatus)
  1550  							So(monorail.ChromiumTestIssuePriority(issue.Issue), ShouldEqual, originalPriority)
  1551  						})
  1552  						Convey("cluster disappearing closes issue", func() {
  1553  							// Drop the corresponding bug cluster. This is consistent with
  1554  							// no more failures in the cluster occuring.
  1555  							newBugClusters := []*analysis.Cluster{}
  1556  							newBugClusters = append(newBugClusters, bugClusters[:firstMonorailRuleIndex]...)
  1557  							newBugClusters = append(newBugClusters, bugClusters[firstMonorailRuleIndex+1:]...)
  1558  							analysisClient.clusters = append(suggestedClusters, newBugClusters...)
  1559  
  1560  							// Act
  1561  							err = UpdateBugsForProject(ctx, opts)
  1562  
  1563  							// Verify
  1564  							So(err, ShouldBeNil)
  1565  							expectedRules[firstMonorailRuleIndex].BugManagementState.PolicyState["exoneration-policy"].IsActive = false
  1566  							expectedRules[firstMonorailRuleIndex].BugManagementState.PolicyState["exoneration-policy"].LastDeactivationTime = timestamppb.New(opts.RunTimestamp)
  1567  							So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1568  							So(issue.Issue.Status.Status, ShouldEqual, monorail.VerifiedStatus)
  1569  							So(monorail.ChromiumTestIssuePriority(issue.Issue), ShouldEqual, originalPriority)
  1570  
  1571  							Convey("rule automatically archived after 30 days", func() {
  1572  								tc.Add(time.Hour * 24 * 30)
  1573  
  1574  								// Act
  1575  								err = UpdateBugsForProject(ctx, opts)
  1576  
  1577  								// Verify
  1578  								So(err, ShouldBeNil)
  1579  								expectedRules[firstMonorailRuleIndex].IsActive = false
  1580  								So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1581  								So(issue.Issue.Status.Status, ShouldEqual, monorail.VerifiedStatus)
  1582  								So(monorail.ChromiumTestIssuePriority(issue.Issue), ShouldEqual, originalPriority)
  1583  							})
  1584  						})
  1585  						Convey("if all policies are removed, bug is auto-closed", func() {
  1586  							projectCfg.BugManagement.Policies = nil
  1587  							err := config.SetTestProjectConfig(ctx, projectsCfg)
  1588  							So(err, ShouldBeNil)
  1589  
  1590  							for _, expectedRule := range expectedRules {
  1591  								expectedRule.BugManagementState.PolicyState = nil
  1592  							}
  1593  
  1594  							// Act
  1595  							err = UpdateBugsForProject(ctx, opts)
  1596  
  1597  							// Verify
  1598  							So(err, ShouldBeNil)
  1599  							So(issue.Issue.Status.Status, ShouldEqual, monorail.VerifiedStatus)
  1600  							So(monorail.ChromiumTestIssuePriority(issue.Issue), ShouldEqual, originalPriority)
  1601  							So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1602  						})
  1603  					})
  1604  				})
  1605  				Convey("duplicate handling", func() {
  1606  					Convey("buganizer to buganizer", func() {
  1607  						// Setup
  1608  						issueOne := buganizerStore.Issues[1]
  1609  						issueTwo := buganizerStore.Issues[2]
  1610  						issueOne.Issue.IssueState.Status = issuetracker.Issue_DUPLICATE
  1611  						issueOne.Issue.IssueState.CanonicalIssueId = issueTwo.Issue.IssueId
  1612  
  1613  						issueOneOriginalCommentCount := len(issueOne.Comments)
  1614  						issueTwoOriginalCommentCount := len(issueTwo.Comments)
  1615  
  1616  						// Ensure rule association and policy activation notified, so we
  1617  						// can confirm whether notifications are correctly reset.
  1618  						rs[0].BugManagementState.RuleAssociationNotified = true
  1619  						for _, policyState := range rs[0].BugManagementState.PolicyState {
  1620  							policyState.ActivationNotified = true
  1621  						}
  1622  						So(rules.SetForTesting(ctx, rs), ShouldBeNil)
  1623  
  1624  						expectedRules[0].BugManagementState.RuleAssociationNotified = true
  1625  						for _, policyState := range expectedRules[0].BugManagementState.PolicyState {
  1626  							policyState.ActivationNotified = true
  1627  						}
  1628  
  1629  						Convey("happy path", func() {
  1630  							// Act
  1631  							err = UpdateBugsForProject(ctx, opts)
  1632  
  1633  							// Verify
  1634  							So(err, ShouldBeNil)
  1635  							expectedRules[0].IsActive = false
  1636  							expectedRules[1].RuleDefinition = "reason LIKE \"want foo, got bar\" OR\ntest = \"testname-0\""
  1637  							So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1638  
  1639  							So(issueOne.Comments, ShouldHaveLength, issueOneOriginalCommentCount+1)
  1640  							So(issueOne.Comments[issueOneOriginalCommentCount].Comment, ShouldContainSubstring, "LUCI Analysis has merged the failure association rule for this bug into the rule for the canonical bug.")
  1641  							So(issueOne.Comments[issueOneOriginalCommentCount].Comment, ShouldContainSubstring, expectedRules[2].RuleID)
  1642  
  1643  							So(issueTwo.Comments, ShouldHaveLength, issueTwoOriginalCommentCount)
  1644  						})
  1645  						Convey("happy path, with comments for duplicate bugs disabled", func() {
  1646  							// Setup
  1647  							projectCfg.BugManagement.DisableDuplicateBugComments = true
  1648  							projectsCfg := map[string]*configpb.ProjectConfig{
  1649  								project: projectCfg,
  1650  							}
  1651  							err = config.SetTestProjectConfig(ctx, projectsCfg)
  1652  							So(err, ShouldBeNil)
  1653  
  1654  							// Act
  1655  							err = UpdateBugsForProject(ctx, opts)
  1656  
  1657  							// Verify
  1658  							So(err, ShouldBeNil)
  1659  							expectedRules[0].IsActive = false
  1660  							expectedRules[1].RuleDefinition = "reason LIKE \"want foo, got bar\" OR\ntest = \"testname-0\""
  1661  							So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1662  
  1663  							So(issueOne.Comments, ShouldHaveLength, issueOneOriginalCommentCount)
  1664  							So(issueTwo.Comments, ShouldHaveLength, issueTwoOriginalCommentCount)
  1665  						})
  1666  						Convey("happy path, bug marked as duplicate of bug without a rule in this project", func() {
  1667  							// Setup
  1668  							issueOne.Issue.IssueState.Status = issuetracker.Issue_DUPLICATE
  1669  							issueOne.Issue.IssueState.CanonicalIssueId = 1234
  1670  
  1671  							buganizerStore.StoreIssue(ctx, buganizer.NewFakeIssue(1234))
  1672  
  1673  							extraRule := &rules.Entry{
  1674  								Project:                 "otherproject",
  1675  								RuleDefinition:          `reason LIKE "blah"`,
  1676  								RuleID:                  "1234567890abcdef1234567890abcdef",
  1677  								BugID:                   bugs.BugID{System: bugs.BuganizerSystem, ID: "1234"},
  1678  								IsActive:                true,
  1679  								IsManagingBug:           true,
  1680  								IsManagingBugPriority:   true,
  1681  								BugManagementState:      &bugspb.BugManagementState{},
  1682  								CreateUser:              "user@chromium.org",
  1683  								LastAuditableUpdateUser: "user@chromium.org",
  1684  							}
  1685  							_, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
  1686  								ms, err := rules.Create(extraRule, "user@chromium.org")
  1687  								if err != nil {
  1688  									return err
  1689  								}
  1690  								span.BufferWrite(ctx, ms)
  1691  								return nil
  1692  							})
  1693  							So(err, ShouldBeNil)
  1694  
  1695  							// Act
  1696  							err = UpdateBugsForProject(ctx, opts)
  1697  
  1698  							// Verify
  1699  							So(err, ShouldBeNil)
  1700  							expectedRules[0].BugID = bugs.BugID{System: bugs.BuganizerSystem, ID: "1234"}
  1701  							// Should reset to false as we didn't create the destination bug.
  1702  							expectedRules[0].IsManagingBug = false
  1703  							// Should reset because of the change in associated bug.
  1704  							expectedRules[0].BugManagementState.RuleAssociationNotified = false
  1705  							for _, policyState := range expectedRules[0].BugManagementState.PolicyState {
  1706  								policyState.ActivationNotified = false
  1707  							}
  1708  							expectedRules = append(expectedRules, extraRule)
  1709  							So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1710  
  1711  							So(issueOne.Comments, ShouldHaveLength, issueOneOriginalCommentCount+1)
  1712  							So(issueOne.Comments[issueOneOriginalCommentCount].Comment, ShouldContainSubstring, "LUCI Analysis has merged the failure association rule for this bug into the rule for the canonical bug.")
  1713  							So(issueOne.Comments[issueOneOriginalCommentCount].Comment, ShouldContainSubstring, expectedRules[0].RuleID)
  1714  						})
  1715  						Convey("error cases", func() {
  1716  							Convey("bugs are in a duplicate bug cycle", func() {
  1717  								// Note that this is a simple cycle with only two bugs.
  1718  								// The implementation allows for larger cycles, however.
  1719  								issueTwo.Issue.IssueState.Status = issuetracker.Issue_DUPLICATE
  1720  								issueTwo.Issue.IssueState.CanonicalIssueId = issueOne.Issue.IssueId
  1721  
  1722  								// Act
  1723  								err = UpdateBugsForProject(ctx, opts)
  1724  
  1725  								// Verify
  1726  								So(err, ShouldBeNil)
  1727  
  1728  								// Issue one kicked out of duplicate status.
  1729  								So(issueOne.Issue.IssueState.Status, ShouldNotEqual, issuetracker.Issue_DUPLICATE)
  1730  
  1731  								// As the cycle is now broken, issue two is merged into
  1732  								// issue one.
  1733  								expectedRules[0].RuleDefinition = "reason LIKE \"want foo, got bar\" OR\ntest = \"testname-0\""
  1734  								expectedRules[1].IsActive = false
  1735  								So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1736  
  1737  								So(issueOne.Comments, ShouldHaveLength, issueOneOriginalCommentCount+1)
  1738  								So(issueOne.Comments[issueOneOriginalCommentCount].Comment, ShouldContainSubstring, "a cycle was detected in the bug merged-into graph")
  1739  							})
  1740  							Convey("merged rule would be too long", func() {
  1741  								// Setup
  1742  								// Make one of the rules we will be merging very close
  1743  								// to the rule length limit.
  1744  								longRule := fmt.Sprintf("test = \"%s\"", strings.Repeat("a", rules.MaxRuleDefinitionLength-10))
  1745  
  1746  								_, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
  1747  									issueOneRule, err := rules.ReadByBug(ctx, bugs.BugID{System: bugs.BuganizerSystem, ID: "1"})
  1748  									if err != nil {
  1749  										return err
  1750  									}
  1751  									issueOneRule[0].RuleDefinition = longRule
  1752  
  1753  									ms, err := rules.Update(issueOneRule[0], rules.UpdateOptions{
  1754  										IsAuditableUpdate: true,
  1755  										PredicateUpdated:  true,
  1756  									}, rules.LUCIAnalysisSystem)
  1757  									if err != nil {
  1758  										return err
  1759  									}
  1760  									span.BufferWrite(ctx, ms)
  1761  									return nil
  1762  								})
  1763  								So(err, ShouldBeNil)
  1764  
  1765  								// Act
  1766  								err = UpdateBugsForProject(ctx, opts)
  1767  
  1768  								// Verify
  1769  								So(err, ShouldBeNil)
  1770  
  1771  								// Rules should not have changed (except for the update we made).
  1772  								expectedRules[0].RuleDefinition = longRule
  1773  								So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1774  
  1775  								// Issue one kicked out of duplicate status.
  1776  								So(issueOne.Issue.IssueState.Status, ShouldNotEqual, issuetracker.Issue_DUPLICATE)
  1777  
  1778  								// Comment should appear on the bug.
  1779  								So(issueOne.Comments, ShouldHaveLength, issueOneOriginalCommentCount+1)
  1780  								So(issueOne.Comments[issueOneOriginalCommentCount].Comment, ShouldContainSubstring, "the merged failure association rule would be too long")
  1781  							})
  1782  							Convey("bug marked as duplicate of bug we cannot access", func() {
  1783  								issueTwo.ShouldReturnAccessPermissionError = true
  1784  
  1785  								// Act
  1786  								err = UpdateBugsForProject(ctx, opts)
  1787  
  1788  								// Verify issue one kicked out of duplicate status.
  1789  								So(err, ShouldBeNil)
  1790  								So(issueOne.Issue.IssueState.Status, ShouldNotEqual, issuetracker.Issue_DUPLICATE)
  1791  								So(issueOne.Comments, ShouldHaveLength, issueOneOriginalCommentCount+1)
  1792  								So(issueOne.Comments[issueOneOriginalCommentCount].Comment, ShouldContainSubstring, "LUCI Analysis cannot merge the association rule for this bug into the rule")
  1793  							})
  1794  							Convey("failed to handle duplicate bug - bug has an assignee", func() {
  1795  								issueTwo.ShouldReturnAccessPermissionError = true
  1796  
  1797  								// Has an assignee.
  1798  								issueOne.Issue.IssueState.Assignee = &issuetracker.User{
  1799  									EmailAddress: "user@google.com",
  1800  								}
  1801  								// Act
  1802  								err = UpdateBugsForProject(ctx, opts)
  1803  
  1804  								// Verify issue is put back to assigned status, instead of New.
  1805  								So(err, ShouldBeNil)
  1806  								So(issueOne.Issue.IssueState.Status, ShouldEqual, issuetracker.Issue_ASSIGNED)
  1807  							})
  1808  							Convey("failed to handle duplicate bug - bug has no assignee", func() {
  1809  								issueTwo.ShouldReturnAccessPermissionError = true
  1810  
  1811  								// Has no assignee.
  1812  								issueOne.Issue.IssueState.Assignee = nil
  1813  
  1814  								// Act
  1815  								err = UpdateBugsForProject(ctx, opts)
  1816  
  1817  								// Verify issue is put back to New status, instead of Assigned.
  1818  								So(err, ShouldBeNil)
  1819  								So(issueOne.Issue.IssueState.Status, ShouldEqual, issuetracker.Issue_NEW)
  1820  							})
  1821  						})
  1822  					})
  1823  					Convey("monorail to monorail", func() {
  1824  						// Note that much of the duplicate handling logic, including error
  1825  						// handling, is shared code and not implemented in the bug system-specific
  1826  						// bug manager. As such, we do not re-test all of the error cases above,
  1827  						// only select cases to confirm the integration is correct.
  1828  
  1829  						issueOne := monorailStore.Issues[0]
  1830  						issueTwo := monorailStore.Issues[1]
  1831  
  1832  						issueOne.Issue.Status.Status = monorail.DuplicateStatus
  1833  						issueOne.Issue.MergedIntoIssueRef = &mpb.IssueRef{
  1834  							Issue: issueTwo.Issue.Name,
  1835  						}
  1836  
  1837  						issueOneRule := expectedRules[firstMonorailRuleIndex]
  1838  						issueTwoRule := expectedRules[firstMonorailRuleIndex+1]
  1839  
  1840  						issueOneOriginalCommentCount := len(issueOne.Comments)
  1841  						issueTwoOriginalCommentCount := len(issueTwo.Comments)
  1842  
  1843  						Convey("happy path", func() {
  1844  							// Act
  1845  							err = UpdateBugsForProject(ctx, opts)
  1846  
  1847  							// Verify
  1848  							So(err, ShouldBeNil)
  1849  							issueOneRule.IsActive = false
  1850  							issueTwoRule.RuleDefinition = "reason LIKE \"want foofoo, got bar\" OR\nreason LIKE \"want foofoofoo, got bar\""
  1851  							So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1852  
  1853  							So(issueOne.Comments, ShouldHaveLength, issueOneOriginalCommentCount+1)
  1854  							So(issueOne.Comments[issueOneOriginalCommentCount].Content, ShouldContainSubstring, "LUCI Analysis has merged the failure association rule for this bug into the rule for the canonical bug.")
  1855  							So(issueOne.Comments[issueOneOriginalCommentCount].Content, ShouldContainSubstring, issueOneRule.RuleID)
  1856  
  1857  							So(issueTwo.Comments, ShouldHaveLength, issueTwoOriginalCommentCount)
  1858  						})
  1859  						Convey("error case", func() {
  1860  							// Note that this is a simple cycle with only two bugs.
  1861  							// The implementation allows for larger cycles, however.
  1862  							issueTwo.Issue.Status.Status = monorail.DuplicateStatus
  1863  							issueTwo.Issue.MergedIntoIssueRef = &mpb.IssueRef{
  1864  								Issue: issueOne.Issue.Name,
  1865  							}
  1866  
  1867  							// Act
  1868  							err = UpdateBugsForProject(ctx, opts)
  1869  
  1870  							// Verify
  1871  							So(err, ShouldBeNil)
  1872  
  1873  							// Issue one kicked out of duplicate status.
  1874  							So(issueOne.Issue.Status.Status, ShouldNotEqual, monorail.DuplicateStatus)
  1875  
  1876  							// As the cycle is now broken, issue two is merged into
  1877  							// issue one.
  1878  							issueOneRule.RuleDefinition = "reason LIKE \"want foofoo, got bar\" OR\nreason LIKE \"want foofoofoo, got bar\""
  1879  							issueTwoRule.IsActive = false
  1880  							So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1881  
  1882  							So(issueOne.Comments, ShouldHaveLength, issueOneOriginalCommentCount+1)
  1883  							So(issueOne.Comments[issueOneOriginalCommentCount].Content, ShouldContainSubstring, "a cycle was detected in the bug merged-into graph")
  1884  						})
  1885  					})
  1886  					Convey("monorail to buganizer", func() {
  1887  						issueOne := monorailStore.Issues[0]
  1888  						issueTwo := buganizerStore.Issues[1]
  1889  
  1890  						issueOne.Issue.Status.Status = monorail.DuplicateStatus
  1891  						issueOne.Issue.MergedIntoIssueRef = &mpb.IssueRef{
  1892  							ExtIdentifier: fmt.Sprintf("b/%v", issueTwo.Issue.IssueId),
  1893  						}
  1894  
  1895  						issueOneRule := expectedRules[firstMonorailRuleIndex]
  1896  						issueTwoRule := expectedRules[0]
  1897  
  1898  						issueOneOriginalCommentCount := len(issueOne.Comments)
  1899  						issueTwoOriginalCommentCount := len(issueTwo.Comments)
  1900  
  1901  						Convey("happy path", func() {
  1902  							// Act
  1903  							err = UpdateBugsForProject(ctx, opts)
  1904  
  1905  							// Verify
  1906  							So(err, ShouldBeNil)
  1907  							issueOneRule.IsActive = false
  1908  							issueTwoRule.RuleDefinition = "reason LIKE \"want foofoo, got bar\" OR\ntest = \"testname-0\""
  1909  							So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1910  
  1911  							So(issueOne.Comments, ShouldHaveLength, issueOneOriginalCommentCount+1)
  1912  							So(issueOne.Comments[issueOneOriginalCommentCount].Content, ShouldContainSubstring, "LUCI Analysis has merged the failure association rule for this bug into the rule for the canonical bug.")
  1913  							So(issueOne.Comments[issueOneOriginalCommentCount].Content, ShouldContainSubstring, issueOneRule.RuleID)
  1914  
  1915  							So(issueTwo.Comments, ShouldHaveLength, issueTwoOriginalCommentCount)
  1916  						})
  1917  					})
  1918  				})
  1919  				Convey("bug marked as archived should archive rule", func() {
  1920  					Convey("buganizer", func() {
  1921  						issueOne := buganizerStore.Issues[1].Issue
  1922  						issueOne.IsArchived = true
  1923  
  1924  						// Act
  1925  						err = UpdateBugsForProject(ctx, opts)
  1926  						So(err, ShouldBeNil)
  1927  
  1928  						// Verify
  1929  						expectedRules[0].IsActive = false
  1930  						So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1931  					})
  1932  					Convey("monorail", func() {
  1933  						issue := monorailStore.Issues[0]
  1934  						issue.Issue.Status.Status = "Archived"
  1935  
  1936  						// Act
  1937  						err = UpdateBugsForProject(ctx, opts)
  1938  						So(err, ShouldBeNil)
  1939  
  1940  						// Verify
  1941  						expectedRules[2].IsActive = false
  1942  						So(verifyRulesResemble(ctx, expectedRules), ShouldBeNil)
  1943  					})
  1944  				})
  1945  			})
  1946  		})
  1947  	})
  1948  }
  1949  
  1950  func createProjectConfig() *configpb.ProjectConfig {
  1951  	return &configpb.ProjectConfig{
  1952  		BugManagement: &configpb.BugManagement{
  1953  			DefaultBugSystem: configpb.BugSystem_BUGANIZER,
  1954  			Buganizer:        buganizer.ChromeOSTestConfig(),
  1955  			Monorail:         monorail.ChromiumTestConfig(),
  1956  			Policies: []*configpb.BugManagementPolicy{
  1957  				createExonerationPolicy(),
  1958  				createCLsRejectedPolicy(),
  1959  			},
  1960  		},
  1961  		LastUpdated: timestamppb.New(time.Date(2000, 1, 2, 3, 4, 5, 6, time.UTC)),
  1962  	}
  1963  }
  1964  
  1965  func createExonerationPolicy() *configpb.BugManagementPolicy {
  1966  	return &configpb.BugManagementPolicy{
  1967  		Id:                "exoneration-policy",
  1968  		Owners:            []string{"username@google.com"},
  1969  		HumanReadableName: "test variant(s) are being exonerated in presubmit",
  1970  		Priority:          configpb.BuganizerPriority_P2,
  1971  		Metrics: []*configpb.BugManagementPolicy_Metric{
  1972  			{
  1973  				MetricId: metrics.CriticalFailuresExonerated.ID.String(),
  1974  				ActivationThreshold: &configpb.MetricThreshold{
  1975  					OneDay: proto.Int64(100),
  1976  				},
  1977  				DeactivationThreshold: &configpb.MetricThreshold{
  1978  					OneDay: proto.Int64(10),
  1979  				},
  1980  			},
  1981  		},
  1982  		Explanation: &configpb.BugManagementPolicy_Explanation{
  1983  			ProblemHtml: "problem",
  1984  			ActionHtml:  "action",
  1985  		},
  1986  		BugTemplate: &configpb.BugManagementPolicy_BugTemplate{
  1987  			CommentTemplate: `{{if .BugID.IsBuganizer }}Buganizer Bug ID: {{ .BugID.BuganizerBugID }}{{end}}` +
  1988  				`{{if .BugID.IsMonorail }}Monorail Project: {{ .BugID.MonorailProject }}; ID: {{ .BugID.MonorailBugID }}{{end}}` +
  1989  				`Rule URL: {{.RuleURL}}`,
  1990  			Monorail: &configpb.BugManagementPolicy_BugTemplate_Monorail{
  1991  				Labels: []string{"Test-Exonerated"},
  1992  			},
  1993  			Buganizer: &configpb.BugManagementPolicy_BugTemplate_Buganizer{
  1994  				Hotlists: []int64{1234},
  1995  			},
  1996  		},
  1997  	}
  1998  }
  1999  
  2000  func createCLsRejectedPolicy() *configpb.BugManagementPolicy {
  2001  	return &configpb.BugManagementPolicy{
  2002  		Id:                "cls-rejected-policy",
  2003  		Owners:            []string{"username@google.com"},
  2004  		HumanReadableName: "many CL(s) are being falsely rejected in presubmit",
  2005  		Priority:          configpb.BuganizerPriority_P1,
  2006  		Metrics: []*configpb.BugManagementPolicy_Metric{
  2007  			{
  2008  				MetricId: metrics.HumanClsFailedPresubmit.ID.String(),
  2009  				ActivationThreshold: &configpb.MetricThreshold{
  2010  					SevenDay: proto.Int64(10),
  2011  				},
  2012  				DeactivationThreshold: &configpb.MetricThreshold{
  2013  					SevenDay: proto.Int64(1),
  2014  				},
  2015  			},
  2016  		},
  2017  		Explanation: &configpb.BugManagementPolicy_Explanation{
  2018  			ProblemHtml: "problem",
  2019  			ActionHtml:  "action",
  2020  		},
  2021  		BugTemplate: &configpb.BugManagementPolicy_BugTemplate{
  2022  			CommentTemplate: `Many CLs are failing presubmit. Policy text goes here.`,
  2023  			Monorail: &configpb.BugManagementPolicy_BugTemplate_Monorail{
  2024  				Labels: []string{"Test-Exonerated"},
  2025  			},
  2026  			Buganizer: &configpb.BugManagementPolicy_BugTemplate_Buganizer{
  2027  				Hotlists: []int64{1234},
  2028  			},
  2029  		},
  2030  	}
  2031  }
  2032  
  2033  // verifyRulesResemble verifies rules stored in Spanner resemble
  2034  // the passed expectations, modulo assigned RuleIDs and
  2035  // audit timestamps.
  2036  func verifyRulesResemble(ctx context.Context, expectedRules []*rules.Entry) error {
  2037  	// Read all rules. Sorted by BugSystem, BugId, Project.
  2038  	rs, err := rules.ReadAllForTesting(span.Single(ctx))
  2039  	if err != nil {
  2040  		return err
  2041  	}
  2042  
  2043  	// Sort expectations in the same order as rules.
  2044  	sortedExpected := make([]*rules.Entry, len(expectedRules))
  2045  	copy(sortedExpected, expectedRules)
  2046  	sort.Slice(sortedExpected, func(i, j int) bool {
  2047  		ruleI := sortedExpected[i]
  2048  		ruleJ := sortedExpected[j]
  2049  		if ruleI.BugID.System != ruleJ.BugID.System {
  2050  			return ruleI.BugID.System < ruleJ.BugID.System
  2051  		}
  2052  		if ruleI.BugID.ID != ruleJ.BugID.ID {
  2053  			return ruleI.BugID.ID < ruleJ.BugID.ID
  2054  		}
  2055  		return ruleI.Project < ruleJ.Project
  2056  	})
  2057  
  2058  	for _, r := range rs {
  2059  		// Accept whatever values the implementation has set
  2060  		// (these values are assigned non-deterministically).
  2061  		r.RuleID = ""
  2062  		r.CreateTime = time.Time{}
  2063  		r.LastAuditableUpdateTime = time.Time{}
  2064  		r.LastUpdateTime = time.Time{}
  2065  		r.PredicateLastUpdateTime = time.Time{}
  2066  		r.IsManagingBugPriorityLastUpdateTime = time.Time{}
  2067  	}
  2068  	for i, rule := range sortedExpected {
  2069  		expectationCopy := rule.Clone()
  2070  		// Clear the fields on the expectations as well.
  2071  		expectationCopy.RuleID = ""
  2072  		expectationCopy.CreateTime = time.Time{}
  2073  		expectationCopy.LastAuditableUpdateTime = time.Time{}
  2074  		expectationCopy.LastUpdateTime = time.Time{}
  2075  		expectationCopy.PredicateLastUpdateTime = time.Time{}
  2076  		expectationCopy.IsManagingBugPriorityLastUpdateTime = time.Time{}
  2077  		sortedExpected[i] = expectationCopy
  2078  	}
  2079  
  2080  	if diff := ShouldResembleProto(rs, sortedExpected); diff != "" {
  2081  		return errors.Reason("stored rules: %s", diff).Err()
  2082  	}
  2083  	return nil
  2084  }
  2085  
  2086  type buganizerBug struct {
  2087  	// Bug ID.
  2088  	ID int64
  2089  	// Expected buganizer component ID.
  2090  	Component int64
  2091  	// Content that is expected to appear in the bug title.
  2092  	ExpectedTitle string
  2093  	// Content that is expected to appear in the bug description.
  2094  	ExpectedContent []string
  2095  	// The policies which were expected to have activated, in the
  2096  	// order they should have reported activation.
  2097  	ExpectedPolicyIDsActivated []string
  2098  }
  2099  
  2100  func expectBuganizerBug(buganizerStore *buganizer.FakeIssueStore, bug buganizerBug) error {
  2101  	issue := buganizerStore.Issues[bug.ID].Issue
  2102  	if issue == nil {
  2103  		return errors.Reason("buganizer issue %v not found", bug.ID).Err()
  2104  	}
  2105  	if issue.IssueId != bug.ID {
  2106  		return errors.Reason("issue ID: got %v, want %v", issue.IssueId, bug.ID).Err()
  2107  	}
  2108  	if !strings.Contains(issue.IssueState.Title, bug.ExpectedTitle) {
  2109  		return errors.Reason("issue title: got %q, expected it to contain %q", issue.IssueState.Title, bug.ExpectedTitle).Err()
  2110  	}
  2111  	if issue.IssueState.ComponentId != bug.Component {
  2112  		return errors.Reason("component: got %v; want %v", issue.IssueState.ComponentId, bug.Component).Err()
  2113  	}
  2114  
  2115  	for _, expectedContent := range bug.ExpectedContent {
  2116  		if !strings.Contains(issue.Description.Comment, expectedContent) {
  2117  			return errors.Reason("issue description: got %q, expected it to contain %q", issue.Description.Comment, expectedContent).Err()
  2118  		}
  2119  	}
  2120  	comments := buganizerStore.Issues[bug.ID].Comments
  2121  	if len(comments) != 1+len(bug.ExpectedPolicyIDsActivated) {
  2122  		return errors.Reason("issue comments: got %v want %v", len(comments), 1+len(bug.ExpectedPolicyIDsActivated)).Err()
  2123  	}
  2124  	for i, activatedPolicyID := range bug.ExpectedPolicyIDsActivated {
  2125  		expectedContent := fmt.Sprintf("(Policy ID: %s)", activatedPolicyID)
  2126  		if !strings.Contains(comments[1+i].Comment, expectedContent) {
  2127  			return errors.Reason("issue comment %v: got %q, expected it to contain %q", i+1, comments[i+1].Comment, expectedContent).Err()
  2128  		}
  2129  	}
  2130  	return nil
  2131  }
  2132  
  2133  type monorailBug struct {
  2134  	// The monorail project.
  2135  	Project string
  2136  	// The monorail bug ID.
  2137  	ID int
  2138  
  2139  	ExpectedComponents []string
  2140  	// Content that is expected to appear in the bug title.
  2141  	ExpectedTitle string
  2142  	// Content that is expected to appear in the bug description.
  2143  	ExpectedContent []string
  2144  	// The policies which were expected to have activated, in the
  2145  	// order they should have reported activation.
  2146  	ExpectedPolicyIDsActivated []string
  2147  }
  2148  
  2149  func expectMonorailBug(monorailStore *monorail.FakeIssuesStore, bug monorailBug) error {
  2150  	var issue *monorail.IssueData
  2151  	name := fmt.Sprintf("projects/%s/issues/%v", bug.Project, bug.ID)
  2152  	for _, iss := range monorailStore.Issues {
  2153  		if iss.Issue.Name == name {
  2154  			issue = iss
  2155  			break
  2156  		}
  2157  	}
  2158  	if issue == nil {
  2159  		return errors.Reason("monorail issue %q not found", name).Err()
  2160  	}
  2161  	if !strings.Contains(issue.Issue.Summary, bug.ExpectedTitle) {
  2162  		return errors.Reason("issue title: got %q, expected it to contain %q", issue.Issue.Summary, bug.ExpectedTitle).Err()
  2163  	}
  2164  	var actualComponents []string
  2165  	for _, component := range issue.Issue.Components {
  2166  		actualComponents = append(actualComponents, component.Component)
  2167  	}
  2168  	if msg := ShouldResemble(actualComponents, bug.ExpectedComponents); msg != "" {
  2169  		return errors.Reason("components: %s", msg).Err()
  2170  	}
  2171  	comments := issue.Comments
  2172  	if len(comments) != 1+len(bug.ExpectedPolicyIDsActivated) {
  2173  		return errors.Reason("issue comments: got %v want %v", len(comments), 1+len(bug.ExpectedPolicyIDsActivated)).Err()
  2174  	}
  2175  	for _, expectedContent := range bug.ExpectedContent {
  2176  		if !strings.Contains(issue.Comments[0].Content, expectedContent) {
  2177  			return errors.Reason("issue description: got %q, expected it to contain %q", issue.Comments[0].Content, expectedContent).Err()
  2178  		}
  2179  	}
  2180  	expectedLink := "https://luci-analysis-test.appspot.com/p/chromeos/rules/"
  2181  	if !strings.Contains(comments[0].Content, expectedLink) {
  2182  		return errors.Reason("issue comment #1: got %q, expected it to contain %q", issue.Comments[0].Content, expectedLink).Err()
  2183  	}
  2184  	for i, activatedPolicyID := range bug.ExpectedPolicyIDsActivated {
  2185  		expectedContent := fmt.Sprintf("(Policy ID: %s)", activatedPolicyID)
  2186  		if !strings.Contains(comments[i+1].Content, expectedContent) {
  2187  			return errors.Reason("issue comment %v: got %q, expected it to contain %q", i+2, comments[i+1].Content, expectedContent).Err()
  2188  		}
  2189  	}
  2190  	return nil
  2191  }