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

     1  // Copyright 2023 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package bugs
    16  
    17  import (
    18  	"fmt"
    19  	"sort"
    20  	"strings"
    21  
    22  	"go.chromium.org/luci/common/errors"
    23  
    24  	bugspb "go.chromium.org/luci/analysis/internal/bugs/proto"
    25  	"go.chromium.org/luci/analysis/internal/clustering"
    26  	configpb "go.chromium.org/luci/analysis/proto/config"
    27  )
    28  
    29  // PolicyApplyer provides methods to apply bug managment policies
    30  // in a manner that is generic to the bug management system being used.
    31  type PolicyApplyer struct {
    32  	// policies are the configured bug management policies for the project.
    33  	policiesByDescendingPriority []*configpb.BugManagementPolicy
    34  
    35  	// templates are the compiled templates for each bug management policy.
    36  	//
    37  	// Maintained in 1:1 correspondance to the `policiesByDescendingPriority` slice,
    38  	// so policiesByDescendingPriority[i] corresponds to templates[i].
    39  	templates []Template
    40  
    41  	// floorPriority is the lowest priority level supported by the
    42  	// bug system. Priorities below this will be rounded up to
    43  	// this floor level. For example, if the priority floor is
    44  	// P3, the policy priority P4 and below will be rounded up to P3.
    45  	// Invariant: not BUGANIZER_PRIORITY_UNSPECIFIED.
    46  	floorPriority configpb.BuganizerPriority
    47  }
    48  
    49  // NewPolicyApplyer initialises a new PolicyApplyer.
    50  func NewPolicyApplyer(policies []*configpb.BugManagementPolicy, floorPriority configpb.BuganizerPriority) (PolicyApplyer, error) {
    51  	if floorPriority == configpb.BuganizerPriority_BUGANIZER_PRIORITY_UNSPECIFIED {
    52  		panic("floorPriority must be specified")
    53  	}
    54  	policiesByDescendingPriority := sortPoliciesByDescendingPriority(policies)
    55  
    56  	templates := make([]Template, 0, len(policiesByDescendingPriority))
    57  	for _, p := range policiesByDescendingPriority {
    58  		template, err := ParseTemplate(p.BugTemplate.CommentTemplate)
    59  		if err != nil {
    60  			return PolicyApplyer{}, errors.Annotate(err, "parsing comment template for policy %q", p.Id).Err()
    61  		}
    62  		templates = append(templates, template)
    63  	}
    64  
    65  	return PolicyApplyer{
    66  		policiesByDescendingPriority: policiesByDescendingPriority,
    67  		templates:                    templates,
    68  		floorPriority:                floorPriority,
    69  	}, nil
    70  }
    71  
    72  // applyPriorityFloor returns the maximum of the given priority
    73  // and the priority floor. For example, if the provided priority
    74  // is P4 and the floor is P3, this methods returns P3.
    75  func (p PolicyApplyer) applyPriorityFloor(priority configpb.BuganizerPriority) configpb.BuganizerPriority {
    76  	// A lower number indicates a higher priority.
    77  	if p.floorPriority < priority {
    78  		return p.floorPriority
    79  	}
    80  	return priority
    81  }
    82  
    83  // PolicyByID returns the policy with the given ID, if
    84  // it is still configured.
    85  func (p PolicyApplyer) PolicyByID(policyID PolicyID) *configpb.BugManagementPolicy {
    86  	for _, policy := range p.policiesByDescendingPriority {
    87  		if policy.Id == string(policyID) {
    88  			return policy
    89  		}
    90  	}
    91  	return nil
    92  }
    93  
    94  // PoliciesByIDs returns the policies with the given IDs, to
    95  // the extent that they are still configured.
    96  func (p PolicyApplyer) PoliciesByIDs(policyIDs map[PolicyID]struct{}) []*configpb.BugManagementPolicy {
    97  	var result []*configpb.BugManagementPolicy
    98  	for _, policy := range p.policiesByDescendingPriority {
    99  		_, ok := policyIDs[PolicyID(policy.Id)]
   100  		if !ok {
   101  			// Policy not selected.
   102  			continue
   103  		}
   104  		result = append(result, policy)
   105  	}
   106  	return result
   107  }
   108  
   109  // RecommendedPriorityAndVerified identifies the priority and verification state
   110  // recommended for a bug with the given set of policies active.
   111  func (p PolicyApplyer) RecommendedPriorityAndVerified(activePolicyIDs map[PolicyID]struct{}) (priority configpb.BuganizerPriority, verified bool) {
   112  	result := configpb.BuganizerPriority_BUGANIZER_PRIORITY_UNSPECIFIED
   113  	for _, policy := range p.policiesByDescendingPriority {
   114  		_, ok := activePolicyIDs[PolicyID(policy.Id)]
   115  		if !ok {
   116  			// Policy not active.
   117  			continue
   118  		}
   119  		// Note that policy.Priority is never UNSPECIFIED, because
   120  		// of config validation.
   121  		priority := p.applyPriorityFloor(policy.Priority)
   122  
   123  		// Keep the track of the highest priority we have seen so far.
   124  		// This is the priority with the lowest number, i.e. P0 < P1 < P2 < P3.
   125  		if result == configpb.BuganizerPriority_BUGANIZER_PRIORITY_UNSPECIFIED || priority < result {
   126  			result = priority
   127  		}
   128  	}
   129  	isVerified := result == configpb.BuganizerPriority_BUGANIZER_PRIORITY_UNSPECIFIED
   130  	return result, isVerified
   131  }
   132  
   133  type BugOptions struct {
   134  	// The current bug management state.
   135  	State *bugspb.BugManagementState
   136  	// Whether we are managing the priority of the bug.
   137  	IsManagingPriority bool
   138  	// The current priority of the bug.
   139  	ExistingPriority configpb.BuganizerPriority
   140  	// Whether the bug is currently verified.
   141  	ExistingVerified bool
   142  }
   143  
   144  // NeedsPriorityOrVerifiedUpdate returns whether a bug needs to have its
   145  // priority or verified status updated, based on the current active policies.
   146  func (p PolicyApplyer) NeedsPriorityOrVerifiedUpdate(opts BugOptions) bool {
   147  	recommendedPriority, recommendedVerified := p.RecommendedPriorityAndVerified(ActivePolicies(opts.State))
   148  
   149  	// Priority updates are only considered if:
   150  	// - We are managing the bug priority
   151  	// - The bug is not verified / transitioning to verified.
   152  	needsPriorityUpdate := opts.IsManagingPriority && !recommendedVerified && recommendedPriority != opts.ExistingPriority
   153  
   154  	needsVerifiedUpdate := recommendedVerified != opts.ExistingVerified
   155  	return needsPriorityUpdate || needsVerifiedUpdate
   156  }
   157  
   158  type BugChange struct {
   159  	// The human-readable justification of the change.
   160  	// This will be blank if no change is proposed.
   161  	Justification Commentary
   162  
   163  	// Whether the bug priority should be updated.
   164  	UpdatePriority bool
   165  	// The new bug priority.
   166  	Priority configpb.BuganizerPriority
   167  
   168  	// Whether the bug verified status should be changed.
   169  	UpdateVerified bool
   170  	// Whether the bug should be verified now.
   171  	ShouldBeVerified bool
   172  }
   173  
   174  // PreparePriorityAndVerifiedChange generates the changes to apply
   175  // to a bug's priority and verified fields, based on the the
   176  // current active policies.
   177  //
   178  // A human readable explanation of the changes to include in a comment
   179  // is also returned.
   180  func (p PolicyApplyer) PreparePriorityAndVerifiedChange(opts BugOptions, uiBaseURL string) (BugChange, error) {
   181  	currentActive := ActivePolicies(opts.State)
   182  	changes := lastPolicyActivationChanges(opts.State)
   183  	previousActive := previouslyActivePolicies(opts.State)
   184  
   185  	recommendedPriority, recommendedVerified := p.RecommendedPriorityAndVerified(currentActive)
   186  	previousRecommendedPriority, previousRecommendedVerified := p.RecommendedPriorityAndVerified(previousActive)
   187  
   188  	isChangingPriority := opts.IsManagingPriority && !recommendedVerified && recommendedPriority != opts.ExistingPriority
   189  	isChangingVerified := recommendedVerified != opts.ExistingVerified
   190  
   191  	if !isChangingPriority && !isChangingVerified {
   192  		// No change is required.
   193  		return BugChange{
   194  			Justification:    Commentary{},
   195  			UpdatePriority:   false,
   196  			Priority:         recommendedPriority,
   197  			UpdateVerified:   false,
   198  			ShouldBeVerified: recommendedVerified,
   199  		}, nil
   200  	}
   201  
   202  	// We generalise the notion of priority here to be over both bug
   203  	// priority and verified status.
   204  	// The priority ranking then is as follows:
   205  	// - (Verified, Any bug priority) [lowest priority level]
   206  	// - (Not verified, P4)
   207  	// - (Not verified, P3)
   208  	// ..
   209  	// - (Not verified, P0)           [highest priority level]
   210  	//
   211  	// For example, going from (Verified, P1) to
   212  	// (Not verified, P2) is a priority increase, as is
   213  	// going from (Not verified, P2) to (Not verified, P1).
   214  	isPriorityIncreasing := (isChangingPriority && !opts.ExistingVerified && recommendedPriority < opts.ExistingPriority) || (isChangingVerified && !recommendedVerified)
   215  	isPriorityDecreasing := (isChangingPriority && !opts.ExistingVerified && recommendedPriority > opts.ExistingPriority) || (isChangingVerified && recommendedVerified)
   216  
   217  	if isPriorityIncreasing == isPriorityDecreasing {
   218  		// This should never happen. Exactly one of
   219  		// isPriorityIncreasing and isPriorityDecreasing
   220  		// should be true.
   221  		return BugChange{}, errors.New("logic error: the priority has changed, but it cannot be determined if the priority is increasing or decreasing")
   222  	}
   223  
   224  	// Builder for comment body.
   225  	var body strings.Builder
   226  
   227  	// If the previous recommendations match the current bug state, then the changes in policy activation explains updates to the bug.
   228  	if (!opts.IsManagingPriority || previousRecommendedVerified || !previousRecommendedVerified && previousRecommendedPriority == opts.ExistingPriority) &&
   229  		(previousRecommendedVerified == opts.ExistingVerified) {
   230  		// We want to show policy activations and deactivations that are:
   231  		// - Consistent with the direction of the policy change (e.g. if we are dropping the
   232  		//   policy priority, we only care about policies which deactivated).
   233  		// - Relevant to the change (e.g. if we dropped the priority from P1 to P2, we only
   234  		//   want the P1 problems that deactivated, not P2 or P3 problems that deactivated).
   235  		explanationFound := false
   236  		if isPriorityIncreasing {
   237  			body.WriteString("Because the following problem(s) have started:\n")
   238  			for _, policy := range p.policiesByDescendingPriority {
   239  				_, isActivating := changes.activatedPolicyIDs[PolicyID(policy.Id)]
   240  				priority := p.applyPriorityFloor(policy.Priority)
   241  				// The policy is activating, and
   242  				// - We are changing the priority, and the priority of the policy is higher than the existing priority OR
   243  				// - We are recommending unverification of a bug that was previously verified.
   244  				if isActivating && ((isChangingPriority && priority < opts.ExistingPriority) || isChangingVerified && !recommendedVerified) {
   245  					body.WriteString(fmt.Sprintf("- %s (%s)\n", policy.HumanReadableName, priority))
   246  					explanationFound = true
   247  				}
   248  			}
   249  		} else {
   250  			body.WriteString("Because the following problem(s) have stopped:\n")
   251  			for _, policy := range p.policiesByDescendingPriority {
   252  				_, isDeactivating := changes.deactivatedPolicyIDs[PolicyID(policy.Id)]
   253  				priority := p.applyPriorityFloor(policy.Priority)
   254  				// The policy is deactivating, and
   255  				// - We are changing the priority, and the priority of the policy is higher than the priority we are recommending now OR
   256  				// - We are recommending verification of a bug that was previously not verified.
   257  				if isDeactivating && ((isChangingPriority && priority < recommendedPriority) || isChangingVerified && recommendedVerified) {
   258  					body.WriteString(fmt.Sprintf("- %s (%s)\n", policy.HumanReadableName, priority))
   259  					explanationFound = true
   260  				}
   261  			}
   262  		}
   263  		if !explanationFound {
   264  			// This should never happen. If the bug's priority/verified status is consistent
   265  			// with the previous bug managment state, then the changes in that state should
   266  			// explain the recommendation.
   267  			return BugChange{}, errors.New("logic error: no explanation could be found for the priority change")
   268  		}
   269  
   270  		if isChangingPriority && isChangingVerified {
   271  			// This case only happens when we are re-opening a bug to a new priority.
   272  			// We never verify a bug and drop its priority at the same time.
   273  			body.WriteString(fmt.Sprintf("The bug has been re-opened as %s.", recommendedPriority))
   274  		} else if isChangingVerified {
   275  			if recommendedVerified {
   276  				body.WriteString("The bug has been verified.")
   277  			} else {
   278  				body.WriteString("The bug has been re-opened.")
   279  			}
   280  		} else if isChangingPriority {
   281  			if recommendedPriority < opts.ExistingPriority {
   282  				body.WriteString(fmt.Sprintf("The bug priority has been increased from %s to %s.", opts.ExistingPriority, recommendedPriority))
   283  			} else {
   284  				body.WriteString(fmt.Sprintf("The bug priority has been decreased from %s to %s.", opts.ExistingPriority, recommendedPriority))
   285  			}
   286  		} else {
   287  			// This code should never be reached.
   288  			return BugChange{}, errors.New("logic error: no priority/verified change being made in a section of code expecting one")
   289  		}
   290  	} else {
   291  
   292  		// Otherwise, the recent changes to active policies do not explain the change in priority / verification.
   293  		// We should justify the bug priority from first principles, based on the policies which are active now.
   294  
   295  		if recommendedVerified {
   296  			if isChangingPriority {
   297  				// This code should never be reached.
   298  				return BugChange{}, errors.New("logic error: priority change being recommended at some time as verification is recommended")
   299  			}
   300  			if !isChangingVerified {
   301  				// This code should never be reached, as we should have exited early above.
   302  				return BugChange{}, errors.New("logic error: no verified change being made in a section of code expecting one")
   303  			}
   304  			// We know !isChangingPriority && isChangingVerified.
   305  			body.WriteString("Because all problems have stopped, the bug has been verified.")
   306  		} else {
   307  			// We are not recommending verification, so some (non-empty) set of problems must be active.
   308  			body.WriteString("Because the following problem(s) are active:\n")
   309  			for _, policy := range p.policiesByDescendingPriority {
   310  				_, isActive := currentActive[PolicyID(policy.Id)]
   311  				if isActive {
   312  					priority := p.applyPriorityFloor(policy.Priority)
   313  					body.WriteString(fmt.Sprintf("- %s (%s)\n", policy.HumanReadableName, priority))
   314  				}
   315  			}
   316  
   317  			body.WriteString("\n")
   318  			if isChangingPriority && isChangingVerified {
   319  				body.WriteString(fmt.Sprintf("The bug has been opened and set to %s.", recommendedPriority))
   320  			} else if isChangingVerified {
   321  				if recommendedVerified {
   322  					body.WriteString("The bug has been verified.")
   323  				} else {
   324  					body.WriteString("The bug has been opened.")
   325  				}
   326  			} else if isChangingPriority {
   327  				body.WriteString(fmt.Sprintf("The bug priority has been set to %s.", recommendedPriority))
   328  			} else {
   329  				// This code should never be reached, as we should have exited early above.
   330  				return BugChange{}, errors.New("logic error: no priority/verified change being made in a section of code expecting one")
   331  			}
   332  		}
   333  	}
   334  
   335  	var footers []string
   336  	if isChangingPriority {
   337  		footers = append(footers, fmt.Sprintf("Why priority is updated: %s", PriorityUpdatedHelpURL(uiBaseURL)))
   338  	}
   339  	if isChangingVerified {
   340  		if recommendedVerified {
   341  			footers = append(footers, fmt.Sprintf("Why issues are verified: %s", BugVerifiedHelpURL(uiBaseURL)))
   342  		} else {
   343  			footers = append(footers, fmt.Sprintf("Why issues are re-opened: %s", BugReopenedHelpURL(uiBaseURL)))
   344  		}
   345  	}
   346  
   347  	return BugChange{
   348  		Justification: Commentary{
   349  			Bodies:  []string{body.String()},
   350  			Footers: footers,
   351  		},
   352  		UpdatePriority:   isChangingPriority,
   353  		Priority:         recommendedPriority,
   354  		UpdateVerified:   isChangingVerified,
   355  		ShouldBeVerified: recommendedVerified,
   356  	}, nil
   357  }
   358  
   359  // SortPolicyIDsByPriorityDescending sorts policy IDs in descending
   360  // priority order (i.e. P0 policies first, then P1, then P2, ...).
   361  // Where multiple policies have the same priority, they are sorted by
   362  // policy ID.
   363  // Only policies which are configured are returned.
   364  func (p PolicyApplyer) SortPolicyIDsByPriorityDescending(policyIDs map[PolicyID]struct{}) []PolicyID {
   365  	var result []PolicyID
   366  	for _, policy := range p.policiesByDescendingPriority {
   367  		if _, ok := policyIDs[PolicyID(policy.Id)]; ok {
   368  			result = append(result, PolicyID(policy.Id))
   369  		}
   370  	}
   371  	return result
   372  }
   373  
   374  // sortPolicies sorts policies in descending priority order. Where
   375  // multiple policies have the same priority, they are sorted by
   376  // policy ID.
   377  func sortPoliciesByDescendingPriority(policies []*configpb.BugManagementPolicy) []*configpb.BugManagementPolicy {
   378  	// Sort policies by priority, then ID.
   379  	var sortedPolicies []*configpb.BugManagementPolicy
   380  	sortedPolicies = append(sortedPolicies, policies...)
   381  	sort.Slice(sortedPolicies, func(i, j int) bool {
   382  		if sortedPolicies[i].Priority != sortedPolicies[j].Priority {
   383  			return sortedPolicies[i].Priority < sortedPolicies[j].Priority
   384  		}
   385  		return sortedPolicies[i].Id < sortedPolicies[j].Id
   386  	})
   387  	return sortedPolicies
   388  }
   389  
   390  func (p PolicyApplyer) problemsDescription(activatedPolicyIDs map[PolicyID]struct{}) string {
   391  
   392  	var policyHumanNames []string
   393  	for _, p := range p.policiesByDescendingPriority {
   394  		if _, isActive := activatedPolicyIDs[PolicyID(p.Id)]; isActive {
   395  			policyHumanNames = append(policyHumanNames, p.HumanReadableName)
   396  		}
   397  	}
   398  
   399  	var result strings.Builder
   400  	result.WriteString("These test failures are causing problem(s) which require your attention, including:\n")
   401  	for _, policyName := range policyHumanNames {
   402  		result.WriteString(fmt.Sprintf("- %s\n", policyName))
   403  	}
   404  	return result.String()
   405  }
   406  
   407  // NewIssueDescription returns the issue description for a new bug.
   408  // uiBaseURL is the URL of the UI base, without trailing slash, e.g. "https://luci-analysis.appspot.com".
   409  func (p PolicyApplyer) NewIssueDescription(description *clustering.ClusterDescription, activatedPolicyIDs map[PolicyID]struct{}, uiBaseURL, ruleURL string) string {
   410  	var problemDescription strings.Builder
   411  	problemDescription.WriteString(p.problemsDescription(activatedPolicyIDs))
   412  	if ruleURL != "" {
   413  		problemDescription.WriteString(fmt.Sprintf("\nSee current problems, failure examples and more in LUCI Analysis at: %s", ruleURL))
   414  	}
   415  
   416  	bodies := []string{
   417  		description.Description,
   418  		problemDescription.String(),
   419  	}
   420  
   421  	footers := []string{
   422  		fmt.Sprintf("How to action this bug: %s", BugFiledHelpURL(uiBaseURL)),
   423  		fmt.Sprintf("Provide feedback: %s", FeedbackURL(uiBaseURL)),
   424  		fmt.Sprintf("Was this bug filed in the wrong component? See: %s", ComponentSelectionHelpURL(uiBaseURL)),
   425  	}
   426  	return Commentary{
   427  		Bodies:  bodies,
   428  		Footers: footers,
   429  	}.ToComment()
   430  }
   431  
   432  // PolicyActivatedComment returns a comment used to notify a bug that a policy
   433  // has activated on a bug for the first time.
   434  func (p PolicyApplyer) PolicyActivatedComment(policyID PolicyID, uiBaseURL string, input TemplateInput) (string, error) {
   435  	var template *Template
   436  	for i, policy := range p.policiesByDescendingPriority {
   437  		if PolicyID(policy.Id) == policyID {
   438  			template = &p.templates[i]
   439  			break
   440  		}
   441  	}
   442  	if template == nil {
   443  		return "", errors.Reason("configuration for policy %q not found", policyID).Err()
   444  	}
   445  	templatedContent, err := template.Execute(input)
   446  	if err != nil {
   447  		return "", errors.Annotate(err, "execute").Err()
   448  	}
   449  	if templatedContent == "" {
   450  		return "", nil
   451  	}
   452  	commentary := Commentary{
   453  		Bodies:  []string{templatedContent},
   454  		Footers: []string{fmt.Sprintf("Why LUCI Analysis posted this comment: %s (Policy ID: %s)", PolicyActivatedHelpURL(uiBaseURL), policyID)},
   455  	}
   456  
   457  	return commentary.ToComment(), nil
   458  }
   459  
   460  func RuleAssociatedCommentary(ruleURL string) Commentary {
   461  	c := Commentary{
   462  		Bodies: []string{fmt.Sprintf("This bug has been associated with failures in LUCI Analysis. To view failure examples or update the association, go to LUCI Analysis at: %s", ruleURL)},
   463  	}
   464  	return c
   465  }
   466  
   467  func ManualPriorityUpdateCommentary() Commentary {
   468  	c := Commentary{
   469  		Bodies: []string{"The bug priority has been manually set. To re-enable automatic priority updates by LUCI Analysis, enable the update priority flag on the rule."},
   470  	}
   471  	return c
   472  }