go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/internal/bugs/buganizer/request_generation.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 buganizer 16 17 import ( 18 "fmt" 19 "strconv" 20 "strings" 21 22 "google.golang.org/protobuf/types/known/fieldmaskpb" 23 24 "go.chromium.org/luci/common/errors" 25 "go.chromium.org/luci/third_party/google.golang.org/genproto/googleapis/devtools/issuetracker/v1" 26 27 "go.chromium.org/luci/analysis/internal/bugs" 28 bugspb "go.chromium.org/luci/analysis/internal/bugs/proto" 29 "go.chromium.org/luci/analysis/internal/clustering" 30 configpb "go.chromium.org/luci/analysis/proto/config" 31 ) 32 33 // The status which are consider to be closed. 34 var ClosedStatuses = map[issuetracker.Issue_Status]struct{}{ 35 issuetracker.Issue_FIXED: {}, 36 issuetracker.Issue_VERIFIED: {}, 37 issuetracker.Issue_NOT_REPRODUCIBLE: {}, 38 issuetracker.Issue_INFEASIBLE: {}, 39 issuetracker.Issue_INTENDED_BEHAVIOR: {}, 40 } 41 42 // This maps the configpb priorities to issuetracker package priorities. 43 var configPriorityToIssueTrackerPriority = map[configpb.BuganizerPriority]issuetracker.Issue_Priority{ 44 configpb.BuganizerPriority_P0: issuetracker.Issue_P0, 45 configpb.BuganizerPriority_P1: issuetracker.Issue_P1, 46 configpb.BuganizerPriority_P2: issuetracker.Issue_P2, 47 configpb.BuganizerPriority_P3: issuetracker.Issue_P3, 48 configpb.BuganizerPriority_P4: issuetracker.Issue_P4, 49 } 50 51 // This is the name of the Priority field in IssueState. 52 // We use this to look for updates to issue priorities. 53 const priorityField = "priority" 54 55 // RequestGenerator generates new bugs or prepares existing ones 56 // for updates. 57 type RequestGenerator struct { 58 // The issuetracker client that will be used to make RPCs to Buganizer. 59 client Client 60 // The LUCI project for which we are generating bug updates. This 61 // is distinct from the Buganizer project. 62 project string 63 // The UI Base URL, e.g. "https://luci-analysis.appspot.com" 64 uiBaseURL string 65 // The email address the service uses to authenticate to Buganizer. 66 selfEmail string 67 // The Buganizer config of the LUCI project config. 68 buganizerCfg *configpb.BuganizerProject 69 // The policy applyer instance used to apply bug management policy. 70 policyApplyer bugs.PolicyApplyer 71 } 72 73 // NewRequestGenerator initializes a new buganizer request generator. 74 func NewRequestGenerator( 75 client Client, 76 project, uiBaseURL, selfEmail string, 77 projectCfg *configpb.ProjectConfig) (*RequestGenerator, error) { 78 if projectCfg.BugManagement.GetBuganizer() == nil { 79 return nil, errors.Reason("buganizer configuration not set").Err() 80 } 81 82 // Buganizer supports all priority levels P4 and above. 83 policyApplyer, err := bugs.NewPolicyApplyer(projectCfg.BugManagement.GetPolicies(), configpb.BuganizerPriority_P4) 84 if err != nil { 85 return nil, errors.Annotate(err, "create policy applyer").Err() 86 } 87 88 return &RequestGenerator{ 89 client: client, 90 uiBaseURL: uiBaseURL, 91 selfEmail: selfEmail, 92 project: project, 93 buganizerCfg: projectCfg.BugManagement.Buganizer, 94 policyApplyer: policyApplyer, 95 }, nil 96 } 97 98 // PrepareNew generates a CreateIssueRequest for a new issue. 99 func (rg *RequestGenerator) PrepareNew(description *clustering.ClusterDescription, activePolicyIDs map[bugs.PolicyID]struct{}, 100 ruleID string, componentID int64) (*issuetracker.CreateIssueRequest, error) { 101 priority, verified := rg.policyApplyer.RecommendedPriorityAndVerified(activePolicyIDs) 102 if verified { 103 return nil, errors.Reason("issue is recommended to be verified from time of creation; are no policies active?").Err() 104 } 105 106 ruleLink := bugs.RuleURL(rg.uiBaseURL, rg.project, ruleID) 107 108 accessLimit := issuetracker.IssueAccessLimit_LIMIT_VIEW_TRUSTED 109 if rg.buganizerCfg.FileWithoutLimitViewTrusted { 110 accessLimit = issuetracker.IssueAccessLimit_LIMIT_NONE 111 } 112 113 issue := &issuetracker.Issue{ 114 IssueState: &issuetracker.IssueState{ 115 ComponentId: componentID, 116 Type: issuetracker.Issue_BUG, 117 Status: issuetracker.Issue_NEW, 118 Priority: toBuganizerPriority(priority), 119 Severity: issuetracker.Issue_S2, 120 Title: bugs.GenerateBugSummary(description.Title), 121 AccessLimit: &issuetracker.IssueAccessLimit{ 122 AccessLevel: accessLimit, 123 }, 124 }, 125 IssueComment: &issuetracker.IssueComment{ 126 Comment: rg.policyApplyer.NewIssueDescription( 127 description, activePolicyIDs, rg.uiBaseURL, ruleLink), 128 }, 129 } 130 131 return &issuetracker.CreateIssueRequest{ 132 Issue: issue, 133 TemplateOptions: &issuetracker.CreateIssueRequest_TemplateOptions{ 134 ApplyTemplate: true, 135 }, 136 }, nil 137 } 138 139 // linkToRuleComment returns a comment that links the user to the failure 140 // association rule in LUCI Analysis. 141 // 142 // ruleID is the LUCI Analysis Rule ID. 143 func (rg *RequestGenerator) linkToRuleComment(ruleID string) string { 144 ruleLink := bugs.RuleURL(rg.uiBaseURL, rg.project, ruleID) 145 return fmt.Sprintf(bugs.LinkTemplate, ruleLink) 146 } 147 148 // noPermissionComment returns a comment that explains why a bug was filed in 149 // the fallback component incorrectly. 150 // 151 // issueId is the Buganizer issueId. 152 func (rg *RequestGenerator) noPermissionComment(componentID int64) string { 153 return fmt.Sprintf(bugs.NoPermissionTemplate, componentID) 154 } 155 156 // PrepareNoPermissionComment prepares a request that adds links to LUCI Analysis to 157 // a Buganizer bug. 158 func (rg *RequestGenerator) PrepareNoPermissionComment(issueID, componentID int64) *issuetracker.CreateIssueCommentRequest { 159 return &issuetracker.CreateIssueCommentRequest{ 160 IssueId: issueID, 161 Comment: &issuetracker.IssueComment{ 162 Comment: rg.noPermissionComment(componentID), 163 }, 164 } 165 } 166 167 // PrepareRuleAssociatedComment prepares a request that notifies the bug 168 // it is associated with failures in LUCI Analysis. 169 func (rg *RequestGenerator) PrepareRuleAssociatedComment(ruleID string, issueID int64) (*issuetracker.CreateIssueCommentRequest, error) { 170 ruleURL := bugs.RuleURL(rg.uiBaseURL, rg.project, ruleID) 171 172 return &issuetracker.CreateIssueCommentRequest{ 173 IssueId: issueID, 174 Comment: &issuetracker.IssueComment{ 175 Comment: bugs.RuleAssociatedCommentary(ruleURL).ToComment(), 176 }, 177 SignificanceOverride: issuetracker.EditSignificance_MINOR, 178 }, nil 179 } 180 181 // SortPolicyIDsByPriorityDescending sorts policy IDs in descending 182 // priority order (i.e. P0 policies first, then P1, then P2, ...). 183 func (rg *RequestGenerator) SortPolicyIDsByPriorityDescending(policyIDs map[bugs.PolicyID]struct{}) []bugs.PolicyID { 184 return rg.policyApplyer.SortPolicyIDsByPriorityDescending(policyIDs) 185 } 186 187 // PreparePolicyActivatedComment prepares a request that notifies a bug that a policy 188 // has activated for the first time. 189 // If the policy has not specified a comment to post, this method returns nil. 190 func (rg *RequestGenerator) PreparePolicyActivatedComment(ruleID string, issueID int64, policyID bugs.PolicyID) (*issuetracker.CreateIssueCommentRequest, error) { 191 templateInput := bugs.TemplateInput{ 192 RuleURL: bugs.RuleURL(rg.uiBaseURL, rg.project, ruleID), 193 BugID: bugs.NewTemplateBugID(bugs.BugID{System: bugs.BuganizerSystem, ID: strconv.FormatInt(issueID, 10)}), 194 } 195 comment, err := rg.policyApplyer.PolicyActivatedComment(policyID, rg.uiBaseURL, templateInput) 196 if err != nil { 197 return nil, err 198 } 199 if comment == "" { 200 return nil, nil 201 } 202 return &issuetracker.CreateIssueCommentRequest{ 203 IssueId: issueID, 204 Comment: &issuetracker.IssueComment{ 205 Comment: comment, 206 }, 207 }, nil 208 } 209 210 // UpdateDuplicateSource updates the source bug of a (source, destination) 211 // duplicate bug pair, after LUCI Analysis has attempted to merge their 212 // failure association rules. 213 func (rg *RequestGenerator) UpdateDuplicateSource(issueID int64, errorMessage, sourceRuleID, destinationRuleID string, isAssigned bool) *issuetracker.ModifyIssueRequest { 214 updateRequest := &issuetracker.ModifyIssueRequest{ 215 IssueId: issueID, 216 AddMask: &fieldmaskpb.FieldMask{ 217 Paths: []string{}, 218 }, 219 Add: &issuetracker.IssueState{}, 220 RemoveMask: &fieldmaskpb.FieldMask{ 221 Paths: []string{}, 222 }, 223 Remove: &issuetracker.IssueState{}, 224 } 225 if errorMessage != "" { 226 if isAssigned { 227 updateRequest.Add.Status = issuetracker.Issue_ASSIGNED 228 } else { 229 updateRequest.Add.Status = issuetracker.Issue_NEW 230 } 231 updateRequest.AddMask.Paths = append(updateRequest.AddMask.Paths, "status") 232 updateRequest.IssueComment = &issuetracker.IssueComment{ 233 Comment: strings.Join([]string{errorMessage, rg.linkToRuleComment(sourceRuleID)}, "\n\n"), 234 } 235 } else { 236 ruleLink := bugs.RuleURL(rg.uiBaseURL, rg.project, destinationRuleID) 237 updateRequest.IssueComment = &issuetracker.IssueComment{ 238 Comment: fmt.Sprintf(bugs.SourceBugRuleUpdatedTemplate, ruleLink), 239 } 240 } 241 return updateRequest 242 } 243 244 // NeedsPriorityOrVerifiedUpdate returns whether the bug priority and/or verified 245 // status needs to be updated. 246 func (rg *RequestGenerator) NeedsPriorityOrVerifiedUpdate(bms *bugspb.BugManagementState, 247 issue *issuetracker.Issue, 248 isManagingBugPriority bool) bool { 249 opts := bugs.BugOptions{ 250 State: bms, 251 IsManagingPriority: isManagingBugPriority, 252 ExistingPriority: fromBuganizerPriority(issue.IssueState.Priority), 253 ExistingVerified: issue.IssueState.Status == issuetracker.Issue_VERIFIED, 254 } 255 return rg.policyApplyer.NeedsPriorityOrVerifiedUpdate(opts) 256 } 257 258 func toBuganizerPriority(priority configpb.BuganizerPriority) issuetracker.Issue_Priority { 259 return configPriorityToIssueTrackerPriority[priority] 260 } 261 262 func fromBuganizerPriority(priority issuetracker.Issue_Priority) configpb.BuganizerPriority { 263 for configPri, issuePri := range configPriorityToIssueTrackerPriority { 264 if issuePri == priority { 265 return configPri 266 } 267 } 268 panic(fmt.Sprintf("fromBuganizerPriority - should be unreachable (priority: %v)", priority)) 269 } 270 271 type MakeUpdateOptions struct { 272 // The identifier of the rule making the update. 273 RuleID string 274 // The bug management state. 275 BugManagementState *bugspb.BugManagementState 276 // The Issue to update. 277 Issue *issuetracker.Issue 278 // Indicates whether the rule is managing bug priority or not. 279 // Use the value on the rule; do not yet set it to false if 280 // HasManuallySetPriority is true. 281 IsManagingBugPriority bool 282 // Whether the user has manually taken control of the bug priority. 283 HasManuallySetPriority bool 284 } 285 286 // MakeUpdateResult is the result of MakePriorityOrVerifiedUpdate. 287 type MakeUpdateResult struct { 288 // The generated request. 289 request *issuetracker.ModifyIssueRequest 290 // disablePriorityUpdates is set when the user has manually 291 // made a priority update since the last time automatic 292 // priority updates were enabled 293 disablePriorityUpdates bool 294 } 295 296 // MakePriorityOrVerifiedUpdate prepares a priority and/or verified update for the 297 // bug with the given bug management state. 298 // **Must** ONLY be called if NeedsPriorityOrVerifiedUpdate(...) returns true. 299 func (rg *RequestGenerator) MakePriorityOrVerifiedUpdate(options MakeUpdateOptions) (MakeUpdateResult, error) { 300 301 opts := bugs.BugOptions{ 302 State: options.BugManagementState, 303 IsManagingPriority: options.IsManagingBugPriority && !options.HasManuallySetPriority, 304 ExistingPriority: fromBuganizerPriority(options.Issue.IssueState.Priority), 305 ExistingVerified: options.Issue.IssueState.Status == issuetracker.Issue_VERIFIED, 306 } 307 308 change, err := rg.policyApplyer.PreparePriorityAndVerifiedChange(opts, rg.uiBaseURL) 309 if err != nil { 310 return MakeUpdateResult{}, errors.Annotate(err, "prepare change").Err() 311 } 312 313 request := &issuetracker.ModifyIssueRequest{ 314 IssueId: options.Issue.IssueId, 315 AddMask: &fieldmaskpb.FieldMask{}, 316 Add: &issuetracker.IssueState{}, 317 RemoveMask: &fieldmaskpb.FieldMask{}, 318 Remove: &issuetracker.IssueState{}, 319 IssueComment: &issuetracker.IssueComment{}, 320 } 321 322 if change.UpdatePriority { 323 request.AddMask.Paths = append(request.AddMask.Paths, "priority") 324 request.Add.Priority = toBuganizerPriority(change.Priority) 325 } 326 if change.UpdateVerified { 327 if change.ShouldBeVerified { 328 // Mark LUCI Analysis the verifier. 329 request.Add.Verifier = &issuetracker.User{ 330 EmailAddress: rg.selfEmail, 331 } 332 request.AddMask.Paths = append(request.AddMask.Paths, "verifier") 333 334 if options.Issue.IssueState.Assignee == nil { 335 // Make LUCI Analysis the assignee if there is no assignee. 336 request.Add.Assignee = &issuetracker.User{ 337 EmailAddress: rg.selfEmail, 338 } 339 request.AddMask.Paths = append(request.AddMask.Paths, "assignee") 340 } 341 342 request.Add.Status = issuetracker.Issue_VERIFIED 343 request.AddMask.Paths = append(request.AddMask.Paths, "status") 344 } else { 345 var status issuetracker.Issue_Status 346 347 if options.Issue.IssueState.Assignee == nil { 348 status = issuetracker.Issue_NEW 349 } else { 350 if options.Issue.IssueState.Assignee.EmailAddress == rg.selfEmail { 351 // In case the current assignee is LUCI Analysis itself 352 // from an earlier bug verification. 353 status = issuetracker.Issue_NEW 354 355 request.Remove.Assignee = &issuetracker.User{} 356 request.RemoveMask.Paths = append(request.RemoveMask.Paths, "assignee") 357 } else { 358 status = issuetracker.Issue_ASSIGNED 359 } 360 } 361 request.Add.Status = status 362 request.AddMask.Paths = append(request.AddMask.Paths, "status") 363 } 364 } 365 366 var result MakeUpdateResult 367 var commentary bugs.Commentary 368 if change.UpdatePriority || change.UpdateVerified { 369 commentary = change.Justification 370 } 371 if options.HasManuallySetPriority { 372 commentary = bugs.MergeCommentary(commentary, bugs.ManualPriorityUpdateCommentary()) 373 result.disablePriorityUpdates = true 374 } 375 commentary.Footers = append(commentary.Footers, rg.linkToRuleComment(options.RuleID)) 376 377 request.IssueComment = &issuetracker.IssueComment{ 378 IssueId: options.Issue.IssueId, 379 Comment: commentary.ToComment(), 380 } 381 result.request = request 382 return result, nil 383 } 384 385 func (rg *RequestGenerator) ExpectedHotlistIDs(activePolicyIDs map[bugs.PolicyID]struct{}) map[int64]struct{} { 386 expectedHotlistIDs := make(map[int64]struct{}) 387 388 policies := rg.policyApplyer.PoliciesByIDs(activePolicyIDs) 389 for _, policy := range policies { 390 buganizerTemplate := policy.BugTemplate.GetBuganizer() 391 if buganizerTemplate != nil { 392 for _, id := range buganizerTemplate.Hotlists { 393 expectedHotlistIDs[id] = struct{}{} 394 } 395 } 396 } 397 return expectedHotlistIDs 398 } 399 400 // PrepareHotlistInsertions returns the CreateHotlistEntry requests 401 // necessary to insert the issue in the hotlists specified by its 402 // bug managment policies. 403 func PrepareHotlistInsertions(hotlistIDs map[int64]struct{}, issueID int64) []*issuetracker.CreateHotlistEntryRequest { 404 var result []*issuetracker.CreateHotlistEntryRequest 405 for hotlistID := range hotlistIDs { 406 request := &issuetracker.CreateHotlistEntryRequest{ 407 HotlistId: hotlistID, 408 HotlistEntry: &issuetracker.HotlistEntry{ 409 IssueId: issueID, 410 }, 411 } 412 result = append(result, request) 413 } 414 return result 415 }