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 }