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  }