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 }