go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/internal/bugs/buganizer/request_generation.go (about)

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