go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/internal/bugs/buganizer/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 buganizer 16 17 import ( 18 "context" 19 "strconv" 20 "strings" 21 "time" 22 23 "google.golang.org/api/iterator" 24 "google.golang.org/protobuf/encoding/prototext" 25 26 "go.chromium.org/luci/common/clock" 27 "go.chromium.org/luci/common/errors" 28 "go.chromium.org/luci/common/logging" 29 "go.chromium.org/luci/third_party/google.golang.org/genproto/googleapis/devtools/issuetracker/v1" 30 31 "go.chromium.org/luci/analysis/internal/bugs" 32 configpb "go.chromium.org/luci/analysis/proto/config" 33 ) 34 35 // The maximum number of issues you can get from Buganizer 36 // in one BatchGetIssues RPC. 37 // This is set by Buganizer. 38 const maxPageSize = 100 39 40 var textPBMultiline = prototext.MarshalOptions{ 41 Multiline: true, 42 } 43 44 // Client represents the interface needed by the bug manager 45 // to manipulate issues in Google Issue Tracker. 46 type Client interface { 47 // Closes the underlying client. 48 Close() 49 // BatchGetIssues returns a list of issues matching the BatchGetIssuesRequest. 50 BatchGetIssues(ctx context.Context, in *issuetracker.BatchGetIssuesRequest) (*issuetracker.BatchGetIssuesResponse, error) 51 // GetIssue returns data about a single issue. 52 GetIssue(ctx context.Context, in *issuetracker.GetIssueRequest) (*issuetracker.Issue, error) 53 // CreateIssue creates an issue using the data provided. 54 CreateIssue(ctx context.Context, in *issuetracker.CreateIssueRequest) (*issuetracker.Issue, error) 55 // ModifyIssue modifies an issue using the data provided. 56 ModifyIssue(ctx context.Context, in *issuetracker.ModifyIssueRequest) (*issuetracker.Issue, error) 57 // ListIssueUpdates lists the updates which occured in an issue, it returns a delegate to an IssueUpdateIterator. 58 // The iterator can be used to fetch IssueUpdates one by one. 59 ListIssueUpdates(ctx context.Context, in *issuetracker.ListIssueUpdatesRequest) IssueUpdateIterator 60 // CreateIssueComment creates an issue comment using the data provided. 61 CreateIssueComment(ctx context.Context, in *issuetracker.CreateIssueCommentRequest) (*issuetracker.IssueComment, error) 62 // UpdateIssueComment updates an issue comment and returns the updated comment. 63 UpdateIssueComment(ctx context.Context, in *issuetracker.UpdateIssueCommentRequest) (*issuetracker.IssueComment, error) 64 // ListIssueComments lists issue comments, it returns a delegate to an IssueCommentIterator. 65 // The iterator can be used to fetch IssueComment one by one. 66 ListIssueComments(ctx context.Context, in *issuetracker.ListIssueCommentsRequest) IssueCommentIterator 67 // GetAutomationAccess checks that automation has permission on a resource. 68 // Does not require any permission on the resource 69 GetAutomationAccess(ctx context.Context, in *issuetracker.GetAutomationAccessRequest) (*issuetracker.GetAutomationAccessResponse, error) 70 // CreateHotlistEntry adds an issue to a hotlist. 71 CreateHotlistEntry(ctx context.Context, in *issuetracker.CreateHotlistEntryRequest) (*issuetracker.HotlistEntry, error) 72 } 73 74 // An interface for an IssueUpdateIterator. 75 type IssueUpdateIterator interface { 76 // Next returns the next update in the list of updates. 77 // If the error is iterator.Done, this means that the iterator is exhausted. 78 // Once iterator.Done is returned, it will always be returned thereafter. 79 Next() (*issuetracker.IssueUpdate, error) 80 } 81 82 // An interface for the IssueCommentIterator. 83 type IssueCommentIterator interface { 84 // Next returns the next comment in the list of comments. 85 // If the error is iterator.Done, this means that the iterator is exhausted. 86 // Once iterator.Done is returned, it will always be returned thereafter. 87 Next() (*issuetracker.IssueComment, error) 88 } 89 90 type BugManager struct { 91 client Client 92 // The email address of the LUCI Analysis instance. This is used to distinguish 93 // priority updates made by LUCI Analysis itself from those made by others. 94 selfEmail string 95 // The LUCI Project. 96 project string 97 // The default buganizer component to file into. 98 defaultComponent *configpb.BuganizerComponent 99 // The generator used to generate updates to Buganizer bugs. 100 // Set if and only if usePolicyBasedManagement. 101 requestGenerator *RequestGenerator 102 // This flags toggles the bug manager to stub the calls to 103 // Buganizer and mock the responses and behaviour of issue manipluation. 104 // Use this flag for testing purposes ONLY. 105 Simulate bool 106 } 107 108 // NewBugManager creates a new Buganizer bug manager than can be 109 // used to manipulate bugs in Buganizer. 110 // Use the `simulate` flag to use the manager in simulation mode 111 // while testing. 112 func NewBugManager(client Client, 113 uiBaseURL, project, selfEmail string, 114 projectCfg *configpb.ProjectConfig, 115 simulate bool) (*BugManager, error) { 116 117 generator, err := NewRequestGenerator( 118 client, 119 project, 120 uiBaseURL, 121 selfEmail, 122 projectCfg, 123 ) 124 if err != nil { 125 return nil, errors.Annotate(err, "create request generator").Err() 126 } 127 defaultComponent := projectCfg.BugManagement.Buganizer.DefaultComponent 128 129 return &BugManager{ 130 client: client, 131 defaultComponent: defaultComponent, 132 project: project, 133 selfEmail: selfEmail, 134 requestGenerator: generator, 135 Simulate: simulate, 136 }, nil 137 } 138 139 // Create creates an issue in Buganizer and returns the issue ID. 140 func (bm *BugManager) Create(ctx context.Context, createRequest bugs.BugCreateRequest) bugs.BugCreateResponse { 141 var response bugs.BugCreateResponse 142 response.Simulated = bm.Simulate 143 response.PolicyActivationsNotified = make(map[bugs.PolicyID]struct{}) 144 145 componentID := bm.defaultComponent.Id 146 buganizerTestMode := ctx.Value(&BuganizerTestModeKey) 147 wantedComponentID := createRequest.BuganizerComponent 148 // Use wanted component if not in test mode. 149 if buganizerTestMode == nil || !buganizerTestMode.(bool) { 150 if wantedComponentID != componentID && wantedComponentID > 0 { 151 permissions, err := bm.checkComponentPermissions(ctx, wantedComponentID) 152 if err != nil { 153 response.Error = errors.Annotate(err, "check permissions to create Buganizer issue").Err() 154 return response 155 } 156 if permissions.appender && permissions.issueDefaultsAppender { 157 componentID = createRequest.BuganizerComponent 158 } 159 } 160 } 161 162 createIssueRequest, err := bm.requestGenerator.PrepareNew( 163 createRequest.Description, 164 createRequest.ActivePolicyIDs, 165 createRequest.RuleID, 166 componentID, 167 ) 168 if err != nil { 169 response.Error = errors.Annotate(err, "prepare new issue").Err() 170 return response 171 } 172 173 var issueID int64 174 if bm.Simulate { 175 logging.Debugf(ctx, "Would create Buganizer issue: %s", textPBMultiline.Format(createIssueRequest)) 176 issueID = 123456 177 } else { 178 issue, err := bm.client.CreateIssue(ctx, createIssueRequest) 179 if err != nil { 180 response.Error = errors.Annotate(err, "create Buganizer issue").Err() 181 return response 182 } 183 issueID = issue.IssueId 184 bugs.BugsCreatedCounter.Add(ctx, 1, bm.project, "buganizer") 185 } 186 // A bug was filed. 187 response.ID = strconv.Itoa(int(issueID)) 188 189 if wantedComponentID > 0 && wantedComponentID != componentID { 190 commentRequest := bm.requestGenerator.PrepareNoPermissionComment(issueID, wantedComponentID) 191 if bm.Simulate { 192 logging.Debugf(ctx, "Would post comment on Buganizer issue: %s", textPBMultiline.Format(commentRequest)) 193 } else { 194 if _, err := bm.client.CreateIssueComment(ctx, commentRequest); err != nil { 195 response.Error = errors.Annotate(err, "create issue link comment").Err() 196 return response 197 } 198 } 199 } 200 201 response.PolicyActivationsNotified, err = bm.notifyPolicyActivation(ctx, createRequest.RuleID, issueID, createRequest.ActivePolicyIDs) 202 if err != nil { 203 response.Error = errors.Annotate(err, "notify policy activations").Err() 204 return response 205 } 206 207 hotlistIDs := bm.requestGenerator.ExpectedHotlistIDs(createRequest.ActivePolicyIDs) 208 if err := bm.insertIntoHotlists(ctx, hotlistIDs, issueID); err != nil { 209 response.Error = errors.Annotate(err, "insert into hotlists").Err() 210 return response 211 } 212 213 return response 214 } 215 216 // notifyPolicyActivation notifies that the given policies have activated. 217 // 218 // This method supports partial success; it returns the set of policies 219 // which were successfully notified even if an error is encountered and 220 // returned. 221 func (bm *BugManager) notifyPolicyActivation(ctx context.Context, ruleID string, issueID int64, policyIDsToNotify map[bugs.PolicyID]struct{}) (map[bugs.PolicyID]struct{}, error) { 222 policiesNotified := make(map[bugs.PolicyID]struct{}) 223 224 // Notify policies which have activated in descending priority order. 225 sortedPolicyIDToNotify := bm.requestGenerator.SortPolicyIDsByPriorityDescending(policyIDsToNotify) 226 for _, policyID := range sortedPolicyIDToNotify { 227 commentRequest, err := bm.requestGenerator.PreparePolicyActivatedComment(ruleID, issueID, policyID) 228 if err != nil { 229 return policiesNotified, errors.Annotate(err, "prepare comment for policy %q", policyID).Err() 230 } 231 // Only post a comment if the policy has specified one. 232 if commentRequest != nil { 233 if err := bm.createIssueComment(ctx, commentRequest); err != nil { 234 return policiesNotified, errors.Annotate(err, "post comment for policy %q", policyID).Err() 235 } 236 } 237 // Policy activation successfully notified. 238 policiesNotified[policyID] = struct{}{} 239 } 240 return policiesNotified, nil 241 } 242 243 // maintainHotlists ensures the has been inserted into the hotlists 244 // configured by the active policies. Note: The issue is not removed 245 // from the hotlist when a policy de-activates on a rule. 246 func (bm *BugManager) insertIntoHotlists(ctx context.Context, hotlistIDs map[int64]struct{}, issueID int64) error { 247 hotlistInsertionRequests := PrepareHotlistInsertions(hotlistIDs, issueID) 248 for _, req := range hotlistInsertionRequests { 249 if bm.Simulate { 250 logging.Debugf(ctx, "Would create hotlist entry: %s", textPBMultiline.Format(req)) 251 } else { 252 if _, err := bm.client.CreateHotlistEntry(ctx, req); err != nil { 253 return errors.Annotate(err, "insert into hotlist %d", req.HotlistId).Err() 254 } 255 } 256 } 257 return nil 258 } 259 260 // Update updates the issues in Buganizer. 261 func (bm *BugManager) Update(ctx context.Context, requests []bugs.BugUpdateRequest) ([]bugs.BugUpdateResponse, error) { 262 issues, err := bm.fetchIssues(ctx, requests) 263 if err != nil { 264 return nil, errors.Annotate(err, "fetch issues for update").Err() 265 } 266 267 issuesByID := make(map[int64]*issuetracker.Issue) 268 for _, fetchedIssue := range issues { 269 issuesByID[fetchedIssue.IssueId] = fetchedIssue 270 } 271 272 var responses []bugs.BugUpdateResponse 273 for _, request := range requests { 274 id, err := strconv.ParseInt(request.Bug.ID, 10, 64) 275 if err != nil { 276 // This should never occur here, as we do a similar conversion in fetchIssues. 277 return nil, errors.Annotate(err, "convert bug id to int").Err() 278 } 279 issue, ok := issuesByID[id] 280 if !ok { 281 // The bug does not exist, or is in a different buganizer project 282 // to the buganizer project configured for this project 283 // or we have no permission to access it. 284 // Take no action. 285 responses = append(responses, bugs.BugUpdateResponse{ 286 IsDuplicate: false, 287 IsDuplicateAndAssigned: false, 288 ShouldArchive: false, 289 PolicyActivationsNotified: make(map[bugs.PolicyID]struct{}), 290 }) 291 logging.Fields{ 292 "Project": bm.project, 293 "BuganizerBugID": request.Bug.ID, 294 }.Warningf(ctx, "Buganizer issue %s not found or we don't have permission to access it (project: %s), skipping.", request.Bug.ID, bm.project) 295 continue 296 } 297 updateResponse := bm.updateIssue(ctx, request, issue) 298 responses = append(responses, updateResponse) 299 } 300 return responses, nil 301 } 302 303 // updateIssue updates the given issue, adjusting its priority, 304 // and verify or unverifying it. 305 func (bm *BugManager) updateIssue(ctx context.Context, request bugs.BugUpdateRequest, issue *issuetracker.Issue) bugs.BugUpdateResponse { 306 var response bugs.BugUpdateResponse 307 response.PolicyActivationsNotified = map[bugs.PolicyID]struct{}{} 308 309 // If the context times out part way through an update, we do 310 // not know if our bug update succeeded (but we have not received the 311 // success response back from Buganizer yet) or the bug update failed. 312 // 313 // This is problematic for bug updates that require changes to the 314 // bug in tandem with updates to the rule, as we do not know if we 315 // need to make the rule update. For example: 316 // - Disabling IsManagingBugPriority in tandem with a comment on 317 // the bug indicating the user has taken priority control of the 318 // bug. 319 // - Notifying the bug is associated with a rule in tandem with 320 // an update to the bug management state recording we send this 321 // notification. 322 // 323 // If we incorrectly assume a bug comment was made when it was not, 324 // we may fail to deliver comments on bugs. 325 // If we incorrectly assume a bug comment was not delivered when it was, 326 // we may end up repeatedly making the same comment. 327 // 328 // We prefer the second over the first, but we try here to reduce the 329 // likelihood of either happening by ensuring we have at least one minute 330 // of time available. 331 if err := bugs.EnsureTimeToDeadline(ctx, time.Minute); err != nil { 332 response.Error = err 333 return response 334 } 335 336 response.ShouldArchive = shouldArchiveRule(ctx, issue, request.IsManagingBug) 337 if issue.IssueState.Status == issuetracker.Issue_DUPLICATE { 338 response.IsDuplicate = true 339 response.IsDuplicateAndAssigned = issue.IssueState.Assignee != nil 340 } 341 342 if !response.IsDuplicate && !response.ShouldArchive { 343 if !request.BugManagementState.RuleAssociationNotified { 344 commentRequest, err := bm.requestGenerator.PrepareRuleAssociatedComment(request.RuleID, issue.IssueId) 345 if err != nil { 346 response.Error = errors.Annotate(err, "prepare rule associated comment").Err() 347 return response 348 } 349 if err := bm.createIssueComment(ctx, commentRequest); err != nil { 350 response.Error = errors.Annotate(err, "create rule associated comment").Err() 351 return response 352 } 353 response.RuleAssociationNotified = true 354 } 355 356 // Identify which policies have activated for the first time and notify them. 357 policyIDsToNotify := bugs.ActivePoliciesPendingNotification(request.BugManagementState) 358 359 var err error 360 response.PolicyActivationsNotified, err = bm.notifyPolicyActivation(ctx, request.RuleID, issue.IssueId, policyIDsToNotify) 361 if err != nil { 362 response.Error = errors.Annotate(err, "notify policy activations").Err() 363 return response 364 } 365 366 // Apply priority/verified updates. 367 if request.IsManagingBug && bm.requestGenerator.NeedsPriorityOrVerifiedUpdate(request.BugManagementState, issue, request.IsManagingBugPriority) { 368 // List issue updates. 369 listUpdatesRequest := &issuetracker.ListIssueUpdatesRequest{ 370 IssueId: issue.IssueId, 371 } 372 it := bm.client.ListIssueUpdates(ctx, listUpdatesRequest) 373 374 // Determine if bug priority manually set. This involves listing issue comments. 375 hasManuallySetPriority, err := bm.hasManuallySetPriority(it, bm.selfEmail, request.IsManagingBugPriorityLastUpdated) 376 if err != nil { 377 response.Error = errors.Annotate(err, "determine if priority manually set").Err() 378 return response 379 } 380 mur, err := bm.requestGenerator.MakePriorityOrVerifiedUpdate(MakeUpdateOptions{ 381 RuleID: request.RuleID, 382 BugManagementState: request.BugManagementState, 383 Issue: issue, 384 IsManagingBugPriority: request.IsManagingBugPriority, 385 HasManuallySetPriority: hasManuallySetPriority, 386 }) 387 if err != nil { 388 response.Error = errors.Annotate(err, "create update request for issue").Err() 389 return response 390 } 391 if bm.Simulate { 392 logging.Debugf(ctx, "Would update Buganizer issue: %s", textPBMultiline.Format(mur.request)) 393 } else { 394 if _, err := bm.client.ModifyIssue(ctx, mur.request); err != nil { 395 response.Error = errors.Annotate(err, "update Buganizer issue").Err() 396 return response 397 } 398 bugs.BugsUpdatedCounter.Add(ctx, 1, bm.project, "buganizer") 399 } 400 response.DisableRulePriorityUpdates = mur.disablePriorityUpdates 401 } 402 403 // Hotlists 404 // Find all hotlists specified on active policies. 405 hotlistsIDsToAdd := bm.requestGenerator.ExpectedHotlistIDs(bugs.ActivePolicies(request.BugManagementState)) 406 407 // Subtract the hotlists already on the bug. 408 for _, hotlistID := range issue.IssueState.HotlistIds { 409 delete(hotlistsIDsToAdd, hotlistID) 410 } 411 412 if err := bm.insertIntoHotlists(ctx, hotlistsIDsToAdd, issue.IssueId); err != nil { 413 response.Error = errors.Annotate(err, "insert issue into hotlists").Err() 414 return response 415 } 416 } 417 418 return response 419 } 420 421 func (bm *BugManager) createIssueComment(ctx context.Context, commentRequest *issuetracker.CreateIssueCommentRequest) error { 422 // Only post a comment if the policy has specified one. 423 if bm.Simulate { 424 logging.Debugf(ctx, "Would post comment on Buganizer issue: %s", textPBMultiline.Format(commentRequest)) 425 } else { 426 if _, err := bm.client.CreateIssueComment(ctx, commentRequest); err != nil { 427 return errors.Annotate(err, "create comment").Err() 428 } 429 bugs.BugsUpdatedCounter.Add(ctx, 1, bm.project, "buganizer") 430 } 431 return nil 432 } 433 434 // hasManuallySetPriority checks whether this issue's priority was last modified by 435 // a user. 436 func (bm *BugManager) hasManuallySetPriority( 437 it IssueUpdateIterator, selfEmail string, isManagingBugPriorityLastUpdated time.Time) (bool, error) { 438 var priorityUpdateTime time.Time 439 var foundUpdate bool 440 // Loops on the list of the issues updates, the updates are in time-descending 441 // order by default. 442 for { 443 update, err := it.Next() 444 if err == iterator.Done { 445 break 446 } 447 if err != nil { 448 return false, errors.Annotate(err, "iterating through issue updates").Err() 449 } 450 if update.Author.EmailAddress != selfEmail { 451 // If the modification was done by a user, we check if 452 // the priority was updated in the list of updated fields. 453 for _, fieldUpdate := range update.FieldUpdates { 454 if fieldUpdate.Field == priorityField { 455 foundUpdate = true 456 priorityUpdateTime = update.Timestamp.AsTime() 457 break 458 } 459 } 460 } 461 if foundUpdate { 462 break 463 } 464 } 465 // We compare the last time the user modified the priority was after 466 // the last time the rule's priority management property was enabled. 467 if foundUpdate && 468 priorityUpdateTime.After(isManagingBugPriorityLastUpdated) { 469 return true, nil 470 } 471 return false, nil 472 } 473 474 func shouldArchiveRule(ctx context.Context, issue *issuetracker.Issue, isManaging bool) bool { 475 // If the bug is set to a status like "Archived", immediately archive 476 // the rule as well. We should not re-open such a bug. 477 if issue.IsArchived { 478 return true 479 } 480 now := clock.Now(ctx) 481 if isManaging { 482 // If LUCI Analysis is managing the bug, 483 // more than 30 days since the issue was verified. 484 return issue.IssueState.Status == issuetracker.Issue_VERIFIED && 485 issue.VerifiedTime.IsValid() && 486 now.Sub(issue.VerifiedTime.AsTime()).Hours() >= 30*24 487 } else { 488 // If the user is managing the bug, 489 // more than 30 days since the issue was closed. 490 _, ok := ClosedStatuses[issue.IssueState.Status] 491 return ok && issue.ResolvedTime.IsValid() && 492 now.Sub(issue.ResolvedTime.AsTime()).Hours() >= 30*24 493 } 494 } 495 496 func (bm *BugManager) fetchIssues(ctx context.Context, requests []bugs.BugUpdateRequest) ([]*issuetracker.Issue, error) { 497 issues := make([]*issuetracker.Issue, 0, len(requests)) 498 499 chunks := chunkRequests(requests) 500 501 for _, chunk := range chunks { 502 ids := make([]int64, 0, len(chunk)) 503 for _, request := range chunk { 504 if request.Bug.System != bugs.BuganizerSystem { 505 // Indicates an implementation error with the caller. 506 panic("Buganizer bug manager can only deal with Buganizer bugs") 507 } 508 id, err := strconv.Atoi(request.Bug.ID) 509 if err != nil { 510 return nil, errors.Annotate(err, "convert bug id to int").Err() 511 } 512 ids = append(ids, int64(id)) 513 } 514 515 fetchedIssues, err := bm.client.BatchGetIssues(ctx, &issuetracker.BatchGetIssuesRequest{ 516 IssueIds: ids, 517 View: issuetracker.IssueView_FULL, 518 }) 519 if err != nil { 520 return nil, errors.Annotate(err, "fetch issues").Err() 521 } 522 issues = append(issues, fetchedIssues.Issues...) 523 } 524 return issues, nil 525 } 526 527 // chunkRequests creates chunks of bug requests that can be used to fetch issues. 528 func chunkRequests(requests []bugs.BugUpdateRequest) [][]bugs.BugUpdateRequest { 529 // Calculate the number of chunks 530 numChunks := (len(requests) / maxPageSize) + 1 531 chunks := make([][]bugs.BugUpdateRequest, 0, numChunks) 532 total := len(requests) 533 534 for i := 0; i < total; i += maxPageSize { 535 var end int 536 if i+maxPageSize < total { 537 end = i + maxPageSize 538 } else { 539 end = total 540 } 541 chunks = append(chunks, requests[i:end]) 542 } 543 544 return chunks 545 } 546 547 // GetMergedInto returns the canonical bug id that this issue is merged into. 548 func (bm *BugManager) GetMergedInto(ctx context.Context, bug bugs.BugID) (*bugs.BugID, error) { 549 if bug.System != bugs.BuganizerSystem { 550 // Indicates an implementation error with the caller. 551 panic("Buganizer bug manager can only deal with Buganizer bugs") 552 } 553 issueId, err := strconv.Atoi(bug.ID) 554 if err != nil { 555 return nil, errors.Annotate(err, "get merged into").Err() 556 } 557 issue, err := bm.client.GetIssue(ctx, &issuetracker.GetIssueRequest{ 558 IssueId: int64(issueId), 559 }) 560 if err != nil { 561 return nil, err 562 } 563 result, err := mergedIntoBug(issue) 564 if err != nil { 565 return nil, errors.Annotate(err, "resolving canoncial merged into bug").Err() 566 } 567 return result, nil 568 } 569 570 // mergedIntoBug determines if the given bug is a duplicate of another 571 // bug, and if so, what the identity of that bug is. 572 func mergedIntoBug(issue *issuetracker.Issue) (*bugs.BugID, error) { 573 if issue.IssueState.Status == issuetracker.Issue_DUPLICATE && 574 issue.IssueState.CanonicalIssueId > 0 { 575 return &bugs.BugID{ 576 System: bugs.BuganizerSystem, 577 ID: strconv.FormatInt(issue.IssueState.CanonicalIssueId, 10), 578 }, nil 579 } 580 return nil, nil 581 } 582 583 // UpdateDuplicateSource updates the source bug of a duplicate 584 // bug relationship. 585 // It normally posts a message advising the user LUCI Analysis 586 // has merged the rule for the source bug to the destination 587 // (merged-into) bug, and provides a new link to the failure 588 // association rule. 589 // If a cycle was detected, it instead posts a message that the 590 // duplicate bug could not be handled and marks the bug no 591 // longer a duplicate to break the cycle. 592 func (bm *BugManager) UpdateDuplicateSource(ctx context.Context, request bugs.UpdateDuplicateSourceRequest) error { 593 if request.BugDetails.Bug.System != bugs.BuganizerSystem { 594 // Indicates an implementation error with the caller. 595 panic("Buganizer bug manager can only deal with Buganizer bugs") 596 } 597 issueId, err := strconv.Atoi(request.BugDetails.Bug.ID) 598 if err != nil { 599 return errors.Annotate(err, "update duplicate source").Err() 600 } 601 req := bm.requestGenerator.UpdateDuplicateSource(int64(issueId), request.ErrorMessage, request.BugDetails.RuleID, request.DestinationRuleID, request.BugDetails.IsAssigned) 602 if bm.Simulate { 603 logging.Debugf(ctx, "Would update Buganizer issue: %s", textPBMultiline.Format(req)) 604 } else { 605 if _, err := bm.client.ModifyIssue(ctx, req); err != nil { 606 return errors.Annotate(err, "failed to update duplicate source Buganizer issue %s", request.BugDetails.Bug.ID).Err() 607 } 608 } 609 return nil 610 } 611 612 // componentPermissions contains the results of checking the permissions of a 613 // Buganizer component. 614 type componentPermissions struct { 615 // appender is permission to create issues in this component. 616 appender bool 617 // issueDefaultsAppender is permission to add comments to issues in 618 // this component. 619 issueDefaultsAppender bool 620 } 621 622 // checkComponentPermissions checks the permissions required to create an issue 623 // in the specified component. 624 func (bm *BugManager) checkComponentPermissions(ctx context.Context, componentID int64) (componentPermissions, error) { 625 var err error 626 permissions := componentPermissions{} 627 permissions.appender, err = bm.checkSinglePermission(ctx, componentID, false, "appender") 628 if err != nil { 629 return permissions, err 630 } 631 permissions.issueDefaultsAppender, err = bm.checkSinglePermission(ctx, componentID, true, "appender") 632 if err != nil { 633 return permissions, err 634 } 635 return permissions, nil 636 } 637 638 // checkSinglePermission checks a single permission of a Buganizer component 639 // ID. You should typically use checkComponentPermission instead of this 640 // method. 641 func (bm *BugManager) checkSinglePermission(ctx context.Context, componentID int64, issueDefaults bool, relation string) (bool, error) { 642 resource := []string{"components", strconv.Itoa(int(componentID))} 643 if issueDefaults { 644 resource = append(resource, "issueDefaults") 645 } 646 automationAccessRequest := &issuetracker.GetAutomationAccessRequest{ 647 User: &issuetracker.User{EmailAddress: bm.selfEmail}, 648 Relation: relation, 649 ResourceName: strings.Join(resource, "/"), 650 } 651 if bm.Simulate { 652 logging.Debugf(ctx, "Would check Buganizer component permission: %s", textPBMultiline.Format(automationAccessRequest)) 653 } else { 654 access, err := bm.client.GetAutomationAccess(ctx, automationAccessRequest) 655 if err != nil { 656 logging.Errorf(ctx, "error when checking buganizer component permissions with request:\n%s\nerror:%s", textPBMultiline.Format(automationAccessRequest), err) 657 return false, err 658 } 659 return access.HasAccess, nil 660 } 661 return false, nil 662 }