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  }