go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/internal/bugs/monorail/manager.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 monorail contains monorail-specific logic for 16 // creating and updating bugs. 17 package monorail 18 19 import ( 20 "context" 21 "fmt" 22 "regexp" 23 "time" 24 25 "google.golang.org/protobuf/encoding/prototext" 26 27 "go.chromium.org/luci/common/clock" 28 "go.chromium.org/luci/common/errors" 29 "go.chromium.org/luci/common/logging" 30 31 "go.chromium.org/luci/analysis/internal/bugs" 32 mpb "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto" 33 configpb "go.chromium.org/luci/analysis/proto/config" 34 ) 35 36 // monorailRe matches monorail issue names, like 37 // "monorail/{monorail_project}/{numeric_id}". 38 var monorailRe = regexp.MustCompile(`^projects/([a-z0-9\-_]+)/issues/([0-9]+)$`) 39 40 // componentRE matches valid full monorail component names. 41 var componentRE = regexp.MustCompile(`^[a-zA-Z]([-_]?[a-zA-Z0-9])+(\>[a-zA-Z]([-_]?[a-zA-Z0-9])+)*$`) 42 43 var textPBMultiline = prototext.MarshalOptions{ 44 Multiline: true, 45 } 46 47 // monorailPageSize is the maximum number of issues that can be requested 48 // through GetIssues at a time. This limit is set by monorail. 49 const monorailPageSize = 100 50 51 // BugManager controls the creation of, and updates to, monorail bugs 52 // for clusters. 53 type BugManager struct { 54 client *Client 55 // The LUCI Project. 56 project string 57 // The monorail project. 58 monorailProject string 59 // The generator used to generate updates to monorail bugs. 60 // Set if and only if usePolicyBasedManagement. 61 generator *RequestGenerator 62 // Simulate, if set, tells BugManager not to make mutating changes 63 // to monorail but only log the changes it would make. Must be set 64 // when running locally as RPCs made from developer systems will 65 // appear as that user, which breaks the detection of user-made 66 // priority changes vs system-made priority changes. 67 Simulate bool 68 } 69 70 // NewBugManager initialises a new bug manager, using the specified 71 // monorail client. 72 func NewBugManager(client *Client, uiBaseURL, project string, projectCfg *configpb.ProjectConfig) (*BugManager, error) { 73 var g *RequestGenerator 74 var monorailProject string 75 var err error 76 g, err = NewGenerator(uiBaseURL, project, projectCfg) 77 if err != nil { 78 return nil, errors.Annotate(err, "create issue generator").Err() 79 } 80 monorailProject = projectCfg.BugManagement.Monorail.Project 81 return &BugManager{ 82 client: client, 83 project: project, 84 monorailProject: monorailProject, 85 generator: g, 86 Simulate: false, 87 }, nil 88 } 89 90 // Create creates a new bug for the given request, returning its name, or 91 // any encountered error. 92 func (m *BugManager) Create(ctx context.Context, request bugs.BugCreateRequest) bugs.BugCreateResponse { 93 var response bugs.BugCreateResponse 94 response.Simulated = m.Simulate 95 response.PolicyActivationsNotified = make(map[bugs.PolicyID]struct{}) 96 97 components := request.MonorailComponents 98 components, err := m.filterToValidComponents(ctx, components) 99 if err != nil { 100 response.Error = errors.Annotate(err, "validate components").Err() 101 return response 102 } 103 104 makeReq, err := m.generator.PrepareNew(request.RuleID, request.ActivePolicyIDs, request.Description, components) 105 if err != nil { 106 response.Error = errors.Annotate(err, "prepare new issue").Err() 107 return response 108 } 109 110 var bugID string 111 if m.Simulate { 112 logging.Debugf(ctx, "Would create Monorail issue: %s", textPBMultiline.Format(makeReq)) 113 bugID = fmt.Sprintf("%s/12345678", m.monorailProject) 114 } else { 115 // Save the issue in Monorail. 116 issue, err := m.client.MakeIssue(ctx, makeReq) 117 if err != nil { 118 response.Error = errors.Annotate(err, "create issue in monorail").Err() 119 return response 120 } 121 bugID, err = fromMonorailIssueName(issue.Name) 122 if err != nil { 123 response.Error = errors.Annotate(err, "parsing monorail issue name").Err() 124 return response 125 } 126 bugs.BugsCreatedCounter.Add(ctx, 1, m.project, "monorail") 127 } 128 // A bug was filed. 129 response.ID = bugID 130 131 response.PolicyActivationsNotified, err = m.notifyPolicyActivation(ctx, request.RuleID, bugID, request.ActivePolicyIDs) 132 if err != nil { 133 response.Error = errors.Annotate(err, "notify policy activations").Err() 134 return response 135 } 136 137 return response 138 } 139 140 // filterToValidComponents limits the given list of components to only those 141 // components which exist in monorail, and are active. 142 func (m *BugManager) filterToValidComponents(ctx context.Context, components []string) ([]string, error) { 143 var result []string 144 for _, c := range components { 145 if !componentRE.MatchString(c) { 146 continue 147 } 148 existsAndActive, err := m.client.GetComponentExistsAndActive(ctx, m.monorailProject, c) 149 if err != nil { 150 return nil, err 151 } 152 if !existsAndActive { 153 continue 154 } 155 result = append(result, c) 156 } 157 return result, nil 158 } 159 160 // notifyPolicyActivation notifies that the given policies have activated. 161 // 162 // This method supports partial success; it returns the set of policies 163 // which were successfully notified even if an error is encountered and 164 // returned. 165 func (m *BugManager) notifyPolicyActivation(ctx context.Context, ruleID, bugID string, policyIDsToNotify map[bugs.PolicyID]struct{}) (map[bugs.PolicyID]struct{}, error) { 166 policiesNotified := make(map[bugs.PolicyID]struct{}) 167 168 // Notify policies which have activated in descending priority order. 169 sortedPolicyIDToNotify := m.generator.SortPolicyIDsByPriorityDescending(policyIDsToNotify) 170 for _, policyID := range sortedPolicyIDToNotify { 171 commentRequest, err := m.generator.PreparePolicyActivatedComment(ruleID, bugID, policyID) 172 if err != nil { 173 return policiesNotified, errors.Annotate(err, "prepare policy activated comment for policy %q", policyID).Err() 174 } 175 // Only post a comment if the policy has specified one. 176 if commentRequest != nil { 177 if err := m.applyModification(ctx, commentRequest); err != nil { 178 return policiesNotified, errors.Annotate(err, "post policy activated comment for policy %q", policyID).Err() 179 } 180 } 181 // Policy activation successfully notified. 182 policiesNotified[policyID] = struct{}{} 183 } 184 return policiesNotified, nil 185 } 186 187 // Update updates the specified list of bugs. 188 func (m *BugManager) Update(ctx context.Context, request []bugs.BugUpdateRequest) ([]bugs.BugUpdateResponse, error) { 189 // Fetch issues for bugs to update. 190 issues, err := m.fetchIssues(ctx, request) 191 if err != nil { 192 return nil, err 193 } 194 195 var responses []bugs.BugUpdateResponse 196 for i, req := range request { 197 issue := issues[i] 198 if issue == nil { 199 // The bug does not exist, or is in a different monorail project 200 // to the monorail project configured for this project. Take 201 // no action. 202 responses = append(responses, bugs.BugUpdateResponse{ 203 IsDuplicate: false, 204 IsDuplicateAndAssigned: false, 205 ShouldArchive: false, 206 PolicyActivationsNotified: map[bugs.PolicyID]struct{}{}, 207 }) 208 logging.Fields{ 209 "Project": m.project, 210 "MonorailBugID": req.Bug.ID, 211 }.Warningf(ctx, "Monorail issue %s not found (project: %s), skipping.", req.Bug.ID, m.project) 212 continue 213 } 214 215 response := m.updateIssue(ctx, req, issue) 216 responses = append(responses, response) 217 } 218 return responses, nil 219 } 220 221 func (m *BugManager) updateIssue(ctx context.Context, request bugs.BugUpdateRequest, issue *mpb.Issue) bugs.BugUpdateResponse { 222 var response bugs.BugUpdateResponse 223 response.PolicyActivationsNotified = map[bugs.PolicyID]struct{}{} 224 225 // If the context times out part way through an update, we do 226 // not know if our bug update succeeded (but we have not received the 227 // success response back from monorail yet) or the bug update failed. 228 // 229 // This is problematic for bug updates that require changes to the 230 // bug in tandem with updates to the rule, as we do not know if we 231 // need to make the rule update. For example: 232 // - Disabling IsManagingBugPriority in tandem with a comment on 233 // the bug indicating the user has taken priority control of the 234 // bug. 235 // - Notifying the bug is associated with a rule in tandem with 236 // an update to the bug management state recording we send this 237 // notification. 238 // 239 // If we incorrectly assume a bug comment was made when it was not, 240 // we may fail to deliver comments on bugs. 241 // If we incorrectly assume a bug comment was not delivered when it was, 242 // we may end up repeatedly making the same comment. 243 // 244 // We prefer the second over the first, but we try here to reduce the 245 // likelihood of either happening by ensuring we have at least one minute 246 // of time available. 247 if err := bugs.EnsureTimeToDeadline(ctx, time.Minute); err != nil { 248 response.Error = err 249 return response 250 } 251 252 if issue.Status.Status == DuplicateStatus { 253 response.IsDuplicate = true 254 response.IsDuplicateAndAssigned = issue.Owner.GetUser() != "" 255 } 256 response.ShouldArchive = shouldArchiveRule(issue, clock.Now(ctx), request.IsManagingBug) 257 response.DisableRulePriorityUpdates = false // Set below if necessary. 258 259 if !response.IsDuplicate && !response.ShouldArchive { 260 if !request.BugManagementState.RuleAssociationNotified { 261 updateRequest, err := m.generator.PrepareRuleAssociatedComment(request.RuleID, request.Bug.ID) 262 if err != nil { 263 response.Error = errors.Annotate(err, "prepare rule associated comment").Err() 264 return response 265 } 266 if err := m.applyModification(ctx, updateRequest); err != nil { 267 response.Error = errors.Annotate(err, "create rule associated comment").Err() 268 return response 269 } 270 response.RuleAssociationNotified = true 271 } 272 273 // Identify which policies have activated for the first time and notify them (if any). 274 policyIDsToNotify := bugs.ActivePoliciesPendingNotification(request.BugManagementState) 275 276 var err error 277 response.PolicyActivationsNotified, err = m.notifyPolicyActivation(ctx, request.RuleID, request.Bug.ID, policyIDsToNotify) 278 if err != nil { 279 response.Error = errors.Annotate(err, "notify policy activations").Err() 280 return response 281 } 282 283 // Apply priority and verified updates, as necessary. This should occur 284 // after we have notified about policy activation, as that is the more 285 // logical order for someone reading the bug. 286 needsUpdate, err := m.generator.NeedsPriorityOrVerifiedUpdate(request.BugManagementState, issue, request.IsManagingBugPriority) 287 if err != nil { 288 response.Error = errors.Annotate(err, "determine if priority/verified update required").Err() 289 return response 290 } 291 if request.IsManagingBug && needsUpdate { 292 comments, err := m.client.ListComments(ctx, issue.Name) 293 if err != nil { 294 response.Error = errors.Annotate(err, "list comments").Err() 295 return response 296 } 297 hasManuallySetPriority := hasManuallySetPriority(comments, request.IsManagingBugPriorityLastUpdated) 298 299 mur, err := m.generator.MakePriorityOrVerifiedUpdate(MakeUpdateOptions{ 300 RuleID: request.RuleID, 301 BugManagementState: request.BugManagementState, 302 Issue: issue, 303 IsManagingBugPriority: request.IsManagingBugPriority, 304 HasManuallySetPriority: hasManuallySetPriority, 305 }) 306 if err != nil { 307 response.Error = errors.Annotate(err, "prepare priority/verified update").Err() 308 return response 309 } 310 response.DisableRulePriorityUpdates = mur.disableBugPriorityUpdates 311 if err := m.applyModification(ctx, mur.request); err != nil { 312 response.Error = errors.Annotate(err, "update monorail issue").Err() 313 return response 314 } 315 } 316 } 317 return response 318 } 319 320 func (m *BugManager) applyModification(ctx context.Context, modifyRequest *mpb.ModifyIssuesRequest) error { 321 if m.Simulate { 322 logging.Debugf(ctx, "Would update Monorail issue: %s", textPBMultiline.Format(modifyRequest)) 323 } else { 324 if err := m.client.ModifyIssues(ctx, modifyRequest); err != nil { 325 return errors.Annotate(err, "apply modificaton").Err() 326 } 327 bugs.BugsUpdatedCounter.Add(ctx, 1, m.project, "monorail") 328 } 329 return nil 330 } 331 332 // shouldArchiveRule determines if the rule managing the given issue should 333 // be archived. 334 func shouldArchiveRule(issue *mpb.Issue, now time.Time, isManaging bool) bool { 335 // If the bug is set to a status like "Archived", immediately archive 336 // the rule as well. We should not re-open such a bug. 337 if _, ok := ArchivedStatuses[issue.Status.Status]; ok { 338 return true 339 } 340 if isManaging { 341 // If LUCI Analysis is managing the bug, 342 // more than 30 days since the issue was verified. 343 return issue.Status.Status == VerifiedStatus && 344 now.Sub(issue.CloseTime.AsTime()).Hours() >= 30*24 345 } else { 346 // If the user is managing the bug, 347 // more than 30 days since the issue was closed. 348 _, ok := ClosedStatuses[issue.Status.Status] 349 return ok && 350 now.Sub(issue.CloseTime.AsTime()).Hours() >= 30*24 351 } 352 } 353 354 // GetMergedInto reads the bug (if any) the given bug was merged into. 355 // If the given bug is not merged into another bug, this returns nil. 356 func (m *BugManager) GetMergedInto(ctx context.Context, bug bugs.BugID) (*bugs.BugID, error) { 357 if bug.System != bugs.MonorailSystem { 358 // Indicates an implementation error with the caller. 359 panic("monorail bug manager can only deal with monorail bugs") 360 } 361 name, err := ToMonorailIssueName(bug.ID) 362 if err != nil { 363 return nil, err 364 } 365 issue, err := m.client.GetIssue(ctx, name) 366 if err != nil { 367 return nil, err 368 } 369 result, err := mergedIntoBug(issue) 370 if err != nil { 371 return nil, errors.Annotate(err, "resolving canoncial merged into bug").Err() 372 } 373 return result, nil 374 } 375 376 // Unduplicate updates the given bug to no longer be marked as duplicating 377 // another bug, posting the given message on the bug. 378 func (m *BugManager) UpdateDuplicateSource(ctx context.Context, request bugs.UpdateDuplicateSourceRequest) error { 379 if request.BugDetails.Bug.System != bugs.MonorailSystem { 380 // Indicates an implementation error with the caller. 381 panic("monorail bug manager can only deal with monorail bugs") 382 } 383 req, err := m.generator.UpdateDuplicateSource(request.BugDetails.Bug.ID, request.ErrorMessage, request.BugDetails.RuleID, request.DestinationRuleID) 384 if err != nil { 385 return errors.Annotate(err, "mark issue as available").Err() 386 } 387 if m.Simulate { 388 logging.Debugf(ctx, "Would update Monorail issue: %s", textPBMultiline.Format(req)) 389 } else { 390 if err := m.client.ModifyIssues(ctx, req); err != nil { 391 return errors.Annotate(err, "failed to update duplicate source monorail issue %s", request.BugDetails.Bug.ID).Err() 392 } 393 } 394 return nil 395 } 396 397 var buganizerExtRefRe = regexp.MustCompile(`^b/([1-9][0-9]{0,16})$`) 398 399 // mergedIntoBug determines if the given bug is a duplicate of another 400 // bug, and if so, what the identity of that bug is. 401 func mergedIntoBug(issue *mpb.Issue) (*bugs.BugID, error) { 402 if issue.Status.Status == DuplicateStatus && 403 issue.MergedIntoIssueRef != nil { 404 if issue.MergedIntoIssueRef.Issue != "" { 405 name, err := fromMonorailIssueName(issue.MergedIntoIssueRef.Issue) 406 if err != nil { 407 // This should not happen unless monorail or the 408 // implementation here is broken. 409 return nil, err 410 } 411 return &bugs.BugID{ 412 System: bugs.MonorailSystem, 413 ID: name, 414 }, nil 415 } 416 matches := buganizerExtRefRe.FindStringSubmatch(issue.MergedIntoIssueRef.ExtIdentifier) 417 if matches == nil { 418 // A non-buganizer external issue tracker was used. This is not 419 // supported by us, treat the issue as not duplicate of something 420 // else and let auto-updating kick the bug out of duplicate state 421 // if there is still impact. The user should manually resolve the 422 // situation. 423 return nil, fmt.Errorf("unsupported non-monorail non-buganizer bug reference: %s", issue.MergedIntoIssueRef.ExtIdentifier) 424 } 425 return &bugs.BugID{ 426 System: bugs.BuganizerSystem, 427 ID: matches[1], 428 }, nil 429 } 430 return nil, nil 431 } 432 433 // fetchIssues fetches monorail issues using the internal bug names like 434 // {monorail_project}/{issue_id}. Issues in the result will be in 1:1 435 // correspondence (by index) to the request. If an issue does not exist, 436 // or is from a monorail project other than the one configured for this 437 // LUCI project, the corresponding item in the response will be nil. 438 func (m *BugManager) fetchIssues(ctx context.Context, request []bugs.BugUpdateRequest) ([]*mpb.Issue, error) { 439 // Calculate the number of requests required, rounding up 440 // to the nearest page. 441 pages := (len(request) + (monorailPageSize - 1)) / monorailPageSize 442 443 response := make([]*mpb.Issue, 0, len(request)) 444 for i := 0; i < pages; i++ { 445 // Divide names into pages of monorailPageSize. 446 pageEnd := (i + 1) * monorailPageSize 447 if pageEnd > len(request) { 448 pageEnd = len(request) 449 } 450 requestPage := request[i*monorailPageSize : pageEnd] 451 452 var ids []string 453 for _, requestItem := range requestPage { 454 if requestItem.Bug.System != bugs.MonorailSystem { 455 // Indicates an implementation error with the caller. 456 panic("monorail bug manager can only deal with monorail bugs") 457 } 458 monorailProject, id, err := toMonorailProjectAndID(requestItem.Bug.ID) 459 if err != nil { 460 return nil, err 461 } 462 if monorailProject != m.monorailProject { 463 // Only query bugs from the same monorail project as what has 464 // been configured for the LUCI Project. 465 continue 466 } 467 ids = append(ids, id) 468 } 469 470 // Guarantees result array in 1:1 correspondence to requested IDs. 471 issues, err := m.client.BatchGetIssues(ctx, m.monorailProject, ids) 472 if err != nil { 473 return nil, err 474 } 475 response = append(response, issues...) 476 } 477 return response, nil 478 } 479 480 // toMonorailProjectAndID splits an internal bug name like 481 // "{monorail_project}/{numeric_id}" to the monorail project and 482 // numeric ID. 483 func toMonorailProjectAndID(bug string) (project, id string, err error) { 484 parts := bugs.MonorailBugIDRe.FindStringSubmatch(bug) 485 if parts == nil { 486 return "", "", fmt.Errorf("invalid bug %q", bug) 487 } 488 return parts[1], parts[2], nil 489 } 490 491 // ToMonorailIssueName converts an internal bug name like 492 // "{monorail_project}/{numeric_id}" to a monorail issue name like 493 // "projects/{project}/issues/{numeric_id}". 494 func ToMonorailIssueName(bug string) (string, error) { 495 parts := bugs.MonorailBugIDRe.FindStringSubmatch(bug) 496 if parts == nil { 497 return "", fmt.Errorf("invalid bug %q", bug) 498 } 499 return fmt.Sprintf("projects/%s/issues/%s", parts[1], parts[2]), nil 500 } 501 502 // fromMonorailIssueName converts a monorail issue name like 503 // "projects/{project}/issues/{numeric_id}" to an internal bug name like 504 // "{monorail_project}/{numeric_id}". 505 func fromMonorailIssueName(name string) (string, error) { 506 parts := monorailRe.FindStringSubmatch(name) 507 if parts == nil { 508 return "", fmt.Errorf("invalid monorail issue name %q", name) 509 } 510 return fmt.Sprintf("%s/%s", parts[1], parts[2]), nil 511 }