feat(issues): parse issue references and store issue to merge request links
- commit
- 8a27537793aa340fa6240dbfcf4962edf1525671
- author
- brice · · 2026-10-20 15:02 UTC
- parent
- c899179
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
18 files · +920 -58
M gen/ermite/storage/issue.pb.go +146 -31
| @@ -97,9 +97,10 @@ type Issue struct { | |||
| 97 | 97 | CommentSeq int64 `protobuf:"varint,8,opt,name=comment_seq,json=commentSeq,proto3" json:"comment_seq,omitempty"` | |
| 98 | 98 | // True for an issue approved from the public form: its text comes from | |
| 99 | 99 | // an anonymous visitor and is rendered as untrusted. | |
| 100 | - | External bool `protobuf:"varint,9,opt,name=external,proto3" json:"external,omitempty"` | |
| 101 | - | unknownFields protoimpl.UnknownFields | |
| 102 | - | sizeCache protoimpl.SizeCache | |
| 100 | + | External bool `protobuf:"varint,9,opt,name=external,proto3" json:"external,omitempty"` | |
| 101 | + | ClosedByMergeRequest int64 `protobuf:"varint,10,opt,name=closed_by_merge_request,json=closedByMergeRequest,proto3" json:"closed_by_merge_request,omitempty"` | |
| 102 | + | unknownFields protoimpl.UnknownFields | |
| 103 | + | sizeCache protoimpl.SizeCache | |
| 103 | 104 | } | |
| 104 | 105 | ||
| 105 | 106 | func (x *Issue) Reset() { | |
| @@ -195,6 +196,111 @@ func (x *Issue) GetExternal() bool { | |||
| 195 | 196 | return false | |
| 196 | 197 | } | |
| 197 | 198 | ||
| 199 | + | func (x *Issue) GetClosedByMergeRequest() int64 { | |
| 200 | + | if x != nil { | |
| 201 | + | return x.ClosedByMergeRequest | |
| 202 | + | } | |
| 203 | + | return 0 | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // IssueLink is stored under isref/<repo>/<issue:010d>/<mr:010d>. | |
| 207 | + | type IssueLink struct { | |
| 208 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 209 | + | Closes bool `protobuf:"varint,1,opt,name=closes,proto3" json:"closes,omitempty"` | |
| 210 | + | unknownFields protoimpl.UnknownFields | |
| 211 | + | sizeCache protoimpl.SizeCache | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func (x *IssueLink) Reset() { | |
| 215 | + | *x = IssueLink{} | |
| 216 | + | mi := &file_ermite_storage_issue_proto_msgTypes[1] | |
| 217 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 218 | + | ms.StoreMessageInfo(mi) | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func (x *IssueLink) String() string { | |
| 222 | + | return protoimpl.X.MessageStringOf(x) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func (*IssueLink) ProtoMessage() {} | |
| 226 | + | ||
| 227 | + | func (x *IssueLink) ProtoReflect() protoreflect.Message { | |
| 228 | + | mi := &file_ermite_storage_issue_proto_msgTypes[1] | |
| 229 | + | if x != nil { | |
| 230 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 231 | + | if ms.LoadMessageInfo() == nil { | |
| 232 | + | ms.StoreMessageInfo(mi) | |
| 233 | + | } | |
| 234 | + | return ms | |
| 235 | + | } | |
| 236 | + | return mi.MessageOf(x) | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // Deprecated: Use IssueLink.ProtoReflect.Descriptor instead. | |
| 240 | + | func (*IssueLink) Descriptor() ([]byte, []int) { | |
| 241 | + | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{1} | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | func (x *IssueLink) GetCloses() bool { | |
| 245 | + | if x != nil { | |
| 246 | + | return x.Closes | |
| 247 | + | } | |
| 248 | + | return false | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | // MergeRequestReferences is stored under mrref/<repo>/<mr:010d>. | |
| 252 | + | type MergeRequestReferences struct { | |
| 253 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 254 | + | Issues []int64 `protobuf:"varint,1,rep,packed,name=issues,proto3" json:"issues,omitempty"` | |
| 255 | + | Closes []int64 `protobuf:"varint,2,rep,packed,name=closes,proto3" json:"closes,omitempty"` | |
| 256 | + | unknownFields protoimpl.UnknownFields | |
| 257 | + | sizeCache protoimpl.SizeCache | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func (x *MergeRequestReferences) Reset() { | |
| 261 | + | *x = MergeRequestReferences{} | |
| 262 | + | mi := &file_ermite_storage_issue_proto_msgTypes[2] | |
| 263 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 264 | + | ms.StoreMessageInfo(mi) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | func (x *MergeRequestReferences) String() string { | |
| 268 | + | return protoimpl.X.MessageStringOf(x) | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func (*MergeRequestReferences) ProtoMessage() {} | |
| 272 | + | ||
| 273 | + | func (x *MergeRequestReferences) ProtoReflect() protoreflect.Message { | |
| 274 | + | mi := &file_ermite_storage_issue_proto_msgTypes[2] | |
| 275 | + | if x != nil { | |
| 276 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 277 | + | if ms.LoadMessageInfo() == nil { | |
| 278 | + | ms.StoreMessageInfo(mi) | |
| 279 | + | } | |
| 280 | + | return ms | |
| 281 | + | } | |
| 282 | + | return mi.MessageOf(x) | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | // Deprecated: Use MergeRequestReferences.ProtoReflect.Descriptor instead. | |
| 286 | + | func (*MergeRequestReferences) Descriptor() ([]byte, []int) { | |
| 287 | + | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{2} | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | func (x *MergeRequestReferences) GetIssues() []int64 { | |
| 291 | + | if x != nil { | |
| 292 | + | return x.Issues | |
| 293 | + | } | |
| 294 | + | return nil | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func (x *MergeRequestReferences) GetCloses() []int64 { | |
| 298 | + | if x != nil { | |
| 299 | + | return x.Closes | |
| 300 | + | } | |
| 301 | + | return nil | |
| 302 | + | } | |
| 303 | + | ||
| 198 | 304 | // IssueComment is stored under the key | |
| 199 | 305 | // "isc/<repo>/<number:010d>/<id:010d>". | |
| 200 | 306 | type IssueComment struct { | |
| @@ -209,7 +315,7 @@ type IssueComment struct { | |||
| 209 | 315 | ||
| 210 | 316 | func (x *IssueComment) Reset() { | |
| 211 | 317 | *x = IssueComment{} | |
| 212 | - | mi := &file_ermite_storage_issue_proto_msgTypes[1] | |
| 318 | + | mi := &file_ermite_storage_issue_proto_msgTypes[3] | |
| 213 | 319 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 214 | 320 | ms.StoreMessageInfo(mi) | |
| 215 | 321 | } | |
| @@ -221,7 +327,7 @@ func (x *IssueComment) String() string { | |||
| 221 | 327 | func (*IssueComment) ProtoMessage() {} | |
| 222 | 328 | ||
| 223 | 329 | func (x *IssueComment) ProtoReflect() protoreflect.Message { | |
| 224 | - | mi := &file_ermite_storage_issue_proto_msgTypes[1] | |
| 330 | + | mi := &file_ermite_storage_issue_proto_msgTypes[3] | |
| 225 | 331 | if x != nil { | |
| 226 | 332 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 227 | 333 | if ms.LoadMessageInfo() == nil { | |
| @@ -234,7 +340,7 @@ func (x *IssueComment) ProtoReflect() protoreflect.Message { | |||
| 234 | 340 | ||
| 235 | 341 | // Deprecated: Use IssueComment.ProtoReflect.Descriptor instead. | |
| 236 | 342 | func (*IssueComment) Descriptor() ([]byte, []int) { | |
| 237 | - | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{1} | |
| 343 | + | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{3} | |
| 238 | 344 | } | |
| 239 | 345 | ||
| 240 | 346 | func (x *IssueComment) GetId() int64 { | |
| @@ -283,7 +389,7 @@ type PendingIssue struct { | |||
| 283 | 389 | ||
| 284 | 390 | func (x *PendingIssue) Reset() { | |
| 285 | 391 | *x = PendingIssue{} | |
| 286 | - | mi := &file_ermite_storage_issue_proto_msgTypes[2] | |
| 392 | + | mi := &file_ermite_storage_issue_proto_msgTypes[4] | |
| 287 | 393 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 288 | 394 | ms.StoreMessageInfo(mi) | |
| 289 | 395 | } | |
| @@ -295,7 +401,7 @@ func (x *PendingIssue) String() string { | |||
| 295 | 401 | func (*PendingIssue) ProtoMessage() {} | |
| 296 | 402 | ||
| 297 | 403 | func (x *PendingIssue) ProtoReflect() protoreflect.Message { | |
| 298 | - | mi := &file_ermite_storage_issue_proto_msgTypes[2] | |
| 404 | + | mi := &file_ermite_storage_issue_proto_msgTypes[4] | |
| 299 | 405 | if x != nil { | |
| 300 | 406 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 301 | 407 | if ms.LoadMessageInfo() == nil { | |
| @@ -308,7 +414,7 @@ func (x *PendingIssue) ProtoReflect() protoreflect.Message { | |||
| 308 | 414 | ||
| 309 | 415 | // Deprecated: Use PendingIssue.ProtoReflect.Descriptor instead. | |
| 310 | 416 | func (*PendingIssue) Descriptor() ([]byte, []int) { | |
| 311 | - | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{2} | |
| 417 | + | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{4} | |
| 312 | 418 | } | |
| 313 | 419 | ||
| 314 | 420 | func (x *PendingIssue) GetTitle() string { | |
| @@ -356,7 +462,7 @@ type IssueBan struct { | |||
| 356 | 462 | ||
| 357 | 463 | func (x *IssueBan) Reset() { | |
| 358 | 464 | *x = IssueBan{} | |
| 359 | - | mi := &file_ermite_storage_issue_proto_msgTypes[3] | |
| 465 | + | mi := &file_ermite_storage_issue_proto_msgTypes[5] | |
| 360 | 466 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 361 | 467 | ms.StoreMessageInfo(mi) | |
| 362 | 468 | } | |
| @@ -368,7 +474,7 @@ func (x *IssueBan) String() string { | |||
| 368 | 474 | func (*IssueBan) ProtoMessage() {} | |
| 369 | 475 | ||
| 370 | 476 | func (x *IssueBan) ProtoReflect() protoreflect.Message { | |
| 371 | - | mi := &file_ermite_storage_issue_proto_msgTypes[3] | |
| 477 | + | mi := &file_ermite_storage_issue_proto_msgTypes[5] | |
| 372 | 478 | if x != nil { | |
| 373 | 479 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 374 | 480 | if ms.LoadMessageInfo() == nil { | |
| @@ -381,7 +487,7 @@ func (x *IssueBan) ProtoReflect() protoreflect.Message { | |||
| 381 | 487 | ||
| 382 | 488 | // Deprecated: Use IssueBan.ProtoReflect.Descriptor instead. | |
| 383 | 489 | func (*IssueBan) Descriptor() ([]byte, []int) { | |
| 384 | - | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{3} | |
| 490 | + | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{5} | |
| 385 | 491 | } | |
| 386 | 492 | ||
| 387 | 493 | func (x *IssueBan) GetUntil() *timestamppb.Timestamp { | |
| @@ -401,7 +507,7 @@ type IssueKey struct { | |||
| 401 | 507 | ||
| 402 | 508 | func (x *IssueKey) Reset() { | |
| 403 | 509 | *x = IssueKey{} | |
| 404 | - | mi := &file_ermite_storage_issue_proto_msgTypes[4] | |
| 510 | + | mi := &file_ermite_storage_issue_proto_msgTypes[6] | |
| 405 | 511 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 406 | 512 | ms.StoreMessageInfo(mi) | |
| 407 | 513 | } | |
| @@ -413,7 +519,7 @@ func (x *IssueKey) String() string { | |||
| 413 | 519 | func (*IssueKey) ProtoMessage() {} | |
| 414 | 520 | ||
| 415 | 521 | func (x *IssueKey) ProtoReflect() protoreflect.Message { | |
| 416 | - | mi := &file_ermite_storage_issue_proto_msgTypes[4] | |
| 522 | + | mi := &file_ermite_storage_issue_proto_msgTypes[6] | |
| 417 | 523 | if x != nil { | |
| 418 | 524 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 419 | 525 | if ms.LoadMessageInfo() == nil { | |
| @@ -426,7 +532,7 @@ func (x *IssueKey) ProtoReflect() protoreflect.Message { | |||
| 426 | 532 | ||
| 427 | 533 | // Deprecated: Use IssueKey.ProtoReflect.Descriptor instead. | |
| 428 | 534 | func (*IssueKey) Descriptor() ([]byte, []int) { | |
| 429 | - | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{4} | |
| 535 | + | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{6} | |
| 430 | 536 | } | |
| 431 | 537 | ||
| 432 | 538 | func (x *IssueKey) GetKey() []byte { | |
| @@ -440,7 +546,7 @@ var File_ermite_storage_issue_proto protoreflect.FileDescriptor | |||
| 440 | 546 | ||
| 441 | 547 | const file_ermite_storage_issue_proto_rawDesc = "" + | |
| 442 | 548 | "\n" + | |
| 443 | - | "\x1aermite/storage/issue.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xc6\x02\n" + | |
| 549 | + | "\x1aermite/storage/issue.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xfd\x02\n" + | |
| 444 | 550 | "\x05Issue\x12\x16\n" + | |
| 445 | 551 | "\x06number\x18\x01 \x01(\x03R\x06number\x12\x14\n" + | |
| 446 | 552 | "\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" + | |
| @@ -453,7 +559,14 @@ const file_ermite_storage_issue_proto_rawDesc = "" + | |||
| 453 | 559 | "updated_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12\x1f\n" + | |
| 454 | 560 | "\vcomment_seq\x18\b \x01(\x03R\n" + | |
| 455 | 561 | "commentSeq\x12\x1a\n" + | |
| 456 | - | "\bexternal\x18\t \x01(\bR\bexternal\"\x85\x01\n" + | |
| 562 | + | "\bexternal\x18\t \x01(\bR\bexternal\x125\n" + | |
| 563 | + | "\x17closed_by_merge_request\x18\n" + | |
| 564 | + | " \x01(\x03R\x14closedByMergeRequest\"#\n" + | |
| 565 | + | "\tIssueLink\x12\x16\n" + | |
| 566 | + | "\x06closes\x18\x01 \x01(\bR\x06closes\"H\n" + | |
| 567 | + | "\x16MergeRequestReferences\x12\x16\n" + | |
| 568 | + | "\x06issues\x18\x01 \x03(\x03R\x06issues\x12\x16\n" + | |
| 569 | + | "\x06closes\x18\x02 \x03(\x03R\x06closes\"\x85\x01\n" + | |
| 457 | 570 | "\fIssueComment\x12\x0e\n" + | |
| 458 | 571 | "\x02id\x18\x01 \x01(\x03R\x02id\x12\x16\n" + | |
| 459 | 572 | "\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" + | |
| @@ -491,23 +604,25 @@ func file_ermite_storage_issue_proto_rawDescGZIP() []byte { | |||
| 491 | 604 | } | |
| 492 | 605 | ||
| 493 | 606 | var file_ermite_storage_issue_proto_enumTypes = make([]protoimpl.EnumInfo, 1) | |
| 494 | - | var file_ermite_storage_issue_proto_msgTypes = make([]protoimpl.MessageInfo, 5) | |
| 607 | + | var file_ermite_storage_issue_proto_msgTypes = make([]protoimpl.MessageInfo, 7) | |
| 495 | 608 | var file_ermite_storage_issue_proto_goTypes = []any{ | |
| 496 | - | (IssueState)(0), // 0: ermite.storage.IssueState | |
| 497 | - | (*Issue)(nil), // 1: ermite.storage.Issue | |
| 498 | - | (*IssueComment)(nil), // 2: ermite.storage.IssueComment | |
| 499 | - | (*PendingIssue)(nil), // 3: ermite.storage.PendingIssue | |
| 500 | - | (*IssueBan)(nil), // 4: ermite.storage.IssueBan | |
| 501 | - | (*IssueKey)(nil), // 5: ermite.storage.IssueKey | |
| 502 | - | (*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp | |
| 609 | + | (IssueState)(0), // 0: ermite.storage.IssueState | |
| 610 | + | (*Issue)(nil), // 1: ermite.storage.Issue | |
| 611 | + | (*IssueLink)(nil), // 2: ermite.storage.IssueLink | |
| 612 | + | (*MergeRequestReferences)(nil), // 3: ermite.storage.MergeRequestReferences | |
| 613 | + | (*IssueComment)(nil), // 4: ermite.storage.IssueComment | |
| 614 | + | (*PendingIssue)(nil), // 5: ermite.storage.PendingIssue | |
| 615 | + | (*IssueBan)(nil), // 6: ermite.storage.IssueBan | |
| 616 | + | (*IssueKey)(nil), // 7: ermite.storage.IssueKey | |
| 617 | + | (*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp | |
| 503 | 618 | } | |
| 504 | 619 | var file_ermite_storage_issue_proto_depIdxs = []int32{ | |
| 505 | 620 | 0, // 0: ermite.storage.Issue.state:type_name -> ermite.storage.IssueState | |
| 506 | - | 6, // 1: ermite.storage.Issue.created_at:type_name -> google.protobuf.Timestamp | |
| 507 | - | 6, // 2: ermite.storage.Issue.updated_at:type_name -> google.protobuf.Timestamp | |
| 508 | - | 6, // 3: ermite.storage.IssueComment.created_at:type_name -> google.protobuf.Timestamp | |
| 509 | - | 6, // 4: ermite.storage.PendingIssue.created_at:type_name -> google.protobuf.Timestamp | |
| 510 | - | 6, // 5: ermite.storage.IssueBan.until:type_name -> google.protobuf.Timestamp | |
| 621 | + | 8, // 1: ermite.storage.Issue.created_at:type_name -> google.protobuf.Timestamp | |
| 622 | + | 8, // 2: ermite.storage.Issue.updated_at:type_name -> google.protobuf.Timestamp | |
| 623 | + | 8, // 3: ermite.storage.IssueComment.created_at:type_name -> google.protobuf.Timestamp | |
| 624 | + | 8, // 4: ermite.storage.PendingIssue.created_at:type_name -> google.protobuf.Timestamp | |
| 625 | + | 8, // 5: ermite.storage.IssueBan.until:type_name -> google.protobuf.Timestamp | |
| 511 | 626 | 6, // [6:6] is the sub-list for method output_type | |
| 512 | 627 | 6, // [6:6] is the sub-list for method input_type | |
| 513 | 628 | 6, // [6:6] is the sub-list for extension type_name | |
| @@ -526,7 +641,7 @@ func file_ermite_storage_issue_proto_init() { | |||
| 526 | 641 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), | |
| 527 | 642 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_issue_proto_rawDesc), len(file_ermite_storage_issue_proto_rawDesc)), | |
| 528 | 643 | NumEnums: 1, | |
| 529 | - | NumMessages: 5, | |
| 644 | + | NumMessages: 7, | |
| 530 | 645 | NumExtensions: 0, | |
| 531 | 646 | NumServices: 0, | |
| 532 | 647 | }, | |
| @@ -97,9 +97,10 @@ type Issue struct { | |||||
| 97 | CommentSeq int64 `protobuf:"varint,8,opt,name=comment_seq,json=commentSeq,proto3" json:"comment_seq,omitempty"` | 97 | CommentSeq int64 `protobuf:"varint,8,opt,name=comment_seq,json=commentSeq,proto3" json:"comment_seq,omitempty"` | ||
| 98 | // True for an issue approved from the public form: its text comes from | 98 | // True for an issue approved from the public form: its text comes from | ||
| 99 | // an anonymous visitor and is rendered as untrusted. | 99 | // an anonymous visitor and is rendered as untrusted. | ||
| 100 | - | External bool `protobuf:"varint,9,opt,name=external,proto3" json:"external,omitempty"` | 100 | + | External bool `protobuf:"varint,9,opt,name=external,proto3" json:"external,omitempty"` |
| 101 | - | unknownFields protoimpl.UnknownFields | 101 | + | ClosedByMergeRequest int64 `protobuf:"varint,10,opt,name=closed_by_merge_request,json=closedByMergeRequest,proto3" json:"closed_by_merge_request,omitempty"` |
| 102 | - | sizeCache protoimpl.SizeCache | 102 | + | unknownFields protoimpl.UnknownFields |
| 103 | + | sizeCache protoimpl.SizeCache | |||
| 103 | } | 104 | } | ||
| 104 | 105 | ||||
| 105 | func (x *Issue) Reset() { | 106 | func (x *Issue) Reset() { | ||
| @@ -195,6 +196,111 @@ func (x *Issue) GetExternal() bool { | |||||
| 195 | return false | 196 | return false | ||
| 196 | } | 197 | } | ||
| 197 | 198 | ||||
| 199 | + | func (x *Issue) GetClosedByMergeRequest() int64 { | |||
| 200 | + | if x != nil { | |||
| 201 | + | return x.ClosedByMergeRequest | |||
| 202 | + | } | |||
| 203 | + | return 0 | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // IssueLink is stored under isref/<repo>/<issue:010d>/<mr:010d>. | |||
| 207 | + | type IssueLink struct { | |||
| 208 | + | state protoimpl.MessageState `protogen:"open.v1"` | |||
| 209 | + | Closes bool `protobuf:"varint,1,opt,name=closes,proto3" json:"closes,omitempty"` | |||
| 210 | + | unknownFields protoimpl.UnknownFields | |||
| 211 | + | sizeCache protoimpl.SizeCache | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func (x *IssueLink) Reset() { | |||
| 215 | + | *x = IssueLink{} | |||
| 216 | + | mi := &file_ermite_storage_issue_proto_msgTypes[1] | |||
| 217 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 218 | + | ms.StoreMessageInfo(mi) | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | func (x *IssueLink) String() string { | |||
| 222 | + | return protoimpl.X.MessageStringOf(x) | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func (*IssueLink) ProtoMessage() {} | |||
| 226 | + | ||||
| 227 | + | func (x *IssueLink) ProtoReflect() protoreflect.Message { | |||
| 228 | + | mi := &file_ermite_storage_issue_proto_msgTypes[1] | |||
| 229 | + | if x != nil { | |||
| 230 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 231 | + | if ms.LoadMessageInfo() == nil { | |||
| 232 | + | ms.StoreMessageInfo(mi) | |||
| 233 | + | } | |||
| 234 | + | return ms | |||
| 235 | + | } | |||
| 236 | + | return mi.MessageOf(x) | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | // Deprecated: Use IssueLink.ProtoReflect.Descriptor instead. | |||
| 240 | + | func (*IssueLink) Descriptor() ([]byte, []int) { | |||
| 241 | + | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{1} | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | func (x *IssueLink) GetCloses() bool { | |||
| 245 | + | if x != nil { | |||
| 246 | + | return x.Closes | |||
| 247 | + | } | |||
| 248 | + | return false | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | // MergeRequestReferences is stored under mrref/<repo>/<mr:010d>. | |||
| 252 | + | type MergeRequestReferences struct { | |||
| 253 | + | state protoimpl.MessageState `protogen:"open.v1"` | |||
| 254 | + | Issues []int64 `protobuf:"varint,1,rep,packed,name=issues,proto3" json:"issues,omitempty"` | |||
| 255 | + | Closes []int64 `protobuf:"varint,2,rep,packed,name=closes,proto3" json:"closes,omitempty"` | |||
| 256 | + | unknownFields protoimpl.UnknownFields | |||
| 257 | + | sizeCache protoimpl.SizeCache | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | func (x *MergeRequestReferences) Reset() { | |||
| 261 | + | *x = MergeRequestReferences{} | |||
| 262 | + | mi := &file_ermite_storage_issue_proto_msgTypes[2] | |||
| 263 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 264 | + | ms.StoreMessageInfo(mi) | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | func (x *MergeRequestReferences) String() string { | |||
| 268 | + | return protoimpl.X.MessageStringOf(x) | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func (*MergeRequestReferences) ProtoMessage() {} | |||
| 272 | + | ||||
| 273 | + | func (x *MergeRequestReferences) ProtoReflect() protoreflect.Message { | |||
| 274 | + | mi := &file_ermite_storage_issue_proto_msgTypes[2] | |||
| 275 | + | if x != nil { | |||
| 276 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 277 | + | if ms.LoadMessageInfo() == nil { | |||
| 278 | + | ms.StoreMessageInfo(mi) | |||
| 279 | + | } | |||
| 280 | + | return ms | |||
| 281 | + | } | |||
| 282 | + | return mi.MessageOf(x) | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | // Deprecated: Use MergeRequestReferences.ProtoReflect.Descriptor instead. | |||
| 286 | + | func (*MergeRequestReferences) Descriptor() ([]byte, []int) { | |||
| 287 | + | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{2} | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | func (x *MergeRequestReferences) GetIssues() []int64 { | |||
| 291 | + | if x != nil { | |||
| 292 | + | return x.Issues | |||
| 293 | + | } | |||
| 294 | + | return nil | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | func (x *MergeRequestReferences) GetCloses() []int64 { | |||
| 298 | + | if x != nil { | |||
| 299 | + | return x.Closes | |||
| 300 | + | } | |||
| 301 | + | return nil | |||
| 302 | + | } | |||
| 303 | + | ||||
| 198 | // IssueComment is stored under the key | 304 | // IssueComment is stored under the key | ||
| 199 | // "isc/<repo>/<number:010d>/<id:010d>". | 305 | // "isc/<repo>/<number:010d>/<id:010d>". | ||
| 200 | type IssueComment struct { | 306 | type IssueComment struct { | ||
| @@ -209,7 +315,7 @@ type IssueComment struct { | |||||
| 209 | 315 | ||||
| 210 | func (x *IssueComment) Reset() { | 316 | func (x *IssueComment) Reset() { | ||
| 211 | *x = IssueComment{} | 317 | *x = IssueComment{} | ||
| 212 | - | mi := &file_ermite_storage_issue_proto_msgTypes[1] | 318 | + | mi := &file_ermite_storage_issue_proto_msgTypes[3] |
| 213 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | 319 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 214 | ms.StoreMessageInfo(mi) | 320 | ms.StoreMessageInfo(mi) | ||
| 215 | } | 321 | } | ||
| @@ -221,7 +327,7 @@ func (x *IssueComment) String() string { | |||||
| 221 | func (*IssueComment) ProtoMessage() {} | 327 | func (*IssueComment) ProtoMessage() {} | ||
| 222 | 328 | ||||
| 223 | func (x *IssueComment) ProtoReflect() protoreflect.Message { | 329 | func (x *IssueComment) ProtoReflect() protoreflect.Message { | ||
| 224 | - | mi := &file_ermite_storage_issue_proto_msgTypes[1] | 330 | + | mi := &file_ermite_storage_issue_proto_msgTypes[3] |
| 225 | if x != nil { | 331 | if x != nil { | ||
| 226 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | 332 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 227 | if ms.LoadMessageInfo() == nil { | 333 | if ms.LoadMessageInfo() == nil { | ||
| @@ -234,7 +340,7 @@ func (x *IssueComment) ProtoReflect() protoreflect.Message { | |||||
| 234 | 340 | ||||
| 235 | // Deprecated: Use IssueComment.ProtoReflect.Descriptor instead. | 341 | // Deprecated: Use IssueComment.ProtoReflect.Descriptor instead. | ||
| 236 | func (*IssueComment) Descriptor() ([]byte, []int) { | 342 | func (*IssueComment) Descriptor() ([]byte, []int) { | ||
| 237 | - | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{1} | 343 | + | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{3} |
| 238 | } | 344 | } | ||
| 239 | 345 | ||||
| 240 | func (x *IssueComment) GetId() int64 { | 346 | func (x *IssueComment) GetId() int64 { | ||
| @@ -283,7 +389,7 @@ type PendingIssue struct { | |||||
| 283 | 389 | ||||
| 284 | func (x *PendingIssue) Reset() { | 390 | func (x *PendingIssue) Reset() { | ||
| 285 | *x = PendingIssue{} | 391 | *x = PendingIssue{} | ||
| 286 | - | mi := &file_ermite_storage_issue_proto_msgTypes[2] | 392 | + | mi := &file_ermite_storage_issue_proto_msgTypes[4] |
| 287 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | 393 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 288 | ms.StoreMessageInfo(mi) | 394 | ms.StoreMessageInfo(mi) | ||
| 289 | } | 395 | } | ||
| @@ -295,7 +401,7 @@ func (x *PendingIssue) String() string { | |||||
| 295 | func (*PendingIssue) ProtoMessage() {} | 401 | func (*PendingIssue) ProtoMessage() {} | ||
| 296 | 402 | ||||
| 297 | func (x *PendingIssue) ProtoReflect() protoreflect.Message { | 403 | func (x *PendingIssue) ProtoReflect() protoreflect.Message { | ||
| 298 | - | mi := &file_ermite_storage_issue_proto_msgTypes[2] | 404 | + | mi := &file_ermite_storage_issue_proto_msgTypes[4] |
| 299 | if x != nil { | 405 | if x != nil { | ||
| 300 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | 406 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 301 | if ms.LoadMessageInfo() == nil { | 407 | if ms.LoadMessageInfo() == nil { | ||
| @@ -308,7 +414,7 @@ func (x *PendingIssue) ProtoReflect() protoreflect.Message { | |||||
| 308 | 414 | ||||
| 309 | // Deprecated: Use PendingIssue.ProtoReflect.Descriptor instead. | 415 | // Deprecated: Use PendingIssue.ProtoReflect.Descriptor instead. | ||
| 310 | func (*PendingIssue) Descriptor() ([]byte, []int) { | 416 | func (*PendingIssue) Descriptor() ([]byte, []int) { | ||
| 311 | - | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{2} | 417 | + | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{4} |
| 312 | } | 418 | } | ||
| 313 | 419 | ||||
| 314 | func (x *PendingIssue) GetTitle() string { | 420 | func (x *PendingIssue) GetTitle() string { | ||
| @@ -356,7 +462,7 @@ type IssueBan struct { | |||||
| 356 | 462 | ||||
| 357 | func (x *IssueBan) Reset() { | 463 | func (x *IssueBan) Reset() { | ||
| 358 | *x = IssueBan{} | 464 | *x = IssueBan{} | ||
| 359 | - | mi := &file_ermite_storage_issue_proto_msgTypes[3] | 465 | + | mi := &file_ermite_storage_issue_proto_msgTypes[5] |
| 360 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | 466 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 361 | ms.StoreMessageInfo(mi) | 467 | ms.StoreMessageInfo(mi) | ||
| 362 | } | 468 | } | ||
| @@ -368,7 +474,7 @@ func (x *IssueBan) String() string { | |||||
| 368 | func (*IssueBan) ProtoMessage() {} | 474 | func (*IssueBan) ProtoMessage() {} | ||
| 369 | 475 | ||||
| 370 | func (x *IssueBan) ProtoReflect() protoreflect.Message { | 476 | func (x *IssueBan) ProtoReflect() protoreflect.Message { | ||
| 371 | - | mi := &file_ermite_storage_issue_proto_msgTypes[3] | 477 | + | mi := &file_ermite_storage_issue_proto_msgTypes[5] |
| 372 | if x != nil { | 478 | if x != nil { | ||
| 373 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | 479 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 374 | if ms.LoadMessageInfo() == nil { | 480 | if ms.LoadMessageInfo() == nil { | ||
| @@ -381,7 +487,7 @@ func (x *IssueBan) ProtoReflect() protoreflect.Message { | |||||
| 381 | 487 | ||||
| 382 | // Deprecated: Use IssueBan.ProtoReflect.Descriptor instead. | 488 | // Deprecated: Use IssueBan.ProtoReflect.Descriptor instead. | ||
| 383 | func (*IssueBan) Descriptor() ([]byte, []int) { | 489 | func (*IssueBan) Descriptor() ([]byte, []int) { | ||
| 384 | - | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{3} | 490 | + | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{5} |
| 385 | } | 491 | } | ||
| 386 | 492 | ||||
| 387 | func (x *IssueBan) GetUntil() *timestamppb.Timestamp { | 493 | func (x *IssueBan) GetUntil() *timestamppb.Timestamp { | ||
| @@ -401,7 +507,7 @@ type IssueKey struct { | |||||
| 401 | 507 | ||||
| 402 | func (x *IssueKey) Reset() { | 508 | func (x *IssueKey) Reset() { | ||
| 403 | *x = IssueKey{} | 509 | *x = IssueKey{} | ||
| 404 | - | mi := &file_ermite_storage_issue_proto_msgTypes[4] | 510 | + | mi := &file_ermite_storage_issue_proto_msgTypes[6] |
| 405 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | 511 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 406 | ms.StoreMessageInfo(mi) | 512 | ms.StoreMessageInfo(mi) | ||
| 407 | } | 513 | } | ||
| @@ -413,7 +519,7 @@ func (x *IssueKey) String() string { | |||||
| 413 | func (*IssueKey) ProtoMessage() {} | 519 | func (*IssueKey) ProtoMessage() {} | ||
| 414 | 520 | ||||
| 415 | func (x *IssueKey) ProtoReflect() protoreflect.Message { | 521 | func (x *IssueKey) ProtoReflect() protoreflect.Message { | ||
| 416 | - | mi := &file_ermite_storage_issue_proto_msgTypes[4] | 522 | + | mi := &file_ermite_storage_issue_proto_msgTypes[6] |
| 417 | if x != nil { | 523 | if x != nil { | ||
| 418 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | 524 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 419 | if ms.LoadMessageInfo() == nil { | 525 | if ms.LoadMessageInfo() == nil { | ||
| @@ -426,7 +532,7 @@ func (x *IssueKey) ProtoReflect() protoreflect.Message { | |||||
| 426 | 532 | ||||
| 427 | // Deprecated: Use IssueKey.ProtoReflect.Descriptor instead. | 533 | // Deprecated: Use IssueKey.ProtoReflect.Descriptor instead. | ||
| 428 | func (*IssueKey) Descriptor() ([]byte, []int) { | 534 | func (*IssueKey) Descriptor() ([]byte, []int) { | ||
| 429 | - | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{4} | 535 | + | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{6} |
| 430 | } | 536 | } | ||
| 431 | 537 | ||||
| 432 | func (x *IssueKey) GetKey() []byte { | 538 | func (x *IssueKey) GetKey() []byte { | ||
| @@ -440,7 +546,7 @@ var File_ermite_storage_issue_proto protoreflect.FileDescriptor | |||||
| 440 | 546 | ||||
| 441 | const file_ermite_storage_issue_proto_rawDesc = "" + | 547 | const file_ermite_storage_issue_proto_rawDesc = "" + | ||
| 442 | "\n" + | 548 | "\n" + | ||
| 443 | - | "\x1aermite/storage/issue.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xc6\x02\n" + | 549 | + | "\x1aermite/storage/issue.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xfd\x02\n" + |
| 444 | "\x05Issue\x12\x16\n" + | 550 | "\x05Issue\x12\x16\n" + | ||
| 445 | "\x06number\x18\x01 \x01(\x03R\x06number\x12\x14\n" + | 551 | "\x06number\x18\x01 \x01(\x03R\x06number\x12\x14\n" + | ||
| 446 | "\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" + | 552 | "\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" + | ||
| @@ -453,7 +559,14 @@ const file_ermite_storage_issue_proto_rawDesc = "" + | |||||
| 453 | "updated_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12\x1f\n" + | 559 | "updated_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12\x1f\n" + | ||
| 454 | "\vcomment_seq\x18\b \x01(\x03R\n" + | 560 | "\vcomment_seq\x18\b \x01(\x03R\n" + | ||
| 455 | "commentSeq\x12\x1a\n" + | 561 | "commentSeq\x12\x1a\n" + | ||
| 456 | - | "\bexternal\x18\t \x01(\bR\bexternal\"\x85\x01\n" + | 562 | + | "\bexternal\x18\t \x01(\bR\bexternal\x125\n" + |
| 563 | + | "\x17closed_by_merge_request\x18\n" + | |||
| 564 | + | " \x01(\x03R\x14closedByMergeRequest\"#\n" + | |||
| 565 | + | "\tIssueLink\x12\x16\n" + | |||
| 566 | + | "\x06closes\x18\x01 \x01(\bR\x06closes\"H\n" + | |||
| 567 | + | "\x16MergeRequestReferences\x12\x16\n" + | |||
| 568 | + | "\x06issues\x18\x01 \x03(\x03R\x06issues\x12\x16\n" + | |||
| 569 | + | "\x06closes\x18\x02 \x03(\x03R\x06closes\"\x85\x01\n" + | |||
| 457 | "\fIssueComment\x12\x0e\n" + | 570 | "\fIssueComment\x12\x0e\n" + | ||
| 458 | "\x02id\x18\x01 \x01(\x03R\x02id\x12\x16\n" + | 571 | "\x02id\x18\x01 \x01(\x03R\x02id\x12\x16\n" + | ||
| 459 | "\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" + | 572 | "\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" + | ||
| @@ -491,23 +604,25 @@ func file_ermite_storage_issue_proto_rawDescGZIP() []byte { | |||||
| 491 | } | 604 | } | ||
| 492 | 605 | ||||
| 493 | var file_ermite_storage_issue_proto_enumTypes = make([]protoimpl.EnumInfo, 1) | 606 | var file_ermite_storage_issue_proto_enumTypes = make([]protoimpl.EnumInfo, 1) | ||
| 494 | - | var file_ermite_storage_issue_proto_msgTypes = make([]protoimpl.MessageInfo, 5) | 607 | + | var file_ermite_storage_issue_proto_msgTypes = make([]protoimpl.MessageInfo, 7) |
| 495 | var file_ermite_storage_issue_proto_goTypes = []any{ | 608 | var file_ermite_storage_issue_proto_goTypes = []any{ | ||
| 496 | - | (IssueState)(0), // 0: ermite.storage.IssueState | 609 | + | (IssueState)(0), // 0: ermite.storage.IssueState |
| 497 | - | (*Issue)(nil), // 1: ermite.storage.Issue | 610 | + | (*Issue)(nil), // 1: ermite.storage.Issue |
| 498 | - | (*IssueComment)(nil), // 2: ermite.storage.IssueComment | 611 | + | (*IssueLink)(nil), // 2: ermite.storage.IssueLink |
| 499 | - | (*PendingIssue)(nil), // 3: ermite.storage.PendingIssue | 612 | + | (*MergeRequestReferences)(nil), // 3: ermite.storage.MergeRequestReferences |
| 500 | - | (*IssueBan)(nil), // 4: ermite.storage.IssueBan | 613 | + | (*IssueComment)(nil), // 4: ermite.storage.IssueComment |
| 501 | - | (*IssueKey)(nil), // 5: ermite.storage.IssueKey | 614 | + | (*PendingIssue)(nil), // 5: ermite.storage.PendingIssue |
| 502 | - | (*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp | 615 | + | (*IssueBan)(nil), // 6: ermite.storage.IssueBan |
| 616 | + | (*IssueKey)(nil), // 7: ermite.storage.IssueKey | |||
| 617 | + | (*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp | |||
| 503 | } | 618 | } | ||
| 504 | var file_ermite_storage_issue_proto_depIdxs = []int32{ | 619 | var file_ermite_storage_issue_proto_depIdxs = []int32{ | ||
| 505 | 0, // 0: ermite.storage.Issue.state:type_name -> ermite.storage.IssueState | 620 | 0, // 0: ermite.storage.Issue.state:type_name -> ermite.storage.IssueState | ||
| 506 | - | 6, // 1: ermite.storage.Issue.created_at:type_name -> google.protobuf.Timestamp | 621 | + | 8, // 1: ermite.storage.Issue.created_at:type_name -> google.protobuf.Timestamp |
| 507 | - | 6, // 2: ermite.storage.Issue.updated_at:type_name -> google.protobuf.Timestamp | 622 | + | 8, // 2: ermite.storage.Issue.updated_at:type_name -> google.protobuf.Timestamp |
| 508 | - | 6, // 3: ermite.storage.IssueComment.created_at:type_name -> google.protobuf.Timestamp | 623 | + | 8, // 3: ermite.storage.IssueComment.created_at:type_name -> google.protobuf.Timestamp |
| 509 | - | 6, // 4: ermite.storage.PendingIssue.created_at:type_name -> google.protobuf.Timestamp | 624 | + | 8, // 4: ermite.storage.PendingIssue.created_at:type_name -> google.protobuf.Timestamp |
| 510 | - | 6, // 5: ermite.storage.IssueBan.until:type_name -> google.protobuf.Timestamp | 625 | + | 8, // 5: ermite.storage.IssueBan.until:type_name -> google.protobuf.Timestamp |
| 511 | 6, // [6:6] is the sub-list for method output_type | 626 | 6, // [6:6] is the sub-list for method output_type | ||
| 512 | 6, // [6:6] is the sub-list for method input_type | 627 | 6, // [6:6] is the sub-list for method input_type | ||
| 513 | 6, // [6:6] is the sub-list for extension type_name | 628 | 6, // [6:6] is the sub-list for extension type_name | ||
| @@ -526,7 +641,7 @@ func file_ermite_storage_issue_proto_init() { | |||||
| 526 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), | 641 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), | ||
| 527 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_issue_proto_rawDesc), len(file_ermite_storage_issue_proto_rawDesc)), | 642 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_issue_proto_rawDesc), len(file_ermite_storage_issue_proto_rawDesc)), | ||
| 528 | NumEnums: 1, | 643 | NumEnums: 1, | ||
| 529 | - | NumMessages: 5, | 644 | + | NumMessages: 7, |
| 530 | NumExtensions: 0, | 645 | NumExtensions: 0, | ||
| 531 | NumServices: 0, | 646 | NumServices: 0, | ||
| 532 | }, | 647 | }, | ||
A internal/adapters/kvstore/issue_links.go +139 -0
| @@ -0,0 +1,139 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package kvstore | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "slices" | |
| 10 | + | "strconv" | |
| 11 | + | "strings" | |
| 12 | + | ||
| 13 | + | kv "bnema.dev/ermite-kv" | |
| 14 | + | storagepb "bnema.dev/ermite/gen/ermite/storage" | |
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports" | |
| 17 | + | "google.golang.org/protobuf/proto" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | const issueRefPrefix = "isref/" | |
| 21 | + | const mrRefPrefix = "mrref/" | |
| 22 | + | ||
| 23 | + | // IssueLinks stores the derived issue-to-merge-request index. | |
| 24 | + | type IssueLinks struct{ db *kv.DB } | |
| 25 | + | ||
| 26 | + | var _ ports.IssueLinkStore = (*IssueLinks)(nil) | |
| 27 | + | ||
| 28 | + | // IssueLinks returns a link store sharing this Store's database and lifetime. | |
| 29 | + | func (s *Store) IssueLinks() *IssueLinks { return &IssueLinks{db: s.db} } | |
| 30 | + | ||
| 31 | + | func mrRefKey(repo domain.RepoName, mr int64) []byte { | |
| 32 | + | return []byte(mrRefPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, mr)) | |
| 33 | + | } | |
| 34 | + | func issueRefKeys(repo domain.RepoName, issue int64) string { | |
| 35 | + | return issueRefPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, issue) + "/" | |
| 36 | + | } | |
| 37 | + | func issueRefKey(repo domain.RepoName, issue, mr int64) []byte { | |
| 38 | + | return []byte(issueRefKeys(repo, issue) + fmt.Sprintf(numberFormat, mr)) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // ReplaceLinks atomically replaces both directions of the index. | |
| 42 | + | func (l *IssueLinks) ReplaceLinks(ctx context.Context, repo domain.RepoName, mr int64, issues, closes []int64) (added, removed []domain.IssueLink, err error) { | |
| 43 | + | key := mrRefKey(repo, mr) | |
| 44 | + | item, exists := l.db.Get(ctx, key) | |
| 45 | + | var old storagepb.MergeRequestReferences | |
| 46 | + | if exists { | |
| 47 | + | if err := proto.Unmarshal(item.Value, &old); err != nil { | |
| 48 | + | return nil, nil, fmt.Errorf("decode MR references: %w", err) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | issues = slices.Sorted(slices.Values(issues)) | |
| 52 | + | issues = slices.Compact(issues) | |
| 53 | + | closes = slices.Sorted(slices.Values(closes)) | |
| 54 | + | closes = slices.Compact(closes) | |
| 55 | + | for _, n := range issues { | |
| 56 | + | if n < 1 { | |
| 57 | + | return nil, nil, domain.ErrInvalid | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | for _, n := range closes { | |
| 61 | + | if !slices.Contains(issues, n) { | |
| 62 | + | return nil, nil, domain.ErrInvalid | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | var b kv.Batch | |
| 66 | + | if err := guardRepoKey(ctx, l.db, &b, repo); err != nil { | |
| 67 | + | return nil, nil, err | |
| 68 | + | } | |
| 69 | + | if exists { | |
| 70 | + | b.RequireVersion(key, item.Version) | |
| 71 | + | } else { | |
| 72 | + | b.RequireAbsent(key) | |
| 73 | + | } | |
| 74 | + | for _, n := range old.GetIssues() { | |
| 75 | + | wasClosing := slices.Contains(old.GetCloses(), n) | |
| 76 | + | if !slices.Contains(issues, n) { | |
| 77 | + | b.Delete(issueRefKey(repo, n, mr)) | |
| 78 | + | removed = append(removed, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: wasClosing}) | |
| 79 | + | } else if wasClosing && !slices.Contains(closes, n) { | |
| 80 | + | removed = append(removed, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: true}) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | for _, n := range issues { | |
| 84 | + | closing := slices.Contains(closes, n) | |
| 85 | + | b.Put(issueRefKey(repo, n, mr), mustMarshal(&storagepb.IssueLink{Closes: closing})) | |
| 86 | + | if !slices.Contains(old.GetIssues(), n) || closing && !slices.Contains(old.GetCloses(), n) { | |
| 87 | + | added = append(added, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: closing}) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | b.Put(key, mustMarshal(&storagepb.MergeRequestReferences{Issues: issues, Closes: closes})) | |
| 91 | + | if _, err := l.db.Apply(ctx, &b); err != nil { | |
| 92 | + | if errors.Is(err, kv.ErrConflict) { | |
| 93 | + | return nil, nil, domain.ErrConflict | |
| 94 | + | } | |
| 95 | + | return nil, nil, fmt.Errorf("replace issue links: %w", err) | |
| 96 | + | } | |
| 97 | + | return added, removed, nil | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // LinksOfIssue returns links ordered by merge request number. | |
| 101 | + | func (l *IssueLinks) LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error) { | |
| 102 | + | var result []domain.IssueLink | |
| 103 | + | prefix := issueRefKeys(repo, issue) | |
| 104 | + | for key, item := range l.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) { | |
| 105 | + | if err := ctx.Err(); err != nil { | |
| 106 | + | return nil, err | |
| 107 | + | } | |
| 108 | + | mr, err := strconv.ParseInt(strings.TrimPrefix(string(key), prefix), 10, 64) | |
| 109 | + | if err != nil { | |
| 110 | + | return nil, fmt.Errorf("decode issue link key: %w", err) | |
| 111 | + | } | |
| 112 | + | var pb storagepb.IssueLink | |
| 113 | + | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |
| 114 | + | return nil, fmt.Errorf("decode issue link: %w", err) | |
| 115 | + | } | |
| 116 | + | result = append(result, domain.IssueLink{Issue: issue, MergeRequest: mr, Closes: pb.GetCloses()}) | |
| 117 | + | if limit > 0 && len(result) >= limit { | |
| 118 | + | break | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | return result, nil | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // LinksOfMergeRequest returns the links recorded by its last rebuild. | |
| 125 | + | func (l *IssueLinks) LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error) { | |
| 126 | + | item, ok := l.db.Get(ctx, mrRefKey(repo, mr)) | |
| 127 | + | if !ok { | |
| 128 | + | return nil, nil | |
| 129 | + | } | |
| 130 | + | var pb storagepb.MergeRequestReferences | |
| 131 | + | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |
| 132 | + | return nil, fmt.Errorf("decode MR references: %w", err) | |
| 133 | + | } | |
| 134 | + | var result []domain.IssueLink | |
| 135 | + | for _, n := range pb.GetIssues() { | |
| 136 | + | result = append(result, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: slices.Contains(pb.GetCloses(), n)}) | |
| 137 | + | } | |
| 138 | + | return result, nil | |
| 139 | + | } | |
| @@ -0,0 +1,139 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package kvstore | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "slices" | |||
| 10 | + | "strconv" | |||
| 11 | + | "strings" | |||
| 12 | + | ||||
| 13 | + | kv "bnema.dev/ermite-kv" | |||
| 14 | + | storagepb "bnema.dev/ermite/gen/ermite/storage" | |||
| 15 | + | "bnema.dev/ermite/internal/domain" | |||
| 16 | + | "bnema.dev/ermite/internal/ports" | |||
| 17 | + | "google.golang.org/protobuf/proto" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | const issueRefPrefix = "isref/" | |||
| 21 | + | const mrRefPrefix = "mrref/" | |||
| 22 | + | ||||
| 23 | + | // IssueLinks stores the derived issue-to-merge-request index. | |||
| 24 | + | type IssueLinks struct{ db *kv.DB } | |||
| 25 | + | ||||
| 26 | + | var _ ports.IssueLinkStore = (*IssueLinks)(nil) | |||
| 27 | + | ||||
| 28 | + | // IssueLinks returns a link store sharing this Store's database and lifetime. | |||
| 29 | + | func (s *Store) IssueLinks() *IssueLinks { return &IssueLinks{db: s.db} } | |||
| 30 | + | ||||
| 31 | + | func mrRefKey(repo domain.RepoName, mr int64) []byte { | |||
| 32 | + | return []byte(mrRefPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, mr)) | |||
| 33 | + | } | |||
| 34 | + | func issueRefKeys(repo domain.RepoName, issue int64) string { | |||
| 35 | + | return issueRefPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, issue) + "/" | |||
| 36 | + | } | |||
| 37 | + | func issueRefKey(repo domain.RepoName, issue, mr int64) []byte { | |||
| 38 | + | return []byte(issueRefKeys(repo, issue) + fmt.Sprintf(numberFormat, mr)) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // ReplaceLinks atomically replaces both directions of the index. | |||
| 42 | + | func (l *IssueLinks) ReplaceLinks(ctx context.Context, repo domain.RepoName, mr int64, issues, closes []int64) (added, removed []domain.IssueLink, err error) { | |||
| 43 | + | key := mrRefKey(repo, mr) | |||
| 44 | + | item, exists := l.db.Get(ctx, key) | |||
| 45 | + | var old storagepb.MergeRequestReferences | |||
| 46 | + | if exists { | |||
| 47 | + | if err := proto.Unmarshal(item.Value, &old); err != nil { | |||
| 48 | + | return nil, nil, fmt.Errorf("decode MR references: %w", err) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | issues = slices.Sorted(slices.Values(issues)) | |||
| 52 | + | issues = slices.Compact(issues) | |||
| 53 | + | closes = slices.Sorted(slices.Values(closes)) | |||
| 54 | + | closes = slices.Compact(closes) | |||
| 55 | + | for _, n := range issues { | |||
| 56 | + | if n < 1 { | |||
| 57 | + | return nil, nil, domain.ErrInvalid | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | for _, n := range closes { | |||
| 61 | + | if !slices.Contains(issues, n) { | |||
| 62 | + | return nil, nil, domain.ErrInvalid | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | var b kv.Batch | |||
| 66 | + | if err := guardRepoKey(ctx, l.db, &b, repo); err != nil { | |||
| 67 | + | return nil, nil, err | |||
| 68 | + | } | |||
| 69 | + | if exists { | |||
| 70 | + | b.RequireVersion(key, item.Version) | |||
| 71 | + | } else { | |||
| 72 | + | b.RequireAbsent(key) | |||
| 73 | + | } | |||
| 74 | + | for _, n := range old.GetIssues() { | |||
| 75 | + | wasClosing := slices.Contains(old.GetCloses(), n) | |||
| 76 | + | if !slices.Contains(issues, n) { | |||
| 77 | + | b.Delete(issueRefKey(repo, n, mr)) | |||
| 78 | + | removed = append(removed, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: wasClosing}) | |||
| 79 | + | } else if wasClosing && !slices.Contains(closes, n) { | |||
| 80 | + | removed = append(removed, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: true}) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | for _, n := range issues { | |||
| 84 | + | closing := slices.Contains(closes, n) | |||
| 85 | + | b.Put(issueRefKey(repo, n, mr), mustMarshal(&storagepb.IssueLink{Closes: closing})) | |||
| 86 | + | if !slices.Contains(old.GetIssues(), n) || closing && !slices.Contains(old.GetCloses(), n) { | |||
| 87 | + | added = append(added, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: closing}) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | b.Put(key, mustMarshal(&storagepb.MergeRequestReferences{Issues: issues, Closes: closes})) | |||
| 91 | + | if _, err := l.db.Apply(ctx, &b); err != nil { | |||
| 92 | + | if errors.Is(err, kv.ErrConflict) { | |||
| 93 | + | return nil, nil, domain.ErrConflict | |||
| 94 | + | } | |||
| 95 | + | return nil, nil, fmt.Errorf("replace issue links: %w", err) | |||
| 96 | + | } | |||
| 97 | + | return added, removed, nil | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // LinksOfIssue returns links ordered by merge request number. | |||
| 101 | + | func (l *IssueLinks) LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error) { | |||
| 102 | + | var result []domain.IssueLink | |||
| 103 | + | prefix := issueRefKeys(repo, issue) | |||
| 104 | + | for key, item := range l.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) { | |||
| 105 | + | if err := ctx.Err(); err != nil { | |||
| 106 | + | return nil, err | |||
| 107 | + | } | |||
| 108 | + | mr, err := strconv.ParseInt(strings.TrimPrefix(string(key), prefix), 10, 64) | |||
| 109 | + | if err != nil { | |||
| 110 | + | return nil, fmt.Errorf("decode issue link key: %w", err) | |||
| 111 | + | } | |||
| 112 | + | var pb storagepb.IssueLink | |||
| 113 | + | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |||
| 114 | + | return nil, fmt.Errorf("decode issue link: %w", err) | |||
| 115 | + | } | |||
| 116 | + | result = append(result, domain.IssueLink{Issue: issue, MergeRequest: mr, Closes: pb.GetCloses()}) | |||
| 117 | + | if limit > 0 && len(result) >= limit { | |||
| 118 | + | break | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | return result, nil | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // LinksOfMergeRequest returns the links recorded by its last rebuild. | |||
| 125 | + | func (l *IssueLinks) LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error) { | |||
| 126 | + | item, ok := l.db.Get(ctx, mrRefKey(repo, mr)) | |||
| 127 | + | if !ok { | |||
| 128 | + | return nil, nil | |||
| 129 | + | } | |||
| 130 | + | var pb storagepb.MergeRequestReferences | |||
| 131 | + | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |||
| 132 | + | return nil, fmt.Errorf("decode MR references: %w", err) | |||
| 133 | + | } | |||
| 134 | + | var result []domain.IssueLink | |||
| 135 | + | for _, n := range pb.GetIssues() { | |||
| 136 | + | result = append(result, domain.IssueLink{Issue: n, MergeRequest: mr, Closes: slices.Contains(pb.GetCloses(), n)}) | |||
| 137 | + | } | |||
| 138 | + | return result, nil | |||
| 139 | + | } | |||
A internal/adapters/kvstore/issue_links_test.go +66 -0
| @@ -0,0 +1,66 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | package kvstore_test | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "bnema.dev/ermite/internal/domain" | |
| 6 | + | "context" | |
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | "testing" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestIssueLinksReplace(t *testing.T) { | |
| 13 | + | ctx := context.Background() | |
| 14 | + | s, _, repo := repoWithMRs(t) | |
| 15 | + | links := s.IssueLinks() | |
| 16 | + | added, removed, err := links.ReplaceLinks(ctx, repo, 9, []int64{3, 2, 3}, []int64{2}) | |
| 17 | + | require.NoError(t, err) | |
| 18 | + | assert.Equal(t, []domain.IssueLink{{Issue: 2, MergeRequest: 9, Closes: true}, {Issue: 3, MergeRequest: 9}}, added) | |
| 19 | + | assert.Empty(t, removed) | |
| 20 | + | got, err := links.LinksOfMergeRequest(ctx, repo, 9) | |
| 21 | + | require.NoError(t, err) | |
| 22 | + | assert.Equal(t, added, got) | |
| 23 | + | added, removed, err = links.ReplaceLinks(ctx, repo, 9, []int64{2, 3}, []int64{2}) | |
| 24 | + | require.NoError(t, err) | |
| 25 | + | assert.Empty(t, added) | |
| 26 | + | assert.Empty(t, removed) | |
| 27 | + | added, removed, err = links.ReplaceLinks(ctx, repo, 9, []int64{3, 4}, []int64{3}) | |
| 28 | + | require.NoError(t, err) | |
| 29 | + | assert.Equal(t, []domain.IssueLink{{Issue: 3, MergeRequest: 9, Closes: true}, {Issue: 4, MergeRequest: 9}}, added) | |
| 30 | + | assert.Equal(t, []domain.IssueLink{{Issue: 2, MergeRequest: 9, Closes: true}}, removed) | |
| 31 | + | got, err = links.LinksOfIssue(ctx, repo, 2, 100) | |
| 32 | + | require.NoError(t, err) | |
| 33 | + | assert.Empty(t, got) | |
| 34 | + | added, removed, err = links.ReplaceLinks(ctx, repo, 9, []int64{3}, nil) | |
| 35 | + | require.NoError(t, err) | |
| 36 | + | assert.Empty(t, added) | |
| 37 | + | assert.Equal(t, []domain.IssueLink{{Issue: 3, MergeRequest: 9, Closes: true}, {Issue: 4, MergeRequest: 9}}, removed) | |
| 38 | + | for _, mr := range []int64{12, 1, 5} { | |
| 39 | + | _, _, err = links.ReplaceLinks(ctx, repo, mr, []int64{3}, nil) | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | } | |
| 42 | + | got, err = links.LinksOfIssue(ctx, repo, 3, 2) | |
| 43 | + | require.NoError(t, err) | |
| 44 | + | assert.Equal(t, []domain.IssueLink{{Issue: 3, MergeRequest: 1}, {Issue: 3, MergeRequest: 5}}, got) | |
| 45 | + | require.NoError(t, s.Delete(ctx, repo)) | |
| 46 | + | assert.Zero(t, countKeys(t, s, "isref/"+repo.String()+"/")) | |
| 47 | + | assert.Zero(t, countKeys(t, s, "mrref/"+repo.String()+"/")) | |
| 48 | + | _, _, err = links.ReplaceLinks(ctx, repo, 9, []int64{2}, nil) | |
| 49 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestIssueClosedByRoundTrip(t *testing.T) { | |
| 53 | + | ctx := context.Background() | |
| 54 | + | s, _, repo := repoWithMRs(t) | |
| 55 | + | issue := newIssue(t, repo, "closed by merge") | |
| 56 | + | issue.State = domain.IssueStateClosed | |
| 57 | + | issue.ClosedByMergeRequest = 17 | |
| 58 | + | stored, err := s.Issues().Create(ctx, issue) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | assert.Equal(t, int64(17), stored.ClosedByMergeRequest) | |
| 61 | + | stored.ClosedByMergeRequest = 0 | |
| 62 | + | require.NoError(t, s.Issues().Update(ctx, stored)) | |
| 63 | + | stored, err = s.Issues().Get(ctx, repo, stored.Number) | |
| 64 | + | require.NoError(t, err) | |
| 65 | + | assert.Zero(t, stored.ClosedByMergeRequest) | |
| 66 | + | } | |
| @@ -0,0 +1,66 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | package kvstore_test | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "bnema.dev/ermite/internal/domain" | |||
| 6 | + | "context" | |||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | "testing" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestIssueLinksReplace(t *testing.T) { | |||
| 13 | + | ctx := context.Background() | |||
| 14 | + | s, _, repo := repoWithMRs(t) | |||
| 15 | + | links := s.IssueLinks() | |||
| 16 | + | added, removed, err := links.ReplaceLinks(ctx, repo, 9, []int64{3, 2, 3}, []int64{2}) | |||
| 17 | + | require.NoError(t, err) | |||
| 18 | + | assert.Equal(t, []domain.IssueLink{{Issue: 2, MergeRequest: 9, Closes: true}, {Issue: 3, MergeRequest: 9}}, added) | |||
| 19 | + | assert.Empty(t, removed) | |||
| 20 | + | got, err := links.LinksOfMergeRequest(ctx, repo, 9) | |||
| 21 | + | require.NoError(t, err) | |||
| 22 | + | assert.Equal(t, added, got) | |||
| 23 | + | added, removed, err = links.ReplaceLinks(ctx, repo, 9, []int64{2, 3}, []int64{2}) | |||
| 24 | + | require.NoError(t, err) | |||
| 25 | + | assert.Empty(t, added) | |||
| 26 | + | assert.Empty(t, removed) | |||
| 27 | + | added, removed, err = links.ReplaceLinks(ctx, repo, 9, []int64{3, 4}, []int64{3}) | |||
| 28 | + | require.NoError(t, err) | |||
| 29 | + | assert.Equal(t, []domain.IssueLink{{Issue: 3, MergeRequest: 9, Closes: true}, {Issue: 4, MergeRequest: 9}}, added) | |||
| 30 | + | assert.Equal(t, []domain.IssueLink{{Issue: 2, MergeRequest: 9, Closes: true}}, removed) | |||
| 31 | + | got, err = links.LinksOfIssue(ctx, repo, 2, 100) | |||
| 32 | + | require.NoError(t, err) | |||
| 33 | + | assert.Empty(t, got) | |||
| 34 | + | added, removed, err = links.ReplaceLinks(ctx, repo, 9, []int64{3}, nil) | |||
| 35 | + | require.NoError(t, err) | |||
| 36 | + | assert.Empty(t, added) | |||
| 37 | + | assert.Equal(t, []domain.IssueLink{{Issue: 3, MergeRequest: 9, Closes: true}, {Issue: 4, MergeRequest: 9}}, removed) | |||
| 38 | + | for _, mr := range []int64{12, 1, 5} { | |||
| 39 | + | _, _, err = links.ReplaceLinks(ctx, repo, mr, []int64{3}, nil) | |||
| 40 | + | require.NoError(t, err) | |||
| 41 | + | } | |||
| 42 | + | got, err = links.LinksOfIssue(ctx, repo, 3, 2) | |||
| 43 | + | require.NoError(t, err) | |||
| 44 | + | assert.Equal(t, []domain.IssueLink{{Issue: 3, MergeRequest: 1}, {Issue: 3, MergeRequest: 5}}, got) | |||
| 45 | + | require.NoError(t, s.Delete(ctx, repo)) | |||
| 46 | + | assert.Zero(t, countKeys(t, s, "isref/"+repo.String()+"/")) | |||
| 47 | + | assert.Zero(t, countKeys(t, s, "mrref/"+repo.String()+"/")) | |||
| 48 | + | _, _, err = links.ReplaceLinks(ctx, repo, 9, []int64{2}, nil) | |||
| 49 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestIssueClosedByRoundTrip(t *testing.T) { | |||
| 53 | + | ctx := context.Background() | |||
| 54 | + | s, _, repo := repoWithMRs(t) | |||
| 55 | + | issue := newIssue(t, repo, "closed by merge") | |||
| 56 | + | issue.State = domain.IssueStateClosed | |||
| 57 | + | issue.ClosedByMergeRequest = 17 | |||
| 58 | + | stored, err := s.Issues().Create(ctx, issue) | |||
| 59 | + | require.NoError(t, err) | |||
| 60 | + | assert.Equal(t, int64(17), stored.ClosedByMergeRequest) | |||
| 61 | + | stored.ClosedByMergeRequest = 0 | |||
| 62 | + | require.NoError(t, s.Issues().Update(ctx, stored)) | |||
| 63 | + | stored, err = s.Issues().Get(ctx, repo, stored.Number) | |||
| 64 | + | require.NoError(t, err) | |||
| 65 | + | assert.Zero(t, stored.ClosedByMergeRequest) | |||
| 66 | + | } | |||
M internal/adapters/kvstore/issues.go +15 -13
| @@ -72,7 +72,7 @@ func indexOpenIssue(b *kv.Batch, issue domain.Issue) { | |||
| 72 | 72 | func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte { | |
| 73 | 73 | var keys [][]byte | |
| 74 | 74 | for _, p := range []string{ | |
| 75 | - | issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo), | |
| 75 | + | issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo), issueRefPrefix + repo.String() + "/", | |
| 76 | 76 | } { | |
| 77 | 77 | for k := range snap.Prefix(ctx, []byte(p)) { | |
| 78 | 78 | keys = append(keys, slices.Clone(k)) | |
| @@ -312,15 +312,16 @@ func encodeIssue(issue domain.Issue) ([]byte, error) { | |||
| 312 | 312 | return nil, err | |
| 313 | 313 | } | |
| 314 | 314 | return mustMarshal(&storagepb.Issue{ | |
| 315 | - | Number: issue.Number, | |
| 316 | - | Title: issue.Title.String(), | |
| 317 | - | Body: issue.Body.String(), | |
| 318 | - | State: state, | |
| 319 | - | Author: issue.Author, | |
| 320 | - | CreatedAt: encodeTime(issue.CreatedAt), | |
| 321 | - | UpdatedAt: encodeTime(issue.UpdatedAt), | |
| 322 | - | CommentSeq: issue.CommentSeq, | |
| 323 | - | External: issue.External, | |
| 315 | + | Number: issue.Number, | |
| 316 | + | Title: issue.Title.String(), | |
| 317 | + | Body: issue.Body.String(), | |
| 318 | + | State: state, | |
| 319 | + | Author: issue.Author, | |
| 320 | + | CreatedAt: encodeTime(issue.CreatedAt), | |
| 321 | + | UpdatedAt: encodeTime(issue.UpdatedAt), | |
| 322 | + | CommentSeq: issue.CommentSeq, | |
| 323 | + | External: issue.External, | |
| 324 | + | ClosedByMergeRequest: issue.ClosedByMergeRequest, | |
| 324 | 325 | }), nil | |
| 325 | 326 | } | |
| 326 | 327 | ||
| @@ -348,9 +349,10 @@ func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) { | |||
| 348 | 349 | Repo: repo, Number: pb.GetNumber(), Title: title, Body: body, State: state, | |
| 349 | 350 | Author: pb.GetAuthor(), | |
| 350 | 351 | CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()), | |
| 351 | - | CommentSeq: pb.GetCommentSeq(), | |
| 352 | - | External: pb.GetExternal(), | |
| 353 | - | Version: item.Version, | |
| 352 | + | CommentSeq: pb.GetCommentSeq(), | |
| 353 | + | External: pb.GetExternal(), | |
| 354 | + | ClosedByMergeRequest: pb.GetClosedByMergeRequest(), | |
| 355 | + | Version: item.Version, | |
| 354 | 356 | }, nil | |
| 355 | 357 | } | |
| 356 | 358 | ||
| @@ -72,7 +72,7 @@ func indexOpenIssue(b *kv.Batch, issue domain.Issue) { | |||||
| 72 | func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte { | 72 | func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte { | ||
| 73 | var keys [][]byte | 73 | var keys [][]byte | ||
| 74 | for _, p := range []string{ | 74 | for _, p := range []string{ | ||
| 75 | - | issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo), | 75 | + | issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo), issueRefPrefix + repo.String() + "/", |
| 76 | } { | 76 | } { | ||
| 77 | for k := range snap.Prefix(ctx, []byte(p)) { | 77 | for k := range snap.Prefix(ctx, []byte(p)) { | ||
| 78 | keys = append(keys, slices.Clone(k)) | 78 | keys = append(keys, slices.Clone(k)) | ||
| @@ -312,15 +312,16 @@ func encodeIssue(issue domain.Issue) ([]byte, error) { | |||||
| 312 | return nil, err | 312 | return nil, err | ||
| 313 | } | 313 | } | ||
| 314 | return mustMarshal(&storagepb.Issue{ | 314 | return mustMarshal(&storagepb.Issue{ | ||
| 315 | - | Number: issue.Number, | 315 | + | Number: issue.Number, |
| 316 | - | Title: issue.Title.String(), | 316 | + | Title: issue.Title.String(), |
| 317 | - | Body: issue.Body.String(), | 317 | + | Body: issue.Body.String(), |
| 318 | - | State: state, | 318 | + | State: state, |
| 319 | - | Author: issue.Author, | 319 | + | Author: issue.Author, |
| 320 | - | CreatedAt: encodeTime(issue.CreatedAt), | 320 | + | CreatedAt: encodeTime(issue.CreatedAt), |
| 321 | - | UpdatedAt: encodeTime(issue.UpdatedAt), | 321 | + | UpdatedAt: encodeTime(issue.UpdatedAt), |
| 322 | - | CommentSeq: issue.CommentSeq, | 322 | + | CommentSeq: issue.CommentSeq, |
| 323 | - | External: issue.External, | 323 | + | External: issue.External, |
| 324 | + | ClosedByMergeRequest: issue.ClosedByMergeRequest, | |||
| 324 | }), nil | 325 | }), nil | ||
| 325 | } | 326 | } | ||
| 326 | 327 | ||||
| @@ -348,9 +349,10 @@ func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) { | |||||
| 348 | Repo: repo, Number: pb.GetNumber(), Title: title, Body: body, State: state, | 349 | Repo: repo, Number: pb.GetNumber(), Title: title, Body: body, State: state, | ||
| 349 | Author: pb.GetAuthor(), | 350 | Author: pb.GetAuthor(), | ||
| 350 | CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()), | 351 | CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()), | ||
| 351 | - | CommentSeq: pb.GetCommentSeq(), | 352 | + | CommentSeq: pb.GetCommentSeq(), |
| 352 | - | External: pb.GetExternal(), | 353 | + | External: pb.GetExternal(), |
| 353 | - | Version: item.Version, | 354 | + | ClosedByMergeRequest: pb.GetClosedByMergeRequest(), |
| 355 | + | Version: item.Version, | |||
| 354 | }, nil | 356 | }, nil | ||
| 355 | } | 357 | } | ||
| 356 | 358 | ||||
M internal/adapters/kvstore/mergerequests.go +1 -1
| @@ -181,7 +181,7 @@ func mergeRequestKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoNa | |||
| 181 | 181 | var keys [][]byte | |
| 182 | 182 | for _, p := range []string{ | |
| 183 | 183 | mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/", | |
| 184 | - | mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/", | |
| 184 | + | mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/", mrRefPrefix + repo.String() + "/", | |
| 185 | 185 | mergePrefix + repo.String() + "/", | |
| 186 | 186 | } { | |
| 187 | 187 | for k := range snap.Prefix(ctx, []byte(p)) { | |
| @@ -181,7 +181,7 @@ func mergeRequestKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoNa | |||||
| 181 | var keys [][]byte | 181 | var keys [][]byte | ||
| 182 | for _, p := range []string{ | 182 | for _, p := range []string{ | ||
| 183 | mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/", | 183 | mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/", | ||
| 184 | - | mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/", | 184 | + | mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/", mrRefPrefix + repo.String() + "/", |
| 185 | mergePrefix + repo.String() + "/", | 185 | mergePrefix + repo.String() + "/", | ||
| 186 | } { | 186 | } { | ||
| 187 | for k := range snap.Prefix(ctx, []byte(p)) { | 187 | for k := range snap.Prefix(ctx, []byte(p)) { | ||
M internal/adapters/rpc/convert.go +2 -2
| @@ -530,8 +530,8 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event { | |||
| 530 | 530 | out.Time = timestamp(ev.At) | |
| 531 | 531 | out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}} | |
| 532 | 532 | default: | |
| 533 | - | // Web-only events, such as domain.IssuePendingRemoved (the API has | |
| 534 | - | // no message for it; watchers list pending issues on demand). | |
| 533 | + | // Web-only events, such as domain.IssueReferenced and | |
| 534 | + | // domain.IssuePendingRemoved: the API has no messages for them. | |
| 535 | 535 | return nil | |
| 536 | 536 | } | |
| 537 | 537 | return out | |
| @@ -530,8 +530,8 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event { | |||||
| 530 | out.Time = timestamp(ev.At) | 530 | out.Time = timestamp(ev.At) | ||
| 531 | out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}} | 531 | out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}} | ||
| 532 | default: | 532 | default: | ||
| 533 | - | // Web-only events, such as domain.IssuePendingRemoved (the API has | 533 | + | // Web-only events, such as domain.IssueReferenced and |
| 534 | - | // no message for it; watchers list pending issues on demand). | 534 | + | // domain.IssuePendingRemoved: the API has no messages for them. |
| 535 | return nil | 535 | return nil | ||
| 536 | } | 536 | } | ||
| 537 | return out | 537 | return out | ||
M internal/domain/event.go +12 -0
| @@ -186,6 +186,18 @@ type IssueOpened struct { | |||
| 186 | 186 | // EventKind implements Event. | |
| 187 | 187 | func (IssueOpened) EventKind() string { return "issue.opened" } | |
| 188 | 188 | ||
| 189 | + | // IssueReferenced refreshes an issue's merge request back-references. | |
| 190 | + | type IssueReferenced struct { | |
| 191 | + | Repo RepoName | |
| 192 | + | Issue, MergeRequest int64 | |
| 193 | + | At time.Time | |
| 194 | + | Visibility Visibility | |
| 195 | + | CreatedAt time.Time | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // EventKind implements Event. | |
| 199 | + | func (IssueReferenced) EventKind() string { return "issue.referenced" } | |
| 200 | + | ||
| 189 | 201 | // IssueClosed is published when an issue was closed. | |
| 190 | 202 | type IssueClosed struct { | |
| 191 | 203 | Issue Issue | |
| @@ -186,6 +186,18 @@ type IssueOpened struct { | |||||
| 186 | // EventKind implements Event. | 186 | // EventKind implements Event. | ||
| 187 | func (IssueOpened) EventKind() string { return "issue.opened" } | 187 | func (IssueOpened) EventKind() string { return "issue.opened" } | ||
| 188 | 188 | ||||
| 189 | + | // IssueReferenced refreshes an issue's merge request back-references. | |||
| 190 | + | type IssueReferenced struct { | |||
| 191 | + | Repo RepoName | |||
| 192 | + | Issue, MergeRequest int64 | |||
| 193 | + | At time.Time | |||
| 194 | + | Visibility Visibility | |||
| 195 | + | CreatedAt time.Time | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | // EventKind implements Event. | |||
| 199 | + | func (IssueReferenced) EventKind() string { return "issue.referenced" } | |||
| 200 | + | ||||
| 189 | // IssueClosed is published when an issue was closed. | 201 | // IssueClosed is published when an issue was closed. | ||
| 190 | type IssueClosed struct { | 202 | type IssueClosed struct { | ||
| 191 | Issue Issue | 203 | Issue Issue | ||
M internal/domain/issue.go +8 -0
| @@ -73,11 +73,19 @@ type Issue struct { | |||
| 73 | 73 | // External is true for an issue approved from the public form: its text | |
| 74 | 74 | // comes from an anonymous visitor and is rendered as untrusted. | |
| 75 | 75 | External bool | |
| 76 | + | // ClosedByMergeRequest is zero unless a merge closed the issue. | |
| 77 | + | ClosedByMergeRequest int64 | |
| 76 | 78 | // Version is set by the store on read: Update fails with ErrConflict when | |
| 77 | 79 | // the stored issue changed since. Opaque to everyone else. | |
| 78 | 80 | Version uint64 | |
| 79 | 81 | } | |
| 80 | 82 | ||
| 83 | + | // IssueLink is a merge request naming an issue in the same repository. | |
| 84 | + | type IssueLink struct { | |
| 85 | + | Issue, MergeRequest int64 | |
| 86 | + | Closes bool | |
| 87 | + | } | |
| 88 | + | ||
| 81 | 89 | // IsOpen reports whether the issue is open. | |
| 82 | 90 | func (i Issue) IsOpen() bool { return i.State == IssueStateOpen } | |
| 83 | 91 | ||
| @@ -73,11 +73,19 @@ type Issue struct { | |||||
| 73 | // External is true for an issue approved from the public form: its text | 73 | // External is true for an issue approved from the public form: its text | ||
| 74 | // comes from an anonymous visitor and is rendered as untrusted. | 74 | // comes from an anonymous visitor and is rendered as untrusted. | ||
| 75 | External bool | 75 | External bool | ||
| 76 | + | // ClosedByMergeRequest is zero unless a merge closed the issue. | |||
| 77 | + | ClosedByMergeRequest int64 | |||
| 76 | // Version is set by the store on read: Update fails with ErrConflict when | 78 | // Version is set by the store on read: Update fails with ErrConflict when | ||
| 77 | // the stored issue changed since. Opaque to everyone else. | 79 | // the stored issue changed since. Opaque to everyone else. | ||
| 78 | Version uint64 | 80 | Version uint64 | ||
| 79 | } | 81 | } | ||
| 80 | 82 | ||||
| 83 | + | // IssueLink is a merge request naming an issue in the same repository. | |||
| 84 | + | type IssueLink struct { | |||
| 85 | + | Issue, MergeRequest int64 | |||
| 86 | + | Closes bool | |||
| 87 | + | } | |||
| 88 | + | ||||
| 81 | // IsOpen reports whether the issue is open. | 89 | // IsOpen reports whether the issue is open. | ||
| 82 | func (i Issue) IsOpen() bool { return i.State == IssueStateOpen } | 90 | func (i Issue) IsOpen() bool { return i.State == IssueStateOpen } | ||
| 83 | 91 | ||||
A internal/domain/reference.go +102 -0
| @@ -0,0 +1,102 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "regexp" | |
| 5 | + | "slices" | |
| 6 | + | "strconv" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const maxReferenceDigits = 10 | |
| 10 | + | ||
| 11 | + | // MaxReferenceTextBytes bounds each title, description, note and commit message scanned. | |
| 12 | + | const MaxReferenceTextBytes = 16 << 10 | |
| 13 | + | ||
| 14 | + | var numberRef = regexp.MustCompile(`(^|[^0-9A-Za-z_&#])#([1-9][0-9]{0,9})\b`) | |
| 15 | + | var closingRef = regexp.MustCompile(`(?i)(?:^|[^0-9A-Za-z_])(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)(?::[ \t]*|[ \t]+)#([1-9][0-9]{0,9})\b((?:[ \t]*,[ \t]*#[1-9][0-9]{0,9}\b|[ \t]+and[ \t]+#[1-9][0-9]{0,9}\b)*)`) | |
| 16 | + | ||
| 17 | + | // FindReferences returns the positive #N numbers in text, sorted and unique. | |
| 18 | + | // Numbers glued to words, entities or another # are excluded, as are zero, | |
| 19 | + | // leading zeroes and numbers longer than ten digits. Code is scanned as text; | |
| 20 | + | // cross-repository references and !N are not supported. | |
| 21 | + | func FindReferences(text string) []int64 { | |
| 22 | + | numbers := make(map[int64]struct{}) | |
| 23 | + | // Resume at the end of the number, retaining its following delimiter for the | |
| 24 | + | // next match (for example, both references in "#1,#2"). | |
| 25 | + | for offset := 0; offset < len(text); { | |
| 26 | + | match := numberRef.FindStringSubmatchIndex(text[offset:]) | |
| 27 | + | if match == nil { | |
| 28 | + | break | |
| 29 | + | } | |
| 30 | + | start, end := offset+match[4], offset+match[5] | |
| 31 | + | // A sliced search must not manufacture a beginning-of-text boundary. | |
| 32 | + | if start < 2 || !referenceWord(text[start-2]) { | |
| 33 | + | n, _ := strconv.ParseInt(text[start:end], 10, 64) | |
| 34 | + | numbers[n] = struct{}{} | |
| 35 | + | } | |
| 36 | + | offset = end | |
| 37 | + | } | |
| 38 | + | return sortedReferences(numbers) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func referenceWord(b byte) bool { | |
| 42 | + | return b >= '0' && b <= '9' || b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z' || b == '_' || b == '&' || b == '#' | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // FindClosingReferences returns sorted, unique references following close, | |
| 46 | + | // closes, closed, fix, fixes, fixed, resolve, resolves or resolved (case | |
| 47 | + | // insensitive). A colon is optional; lists use commas or "and", not spaces. | |
| 48 | + | // Like FindReferences, this scans code spans and fences as ordinary text. | |
| 49 | + | func FindClosingReferences(text string) []int64 { | |
| 50 | + | numbers := make(map[int64]struct{}) | |
| 51 | + | for _, match := range closingRef.FindAllString(text, -1) { | |
| 52 | + | for _, n := range FindReferences(match) { | |
| 53 | + | numbers[n] = struct{}{} | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | return sortedReferences(numbers) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func sortedReferences(numbers map[int64]struct{}) []int64 { | |
| 60 | + | var result []int64 | |
| 61 | + | for n := range numbers { | |
| 62 | + | result = append(result, n) | |
| 63 | + | } | |
| 64 | + | slices.Sort(result) | |
| 65 | + | return result | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // MergeRequestReferences describes the issues named by a merge request. | |
| 69 | + | type MergeRequestReferences struct { | |
| 70 | + | Mentions []int64 | |
| 71 | + | Closes []int64 | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // ScanMergeRequest scans titles, descriptions, notes and commit messages for | |
| 75 | + | // references. Notes can mention issues but cannot close them. Each individual | |
| 76 | + | // text is truncated to MaxReferenceTextBytes before scanning; no Markdown or | |
| 77 | + | // cross-repository interpretation is performed. | |
| 78 | + | func ScanMergeRequest(title, description string, notes, commitMessages []string) MergeRequestReferences { | |
| 79 | + | mentions, closes := make(map[int64]struct{}), make(map[int64]struct{}) | |
| 80 | + | scan := func(text string, closing bool) { | |
| 81 | + | if len(text) > MaxReferenceTextBytes { | |
| 82 | + | text = text[:MaxReferenceTextBytes] | |
| 83 | + | } | |
| 84 | + | for _, n := range FindReferences(text) { | |
| 85 | + | mentions[n] = struct{}{} | |
| 86 | + | } | |
| 87 | + | if closing { | |
| 88 | + | for _, n := range FindClosingReferences(text) { | |
| 89 | + | closes[n] = struct{}{} | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | scan(title, true) | |
| 94 | + | scan(description, true) | |
| 95 | + | for _, text := range notes { | |
| 96 | + | scan(text, false) | |
| 97 | + | } | |
| 98 | + | for _, text := range commitMessages { | |
| 99 | + | scan(text, true) | |
| 100 | + | } | |
| 101 | + | return MergeRequestReferences{Mentions: sortedReferences(mentions), Closes: sortedReferences(closes)} | |
| 102 | + | } | |
| @@ -0,0 +1,102 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "regexp" | |||
| 5 | + | "slices" | |||
| 6 | + | "strconv" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | const maxReferenceDigits = 10 | |||
| 10 | + | ||||
| 11 | + | // MaxReferenceTextBytes bounds each title, description, note and commit message scanned. | |||
| 12 | + | const MaxReferenceTextBytes = 16 << 10 | |||
| 13 | + | ||||
| 14 | + | var numberRef = regexp.MustCompile(`(^|[^0-9A-Za-z_&#])#([1-9][0-9]{0,9})\b`) | |||
| 15 | + | var closingRef = regexp.MustCompile(`(?i)(?:^|[^0-9A-Za-z_])(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)(?::[ \t]*|[ \t]+)#([1-9][0-9]{0,9})\b((?:[ \t]*,[ \t]*#[1-9][0-9]{0,9}\b|[ \t]+and[ \t]+#[1-9][0-9]{0,9}\b)*)`) | |||
| 16 | + | ||||
| 17 | + | // FindReferences returns the positive #N numbers in text, sorted and unique. | |||
| 18 | + | // Numbers glued to words, entities or another # are excluded, as are zero, | |||
| 19 | + | // leading zeroes and numbers longer than ten digits. Code is scanned as text; | |||
| 20 | + | // cross-repository references and !N are not supported. | |||
| 21 | + | func FindReferences(text string) []int64 { | |||
| 22 | + | numbers := make(map[int64]struct{}) | |||
| 23 | + | // Resume at the end of the number, retaining its following delimiter for the | |||
| 24 | + | // next match (for example, both references in "#1,#2"). | |||
| 25 | + | for offset := 0; offset < len(text); { | |||
| 26 | + | match := numberRef.FindStringSubmatchIndex(text[offset:]) | |||
| 27 | + | if match == nil { | |||
| 28 | + | break | |||
| 29 | + | } | |||
| 30 | + | start, end := offset+match[4], offset+match[5] | |||
| 31 | + | // A sliced search must not manufacture a beginning-of-text boundary. | |||
| 32 | + | if start < 2 || !referenceWord(text[start-2]) { | |||
| 33 | + | n, _ := strconv.ParseInt(text[start:end], 10, 64) | |||
| 34 | + | numbers[n] = struct{}{} | |||
| 35 | + | } | |||
| 36 | + | offset = end | |||
| 37 | + | } | |||
| 38 | + | return sortedReferences(numbers) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func referenceWord(b byte) bool { | |||
| 42 | + | return b >= '0' && b <= '9' || b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z' || b == '_' || b == '&' || b == '#' | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // FindClosingReferences returns sorted, unique references following close, | |||
| 46 | + | // closes, closed, fix, fixes, fixed, resolve, resolves or resolved (case | |||
| 47 | + | // insensitive). A colon is optional; lists use commas or "and", not spaces. | |||
| 48 | + | // Like FindReferences, this scans code spans and fences as ordinary text. | |||
| 49 | + | func FindClosingReferences(text string) []int64 { | |||
| 50 | + | numbers := make(map[int64]struct{}) | |||
| 51 | + | for _, match := range closingRef.FindAllString(text, -1) { | |||
| 52 | + | for _, n := range FindReferences(match) { | |||
| 53 | + | numbers[n] = struct{}{} | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | return sortedReferences(numbers) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func sortedReferences(numbers map[int64]struct{}) []int64 { | |||
| 60 | + | var result []int64 | |||
| 61 | + | for n := range numbers { | |||
| 62 | + | result = append(result, n) | |||
| 63 | + | } | |||
| 64 | + | slices.Sort(result) | |||
| 65 | + | return result | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // MergeRequestReferences describes the issues named by a merge request. | |||
| 69 | + | type MergeRequestReferences struct { | |||
| 70 | + | Mentions []int64 | |||
| 71 | + | Closes []int64 | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // ScanMergeRequest scans titles, descriptions, notes and commit messages for | |||
| 75 | + | // references. Notes can mention issues but cannot close them. Each individual | |||
| 76 | + | // text is truncated to MaxReferenceTextBytes before scanning; no Markdown or | |||
| 77 | + | // cross-repository interpretation is performed. | |||
| 78 | + | func ScanMergeRequest(title, description string, notes, commitMessages []string) MergeRequestReferences { | |||
| 79 | + | mentions, closes := make(map[int64]struct{}), make(map[int64]struct{}) | |||
| 80 | + | scan := func(text string, closing bool) { | |||
| 81 | + | if len(text) > MaxReferenceTextBytes { | |||
| 82 | + | text = text[:MaxReferenceTextBytes] | |||
| 83 | + | } | |||
| 84 | + | for _, n := range FindReferences(text) { | |||
| 85 | + | mentions[n] = struct{}{} | |||
| 86 | + | } | |||
| 87 | + | if closing { | |||
| 88 | + | for _, n := range FindClosingReferences(text) { | |||
| 89 | + | closes[n] = struct{}{} | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | scan(title, true) | |||
| 94 | + | scan(description, true) | |||
| 95 | + | for _, text := range notes { | |||
| 96 | + | scan(text, false) | |||
| 97 | + | } | |||
| 98 | + | for _, text := range commitMessages { | |||
| 99 | + | scan(text, true) | |||
| 100 | + | } | |||
| 101 | + | return MergeRequestReferences{Mentions: sortedReferences(mentions), Closes: sortedReferences(closes)} | |||
| 102 | + | } | |||
A internal/domain/reference_test.go +59 -0
| @@ -0,0 +1,59 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "reflect" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestFindReferences(t *testing.T) { | |
| 10 | + | tests := []struct { | |
| 11 | + | text string | |
| 12 | + | mentions, closes []int64 | |
| 13 | + | }{ | |
| 14 | + | {"abc#3 a_b#3 1#3 ' ##3 #3abc #03 #0", nil, nil}, | |
| 15 | + | {"(#3) see #3, \"#3\"", []int64{3}, nil}, | |
| 16 | + | {"fixes #3.", []int64{3}, []int64{3}}, | |
| 17 | + | {"Fixes: #3", []int64{3}, []int64{3}}, | |
| 18 | + | {"fixes:#3", []int64{3}, []int64{3}}, | |
| 19 | + | {"Closes #3", []int64{3}, []int64{3}}, | |
| 20 | + | {"RESOLVED #3", []int64{3}, []int64{3}}, | |
| 21 | + | {"fix #3", []int64{3}, []int64{3}}, | |
| 22 | + | {"prefixes #3", []int64{3}, nil}, | |
| 23 | + | {"fixes 3 fixes#3", nil, nil}, | |
| 24 | + | {"fixes #3, #4 and #5", []int64{3, 4, 5}, []int64{3, 4, 5}}, | |
| 25 | + | {"fixes #3 #4", []int64{3, 4}, []int64{3}}, | |
| 26 | + | {"#5,#3,#5", []int64{3, 5}, nil}, | |
| 27 | + | {"#1234567890 #12345678901 !3 owner/repo#3", []int64{1234567890}, nil}, | |
| 28 | + | {"`fixes #3`\n```\ncloses #4\n```", []int64{3, 4}, []int64{3, 4}}, | |
| 29 | + | {"close #1 closed #2 fixes #3 fixed #4 resolve #5 resolves #6", []int64{1, 2, 3, 4, 5, 6}, []int64{1, 2, 3, 4, 5, 6}}, | |
| 30 | + | } | |
| 31 | + | for _, tt := range tests { | |
| 32 | + | t.Run(tt.text, func(t *testing.T) { | |
| 33 | + | if got := FindReferences(tt.text); !reflect.DeepEqual(got, tt.mentions) { | |
| 34 | + | t.Errorf("mentions = %v, want %v", got, tt.mentions) | |
| 35 | + | } | |
| 36 | + | if got := FindClosingReferences(tt.text); !reflect.DeepEqual(got, tt.closes) { | |
| 37 | + | t.Errorf("closes = %v, want %v", got, tt.closes) | |
| 38 | + | } | |
| 39 | + | }) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestScanMergeRequest(t *testing.T) { | |
| 44 | + | got := ScanMergeRequest("fixes #3", "#2", []string{"closes #4"}, []string{"resolved #5", strings.Repeat(" ", MaxReferenceTextBytes) + "fixes #6"}) | |
| 45 | + | want := MergeRequestReferences{Mentions: []int64{2, 3, 4, 5}, Closes: []int64{3, 5}} | |
| 46 | + | if !reflect.DeepEqual(got, want) { | |
| 47 | + | t.Fatalf("got %+v, want %+v", got, want) | |
| 48 | + | } | |
| 49 | + | for _, scan := range []func(string) MergeRequestReferences{ | |
| 50 | + | func(s string) MergeRequestReferences { return ScanMergeRequest(s, "", nil, nil) }, | |
| 51 | + | func(s string) MergeRequestReferences { return ScanMergeRequest("", s, nil, nil) }, | |
| 52 | + | func(s string) MergeRequestReferences { return ScanMergeRequest("", "", []string{s}, nil) }, | |
| 53 | + | func(s string) MergeRequestReferences { return ScanMergeRequest("", "", nil, []string{s}) }, | |
| 54 | + | } { | |
| 55 | + | if got := scan(strings.Repeat(" ", MaxReferenceTextBytes) + "#7"); len(got.Mentions) != 0 || len(got.Closes) != 0 { | |
| 56 | + | t.Errorf("text beyond limit scanned: %+v", got) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | } | |
| @@ -0,0 +1,59 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "reflect" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestFindReferences(t *testing.T) { | |||
| 10 | + | tests := []struct { | |||
| 11 | + | text string | |||
| 12 | + | mentions, closes []int64 | |||
| 13 | + | }{ | |||
| 14 | + | {"abc#3 a_b#3 1#3 ' ##3 #3abc #03 #0", nil, nil}, | |||
| 15 | + | {"(#3) see #3, \"#3\"", []int64{3}, nil}, | |||
| 16 | + | {"fixes #3.", []int64{3}, []int64{3}}, | |||
| 17 | + | {"Fixes: #3", []int64{3}, []int64{3}}, | |||
| 18 | + | {"fixes:#3", []int64{3}, []int64{3}}, | |||
| 19 | + | {"Closes #3", []int64{3}, []int64{3}}, | |||
| 20 | + | {"RESOLVED #3", []int64{3}, []int64{3}}, | |||
| 21 | + | {"fix #3", []int64{3}, []int64{3}}, | |||
| 22 | + | {"prefixes #3", []int64{3}, nil}, | |||
| 23 | + | {"fixes 3 fixes#3", nil, nil}, | |||
| 24 | + | {"fixes #3, #4 and #5", []int64{3, 4, 5}, []int64{3, 4, 5}}, | |||
| 25 | + | {"fixes #3 #4", []int64{3, 4}, []int64{3}}, | |||
| 26 | + | {"#5,#3,#5", []int64{3, 5}, nil}, | |||
| 27 | + | {"#1234567890 #12345678901 !3 owner/repo#3", []int64{1234567890}, nil}, | |||
| 28 | + | {"`fixes #3`\n```\ncloses #4\n```", []int64{3, 4}, []int64{3, 4}}, | |||
| 29 | + | {"close #1 closed #2 fixes #3 fixed #4 resolve #5 resolves #6", []int64{1, 2, 3, 4, 5, 6}, []int64{1, 2, 3, 4, 5, 6}}, | |||
| 30 | + | } | |||
| 31 | + | for _, tt := range tests { | |||
| 32 | + | t.Run(tt.text, func(t *testing.T) { | |||
| 33 | + | if got := FindReferences(tt.text); !reflect.DeepEqual(got, tt.mentions) { | |||
| 34 | + | t.Errorf("mentions = %v, want %v", got, tt.mentions) | |||
| 35 | + | } | |||
| 36 | + | if got := FindClosingReferences(tt.text); !reflect.DeepEqual(got, tt.closes) { | |||
| 37 | + | t.Errorf("closes = %v, want %v", got, tt.closes) | |||
| 38 | + | } | |||
| 39 | + | }) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestScanMergeRequest(t *testing.T) { | |||
| 44 | + | got := ScanMergeRequest("fixes #3", "#2", []string{"closes #4"}, []string{"resolved #5", strings.Repeat(" ", MaxReferenceTextBytes) + "fixes #6"}) | |||
| 45 | + | want := MergeRequestReferences{Mentions: []int64{2, 3, 4, 5}, Closes: []int64{3, 5}} | |||
| 46 | + | if !reflect.DeepEqual(got, want) { | |||
| 47 | + | t.Fatalf("got %+v, want %+v", got, want) | |||
| 48 | + | } | |||
| 49 | + | for _, scan := range []func(string) MergeRequestReferences{ | |||
| 50 | + | func(s string) MergeRequestReferences { return ScanMergeRequest(s, "", nil, nil) }, | |||
| 51 | + | func(s string) MergeRequestReferences { return ScanMergeRequest("", s, nil, nil) }, | |||
| 52 | + | func(s string) MergeRequestReferences { return ScanMergeRequest("", "", []string{s}, nil) }, | |||
| 53 | + | func(s string) MergeRequestReferences { return ScanMergeRequest("", "", nil, []string{s}) }, | |||
| 54 | + | } { | |||
| 55 | + | if got := scan(strings.Repeat(" ", MaxReferenceTextBytes) + "#7"); len(got.Mentions) != 0 || len(got.Closes) != 0 { | |||
| 56 | + | t.Errorf("text beyond limit scanned: %+v", got) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | } | |||
A internal/ports/mocks/IssueLinkStore.go +296 -0
| @@ -0,0 +1,296 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewIssueLinkStore creates a new instance of IssueLinkStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewIssueLinkStore(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *IssueLinkStore { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &IssueLinkStore{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // IssueLinkStore is an autogenerated mock type for the IssueLinkStore type | |
| 38 | + | type IssueLinkStore struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type IssueLinkStore_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *IssueLinkStore) EXPECT() *IssueLinkStore_Expecter { | |
| 47 | + | return &IssueLinkStore_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // LinksOfIssue provides a mock function for the type IssueLinkStore | |
| 51 | + | func (_mock *IssueLinkStore) LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error) { | |
| 52 | + | ret := _mock.Called(ctx, repo, issue, limit) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for LinksOfIssue") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 []domain.IssueLink | |
| 59 | + | var r1 error | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, int) ([]domain.IssueLink, error)); ok { | |
| 61 | + | return returnFunc(ctx, repo, issue, limit) | |
| 62 | + | } | |
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, int) []domain.IssueLink); ok { | |
| 64 | + | r0 = returnFunc(ctx, repo, issue, limit) | |
| 65 | + | } else { | |
| 66 | + | if ret.Get(0) != nil { | |
| 67 | + | r0 = ret.Get(0).([]domain.IssueLink) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64, int) error); ok { | |
| 71 | + | r1 = returnFunc(ctx, repo, issue, limit) | |
| 72 | + | } else { | |
| 73 | + | r1 = ret.Error(1) | |
| 74 | + | } | |
| 75 | + | return r0, r1 | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // IssueLinkStore_LinksOfIssue_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinksOfIssue' | |
| 79 | + | type IssueLinkStore_LinksOfIssue_Call struct { | |
| 80 | + | *mock.Call | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // LinksOfIssue is a helper method to define mock.On call | |
| 84 | + | // - ctx context.Context | |
| 85 | + | // - repo domain.RepoName | |
| 86 | + | // - issue int64 | |
| 87 | + | // - limit int | |
| 88 | + | func (_e *IssueLinkStore_Expecter) LinksOfIssue(ctx any, repo any, issue any, limit any) *IssueLinkStore_LinksOfIssue_Call { | |
| 89 | + | return &IssueLinkStore_LinksOfIssue_Call{Call: _e.mock.On("LinksOfIssue", ctx, repo, issue, limit)} | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (_c *IssueLinkStore_LinksOfIssue_Call) Run(run func(ctx context.Context, repo domain.RepoName, issue int64, limit int)) *IssueLinkStore_LinksOfIssue_Call { | |
| 93 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 94 | + | var arg0 context.Context | |
| 95 | + | if args[0] != nil { | |
| 96 | + | arg0 = args[0].(context.Context) | |
| 97 | + | } | |
| 98 | + | var arg1 domain.RepoName | |
| 99 | + | if args[1] != nil { | |
| 100 | + | arg1 = args[1].(domain.RepoName) | |
| 101 | + | } | |
| 102 | + | var arg2 int64 | |
| 103 | + | if args[2] != nil { | |
| 104 | + | arg2 = args[2].(int64) | |
| 105 | + | } | |
| 106 | + | var arg3 int | |
| 107 | + | if args[3] != nil { | |
| 108 | + | arg3 = args[3].(int) | |
| 109 | + | } | |
| 110 | + | run( | |
| 111 | + | arg0, | |
| 112 | + | arg1, | |
| 113 | + | arg2, | |
| 114 | + | arg3, | |
| 115 | + | ) | |
| 116 | + | }) | |
| 117 | + | return _c | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func (_c *IssueLinkStore_LinksOfIssue_Call) Return(issueLinks []domain.IssueLink, err error) *IssueLinkStore_LinksOfIssue_Call { | |
| 121 | + | _c.Call.Return(issueLinks, err) | |
| 122 | + | return _c | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func (_c *IssueLinkStore_LinksOfIssue_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error)) *IssueLinkStore_LinksOfIssue_Call { | |
| 126 | + | _c.Call.Return(run) | |
| 127 | + | return _c | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // LinksOfMergeRequest provides a mock function for the type IssueLinkStore | |
| 131 | + | func (_mock *IssueLinkStore) LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error) { | |
| 132 | + | ret := _mock.Called(ctx, repo, mr) | |
| 133 | + | ||
| 134 | + | if len(ret) == 0 { | |
| 135 | + | panic("no return value specified for LinksOfMergeRequest") | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | var r0 []domain.IssueLink | |
| 139 | + | var r1 error | |
| 140 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) ([]domain.IssueLink, error)); ok { | |
| 141 | + | return returnFunc(ctx, repo, mr) | |
| 142 | + | } | |
| 143 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) []domain.IssueLink); ok { | |
| 144 | + | r0 = returnFunc(ctx, repo, mr) | |
| 145 | + | } else { | |
| 146 | + | if ret.Get(0) != nil { | |
| 147 | + | r0 = ret.Get(0).([]domain.IssueLink) | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok { | |
| 151 | + | r1 = returnFunc(ctx, repo, mr) | |
| 152 | + | } else { | |
| 153 | + | r1 = ret.Error(1) | |
| 154 | + | } | |
| 155 | + | return r0, r1 | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // IssueLinkStore_LinksOfMergeRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinksOfMergeRequest' | |
| 159 | + | type IssueLinkStore_LinksOfMergeRequest_Call struct { | |
| 160 | + | *mock.Call | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // LinksOfMergeRequest is a helper method to define mock.On call | |
| 164 | + | // - ctx context.Context | |
| 165 | + | // - repo domain.RepoName | |
| 166 | + | // - mr int64 | |
| 167 | + | func (_e *IssueLinkStore_Expecter) LinksOfMergeRequest(ctx any, repo any, mr any) *IssueLinkStore_LinksOfMergeRequest_Call { | |
| 168 | + | return &IssueLinkStore_LinksOfMergeRequest_Call{Call: _e.mock.On("LinksOfMergeRequest", ctx, repo, mr)} | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func (_c *IssueLinkStore_LinksOfMergeRequest_Call) Run(run func(ctx context.Context, repo domain.RepoName, mr int64)) *IssueLinkStore_LinksOfMergeRequest_Call { | |
| 172 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 173 | + | var arg0 context.Context | |
| 174 | + | if args[0] != nil { | |
| 175 | + | arg0 = args[0].(context.Context) | |
| 176 | + | } | |
| 177 | + | var arg1 domain.RepoName | |
| 178 | + | if args[1] != nil { | |
| 179 | + | arg1 = args[1].(domain.RepoName) | |
| 180 | + | } | |
| 181 | + | var arg2 int64 | |
| 182 | + | if args[2] != nil { | |
| 183 | + | arg2 = args[2].(int64) | |
| 184 | + | } | |
| 185 | + | run( | |
| 186 | + | arg0, | |
| 187 | + | arg1, | |
| 188 | + | arg2, | |
| 189 | + | ) | |
| 190 | + | }) | |
| 191 | + | return _c | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func (_c *IssueLinkStore_LinksOfMergeRequest_Call) Return(issueLinks []domain.IssueLink, err error) *IssueLinkStore_LinksOfMergeRequest_Call { | |
| 195 | + | _c.Call.Return(issueLinks, err) | |
| 196 | + | return _c | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func (_c *IssueLinkStore_LinksOfMergeRequest_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error)) *IssueLinkStore_LinksOfMergeRequest_Call { | |
| 200 | + | _c.Call.Return(run) | |
| 201 | + | return _c | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // ReplaceLinks provides a mock function for the type IssueLinkStore | |
| 205 | + | func (_mock *IssueLinkStore) ReplaceLinks(ctx context.Context, repo domain.RepoName, mr int64, issues []int64, closes []int64) ([]domain.IssueLink, []domain.IssueLink, error) { | |
| 206 | + | ret := _mock.Called(ctx, repo, mr, issues, closes) | |
| 207 | + | ||
| 208 | + | if len(ret) == 0 { | |
| 209 | + | panic("no return value specified for ReplaceLinks") | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | var r0 []domain.IssueLink | |
| 213 | + | var r1 []domain.IssueLink | |
| 214 | + | var r2 error | |
| 215 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, []int64, []int64) ([]domain.IssueLink, []domain.IssueLink, error)); ok { | |
| 216 | + | return returnFunc(ctx, repo, mr, issues, closes) | |
| 217 | + | } | |
| 218 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, []int64, []int64) []domain.IssueLink); ok { | |
| 219 | + | r0 = returnFunc(ctx, repo, mr, issues, closes) | |
| 220 | + | } else { | |
| 221 | + | if ret.Get(0) != nil { | |
| 222 | + | r0 = ret.Get(0).([]domain.IssueLink) | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64, []int64, []int64) []domain.IssueLink); ok { | |
| 226 | + | r1 = returnFunc(ctx, repo, mr, issues, closes) | |
| 227 | + | } else { | |
| 228 | + | if ret.Get(1) != nil { | |
| 229 | + | r1 = ret.Get(1).([]domain.IssueLink) | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName, int64, []int64, []int64) error); ok { | |
| 233 | + | r2 = returnFunc(ctx, repo, mr, issues, closes) | |
| 234 | + | } else { | |
| 235 | + | r2 = ret.Error(2) | |
| 236 | + | } | |
| 237 | + | return r0, r1, r2 | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // IssueLinkStore_ReplaceLinks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReplaceLinks' | |
| 241 | + | type IssueLinkStore_ReplaceLinks_Call struct { | |
| 242 | + | *mock.Call | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | // ReplaceLinks is a helper method to define mock.On call | |
| 246 | + | // - ctx context.Context | |
| 247 | + | // - repo domain.RepoName | |
| 248 | + | // - mr int64 | |
| 249 | + | // - issues []int64 | |
| 250 | + | // - closes []int64 | |
| 251 | + | func (_e *IssueLinkStore_Expecter) ReplaceLinks(ctx any, repo any, mr any, issues any, closes any) *IssueLinkStore_ReplaceLinks_Call { | |
| 252 | + | return &IssueLinkStore_ReplaceLinks_Call{Call: _e.mock.On("ReplaceLinks", ctx, repo, mr, issues, closes)} | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func (_c *IssueLinkStore_ReplaceLinks_Call) Run(run func(ctx context.Context, repo domain.RepoName, mr int64, issues []int64, closes []int64)) *IssueLinkStore_ReplaceLinks_Call { | |
| 256 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 257 | + | var arg0 context.Context | |
| 258 | + | if args[0] != nil { | |
| 259 | + | arg0 = args[0].(context.Context) | |
| 260 | + | } | |
| 261 | + | var arg1 domain.RepoName | |
| 262 | + | if args[1] != nil { | |
| 263 | + | arg1 = args[1].(domain.RepoName) | |
| 264 | + | } | |
| 265 | + | var arg2 int64 | |
| 266 | + | if args[2] != nil { | |
| 267 | + | arg2 = args[2].(int64) | |
| 268 | + | } | |
| 269 | + | var arg3 []int64 | |
| 270 | + | if args[3] != nil { | |
| 271 | + | arg3 = args[3].([]int64) | |
| 272 | + | } | |
| 273 | + | var arg4 []int64 | |
| 274 | + | if args[4] != nil { | |
| 275 | + | arg4 = args[4].([]int64) | |
| 276 | + | } | |
| 277 | + | run( | |
| 278 | + | arg0, | |
| 279 | + | arg1, | |
| 280 | + | arg2, | |
| 281 | + | arg3, | |
| 282 | + | arg4, | |
| 283 | + | ) | |
| 284 | + | }) | |
| 285 | + | return _c | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func (_c *IssueLinkStore_ReplaceLinks_Call) Return(added []domain.IssueLink, removed []domain.IssueLink, err error) *IssueLinkStore_ReplaceLinks_Call { | |
| 289 | + | _c.Call.Return(added, removed, err) | |
| 290 | + | return _c | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | func (_c *IssueLinkStore_ReplaceLinks_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, mr int64, issues []int64, closes []int64) ([]domain.IssueLink, []domain.IssueLink, error)) *IssueLinkStore_ReplaceLinks_Call { | |
| 294 | + | _c.Call.Return(run) | |
| 295 | + | return _c | |
| 296 | + | } | |
| @@ -0,0 +1,296 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | mock "github.com/stretchr/testify/mock" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // NewIssueLinkStore creates a new instance of IssueLinkStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 15 | + | // The first argument is typically a *testing.T value. | |||
| 16 | + | func NewIssueLinkStore(t interface { | |||
| 17 | + | mock.TestingT | |||
| 18 | + | Cleanup(func()) | |||
| 19 | + | }) *IssueLinkStore { | |||
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 21 | + | helper.Helper() | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | mock := &IssueLinkStore{} | |||
| 25 | + | mock.Mock.Test(t) | |||
| 26 | + | ||||
| 27 | + | t.Cleanup(func() { | |||
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 29 | + | helper.Helper() | |||
| 30 | + | } | |||
| 31 | + | mock.AssertExpectations(t) | |||
| 32 | + | }) | |||
| 33 | + | ||||
| 34 | + | return mock | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // IssueLinkStore is an autogenerated mock type for the IssueLinkStore type | |||
| 38 | + | type IssueLinkStore struct { | |||
| 39 | + | mock.Mock | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type IssueLinkStore_Expecter struct { | |||
| 43 | + | mock *mock.Mock | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (_m *IssueLinkStore) EXPECT() *IssueLinkStore_Expecter { | |||
| 47 | + | return &IssueLinkStore_Expecter{mock: &_m.Mock} | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // LinksOfIssue provides a mock function for the type IssueLinkStore | |||
| 51 | + | func (_mock *IssueLinkStore) LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error) { | |||
| 52 | + | ret := _mock.Called(ctx, repo, issue, limit) | |||
| 53 | + | ||||
| 54 | + | if len(ret) == 0 { | |||
| 55 | + | panic("no return value specified for LinksOfIssue") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | var r0 []domain.IssueLink | |||
| 59 | + | var r1 error | |||
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, int) ([]domain.IssueLink, error)); ok { | |||
| 61 | + | return returnFunc(ctx, repo, issue, limit) | |||
| 62 | + | } | |||
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, int) []domain.IssueLink); ok { | |||
| 64 | + | r0 = returnFunc(ctx, repo, issue, limit) | |||
| 65 | + | } else { | |||
| 66 | + | if ret.Get(0) != nil { | |||
| 67 | + | r0 = ret.Get(0).([]domain.IssueLink) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64, int) error); ok { | |||
| 71 | + | r1 = returnFunc(ctx, repo, issue, limit) | |||
| 72 | + | } else { | |||
| 73 | + | r1 = ret.Error(1) | |||
| 74 | + | } | |||
| 75 | + | return r0, r1 | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // IssueLinkStore_LinksOfIssue_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinksOfIssue' | |||
| 79 | + | type IssueLinkStore_LinksOfIssue_Call struct { | |||
| 80 | + | *mock.Call | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // LinksOfIssue is a helper method to define mock.On call | |||
| 84 | + | // - ctx context.Context | |||
| 85 | + | // - repo domain.RepoName | |||
| 86 | + | // - issue int64 | |||
| 87 | + | // - limit int | |||
| 88 | + | func (_e *IssueLinkStore_Expecter) LinksOfIssue(ctx any, repo any, issue any, limit any) *IssueLinkStore_LinksOfIssue_Call { | |||
| 89 | + | return &IssueLinkStore_LinksOfIssue_Call{Call: _e.mock.On("LinksOfIssue", ctx, repo, issue, limit)} | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func (_c *IssueLinkStore_LinksOfIssue_Call) Run(run func(ctx context.Context, repo domain.RepoName, issue int64, limit int)) *IssueLinkStore_LinksOfIssue_Call { | |||
| 93 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 94 | + | var arg0 context.Context | |||
| 95 | + | if args[0] != nil { | |||
| 96 | + | arg0 = args[0].(context.Context) | |||
| 97 | + | } | |||
| 98 | + | var arg1 domain.RepoName | |||
| 99 | + | if args[1] != nil { | |||
| 100 | + | arg1 = args[1].(domain.RepoName) | |||
| 101 | + | } | |||
| 102 | + | var arg2 int64 | |||
| 103 | + | if args[2] != nil { | |||
| 104 | + | arg2 = args[2].(int64) | |||
| 105 | + | } | |||
| 106 | + | var arg3 int | |||
| 107 | + | if args[3] != nil { | |||
| 108 | + | arg3 = args[3].(int) | |||
| 109 | + | } | |||
| 110 | + | run( | |||
| 111 | + | arg0, | |||
| 112 | + | arg1, | |||
| 113 | + | arg2, | |||
| 114 | + | arg3, | |||
| 115 | + | ) | |||
| 116 | + | }) | |||
| 117 | + | return _c | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func (_c *IssueLinkStore_LinksOfIssue_Call) Return(issueLinks []domain.IssueLink, err error) *IssueLinkStore_LinksOfIssue_Call { | |||
| 121 | + | _c.Call.Return(issueLinks, err) | |||
| 122 | + | return _c | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func (_c *IssueLinkStore_LinksOfIssue_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error)) *IssueLinkStore_LinksOfIssue_Call { | |||
| 126 | + | _c.Call.Return(run) | |||
| 127 | + | return _c | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // LinksOfMergeRequest provides a mock function for the type IssueLinkStore | |||
| 131 | + | func (_mock *IssueLinkStore) LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error) { | |||
| 132 | + | ret := _mock.Called(ctx, repo, mr) | |||
| 133 | + | ||||
| 134 | + | if len(ret) == 0 { | |||
| 135 | + | panic("no return value specified for LinksOfMergeRequest") | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | var r0 []domain.IssueLink | |||
| 139 | + | var r1 error | |||
| 140 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) ([]domain.IssueLink, error)); ok { | |||
| 141 | + | return returnFunc(ctx, repo, mr) | |||
| 142 | + | } | |||
| 143 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) []domain.IssueLink); ok { | |||
| 144 | + | r0 = returnFunc(ctx, repo, mr) | |||
| 145 | + | } else { | |||
| 146 | + | if ret.Get(0) != nil { | |||
| 147 | + | r0 = ret.Get(0).([]domain.IssueLink) | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok { | |||
| 151 | + | r1 = returnFunc(ctx, repo, mr) | |||
| 152 | + | } else { | |||
| 153 | + | r1 = ret.Error(1) | |||
| 154 | + | } | |||
| 155 | + | return r0, r1 | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | // IssueLinkStore_LinksOfMergeRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinksOfMergeRequest' | |||
| 159 | + | type IssueLinkStore_LinksOfMergeRequest_Call struct { | |||
| 160 | + | *mock.Call | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | // LinksOfMergeRequest is a helper method to define mock.On call | |||
| 164 | + | // - ctx context.Context | |||
| 165 | + | // - repo domain.RepoName | |||
| 166 | + | // - mr int64 | |||
| 167 | + | func (_e *IssueLinkStore_Expecter) LinksOfMergeRequest(ctx any, repo any, mr any) *IssueLinkStore_LinksOfMergeRequest_Call { | |||
| 168 | + | return &IssueLinkStore_LinksOfMergeRequest_Call{Call: _e.mock.On("LinksOfMergeRequest", ctx, repo, mr)} | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func (_c *IssueLinkStore_LinksOfMergeRequest_Call) Run(run func(ctx context.Context, repo domain.RepoName, mr int64)) *IssueLinkStore_LinksOfMergeRequest_Call { | |||
| 172 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 173 | + | var arg0 context.Context | |||
| 174 | + | if args[0] != nil { | |||
| 175 | + | arg0 = args[0].(context.Context) | |||
| 176 | + | } | |||
| 177 | + | var arg1 domain.RepoName | |||
| 178 | + | if args[1] != nil { | |||
| 179 | + | arg1 = args[1].(domain.RepoName) | |||
| 180 | + | } | |||
| 181 | + | var arg2 int64 | |||
| 182 | + | if args[2] != nil { | |||
| 183 | + | arg2 = args[2].(int64) | |||
| 184 | + | } | |||
| 185 | + | run( | |||
| 186 | + | arg0, | |||
| 187 | + | arg1, | |||
| 188 | + | arg2, | |||
| 189 | + | ) | |||
| 190 | + | }) | |||
| 191 | + | return _c | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | func (_c *IssueLinkStore_LinksOfMergeRequest_Call) Return(issueLinks []domain.IssueLink, err error) *IssueLinkStore_LinksOfMergeRequest_Call { | |||
| 195 | + | _c.Call.Return(issueLinks, err) | |||
| 196 | + | return _c | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func (_c *IssueLinkStore_LinksOfMergeRequest_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error)) *IssueLinkStore_LinksOfMergeRequest_Call { | |||
| 200 | + | _c.Call.Return(run) | |||
| 201 | + | return _c | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // ReplaceLinks provides a mock function for the type IssueLinkStore | |||
| 205 | + | func (_mock *IssueLinkStore) ReplaceLinks(ctx context.Context, repo domain.RepoName, mr int64, issues []int64, closes []int64) ([]domain.IssueLink, []domain.IssueLink, error) { | |||
| 206 | + | ret := _mock.Called(ctx, repo, mr, issues, closes) | |||
| 207 | + | ||||
| 208 | + | if len(ret) == 0 { | |||
| 209 | + | panic("no return value specified for ReplaceLinks") | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | var r0 []domain.IssueLink | |||
| 213 | + | var r1 []domain.IssueLink | |||
| 214 | + | var r2 error | |||
| 215 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, []int64, []int64) ([]domain.IssueLink, []domain.IssueLink, error)); ok { | |||
| 216 | + | return returnFunc(ctx, repo, mr, issues, closes) | |||
| 217 | + | } | |||
| 218 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, []int64, []int64) []domain.IssueLink); ok { | |||
| 219 | + | r0 = returnFunc(ctx, repo, mr, issues, closes) | |||
| 220 | + | } else { | |||
| 221 | + | if ret.Get(0) != nil { | |||
| 222 | + | r0 = ret.Get(0).([]domain.IssueLink) | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64, []int64, []int64) []domain.IssueLink); ok { | |||
| 226 | + | r1 = returnFunc(ctx, repo, mr, issues, closes) | |||
| 227 | + | } else { | |||
| 228 | + | if ret.Get(1) != nil { | |||
| 229 | + | r1 = ret.Get(1).([]domain.IssueLink) | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName, int64, []int64, []int64) error); ok { | |||
| 233 | + | r2 = returnFunc(ctx, repo, mr, issues, closes) | |||
| 234 | + | } else { | |||
| 235 | + | r2 = ret.Error(2) | |||
| 236 | + | } | |||
| 237 | + | return r0, r1, r2 | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | // IssueLinkStore_ReplaceLinks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReplaceLinks' | |||
| 241 | + | type IssueLinkStore_ReplaceLinks_Call struct { | |||
| 242 | + | *mock.Call | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | // ReplaceLinks is a helper method to define mock.On call | |||
| 246 | + | // - ctx context.Context | |||
| 247 | + | // - repo domain.RepoName | |||
| 248 | + | // - mr int64 | |||
| 249 | + | // - issues []int64 | |||
| 250 | + | // - closes []int64 | |||
| 251 | + | func (_e *IssueLinkStore_Expecter) ReplaceLinks(ctx any, repo any, mr any, issues any, closes any) *IssueLinkStore_ReplaceLinks_Call { | |||
| 252 | + | return &IssueLinkStore_ReplaceLinks_Call{Call: _e.mock.On("ReplaceLinks", ctx, repo, mr, issues, closes)} | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | func (_c *IssueLinkStore_ReplaceLinks_Call) Run(run func(ctx context.Context, repo domain.RepoName, mr int64, issues []int64, closes []int64)) *IssueLinkStore_ReplaceLinks_Call { | |||
| 256 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 257 | + | var arg0 context.Context | |||
| 258 | + | if args[0] != nil { | |||
| 259 | + | arg0 = args[0].(context.Context) | |||
| 260 | + | } | |||
| 261 | + | var arg1 domain.RepoName | |||
| 262 | + | if args[1] != nil { | |||
| 263 | + | arg1 = args[1].(domain.RepoName) | |||
| 264 | + | } | |||
| 265 | + | var arg2 int64 | |||
| 266 | + | if args[2] != nil { | |||
| 267 | + | arg2 = args[2].(int64) | |||
| 268 | + | } | |||
| 269 | + | var arg3 []int64 | |||
| 270 | + | if args[3] != nil { | |||
| 271 | + | arg3 = args[3].([]int64) | |||
| 272 | + | } | |||
| 273 | + | var arg4 []int64 | |||
| 274 | + | if args[4] != nil { | |||
| 275 | + | arg4 = args[4].([]int64) | |||
| 276 | + | } | |||
| 277 | + | run( | |||
| 278 | + | arg0, | |||
| 279 | + | arg1, | |||
| 280 | + | arg2, | |||
| 281 | + | arg3, | |||
| 282 | + | arg4, | |||
| 283 | + | ) | |||
| 284 | + | }) | |||
| 285 | + | return _c | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | func (_c *IssueLinkStore_ReplaceLinks_Call) Return(added []domain.IssueLink, removed []domain.IssueLink, err error) *IssueLinkStore_ReplaceLinks_Call { | |||
| 289 | + | _c.Call.Return(added, removed, err) | |||
| 290 | + | return _c | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | func (_c *IssueLinkStore_ReplaceLinks_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, mr int64, issues []int64, closes []int64) ([]domain.IssueLink, []domain.IssueLink, error)) *IssueLinkStore_ReplaceLinks_Call { | |||
| 294 | + | _c.Call.Return(run) | |||
| 295 | + | return _c | |||
| 296 | + | } | |||
M internal/ports/ports.go +12 -0
| @@ -536,6 +536,18 @@ type EventBus interface { | |||
| 536 | 536 | Subscribe(ctx context.Context) <-chan domain.Event | |
| 537 | 537 | } | |
| 538 | 538 | ||
| 539 | + | // IssueLinkStore keeps which merge requests name which issues. | |
| 540 | + | type IssueLinkStore interface { | |
| 541 | + | // ReplaceLinks atomically replaces an MR's links (closes is a subset of issues). | |
| 542 | + | // Added includes new links and links whose closes flag turned on. | |
| 543 | + | // Returns ErrNotFound for a missing repository, ErrConflict on a race. | |
| 544 | + | ReplaceLinks(ctx context.Context, repo domain.RepoName, mr int64, issues, closes []int64) (added, removed []domain.IssueLink, err error) | |
| 545 | + | // LinksOfIssue returns links ascending by MR number, at most limit. | |
| 546 | + | LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error) | |
| 547 | + | // LinksOfMergeRequest returns the issues named by an MR. | |
| 548 | + | LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error) | |
| 549 | + | } | |
| 550 | + | ||
| 539 | 551 | // Clock tells the time. | |
| 540 | 552 | type Clock interface { | |
| 541 | 553 | Now() time.Time | |
| @@ -536,6 +536,18 @@ type EventBus interface { | |||||
| 536 | Subscribe(ctx context.Context) <-chan domain.Event | 536 | Subscribe(ctx context.Context) <-chan domain.Event | ||
| 537 | } | 537 | } | ||
| 538 | 538 | ||||
| 539 | + | // IssueLinkStore keeps which merge requests name which issues. | |||
| 540 | + | type IssueLinkStore interface { | |||
| 541 | + | // ReplaceLinks atomically replaces an MR's links (closes is a subset of issues). | |||
| 542 | + | // Added includes new links and links whose closes flag turned on. | |||
| 543 | + | // Returns ErrNotFound for a missing repository, ErrConflict on a race. | |||
| 544 | + | ReplaceLinks(ctx context.Context, repo domain.RepoName, mr int64, issues, closes []int64) (added, removed []domain.IssueLink, err error) | |||
| 545 | + | // LinksOfIssue returns links ascending by MR number, at most limit. | |||
| 546 | + | LinksOfIssue(ctx context.Context, repo domain.RepoName, issue int64, limit int) ([]domain.IssueLink, error) | |||
| 547 | + | // LinksOfMergeRequest returns the issues named by an MR. | |||
| 548 | + | LinksOfMergeRequest(ctx context.Context, repo domain.RepoName, mr int64) ([]domain.IssueLink, error) | |||
| 549 | + | } | |||
| 550 | + | ||||
| 539 | // Clock tells the time. | 551 | // Clock tells the time. | ||
| 540 | type Clock interface { | 552 | type Clock interface { | ||
| 541 | Now() time.Time | 553 | Now() time.Time | ||
M internal/ports/services.go +2 -1
| @@ -203,7 +203,8 @@ type IssueSubmissionService interface { | |||
| 203 | 203 | // implements it. | |
| 204 | 204 | type IssueNumberResolver interface { | |
| 205 | 205 | // Kind needs the access of reading the repository and answers | |
| 206 | - | // domain.ErrNotFound otherwise, also when issues are off. | |
| 206 | + | // domain.ErrNotFound otherwise. Issues are hidden when disabled; merge | |
| 207 | + | // requests are resolved even when issues are off. | |
| 207 | 208 | Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) | |
| 208 | 209 | } | |
| 209 | 210 | ||
| @@ -203,7 +203,8 @@ type IssueSubmissionService interface { | |||||
| 203 | // implements it. | 203 | // implements it. | ||
| 204 | type IssueNumberResolver interface { | 204 | type IssueNumberResolver interface { | ||
| 205 | // Kind needs the access of reading the repository and answers | 205 | // Kind needs the access of reading the repository and answers | ||
| 206 | - | // domain.ErrNotFound otherwise, also when issues are off. | 206 | + | // domain.ErrNotFound otherwise. Issues are hidden when disabled; merge |
| 207 | + | // requests are resolved even when issues are off. | |||
| 207 | Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) | 208 | Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) | ||
| 208 | } | 209 | } | ||
| 209 | 210 | ||||
M internal/usecase/events.go +7 -1
| @@ -186,6 +186,8 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent { | |||
| 186 | 186 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 187 | 187 | case domain.CommentAdded: | |
| 188 | 188 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 189 | + | case domain.IssueReferenced: | |
| 190 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 189 | 191 | case domain.IssueOpened: | |
| 190 | 192 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |
| 191 | 193 | case domain.IssueClosed: | |
| @@ -537,6 +539,8 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f | |||
| 537 | 539 | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | |
| 538 | 540 | case domain.CommentAdded: | |
| 539 | 541 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | |
| 542 | + | case domain.IssueReferenced: | |
| 543 | + | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | |
| 540 | 544 | case domain.IssueOpened: | |
| 541 | 545 | return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send) | |
| 542 | 546 | case domain.IssueClosed: | |
| @@ -637,7 +641,7 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName | |||
| 637 | 641 | // isIssueEvent reports whether ev is about an issue. | |
| 638 | 642 | func isIssueEvent(ev domain.Event) bool { | |
| 639 | 643 | switch ev.(type) { | |
| 640 | - | case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented: | |
| 644 | + | case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueReferenced: | |
| 641 | 645 | return true | |
| 642 | 646 | } | |
| 643 | 647 | return false | |
| @@ -656,6 +660,8 @@ func isRepoLifecycleEvent(ev domain.Event) bool { | |||
| 656 | 660 | // isItemEvent reports whether ev is about a merge request or an issue. | |
| 657 | 661 | func isItemEvent(ev domain.Event) bool { | |
| 658 | 662 | switch ev.(type) { | |
| 663 | + | case domain.IssueReferenced: | |
| 664 | + | return false // back-reference refreshes do not change the inbox | |
| 659 | 665 | case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, | |
| 660 | 666 | domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, | |
| 661 | 667 | domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved: | |
| @@ -186,6 +186,8 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent { | |||||
| 186 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | 186 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | ||
| 187 | case domain.CommentAdded: | 187 | case domain.CommentAdded: | ||
| 188 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | 188 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | ||
| 189 | + | case domain.IssueReferenced: | |||
| 190 | + | return repoSnapshot(ev.Visibility, ev.CreatedAt) | |||
| 189 | case domain.IssueOpened: | 191 | case domain.IssueOpened: | ||
| 190 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | 192 | return repoSnapshot(ev.Visibility, ev.CreatedAt) | ||
| 191 | case domain.IssueClosed: | 193 | case domain.IssueClosed: | ||
| @@ -537,6 +539,8 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f | |||||
| 537 | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | 539 | return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send) | ||
| 538 | case domain.CommentAdded: | 540 | case domain.CommentAdded: | ||
| 539 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | 541 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | ||
| 542 | + | case domain.IssueReferenced: | |||
| 543 | + | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | |||
| 540 | case domain.IssueOpened: | 544 | case domain.IssueOpened: | ||
| 541 | return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send) | 545 | return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send) | ||
| 542 | case domain.IssueClosed: | 546 | case domain.IssueClosed: | ||
| @@ -637,7 +641,7 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName | |||||
| 637 | // isIssueEvent reports whether ev is about an issue. | 641 | // isIssueEvent reports whether ev is about an issue. | ||
| 638 | func isIssueEvent(ev domain.Event) bool { | 642 | func isIssueEvent(ev domain.Event) bool { | ||
| 639 | switch ev.(type) { | 643 | switch ev.(type) { | ||
| 640 | - | case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented: | 644 | + | case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueReferenced: |
| 641 | return true | 645 | return true | ||
| 642 | } | 646 | } | ||
| 643 | return false | 647 | return false | ||
| @@ -656,6 +660,8 @@ func isRepoLifecycleEvent(ev domain.Event) bool { | |||||
| 656 | // isItemEvent reports whether ev is about a merge request or an issue. | 660 | // isItemEvent reports whether ev is about a merge request or an issue. | ||
| 657 | func isItemEvent(ev domain.Event) bool { | 661 | func isItemEvent(ev domain.Event) bool { | ||
| 658 | switch ev.(type) { | 662 | switch ev.(type) { | ||
| 663 | + | case domain.IssueReferenced: | |||
| 664 | + | return false // back-reference refreshes do not change the inbox | |||
| 659 | case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, | 665 | case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, | ||
| 660 | domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, | 666 | domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, | ||
| 661 | domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved: | 667 | domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved: | ||
M internal/usecase/events_test.go +24 -0
| @@ -1010,3 +1010,27 @@ func TestMergeabilityEventReachesOwnerTopic(t *testing.T) { | |||
| 1010 | 1010 | assert.Equal(t, priv, owner.next(t).Event.(domain.MergeRequestMergeability).Repo) | |
| 1011 | 1011 | }) | |
| 1012 | 1012 | } | |
| 1013 | + | ||
| 1014 | + | func TestIssueReferencedRepoOnly(t *testing.T) { | |
| 1015 | + | synctest.Test(t, func(t *testing.T) { | |
| 1016 | + | r := newEventRig(t, 16) | |
| 1017 | + | n := name(t, "linked") | |
| 1018 | + | settings := domain.DefaultRepoSettings() | |
| 1019 | + | settings.Issues = domain.IssuesOwner | |
| 1020 | + | r.meta.EXPECT().Get(mock.Anything, n).Return(domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: settings}, nil).Maybe() | |
| 1021 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 1022 | + | defer cancel() | |
| 1023 | + | repo := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:linked"}}) | |
| 1024 | + | owner := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}}) | |
| 1025 | + | synctest.Wait() | |
| 1026 | + | ev := domain.IssueReferenced{Repo: n, Issue: 2, MergeRequest: 3, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now} | |
| 1027 | + | r.feed <- ev | |
| 1028 | + | assert.Equal(t, ev, repo.next(t).Event) | |
| 1029 | + | synctest.Wait() | |
| 1030 | + | select { | |
| 1031 | + | case got := <-owner.got: | |
| 1032 | + | t.Fatalf("owner received %+v", got) | |
| 1033 | + | default: | |
| 1034 | + | } | |
| 1035 | + | }) | |
| 1036 | + | } | |
| @@ -1010,3 +1010,27 @@ func TestMergeabilityEventReachesOwnerTopic(t *testing.T) { | |||||
| 1010 | assert.Equal(t, priv, owner.next(t).Event.(domain.MergeRequestMergeability).Repo) | 1010 | assert.Equal(t, priv, owner.next(t).Event.(domain.MergeRequestMergeability).Repo) | ||
| 1011 | }) | 1011 | }) | ||
| 1012 | } | 1012 | } | ||
| 1013 | + | ||||
| 1014 | + | func TestIssueReferencedRepoOnly(t *testing.T) { | |||
| 1015 | + | synctest.Test(t, func(t *testing.T) { | |||
| 1016 | + | r := newEventRig(t, 16) | |||
| 1017 | + | n := name(t, "linked") | |||
| 1018 | + | settings := domain.DefaultRepoSettings() | |||
| 1019 | + | settings.Issues = domain.IssuesOwner | |||
| 1020 | + | r.meta.EXPECT().Get(mock.Anything, n).Return(domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: settings}, nil).Maybe() | |||
| 1021 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 1022 | + | defer cancel() | |||
| 1023 | + | repo := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:linked"}}) | |||
| 1024 | + | owner := r.watch(ctx, domain.Owner(), ports.WatchParams{Topics: []string{"owner"}}) | |||
| 1025 | + | synctest.Wait() | |||
| 1026 | + | ev := domain.IssueReferenced{Repo: n, Issue: 2, MergeRequest: 3, At: now, Visibility: domain.VisibilityPublic, CreatedAt: now} | |||
| 1027 | + | r.feed <- ev | |||
| 1028 | + | assert.Equal(t, ev, repo.next(t).Event) | |||
| 1029 | + | synctest.Wait() | |||
| 1030 | + | select { | |||
| 1031 | + | case got := <-owner.got: | |||
| 1032 | + | t.Fatalf("owner received %+v", got) | |||
| 1033 | + | default: | |||
| 1034 | + | } | |||
| 1035 | + | }) | |||
| 1036 | + | } | |||
M internal/usecase/issues.go +13 -7
| @@ -274,16 +274,22 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain | |||
| 274 | 274 | } | |
| 275 | 275 | ||
| 276 | 276 | // Kind tells what number n of the repository names. The principal needs the | |
| 277 | - | // access of reading the repository, and issues must be on there: otherwise it | |
| 278 | - | // is domain.ErrNotFound. | |
| 277 | + | // access of reading the repository. Issues are resolved only when enabled; | |
| 278 | + | // merge requests are resolved even when issues are off. | |
| 279 | 279 | func (i *Issues) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) { | |
| 280 | - | if _, err := i.readable(ctx, p, name); err != nil { | |
| 280 | + | repo, err := lookupRepo(ctx, i.meta, name) | |
| 281 | + | if err != nil { | |
| 281 | 282 | return domain.NumberNone, err | |
| 282 | 283 | } | |
| 283 | - | if _, err := i.store.Get(ctx, name, n); err == nil { | |
| 284 | - | return domain.NumberIssue, nil | |
| 285 | - | } else if !errors.Is(err, domain.ErrNotFound) { | |
| 286 | - | return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err) | |
| 284 | + | if !canRead(p, repo) { | |
| 285 | + | return domain.NumberNone, domain.ErrNotFound | |
| 286 | + | } | |
| 287 | + | if repo.Settings.IssuesEnabled() { | |
| 288 | + | if _, err := i.store.Get(ctx, name, n); err == nil { | |
| 289 | + | return domain.NumberIssue, nil | |
| 290 | + | } else if !errors.Is(err, domain.ErrNotFound) { | |
| 291 | + | return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err) | |
| 292 | + | } | |
| 287 | 293 | } | |
| 288 | 294 | if _, err := i.mrs.Get(ctx, name, n); err == nil { | |
| 289 | 295 | return domain.NumberMergeRequest, nil | |
| @@ -274,16 +274,22 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain | |||||
| 274 | } | 274 | } | ||
| 275 | 275 | ||||
| 276 | // Kind tells what number n of the repository names. The principal needs the | 276 | // Kind tells what number n of the repository names. The principal needs the | ||
| 277 | - | // access of reading the repository, and issues must be on there: otherwise it | 277 | + | // access of reading the repository. Issues are resolved only when enabled; |
| 278 | - | // is domain.ErrNotFound. | 278 | + | // merge requests are resolved even when issues are off. |
| 279 | func (i *Issues) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) { | 279 | func (i *Issues) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) { | ||
| 280 | - | if _, err := i.readable(ctx, p, name); err != nil { | 280 | + | repo, err := lookupRepo(ctx, i.meta, name) |
| 281 | + | if err != nil { | |||
| 281 | return domain.NumberNone, err | 282 | return domain.NumberNone, err | ||
| 282 | } | 283 | } | ||
| 283 | - | if _, err := i.store.Get(ctx, name, n); err == nil { | 284 | + | if !canRead(p, repo) { |
| 284 | - | return domain.NumberIssue, nil | 285 | + | return domain.NumberNone, domain.ErrNotFound |
| 285 | - | } else if !errors.Is(err, domain.ErrNotFound) { | 286 | + | } |
| 286 | - | return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err) | 287 | + | if repo.Settings.IssuesEnabled() { |
| 288 | + | if _, err := i.store.Get(ctx, name, n); err == nil { | |||
| 289 | + | return domain.NumberIssue, nil | |||
| 290 | + | } else if !errors.Is(err, domain.ErrNotFound) { | |||
| 291 | + | return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err) | |||
| 292 | + | } | |||
| 287 | } | 293 | } | ||
| 288 | if _, err := i.mrs.Get(ctx, name, n); err == nil { | 294 | if _, err := i.mrs.Get(ctx, name, n); err == nil { | ||
| 289 | return domain.NumberMergeRequest, nil | 295 | return domain.NumberMergeRequest, nil | ||
M internal/usecase/issues_test.go +4 -2
| @@ -65,8 +65,10 @@ func TestIssuesOffHidesEverything(t *testing.T) { | |||
| 65 | 65 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 66 | 66 | _, err = u.issues.Reopen(ctx, domain.Root(), n, 1) | |
| 67 | 67 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 68 | - | _, err = u.issues.Kind(ctx, domain.Root(), n, 1) | |
| 69 | - | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 68 | + | u.mrs.EXPECT().Get(mock.Anything, n, int64(1)).Return(domain.MergeRequest{Number: 1}, nil) | |
| 69 | + | kind, err := u.issues.Kind(ctx, domain.Root(), n, 1) | |
| 70 | + | require.NoError(t, err) | |
| 71 | + | assert.Equal(t, domain.NumberMergeRequest, kind) | |
| 70 | 72 | } | |
| 71 | 73 | } | |
| 72 | 74 | ||
| @@ -65,8 +65,10 @@ func TestIssuesOffHidesEverything(t *testing.T) { | |||||
| 65 | assert.ErrorIs(t, err, domain.ErrNotFound) | 65 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 66 | _, err = u.issues.Reopen(ctx, domain.Root(), n, 1) | 66 | _, err = u.issues.Reopen(ctx, domain.Root(), n, 1) | ||
| 67 | assert.ErrorIs(t, err, domain.ErrNotFound) | 67 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 68 | - | _, err = u.issues.Kind(ctx, domain.Root(), n, 1) | 68 | + | u.mrs.EXPECT().Get(mock.Anything, n, int64(1)).Return(domain.MergeRequest{Number: 1}, nil) |
| 69 | - | assert.ErrorIs(t, err, domain.ErrNotFound) | 69 | + | kind, err := u.issues.Kind(ctx, domain.Root(), n, 1) |
| 70 | + | require.NoError(t, err) | |||
| 71 | + | assert.Equal(t, domain.NumberMergeRequest, kind) | |||
| 70 | } | 72 | } | ||
| 71 | } | 73 | } | ||
| 72 | 74 | ||||
M proto/ermite/storage/issue.proto +12 -0
| @@ -36,6 +36,18 @@ message Issue { | |||
| 36 | 36 | // True for an issue approved from the public form: its text comes from | |
| 37 | 37 | // an anonymous visitor and is rendered as untrusted. | |
| 38 | 38 | bool external = 9; | |
| 39 | + | int64 closed_by_merge_request = 10; | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // IssueLink is stored under isref/<repo>/<issue:010d>/<mr:010d>. | |
| 43 | + | message IssueLink { | |
| 44 | + | bool closes = 1; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // MergeRequestReferences is stored under mrref/<repo>/<mr:010d>. | |
| 48 | + | message MergeRequestReferences { | |
| 49 | + | repeated int64 issues = 1; | |
| 50 | + | repeated int64 closes = 2; | |
| 39 | 51 | } | |
| 40 | 52 | ||
| 41 | 53 | // IssueComment is stored under the key | |
| @@ -36,6 +36,18 @@ message Issue { | |||||
| 36 | // True for an issue approved from the public form: its text comes from | 36 | // True for an issue approved from the public form: its text comes from | ||
| 37 | // an anonymous visitor and is rendered as untrusted. | 37 | // an anonymous visitor and is rendered as untrusted. | ||
| 38 | bool external = 9; | 38 | bool external = 9; | ||
| 39 | + | int64 closed_by_merge_request = 10; | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // IssueLink is stored under isref/<repo>/<issue:010d>/<mr:010d>. | |||
| 43 | + | message IssueLink { | |||
| 44 | + | bool closes = 1; | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // MergeRequestReferences is stored under mrref/<repo>/<mr:010d>. | |||
| 48 | + | message MergeRequestReferences { | |||
| 49 | + | repeated int64 issues = 1; | |||
| 50 | + | repeated int64 closes = 2; | |||
| 39 | } | 51 | } | ||
| 40 | 52 | ||||
| 41 | // IssueComment is stored under the key | 53 | // IssueComment is stored under the key | ||