detached 8a27537

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
9797 CommentSeq int64 `protobuf:"varint,8,opt,name=comment_seq,json=commentSeq,proto3" json:"comment_seq,omitempty"`
9898 // True for an issue approved from the public form: its text comes from
9999 // 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
103104 }
104105
105106 func (x *Issue) Reset() {
@@ -195,6 +196,111 @@ func (x *Issue) GetExternal() bool {
195196 return false
196197 }
197198
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+
198304 // IssueComment is stored under the key
199305 // "isc/<repo>/<number:010d>/<id:010d>".
200306 type IssueComment struct {
@@ -209,7 +315,7 @@ type IssueComment struct {
209315
210316 func (x *IssueComment) Reset() {
211317 *x = IssueComment{}
212- mi := &file_ermite_storage_issue_proto_msgTypes[1]
318+ mi := &file_ermite_storage_issue_proto_msgTypes[3]
213319 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
214320 ms.StoreMessageInfo(mi)
215321 }
@@ -221,7 +327,7 @@ func (x *IssueComment) String() string {
221327 func (*IssueComment) ProtoMessage() {}
222328
223329 func (x *IssueComment) ProtoReflect() protoreflect.Message {
224- mi := &file_ermite_storage_issue_proto_msgTypes[1]
330+ mi := &file_ermite_storage_issue_proto_msgTypes[3]
225331 if x != nil {
226332 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
227333 if ms.LoadMessageInfo() == nil {
@@ -234,7 +340,7 @@ func (x *IssueComment) ProtoReflect() protoreflect.Message {
234340
235341 // Deprecated: Use IssueComment.ProtoReflect.Descriptor instead.
236342 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}
238344 }
239345
240346 func (x *IssueComment) GetId() int64 {
@@ -283,7 +389,7 @@ type PendingIssue struct {
283389
284390 func (x *PendingIssue) Reset() {
285391 *x = PendingIssue{}
286- mi := &file_ermite_storage_issue_proto_msgTypes[2]
392+ mi := &file_ermite_storage_issue_proto_msgTypes[4]
287393 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
288394 ms.StoreMessageInfo(mi)
289395 }
@@ -295,7 +401,7 @@ func (x *PendingIssue) String() string {
295401 func (*PendingIssue) ProtoMessage() {}
296402
297403 func (x *PendingIssue) ProtoReflect() protoreflect.Message {
298- mi := &file_ermite_storage_issue_proto_msgTypes[2]
404+ mi := &file_ermite_storage_issue_proto_msgTypes[4]
299405 if x != nil {
300406 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
301407 if ms.LoadMessageInfo() == nil {
@@ -308,7 +414,7 @@ func (x *PendingIssue) ProtoReflect() protoreflect.Message {
308414
309415 // Deprecated: Use PendingIssue.ProtoReflect.Descriptor instead.
310416 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}
312418 }
313419
314420 func (x *PendingIssue) GetTitle() string {
@@ -356,7 +462,7 @@ type IssueBan struct {
356462
357463 func (x *IssueBan) Reset() {
358464 *x = IssueBan{}
359- mi := &file_ermite_storage_issue_proto_msgTypes[3]
465+ mi := &file_ermite_storage_issue_proto_msgTypes[5]
360466 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
361467 ms.StoreMessageInfo(mi)
362468 }
@@ -368,7 +474,7 @@ func (x *IssueBan) String() string {
368474 func (*IssueBan) ProtoMessage() {}
369475
370476 func (x *IssueBan) ProtoReflect() protoreflect.Message {
371- mi := &file_ermite_storage_issue_proto_msgTypes[3]
477+ mi := &file_ermite_storage_issue_proto_msgTypes[5]
372478 if x != nil {
373479 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
374480 if ms.LoadMessageInfo() == nil {
@@ -381,7 +487,7 @@ func (x *IssueBan) ProtoReflect() protoreflect.Message {
381487
382488 // Deprecated: Use IssueBan.ProtoReflect.Descriptor instead.
383489 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}
385491 }
386492
387493 func (x *IssueBan) GetUntil() *timestamppb.Timestamp {
@@ -401,7 +507,7 @@ type IssueKey struct {
401507
402508 func (x *IssueKey) Reset() {
403509 *x = IssueKey{}
404- mi := &file_ermite_storage_issue_proto_msgTypes[4]
510+ mi := &file_ermite_storage_issue_proto_msgTypes[6]
405511 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
406512 ms.StoreMessageInfo(mi)
407513 }
@@ -413,7 +519,7 @@ func (x *IssueKey) String() string {
413519 func (*IssueKey) ProtoMessage() {}
414520
415521 func (x *IssueKey) ProtoReflect() protoreflect.Message {
416- mi := &file_ermite_storage_issue_proto_msgTypes[4]
522+ mi := &file_ermite_storage_issue_proto_msgTypes[6]
417523 if x != nil {
418524 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
419525 if ms.LoadMessageInfo() == nil {
@@ -426,7 +532,7 @@ func (x *IssueKey) ProtoReflect() protoreflect.Message {
426532
427533 // Deprecated: Use IssueKey.ProtoReflect.Descriptor instead.
428534 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}
430536 }
431537
432538 func (x *IssueKey) GetKey() []byte {
@@ -440,7 +546,7 @@ var File_ermite_storage_issue_proto protoreflect.FileDescriptor
440546
441547 const file_ermite_storage_issue_proto_rawDesc = "" +
442548 "\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" +
444550 "\x05Issue\x12\x16\n" +
445551 "\x06number\x18\x01 \x01(\x03R\x06number\x12\x14\n" +
446552 "\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" +
@@ -453,7 +559,14 @@ const file_ermite_storage_issue_proto_rawDesc = "" +
453559 "updated_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12\x1f\n" +
454560 "\vcomment_seq\x18\b \x01(\x03R\n" +
455561 "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" +
457570 "\fIssueComment\x12\x0e\n" +
458571 "\x02id\x18\x01 \x01(\x03R\x02id\x12\x16\n" +
459572 "\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" +
@@ -491,23 +604,25 @@ func file_ermite_storage_issue_proto_rawDescGZIP() []byte {
491604 }
492605
493606 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)
495608 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
503618 }
504619 var file_ermite_storage_issue_proto_depIdxs = []int32{
505620 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
511626 6, // [6:6] is the sub-list for method output_type
512627 6, // [6:6] is the sub-list for method input_type
513628 6, // [6:6] is the sub-list for extension type_name
@@ -526,7 +641,7 @@ func file_ermite_storage_issue_proto_init() {
526641 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
527642 RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_issue_proto_rawDesc), len(file_ermite_storage_issue_proto_rawDesc)),
528643 NumEnums: 1,
529- NumMessages: 5,
644+ NumMessages: 7,
530645 NumExtensions: 0,
531646 NumServices: 0,
532647 },
@@ -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 from98 // 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.UnknownFields101+ ClosedByMergeRequest int64 `protobuf:"varint,10,opt,name=closed_by_merge_request,json=closedByMergeRequest,proto3" json:"closed_by_merge_request,omitempty"`
102- sizeCache protoimpl.SizeCache102+ 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 false196 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 key304 // 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.IssueState609+ (IssueState)(0), // 0: ermite.storage.IssueState
497- (*Issue)(nil), // 1: ermite.storage.Issue610+ (*Issue)(nil), // 1: ermite.storage.Issue
498- (*IssueComment)(nil), // 2: ermite.storage.IssueComment611+ (*IssueLink)(nil), // 2: ermite.storage.IssueLink
499- (*PendingIssue)(nil), // 3: ermite.storage.PendingIssue612+ (*MergeRequestReferences)(nil), // 3: ermite.storage.MergeRequestReferences
500- (*IssueBan)(nil), // 4: ermite.storage.IssueBan613+ (*IssueComment)(nil), // 4: ermite.storage.IssueComment
501- (*IssueKey)(nil), // 5: ermite.storage.IssueKey614+ (*PendingIssue)(nil), // 5: ermite.storage.PendingIssue
502- (*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp615+ (*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.IssueState620 0, // 0: ermite.storage.Issue.state:type_name -> ermite.storage.IssueState
506- 6, // 1: ermite.storage.Issue.created_at:type_name -> google.protobuf.Timestamp621+ 8, // 1: ermite.storage.Issue.created_at:type_name -> google.protobuf.Timestamp
507- 6, // 2: ermite.storage.Issue.updated_at:type_name -> google.protobuf.Timestamp622+ 8, // 2: ermite.storage.Issue.updated_at:type_name -> google.protobuf.Timestamp
508- 6, // 3: ermite.storage.IssueComment.created_at:type_name -> google.protobuf.Timestamp623+ 8, // 3: ermite.storage.IssueComment.created_at:type_name -> google.protobuf.Timestamp
509- 6, // 4: ermite.storage.PendingIssue.created_at:type_name -> google.protobuf.Timestamp624+ 8, // 4: ermite.storage.PendingIssue.created_at:type_name -> google.protobuf.Timestamp
510- 6, // 5: ermite.storage.IssueBan.until:type_name -> google.protobuf.Timestamp625+ 8, // 5: ermite.storage.IssueBan.until:type_name -> google.protobuf.Timestamp
511 6, // [6:6] is the sub-list for method output_type626 6, // [6:6] is the sub-list for method output_type
512 6, // [6:6] is the sub-list for method input_type627 6, // [6:6] is the sub-list for method input_type
513 6, // [6:6] is the sub-list for extension type_name628 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) {
7272 func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
7373 var keys [][]byte
7474 for _, p := range []string{
75- issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo),
75+ issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo), issueRefPrefix + repo.String() + "/",
7676 } {
7777 for k := range snap.Prefix(ctx, []byte(p)) {
7878 keys = append(keys, slices.Clone(k))
@@ -312,15 +312,16 @@ func encodeIssue(issue domain.Issue) ([]byte, error) {
312312 return nil, err
313313 }
314314 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,
324325 }), nil
325326 }
326327
@@ -348,9 +349,10 @@ func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) {
348349 Repo: repo, Number: pb.GetNumber(), Title: title, Body: body, State: state,
349350 Author: pb.GetAuthor(),
350351 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,
354356 }, nil
355357 }
356358
@@ -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 [][]byte73 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, err312 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 }), nil325 }), 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 }, nil356 }, 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
181181 var keys [][]byte
182182 for _, p := range []string{
183183 mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/",
184- mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/",
184+ mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/", mrRefPrefix + repo.String() + "/",
185185 mergePrefix + repo.String() + "/",
186186 } {
187187 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 [][]byte181 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 {
530530 out.Time = timestamp(ev.At)
531531 out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}}
532532 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.
535535 return nil
536536 }
537537 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 has533+ // 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 nil535 return nil
536 }536 }
537 return out537 return out
M internal/domain/event.go +12 -0
@@ -186,6 +186,18 @@ type IssueOpened struct {
186186 // EventKind implements Event.
187187 func (IssueOpened) EventKind() string { return "issue.opened" }
188188
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+
189201 // IssueClosed is published when an issue was closed.
190202 type IssueClosed struct {
191203 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 Issue203 Issue Issue
M internal/domain/issue.go +8 -0
@@ -73,11 +73,19 @@ type Issue struct {
7373 // External is true for an issue approved from the public form: its text
7474 // comes from an anonymous visitor and is rendered as untrusted.
7575 External bool
76+ // ClosedByMergeRequest is zero unless a merge closed the issue.
77+ ClosedByMergeRequest int64
7678 // Version is set by the store on read: Update fails with ErrConflict when
7779 // the stored issue changed since. Opaque to everyone else.
7880 Version uint64
7981 }
8082
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+
8189 // IsOpen reports whether the issue is open.
8290 func (i Issue) IsOpen() bool { return i.State == IssueStateOpen }
8391
@@ -73,11 +73,19 @@ type Issue struct {
73 // External is true for an issue approved from the public form: its text73 // 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 bool75 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 when78 // 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 uint6480 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 &#39; ##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 &#39; ##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 {
536536 Subscribe(ctx context.Context) <-chan domain.Event
537537 }
538538
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+
539551 // Clock tells the time.
540552 type Clock interface {
541553 Now() time.Time
@@ -536,6 +536,18 @@ type EventBus interface {
536 Subscribe(ctx context.Context) <-chan domain.Event536 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.Time553 Now() time.Time
M internal/ports/services.go +2 -1
@@ -203,7 +203,8 @@ type IssueSubmissionService interface {
203203 // implements it.
204204 type IssueNumberResolver interface {
205205 // 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.
207208 Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error)
208209 }
209210
@@ -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 answers205 // 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 {
186186 return repoSnapshot(ev.Visibility, ev.CreatedAt)
187187 case domain.CommentAdded:
188188 return repoSnapshot(ev.Visibility, ev.CreatedAt)
189+ case domain.IssueReferenced:
190+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
189191 case domain.IssueOpened:
190192 return repoSnapshot(ev.Visibility, ev.CreatedAt)
191193 case domain.IssueClosed:
@@ -537,6 +539,8 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
537539 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
538540 case domain.CommentAdded:
539541 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)
540544 case domain.IssueOpened:
541545 return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
542546 case domain.IssueClosed:
@@ -637,7 +641,7 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName
637641 // isIssueEvent reports whether ev is about an issue.
638642 func isIssueEvent(ev domain.Event) bool {
639643 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:
641645 return true
642646 }
643647 return false
@@ -656,6 +660,8 @@ func isRepoLifecycleEvent(ev domain.Event) bool {
656660 // isItemEvent reports whether ev is about a merge request or an issue.
657661 func isItemEvent(ev domain.Event) bool {
658662 switch ev.(type) {
663+ case domain.IssueReferenced:
664+ return false // back-reference refreshes do not change the inbox
659665 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
660666 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,
661667 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 true645 return true
642 }646 }
643 return false647 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) {
10101010 assert.Equal(t, priv, owner.next(t).Event.(domain.MergeRequestMergeability).Repo)
10111011 })
10121012 }
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
274274 }
275275
276276 // 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.
279279 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 {
281282 return domain.NumberNone, err
282283 }
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+ }
287293 }
288294 if _, err := i.mrs.Get(ctx, name, n); err == nil {
289295 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 the276 // 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 it277+// 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, err282 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, nil285+ 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, nil295 return domain.NumberMergeRequest, nil
M internal/usecase/issues_test.go +4 -2
@@ -65,8 +65,10 @@ func TestIssuesOffHidesEverything(t *testing.T) {
6565 assert.ErrorIs(t, err, domain.ErrNotFound)
6666 _, err = u.issues.Reopen(ctx, domain.Root(), n, 1)
6767 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)
7072 }
7173 }
7274
@@ -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 {
3636 // True for an issue approved from the public form: its text comes from
3737 // an anonymous visitor and is rendered as untrusted.
3838 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;
3951 }
4052
4153 // 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 from36 // 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 key53 // IssueComment is stored under the key

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 43

↑↓ moveenter openesc close