go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/internal/bugs/monorail/fakes.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
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"regexp"
    21  	"sort"
    22  	"strconv"
    23  	"strings"
    24  	"time"
    25  
    26  	"google.golang.org/grpc"
    27  	"google.golang.org/grpc/codes"
    28  	emptypb "google.golang.org/protobuf/types/known/emptypb"
    29  	"google.golang.org/protobuf/types/known/timestamppb"
    30  
    31  	"go.chromium.org/luci/common/clock"
    32  	"go.chromium.org/luci/common/errors"
    33  	"go.chromium.org/luci/common/proto/mask"
    34  	"go.chromium.org/luci/grpc/appstatus"
    35  
    36  	mpb "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto"
    37  )
    38  
    39  // projectsRE matches valid monorail project references.
    40  var projectsRE = regexp.MustCompile(`projects/[a-z0-9\-_]+`)
    41  
    42  // fakeIssuesClient provides a fake implementation of a monorail client, for testing. See:
    43  // https://source.chromium.org/chromium/infra/infra/+/main:appengine/monorail/api/v3/api_proto/issues.proto
    44  type fakeIssuesClient struct {
    45  	store *FakeIssuesStore
    46  	// User is the identity of the user interacting with monorail.
    47  	user string
    48  }
    49  
    50  // UseFakeIssuesClient installs a given fake IssuesClient into the context so that
    51  // it is used instead of making RPCs to monorail. The client will behave as if
    52  // the given user is authenticated.
    53  func UseFakeIssuesClient(ctx context.Context, store *FakeIssuesStore, user string) context.Context {
    54  	issuesClient := &fakeIssuesClient{store: store, user: user}
    55  	projectsClient := &fakeProjectsClient{store: store}
    56  	return context.WithValue(ctx, &testMonorailClientKey, &Client{
    57  		updateIssuesClient: mpb.IssuesClient(issuesClient),
    58  		issuesClient:       mpb.IssuesClient(issuesClient),
    59  		projectsClient:     mpb.ProjectsClient(projectsClient),
    60  	})
    61  }
    62  
    63  func (f *fakeIssuesClient) GetIssue(ctx context.Context, in *mpb.GetIssueRequest, opts ...grpc.CallOption) (*mpb.Issue, error) {
    64  	issue := f.issueByName(in.Name)
    65  	if issue == nil {
    66  		return nil, errors.New("issue not found")
    67  	}
    68  	// Copy proto so that if the consumer modifies the proto,
    69  	// the stored proto does not change.
    70  	return CopyIssue(issue.Issue), nil
    71  }
    72  
    73  func (f *fakeIssuesClient) issueByName(name string) *IssueData {
    74  	for _, issue := range f.store.Issues {
    75  		if issue.Issue.Name == name {
    76  			return issue
    77  		}
    78  	}
    79  	return nil
    80  }
    81  
    82  func (f *fakeIssuesClient) BatchGetIssues(ctx context.Context, in *mpb.BatchGetIssuesRequest, opts ...grpc.CallOption) (*mpb.BatchGetIssuesResponse, error) {
    83  	result := &mpb.BatchGetIssuesResponse{}
    84  	for _, name := range in.Names {
    85  		issue := f.issueByName(name)
    86  		if issue == nil {
    87  			return nil, fmt.Errorf("issue %q not found", name)
    88  		}
    89  		// Copy proto so that if the consumer modifies the proto,
    90  		// the stored proto does not change.
    91  		result.Issues = append(result.Issues, CopyIssue(issue.Issue))
    92  	}
    93  	return result, nil
    94  }
    95  
    96  // queryRE matches queries supported by the fake, of the form "ID=123123,456456,789789".
    97  var queryRE = regexp.MustCompile(`ID=([1-9][0-9]*(?:,[1-9][0-9]*)*)`)
    98  
    99  func (f *fakeIssuesClient) SearchIssues(ctx context.Context, in *mpb.SearchIssuesRequest, opts ...grpc.CallOption) (*mpb.SearchIssuesResponse, error) {
   100  	if len(in.Projects) != 1 {
   101  		return nil, errors.New("expected exactly one project to search")
   102  	}
   103  	project := in.Projects[0]
   104  	if !strings.HasPrefix(project, "projects/") {
   105  		return nil, errors.Reason("invalid resource name: %s", project).Err()
   106  	}
   107  
   108  	m := queryRE.FindStringSubmatch(in.Query)
   109  	if m == nil {
   110  		return nil, errors.New("query pattern not supported by fake")
   111  	}
   112  
   113  	var result []*mpb.Issue
   114  	ids := strings.Split(m[1], ",")
   115  	for _, id := range ids {
   116  		name := fmt.Sprintf("%s/issues/%s", project, id)
   117  		issue := f.issueByName(name)
   118  		if issue != nil {
   119  			result = append(result, CopyIssue(issue.Issue))
   120  		}
   121  	}
   122  
   123  	// Test pagination: The first time around, return the first half
   124  	// of the results. The second time around, return the other half.
   125  	nextPageToken := "fakeToken"
   126  	startingOffset := 0
   127  	endingOffset := len(result) / 2
   128  	if in.PageToken == "fakeToken" {
   129  		startingOffset = len(result) / 2
   130  		endingOffset = len(result)
   131  		nextPageToken = ""
   132  	} else if in.PageToken != "" {
   133  		return nil, errors.New("invalid page token")
   134  	}
   135  
   136  	return &mpb.SearchIssuesResponse{
   137  		Issues:        result[startingOffset:endingOffset],
   138  		NextPageToken: nextPageToken,
   139  	}, nil
   140  }
   141  
   142  func (f *fakeIssuesClient) ListComments(ctx context.Context, in *mpb.ListCommentsRequest, opts ...grpc.CallOption) (*mpb.ListCommentsResponse, error) {
   143  	issue := f.issueByName(in.Parent)
   144  	if issue == nil {
   145  		return nil, fmt.Errorf("issue %q not found", in.Parent)
   146  	}
   147  	startIndex := 0
   148  	if in.PageToken != "" {
   149  		start, err := strconv.Atoi(in.PageToken)
   150  		if err != nil {
   151  			return nil, fmt.Errorf("invalid page token %q", in.PageToken)
   152  		}
   153  		startIndex = start
   154  	}
   155  	// The specification for ListComments says that 100 is both the default
   156  	// and the maximum value.
   157  	pageSize := int(in.PageSize)
   158  	if pageSize > 100 || pageSize <= 0 {
   159  		pageSize = 100
   160  	}
   161  	endIndex := startIndex + pageSize
   162  	finished := false
   163  	if endIndex > len(issue.Comments) {
   164  		endIndex = len(issue.Comments)
   165  		finished = true
   166  	}
   167  	comments := issue.Comments[startIndex:endIndex]
   168  
   169  	result := &mpb.ListCommentsResponse{
   170  		Comments: CopyComments(comments),
   171  	}
   172  	if !finished {
   173  		result.NextPageToken = strconv.Itoa(endIndex)
   174  	}
   175  	return result, nil
   176  }
   177  
   178  // Implements a version of ModifyIssues that operates on local data instead of using monorail service.
   179  // Reference implementation:
   180  // https://source.chromium.org/chromium/infra/infra/+/main:appengine/monorail/api/v3/issues_servicer.py?q=%22def%20ModifyIssues%22
   181  // https://source.chromium.org/chromium/infra/infra/+/main:appengine/monorail/api/v3/converters.py?q=IngestIssueDeltas&type=cs
   182  func (f *fakeIssuesClient) ModifyIssues(ctx context.Context, in *mpb.ModifyIssuesRequest, opts ...grpc.CallOption) (*mpb.ModifyIssuesResponse, error) {
   183  	// Current implementation would erroneously update the first issue
   184  	// if the delta for the second issue failed validation. Currently our
   185  	// fakes don't need this fidelity so it has not been implemented.
   186  	if len(in.Deltas) > 1 {
   187  		return nil, errors.New("not implemented for more than one delta")
   188  	}
   189  	if f.store.UpdateError != nil {
   190  		// We have been configured to return an error on attempts to modify any issue.
   191  		return nil, f.store.UpdateError
   192  	}
   193  	var updatedIssues []*mpb.Issue
   194  	for _, delta := range in.Deltas {
   195  		name := delta.Issue.Name
   196  		issue := f.issueByName(name)
   197  		if issue == nil {
   198  			return nil, fmt.Errorf("issue %q not found", name)
   199  		}
   200  		if issue.UpdateError != nil {
   201  			// We have been configured to return an error on attempts to modify this issue.
   202  			return nil, issue.UpdateError
   203  		}
   204  		if !delta.UpdateMask.IsValid(issue.Issue) {
   205  			return nil, fmt.Errorf("update mask for issue %q not valid", name)
   206  		}
   207  		const isFieldNameJSON = false
   208  		const isUpdateMask = true
   209  		m, err := mask.FromFieldMask(delta.UpdateMask, issue.Issue, isFieldNameJSON, isUpdateMask)
   210  		if err != nil {
   211  			return nil, errors.Annotate(err, "update mask for issue %q not valid", name).Err()
   212  		}
   213  
   214  		// Effect deletions.
   215  		if len(delta.BlockedOnIssuesRemove) > 0 || len(delta.BlockingIssuesRemove) > 0 ||
   216  			len(delta.CcsRemove) > 0 || len(delta.ComponentsRemove) > 0 || len(delta.FieldValsRemove) > 0 {
   217  			return nil, errors.New("some removals are not supported by the current fake")
   218  		}
   219  		issue.Issue.Labels = mergeLabelDeletions(issue.Issue.Labels, delta.LabelsRemove)
   220  
   221  		// Keep only the bits of the delta that are also in the field mask.
   222  		filteredDelta := &mpb.Issue{}
   223  		if err := m.Merge(delta.Issue, filteredDelta); err != nil {
   224  			return nil, errors.Annotate(err, "failed to merge for issue %q", name).Err()
   225  		}
   226  
   227  		// Items in the delta's lists (like field values and labels) are treated as item-wise
   228  		// additions or updates, not as a list-wise replacement.
   229  		mergedDelta := CopyIssue(filteredDelta)
   230  		mergedDelta.FieldValues = mergeFieldValues(issue.Issue.FieldValues, filteredDelta.FieldValues)
   231  		mergedDelta.Labels = mergeLabels(issue.Issue.Labels, filteredDelta.Labels)
   232  		if len(mergedDelta.BlockedOnIssueRefs) > 0 || len(mergedDelta.BlockingIssueRefs) > 0 ||
   233  			len(mergedDelta.CcUsers) > 0 || len(mergedDelta.Components) > 0 {
   234  			return nil, errors.New("some additions are not supported by the current fake")
   235  		}
   236  
   237  		// Apply the delta to the saved issue.
   238  		if err := m.Merge(mergedDelta, issue.Issue); err != nil {
   239  			return nil, errors.Annotate(err, "failed to merge for issue %q", name).Err()
   240  		}
   241  
   242  		// If the status was modified.
   243  		if mergedDelta.Status != nil {
   244  			now := clock.Now(ctx)
   245  			issue.Issue.StatusModifyTime = timestamppb.New(now)
   246  
   247  			if _, ok := ClosedStatuses[mergedDelta.Status.Status]; ok {
   248  				if !issue.Issue.CloseTime.IsValid() {
   249  					// Close time is zero or unset. Set it.
   250  					issue.Issue.CloseTime = timestamppb.New(now)
   251  				}
   252  			} else {
   253  				issue.Issue.CloseTime = nil
   254  			}
   255  		}
   256  
   257  		// Currently only some amendments are created. Support for other
   258  		// amendments can be added if needed.
   259  		amendments := f.createAmendments(filteredDelta.Labels, delta.LabelsRemove, filteredDelta.FieldValues)
   260  		issue.Comments = append(issue.Comments, &mpb.Comment{
   261  			Name:       fmt.Sprintf("%s/comment/%v", name, len(issue.Comments)),
   262  			State:      mpb.IssueContentState_ACTIVE,
   263  			Type:       mpb.Comment_DESCRIPTION,
   264  			Content:    in.CommentContent,
   265  			Commenter:  f.user,
   266  			Amendments: amendments,
   267  			CreateTime: timestamppb.New(clock.Now(ctx).Add(2 * time.Minute)),
   268  		})
   269  		if in.NotifyType == mpb.NotifyType_EMAIL {
   270  			issue.NotifyCount++
   271  		}
   272  		// Copy the proto so that if the consumer modifies it, the saved proto
   273  		// is not changed.
   274  		updatedIssues = append(updatedIssues, CopyIssue(issue.Issue))
   275  	}
   276  	result := &mpb.ModifyIssuesResponse{
   277  		Issues: updatedIssues,
   278  	}
   279  	return result, nil
   280  }
   281  
   282  func (f *fakeIssuesClient) createAmendments(labelUpdate []*mpb.Issue_LabelValue, labelDeletions []string, fieldUpdates []*mpb.FieldValue) []*mpb.Comment_Amendment {
   283  	var amendments []string
   284  	for _, l := range labelUpdate {
   285  		amendments = append(amendments, l.Label)
   286  	}
   287  	for _, l := range labelDeletions {
   288  		amendments = append(amendments, "-"+l)
   289  	}
   290  	for _, fv := range fieldUpdates {
   291  		if fv.Field == f.store.PriorityFieldName {
   292  			amendments = append(amendments, "Pri-"+fv.Value)
   293  		}
   294  		// Other field updates are currently not fully supported by the fake.
   295  	}
   296  
   297  	if len(amendments) > 0 {
   298  		return []*mpb.Comment_Amendment{
   299  			{
   300  				FieldName:       "Labels",
   301  				NewOrDeltaValue: strings.Join(amendments, " "),
   302  			},
   303  		}
   304  	}
   305  	return nil
   306  }
   307  
   308  // mergeFieldValues applies the updates in update to the existing field values
   309  // and return the result.
   310  func mergeFieldValues(existing []*mpb.FieldValue, update []*mpb.FieldValue) []*mpb.FieldValue {
   311  	merge := make(map[string]*mpb.FieldValue)
   312  	for _, fv := range existing {
   313  		merge[fv.Field] = fv
   314  	}
   315  	for _, fv := range update {
   316  		merge[fv.Field] = fv
   317  	}
   318  	var result []*mpb.FieldValue
   319  	for _, v := range merge {
   320  		result = append(result, v)
   321  	}
   322  	// Ensure the result of merging is predictable, as the order we iterate
   323  	// over maps is not guaranteed.
   324  	SortFieldValues(result)
   325  	return result
   326  }
   327  
   328  // SortFieldValues sorts the given labels in alphabetical order.
   329  func SortFieldValues(input []*mpb.FieldValue) {
   330  	sort.Slice(input, func(i, j int) bool {
   331  		return input[i].Field < input[j].Field
   332  	})
   333  }
   334  
   335  // mergeLabels applies the updates in update to the existing labels
   336  // and return the result.
   337  func mergeLabels(existing []*mpb.Issue_LabelValue, update []*mpb.Issue_LabelValue) []*mpb.Issue_LabelValue {
   338  	merge := make(map[string]*mpb.Issue_LabelValue)
   339  	for _, l := range existing {
   340  		merge[l.Label] = l
   341  	}
   342  	for _, l := range update {
   343  		merge[l.Label] = l
   344  	}
   345  	var result []*mpb.Issue_LabelValue
   346  	for _, v := range merge {
   347  		result = append(result, v)
   348  	}
   349  	// Ensure the result of merging is predictable, as the order we iterate
   350  	// over maps is not guaranteed.
   351  	SortLabels(result)
   352  	return result
   353  }
   354  
   355  func mergeLabelDeletions(existing []*mpb.Issue_LabelValue, deletes []string) []*mpb.Issue_LabelValue {
   356  	merge := make(map[string]*mpb.Issue_LabelValue)
   357  	for _, l := range existing {
   358  		merge[l.Label] = l
   359  	}
   360  	for _, l := range deletes {
   361  		delete(merge, l)
   362  	}
   363  	var result []*mpb.Issue_LabelValue
   364  	for _, v := range merge {
   365  		result = append(result, v)
   366  	}
   367  	// Ensure the result of merging is predictable, as the order we iterate
   368  	// over maps is not guaranteed.
   369  	SortLabels(result)
   370  	return result
   371  }
   372  
   373  // SortLabels sorts the given labels in alphabetical order.
   374  func SortLabels(input []*mpb.Issue_LabelValue) {
   375  	sort.Slice(input, func(i, j int) bool {
   376  		return input[i].Label < input[j].Label
   377  	})
   378  }
   379  
   380  func (f *fakeIssuesClient) ModifyIssueApprovalValues(ctx context.Context, in *mpb.ModifyIssueApprovalValuesRequest, opts ...grpc.CallOption) (*mpb.ModifyIssueApprovalValuesResponse, error) {
   381  	return nil, errors.New("not implemented")
   382  }
   383  
   384  func (f *fakeIssuesClient) ListApprovalValues(ctx context.Context, in *mpb.ListApprovalValuesRequest, opts ...grpc.CallOption) (*mpb.ListApprovalValuesResponse, error) {
   385  	return nil, errors.New("not implemented")
   386  }
   387  
   388  func (f *fakeIssuesClient) ModifyCommentState(ctx context.Context, in *mpb.ModifyCommentStateRequest, opts ...grpc.CallOption) (*mpb.ModifyCommentStateResponse, error) {
   389  	return nil, errors.New("not implemented")
   390  }
   391  
   392  func (f *fakeIssuesClient) MakeIssueFromTemplate(ctx context.Context, in *mpb.MakeIssueFromTemplateRequest, opts ...grpc.CallOption) (*mpb.Issue, error) {
   393  	return nil, errors.New("not implemented")
   394  }
   395  
   396  func (f *fakeIssuesClient) MakeIssue(ctx context.Context, in *mpb.MakeIssueRequest, opts ...grpc.CallOption) (*mpb.Issue, error) {
   397  	if !projectsRE.MatchString(in.Parent) {
   398  		return nil, errors.New("parent project must be specified and match the form 'projects/{project_id}'")
   399  	}
   400  
   401  	now := clock.Now(ctx)
   402  
   403  	// Copy the proto so that if the request proto is later modified, the save proto is not changed.
   404  	saved := CopyIssue(in.Issue)
   405  	saved.Name = fmt.Sprintf("%s/issues/%v", in.Parent, f.store.NextID)
   406  	saved.Reporter = f.user
   407  	saved.StatusModifyTime = timestamppb.New(now)
   408  
   409  	// Ensure data is stored in sorted order, to ensure comparisons in test code are stable.
   410  	SortFieldValues(saved.FieldValues)
   411  	SortLabels(saved.Labels)
   412  
   413  	f.store.NextID++
   414  	issue := &IssueData{
   415  		Issue: saved,
   416  		Comments: []*mpb.Comment{
   417  			{
   418  				Name:      fmt.Sprintf("%s/comment/1", saved.Name),
   419  				State:     mpb.IssueContentState_ACTIVE,
   420  				Type:      mpb.Comment_DESCRIPTION,
   421  				Content:   in.Description,
   422  				Commenter: in.Issue.Reporter,
   423  			},
   424  		},
   425  		NotifyCount: 0,
   426  	}
   427  	if in.NotifyType == mpb.NotifyType_EMAIL {
   428  		issue.NotifyCount = 1
   429  	}
   430  
   431  	f.store.Issues = append(f.store.Issues, issue)
   432  
   433  	// Copy the proto so that if the consumer modifies it, the saved proto is not changed.
   434  	return CopyIssue(saved), nil
   435  }
   436  
   437  type fakeProjectsClient struct {
   438  	store *FakeIssuesStore
   439  }
   440  
   441  // Creates a new FieldDef (custom field).
   442  func (f *fakeProjectsClient) CreateFieldDef(ctx context.Context, in *mpb.CreateFieldDefRequest, opts ...grpc.CallOption) (*mpb.FieldDef, error) {
   443  	return nil, errors.New("not implemented")
   444  }
   445  
   446  // Gets a ComponentDef given the reference.
   447  func (f *fakeProjectsClient) GetComponentDef(ctx context.Context, in *mpb.GetComponentDefRequest, opts ...grpc.CallOption) (*mpb.ComponentDef, error) {
   448  	for _, c := range f.store.ComponentNames {
   449  		if c == in.Name {
   450  			return &mpb.ComponentDef{
   451  				Name:  c,
   452  				State: mpb.ComponentDef_ACTIVE,
   453  			}, nil
   454  		}
   455  	}
   456  	return nil, appstatus.GRPCifyAndLog(ctx, appstatus.Error(codes.NotFound, "not found"))
   457  }
   458  
   459  // Creates a new ComponentDef.
   460  func (f *fakeProjectsClient) CreateComponentDef(ctx context.Context, in *mpb.CreateComponentDefRequest, opts ...grpc.CallOption) (*mpb.ComponentDef, error) {
   461  	return nil, errors.New("not implemented")
   462  }
   463  
   464  // Deletes a ComponentDef.
   465  func (f *fakeProjectsClient) DeleteComponentDef(ctx context.Context, in *mpb.DeleteComponentDefRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
   466  	return nil, errors.New("not implemented")
   467  }
   468  
   469  // Returns all templates for specified project.
   470  func (f *fakeProjectsClient) ListIssueTemplates(ctx context.Context, in *mpb.ListIssueTemplatesRequest, opts ...grpc.CallOption) (*mpb.ListIssueTemplatesResponse, error) {
   471  	return nil, errors.New("not implemented")
   472  }
   473  
   474  // Returns all field defs for specified project.
   475  func (f *fakeProjectsClient) ListComponentDefs(ctx context.Context, in *mpb.ListComponentDefsRequest, opts ...grpc.CallOption) (*mpb.ListComponentDefsResponse, error) {
   476  	return nil, errors.New("not implemented")
   477  }
   478  
   479  // Returns all projects hosted on Monorail.
   480  func (f *fakeProjectsClient) ListProjects(ctx context.Context, in *mpb.ListProjectsRequest, opts ...grpc.CallOption) (*mpb.ListProjectsResponse, error) {
   481  	return nil, errors.New("not implemented")
   482  }