detached c52c40f

feat(storage): store issues on the merge request sequence, schema 4

commit
c52c40f8d9839eaef0288029b066baac8011cebf
author
brice · · 2026-10-19 05:26 UTC
parent
b41adf0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +1931 -64
M docs/configuration.md +1 -1
@@ -66,4 +66,4 @@ The hook socket and scripts git calls on each push live in `$ERMITE_DATA_DIR/run
6666
6767 ## Storage schema
6868
69-This build uses storage schema 3. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.
69+This build uses storage schema 4. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.
@@ -66,4 +66,4 @@ The hook socket and scripts git calls on each push live in `$ERMITE_DATA_DIR/run
66 66
67 ## Storage schema67 ## Storage schema
68 68
69-This build uses storage schema 3. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.69+This build uses storage schema 4. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.
A gen/ermite/storage/issue.pb.go +343 -0 generated file: collapsed
M gen/ermite/storage/merge_request.pb.go +102 -31
@@ -76,6 +76,59 @@ func (MergeMode) EnumDescriptor() ([]byte, []int) {
7676 return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{0}
7777 }
7878
79+// IssuesMode says who may write issues in a repository.
80+type IssuesMode int32
81+
82+const (
83+ IssuesMode_ISSUES_MODE_UNSPECIFIED IssuesMode = 0
84+ IssuesMode_ISSUES_MODE_OFF IssuesMode = 1
85+ IssuesMode_ISSUES_MODE_OWNER IssuesMode = 2
86+ IssuesMode_ISSUES_MODE_PUBLIC IssuesMode = 3
87+)
88+
89+// Enum value maps for IssuesMode.
90+var (
91+ IssuesMode_name = map[int32]string{
92+ 0: "ISSUES_MODE_UNSPECIFIED",
93+ 1: "ISSUES_MODE_OFF",
94+ 2: "ISSUES_MODE_OWNER",
95+ 3: "ISSUES_MODE_PUBLIC",
96+ }
97+ IssuesMode_value = map[string]int32{
98+ "ISSUES_MODE_UNSPECIFIED": 0,
99+ "ISSUES_MODE_OFF": 1,
100+ "ISSUES_MODE_OWNER": 2,
101+ "ISSUES_MODE_PUBLIC": 3,
102+ }
103+)
104+
105+func (x IssuesMode) Enum() *IssuesMode {
106+ p := new(IssuesMode)
107+ *p = x
108+ return p
109+}
110+
111+func (x IssuesMode) String() string {
112+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
113+}
114+
115+func (IssuesMode) Descriptor() protoreflect.EnumDescriptor {
116+ return file_ermite_storage_merge_request_proto_enumTypes[1].Descriptor()
117+}
118+
119+func (IssuesMode) Type() protoreflect.EnumType {
120+ return &file_ermite_storage_merge_request_proto_enumTypes[1]
121+}
122+
123+func (x IssuesMode) Number() protoreflect.EnumNumber {
124+ return protoreflect.EnumNumber(x)
125+}
126+
127+// Deprecated: Use IssuesMode.Descriptor instead.
128+func (IssuesMode) EnumDescriptor() ([]byte, []int) {
129+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{1}
130+}
131+
79132 // MergeRequestState is where a merge request is in its life.
80133 type MergeRequestState int32
81134
@@ -113,11 +166,11 @@ func (x MergeRequestState) String() string {
113166 }
114167
115168 func (MergeRequestState) Descriptor() protoreflect.EnumDescriptor {
116- return file_ermite_storage_merge_request_proto_enumTypes[1].Descriptor()
169+ return file_ermite_storage_merge_request_proto_enumTypes[2].Descriptor()
117170 }
118171
119172 func (MergeRequestState) Type() protoreflect.EnumType {
120- return &file_ermite_storage_merge_request_proto_enumTypes[1]
173+ return &file_ermite_storage_merge_request_proto_enumTypes[2]
121174 }
122175
123176 func (x MergeRequestState) Number() protoreflect.EnumNumber {
@@ -126,7 +179,7 @@ func (x MergeRequestState) Number() protoreflect.EnumNumber {
126179
127180 // Deprecated: Use MergeRequestState.Descriptor instead.
128181 func (MergeRequestState) EnumDescriptor() ([]byte, []int) {
129- return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{1}
182+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{2}
130183 }
131184
132185 // DiffSide is the side of a diff a comment is attached to.
@@ -164,11 +217,11 @@ func (x DiffSide) String() string {
164217 }
165218
166219 func (DiffSide) Descriptor() protoreflect.EnumDescriptor {
167- return file_ermite_storage_merge_request_proto_enumTypes[2].Descriptor()
220+ return file_ermite_storage_merge_request_proto_enumTypes[3].Descriptor()
168221 }
169222
170223 func (DiffSide) Type() protoreflect.EnumType {
171- return &file_ermite_storage_merge_request_proto_enumTypes[2]
224+ return &file_ermite_storage_merge_request_proto_enumTypes[3]
172225 }
173226
174227 func (x DiffSide) Number() protoreflect.EnumNumber {
@@ -177,7 +230,7 @@ func (x DiffSide) Number() protoreflect.EnumNumber {
177230
178231 // Deprecated: Use DiffSide.Descriptor instead.
179232 func (DiffSide) EnumDescriptor() ([]byte, []int) {
180- return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{2}
233+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{3}
181234 }
182235
183236 // RepoSettings are the merge settings of a repository, stored inside Repo.
@@ -186,8 +239,10 @@ type RepoSettings struct {
186239 // Never empty once stored.
187240 AllowedMergeModes []MergeMode `protobuf:"varint,1,rep,packed,name=allowed_merge_modes,json=allowedMergeModes,proto3,enum=ermite.storage.MergeMode" json:"allowed_merge_modes,omitempty"`
188241 FailingChecksBlock bool `protobuf:"varint,2,opt,name=failing_checks_block,json=failingChecksBlock,proto3" json:"failing_checks_block,omitempty"`
189- unknownFields protoimpl.UnknownFields
190- sizeCache protoimpl.SizeCache
242+ // Who may write issues; unspecified reads as off.
243+ Issues IssuesMode `protobuf:"varint,3,opt,name=issues,proto3,enum=ermite.storage.IssuesMode" json:"issues,omitempty"`
244+ unknownFields protoimpl.UnknownFields
245+ sizeCache protoimpl.SizeCache
191246 }
192247
193248 func (x *RepoSettings) Reset() {
@@ -234,6 +289,13 @@ func (x *RepoSettings) GetFailingChecksBlock() bool {
234289 return false
235290 }
236291
292+func (x *RepoSettings) GetIssues() IssuesMode {
293+ if x != nil {
294+ return x.Issues
295+ }
296+ return IssuesMode_ISSUES_MODE_UNSPECIFIED
297+}
298+
237299 // MergeRequest is stored under the key "mr/<repo>/<number:010d>".
238300 type MergeRequest struct {
239301 state protoimpl.MessageState `protogen:"open.v1"`
@@ -548,10 +610,11 @@ var File_ermite_storage_merge_request_proto protoreflect.FileDescriptor
548610
549611 const file_ermite_storage_merge_request_proto_rawDesc = "" +
550612 "\n" +
551- "\"ermite/storage/merge_request.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\x8b\x01\n" +
613+ "\"ermite/storage/merge_request.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xbf\x01\n" +
552614 "\fRepoSettings\x12I\n" +
553615 "\x13allowed_merge_modes\x18\x01 \x03(\x0e2\x19.ermite.storage.MergeModeR\x11allowedMergeModes\x120\n" +
554- "\x14failing_checks_block\x18\x02 \x01(\bR\x12failingChecksBlock\"\xa6\x04\n" +
616+ "\x14failing_checks_block\x18\x02 \x01(\bR\x12failingChecksBlock\x122\n" +
617+ "\x06issues\x18\x03 \x01(\x0e2\x1a.ermite.storage.IssuesModeR\x06issues\"\xa6\x04\n" +
555618 "\fMergeRequest\x12\x16\n" +
556619 "\x06number\x18\x01 \x01(\x03R\x06number\x12\x14\n" +
557620 "\x05title\x18\x02 \x01(\tR\x05title\x12 \n" +
@@ -588,7 +651,13 @@ const file_ermite_storage_merge_request_proto_rawDesc = "" +
588651 "\tMergeMode\x12\x1a\n" +
589652 "\x16MERGE_MODE_UNSPECIFIED\x10\x00\x12\x1b\n" +
590653 "\x17MERGE_MODE_FAST_FORWARD\x10\x01\x12\x15\n" +
591- "\x11MERGE_MODE_SQUASH\x10\x02*\x96\x01\n" +
654+ "\x11MERGE_MODE_SQUASH\x10\x02*m\n" +
655+ "\n" +
656+ "IssuesMode\x12\x1b\n" +
657+ "\x17ISSUES_MODE_UNSPECIFIED\x10\x00\x12\x13\n" +
658+ "\x0fISSUES_MODE_OFF\x10\x01\x12\x15\n" +
659+ "\x11ISSUES_MODE_OWNER\x10\x02\x12\x16\n" +
660+ "\x12ISSUES_MODE_PUBLIC\x10\x03*\x96\x01\n" +
592661 "\x11MergeRequestState\x12#\n" +
593662 "\x1fMERGE_REQUEST_STATE_UNSPECIFIED\x10\x00\x12\x1c\n" +
594663 "\x18MERGE_REQUEST_STATE_OPEN\x10\x01\x12\x1e\n" +
@@ -611,31 +680,33 @@ func file_ermite_storage_merge_request_proto_rawDescGZIP() []byte {
611680 return file_ermite_storage_merge_request_proto_rawDescData
612681 }
613682
614-var file_ermite_storage_merge_request_proto_enumTypes = make([]protoimpl.EnumInfo, 3)
683+var file_ermite_storage_merge_request_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
615684 var file_ermite_storage_merge_request_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
616685 var file_ermite_storage_merge_request_proto_goTypes = []any{
617686 (MergeMode)(0), // 0: ermite.storage.MergeMode
618- (MergeRequestState)(0), // 1: ermite.storage.MergeRequestState
619- (DiffSide)(0), // 2: ermite.storage.DiffSide
620- (*RepoSettings)(nil), // 3: ermite.storage.RepoSettings
621- (*MergeRequest)(nil), // 4: ermite.storage.MergeRequest
622- (*Comment)(nil), // 5: ermite.storage.Comment
623- (*Sequence)(nil), // 6: ermite.storage.Sequence
624- (*timestamppb.Timestamp)(nil), // 7: google.protobuf.Timestamp
687+ (IssuesMode)(0), // 1: ermite.storage.IssuesMode
688+ (MergeRequestState)(0), // 2: ermite.storage.MergeRequestState
689+ (DiffSide)(0), // 3: ermite.storage.DiffSide
690+ (*RepoSettings)(nil), // 4: ermite.storage.RepoSettings
691+ (*MergeRequest)(nil), // 5: ermite.storage.MergeRequest
692+ (*Comment)(nil), // 6: ermite.storage.Comment
693+ (*Sequence)(nil), // 7: ermite.storage.Sequence
694+ (*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp
625695 }
626696 var file_ermite_storage_merge_request_proto_depIdxs = []int32{
627697 0, // 0: ermite.storage.RepoSettings.allowed_merge_modes:type_name -> ermite.storage.MergeMode
628- 1, // 1: ermite.storage.MergeRequest.state:type_name -> ermite.storage.MergeRequestState
629- 7, // 2: ermite.storage.MergeRequest.created_at:type_name -> google.protobuf.Timestamp
630- 7, // 3: ermite.storage.MergeRequest.updated_at:type_name -> google.protobuf.Timestamp
631- 0, // 4: ermite.storage.MergeRequest.merge_mode:type_name -> ermite.storage.MergeMode
632- 2, // 5: ermite.storage.Comment.side:type_name -> ermite.storage.DiffSide
633- 7, // 6: ermite.storage.Comment.created_at:type_name -> google.protobuf.Timestamp
634- 7, // [7:7] is the sub-list for method output_type
635- 7, // [7:7] is the sub-list for method input_type
636- 7, // [7:7] is the sub-list for extension type_name
637- 7, // [7:7] is the sub-list for extension extendee
638- 0, // [0:7] is the sub-list for field type_name
698+ 1, // 1: ermite.storage.RepoSettings.issues:type_name -> ermite.storage.IssuesMode
699+ 2, // 2: ermite.storage.MergeRequest.state:type_name -> ermite.storage.MergeRequestState
700+ 8, // 3: ermite.storage.MergeRequest.created_at:type_name -> google.protobuf.Timestamp
701+ 8, // 4: ermite.storage.MergeRequest.updated_at:type_name -> google.protobuf.Timestamp
702+ 0, // 5: ermite.storage.MergeRequest.merge_mode:type_name -> ermite.storage.MergeMode
703+ 3, // 6: ermite.storage.Comment.side:type_name -> ermite.storage.DiffSide
704+ 8, // 7: ermite.storage.Comment.created_at:type_name -> google.protobuf.Timestamp
705+ 8, // [8:8] is the sub-list for method output_type
706+ 8, // [8:8] is the sub-list for method input_type
707+ 8, // [8:8] is the sub-list for extension type_name
708+ 8, // [8:8] is the sub-list for extension extendee
709+ 0, // [0:8] is the sub-list for field type_name
639710 }
640711
641712 func init() { file_ermite_storage_merge_request_proto_init() }
@@ -648,7 +719,7 @@ func file_ermite_storage_merge_request_proto_init() {
648719 File: protoimpl.DescBuilder{
649720 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
650721 RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_merge_request_proto_rawDesc), len(file_ermite_storage_merge_request_proto_rawDesc)),
651- NumEnums: 3,
722+ NumEnums: 4,
652723 NumMessages: 4,
653724 NumExtensions: 0,
654725 NumServices: 0,
@@ -76,6 +76,59 @@ func (MergeMode) EnumDescriptor() ([]byte, []int) {
76 return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{0}76 return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{0}
77 }77 }
78 78
79+// IssuesMode says who may write issues in a repository.
80+type IssuesMode int32
81+
82+const (
83+ IssuesMode_ISSUES_MODE_UNSPECIFIED IssuesMode = 0
84+ IssuesMode_ISSUES_MODE_OFF IssuesMode = 1
85+ IssuesMode_ISSUES_MODE_OWNER IssuesMode = 2
86+ IssuesMode_ISSUES_MODE_PUBLIC IssuesMode = 3
87+)
88+
89+// Enum value maps for IssuesMode.
90+var (
91+ IssuesMode_name = map[int32]string{
92+ 0: "ISSUES_MODE_UNSPECIFIED",
93+ 1: "ISSUES_MODE_OFF",
94+ 2: "ISSUES_MODE_OWNER",
95+ 3: "ISSUES_MODE_PUBLIC",
96+ }
97+ IssuesMode_value = map[string]int32{
98+ "ISSUES_MODE_UNSPECIFIED": 0,
99+ "ISSUES_MODE_OFF": 1,
100+ "ISSUES_MODE_OWNER": 2,
101+ "ISSUES_MODE_PUBLIC": 3,
102+ }
103+)
104+
105+func (x IssuesMode) Enum() *IssuesMode {
106+ p := new(IssuesMode)
107+ *p = x
108+ return p
109+}
110+
111+func (x IssuesMode) String() string {
112+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
113+}
114+
115+func (IssuesMode) Descriptor() protoreflect.EnumDescriptor {
116+ return file_ermite_storage_merge_request_proto_enumTypes[1].Descriptor()
117+}
118+
119+func (IssuesMode) Type() protoreflect.EnumType {
120+ return &file_ermite_storage_merge_request_proto_enumTypes[1]
121+}
122+
123+func (x IssuesMode) Number() protoreflect.EnumNumber {
124+ return protoreflect.EnumNumber(x)
125+}
126+
127+// Deprecated: Use IssuesMode.Descriptor instead.
128+func (IssuesMode) EnumDescriptor() ([]byte, []int) {
129+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{1}
130+}
131+
79 // MergeRequestState is where a merge request is in its life.132 // MergeRequestState is where a merge request is in its life.
80 type MergeRequestState int32133 type MergeRequestState int32
81 134
@@ -113,11 +166,11 @@ func (x MergeRequestState) String() string {
113 }166 }
114 167
115 func (MergeRequestState) Descriptor() protoreflect.EnumDescriptor {168 func (MergeRequestState) Descriptor() protoreflect.EnumDescriptor {
116- return file_ermite_storage_merge_request_proto_enumTypes[1].Descriptor()169+ return file_ermite_storage_merge_request_proto_enumTypes[2].Descriptor()
117 }170 }
118 171
119 func (MergeRequestState) Type() protoreflect.EnumType {172 func (MergeRequestState) Type() protoreflect.EnumType {
120- return &file_ermite_storage_merge_request_proto_enumTypes[1]173+ return &file_ermite_storage_merge_request_proto_enumTypes[2]
121 }174 }
122 175
123 func (x MergeRequestState) Number() protoreflect.EnumNumber {176 func (x MergeRequestState) Number() protoreflect.EnumNumber {
@@ -126,7 +179,7 @@ func (x MergeRequestState) Number() protoreflect.EnumNumber {
126 179
127 // Deprecated: Use MergeRequestState.Descriptor instead.180 // Deprecated: Use MergeRequestState.Descriptor instead.
128 func (MergeRequestState) EnumDescriptor() ([]byte, []int) {181 func (MergeRequestState) EnumDescriptor() ([]byte, []int) {
129- return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{1}182+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{2}
130 }183 }
131 184
132 // DiffSide is the side of a diff a comment is attached to.185 // DiffSide is the side of a diff a comment is attached to.
@@ -164,11 +217,11 @@ func (x DiffSide) String() string {
164 }217 }
165 218
166 func (DiffSide) Descriptor() protoreflect.EnumDescriptor {219 func (DiffSide) Descriptor() protoreflect.EnumDescriptor {
167- return file_ermite_storage_merge_request_proto_enumTypes[2].Descriptor()220+ return file_ermite_storage_merge_request_proto_enumTypes[3].Descriptor()
168 }221 }
169 222
170 func (DiffSide) Type() protoreflect.EnumType {223 func (DiffSide) Type() protoreflect.EnumType {
171- return &file_ermite_storage_merge_request_proto_enumTypes[2]224+ return &file_ermite_storage_merge_request_proto_enumTypes[3]
172 }225 }
173 226
174 func (x DiffSide) Number() protoreflect.EnumNumber {227 func (x DiffSide) Number() protoreflect.EnumNumber {
@@ -177,7 +230,7 @@ func (x DiffSide) Number() protoreflect.EnumNumber {
177 230
178 // Deprecated: Use DiffSide.Descriptor instead.231 // Deprecated: Use DiffSide.Descriptor instead.
179 func (DiffSide) EnumDescriptor() ([]byte, []int) {232 func (DiffSide) EnumDescriptor() ([]byte, []int) {
180- return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{2}233+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{3}
181 }234 }
182 235
183 // RepoSettings are the merge settings of a repository, stored inside Repo.236 // RepoSettings are the merge settings of a repository, stored inside Repo.
@@ -186,8 +239,10 @@ type RepoSettings struct {
186 // Never empty once stored.239 // Never empty once stored.
187 AllowedMergeModes []MergeMode `protobuf:"varint,1,rep,packed,name=allowed_merge_modes,json=allowedMergeModes,proto3,enum=ermite.storage.MergeMode" json:"allowed_merge_modes,omitempty"`240 AllowedMergeModes []MergeMode `protobuf:"varint,1,rep,packed,name=allowed_merge_modes,json=allowedMergeModes,proto3,enum=ermite.storage.MergeMode" json:"allowed_merge_modes,omitempty"`
188 FailingChecksBlock bool `protobuf:"varint,2,opt,name=failing_checks_block,json=failingChecksBlock,proto3" json:"failing_checks_block,omitempty"`241 FailingChecksBlock bool `protobuf:"varint,2,opt,name=failing_checks_block,json=failingChecksBlock,proto3" json:"failing_checks_block,omitempty"`
189- unknownFields protoimpl.UnknownFields242+ // Who may write issues; unspecified reads as off.
190- sizeCache protoimpl.SizeCache243+ Issues IssuesMode `protobuf:"varint,3,opt,name=issues,proto3,enum=ermite.storage.IssuesMode" json:"issues,omitempty"`
244+ unknownFields protoimpl.UnknownFields
245+ sizeCache protoimpl.SizeCache
191 }246 }
192 247
193 func (x *RepoSettings) Reset() {248 func (x *RepoSettings) Reset() {
@@ -234,6 +289,13 @@ func (x *RepoSettings) GetFailingChecksBlock() bool {
234 return false289 return false
235 }290 }
236 291
292+func (x *RepoSettings) GetIssues() IssuesMode {
293+ if x != nil {
294+ return x.Issues
295+ }
296+ return IssuesMode_ISSUES_MODE_UNSPECIFIED
297+}
298+
237 // MergeRequest is stored under the key "mr/<repo>/<number:010d>".299 // MergeRequest is stored under the key "mr/<repo>/<number:010d>".
238 type MergeRequest struct {300 type MergeRequest struct {
239 state protoimpl.MessageState `protogen:"open.v1"`301 state protoimpl.MessageState `protogen:"open.v1"`
@@ -548,10 +610,11 @@ var File_ermite_storage_merge_request_proto protoreflect.FileDescriptor
548 610
549 const file_ermite_storage_merge_request_proto_rawDesc = "" +611 const file_ermite_storage_merge_request_proto_rawDesc = "" +
550 "\n" +612 "\n" +
551- "\"ermite/storage/merge_request.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\x8b\x01\n" +613+ "\"ermite/storage/merge_request.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xbf\x01\n" +
552 "\fRepoSettings\x12I\n" +614 "\fRepoSettings\x12I\n" +
553 "\x13allowed_merge_modes\x18\x01 \x03(\x0e2\x19.ermite.storage.MergeModeR\x11allowedMergeModes\x120\n" +615 "\x13allowed_merge_modes\x18\x01 \x03(\x0e2\x19.ermite.storage.MergeModeR\x11allowedMergeModes\x120\n" +
554- "\x14failing_checks_block\x18\x02 \x01(\bR\x12failingChecksBlock\"\xa6\x04\n" +616+ "\x14failing_checks_block\x18\x02 \x01(\bR\x12failingChecksBlock\x122\n" +
617+ "\x06issues\x18\x03 \x01(\x0e2\x1a.ermite.storage.IssuesModeR\x06issues\"\xa6\x04\n" +
555 "\fMergeRequest\x12\x16\n" +618 "\fMergeRequest\x12\x16\n" +
556 "\x06number\x18\x01 \x01(\x03R\x06number\x12\x14\n" +619 "\x06number\x18\x01 \x01(\x03R\x06number\x12\x14\n" +
557 "\x05title\x18\x02 \x01(\tR\x05title\x12 \n" +620 "\x05title\x18\x02 \x01(\tR\x05title\x12 \n" +
@@ -588,7 +651,13 @@ const file_ermite_storage_merge_request_proto_rawDesc = "" +
588 "\tMergeMode\x12\x1a\n" +651 "\tMergeMode\x12\x1a\n" +
589 "\x16MERGE_MODE_UNSPECIFIED\x10\x00\x12\x1b\n" +652 "\x16MERGE_MODE_UNSPECIFIED\x10\x00\x12\x1b\n" +
590 "\x17MERGE_MODE_FAST_FORWARD\x10\x01\x12\x15\n" +653 "\x17MERGE_MODE_FAST_FORWARD\x10\x01\x12\x15\n" +
591- "\x11MERGE_MODE_SQUASH\x10\x02*\x96\x01\n" +654+ "\x11MERGE_MODE_SQUASH\x10\x02*m\n" +
655+ "\n" +
656+ "IssuesMode\x12\x1b\n" +
657+ "\x17ISSUES_MODE_UNSPECIFIED\x10\x00\x12\x13\n" +
658+ "\x0fISSUES_MODE_OFF\x10\x01\x12\x15\n" +
659+ "\x11ISSUES_MODE_OWNER\x10\x02\x12\x16\n" +
660+ "\x12ISSUES_MODE_PUBLIC\x10\x03*\x96\x01\n" +
592 "\x11MergeRequestState\x12#\n" +661 "\x11MergeRequestState\x12#\n" +
593 "\x1fMERGE_REQUEST_STATE_UNSPECIFIED\x10\x00\x12\x1c\n" +662 "\x1fMERGE_REQUEST_STATE_UNSPECIFIED\x10\x00\x12\x1c\n" +
594 "\x18MERGE_REQUEST_STATE_OPEN\x10\x01\x12\x1e\n" +663 "\x18MERGE_REQUEST_STATE_OPEN\x10\x01\x12\x1e\n" +
@@ -611,31 +680,33 @@ func file_ermite_storage_merge_request_proto_rawDescGZIP() []byte {
611 return file_ermite_storage_merge_request_proto_rawDescData680 return file_ermite_storage_merge_request_proto_rawDescData
612 }681 }
613 682
614-var file_ermite_storage_merge_request_proto_enumTypes = make([]protoimpl.EnumInfo, 3)683+var file_ermite_storage_merge_request_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
615 var file_ermite_storage_merge_request_proto_msgTypes = make([]protoimpl.MessageInfo, 4)684 var file_ermite_storage_merge_request_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
616 var file_ermite_storage_merge_request_proto_goTypes = []any{685 var file_ermite_storage_merge_request_proto_goTypes = []any{
617 (MergeMode)(0), // 0: ermite.storage.MergeMode686 (MergeMode)(0), // 0: ermite.storage.MergeMode
618- (MergeRequestState)(0), // 1: ermite.storage.MergeRequestState687+ (IssuesMode)(0), // 1: ermite.storage.IssuesMode
619- (DiffSide)(0), // 2: ermite.storage.DiffSide688+ (MergeRequestState)(0), // 2: ermite.storage.MergeRequestState
620- (*RepoSettings)(nil), // 3: ermite.storage.RepoSettings689+ (DiffSide)(0), // 3: ermite.storage.DiffSide
621- (*MergeRequest)(nil), // 4: ermite.storage.MergeRequest690+ (*RepoSettings)(nil), // 4: ermite.storage.RepoSettings
622- (*Comment)(nil), // 5: ermite.storage.Comment691+ (*MergeRequest)(nil), // 5: ermite.storage.MergeRequest
623- (*Sequence)(nil), // 6: ermite.storage.Sequence692+ (*Comment)(nil), // 6: ermite.storage.Comment
624- (*timestamppb.Timestamp)(nil), // 7: google.protobuf.Timestamp693+ (*Sequence)(nil), // 7: ermite.storage.Sequence
694+ (*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp
625 }695 }
626 var file_ermite_storage_merge_request_proto_depIdxs = []int32{696 var file_ermite_storage_merge_request_proto_depIdxs = []int32{
627 0, // 0: ermite.storage.RepoSettings.allowed_merge_modes:type_name -> ermite.storage.MergeMode697 0, // 0: ermite.storage.RepoSettings.allowed_merge_modes:type_name -> ermite.storage.MergeMode
628- 1, // 1: ermite.storage.MergeRequest.state:type_name -> ermite.storage.MergeRequestState698+ 1, // 1: ermite.storage.RepoSettings.issues:type_name -> ermite.storage.IssuesMode
629- 7, // 2: ermite.storage.MergeRequest.created_at:type_name -> google.protobuf.Timestamp699+ 2, // 2: ermite.storage.MergeRequest.state:type_name -> ermite.storage.MergeRequestState
630- 7, // 3: ermite.storage.MergeRequest.updated_at:type_name -> google.protobuf.Timestamp700+ 8, // 3: ermite.storage.MergeRequest.created_at:type_name -> google.protobuf.Timestamp
631- 0, // 4: ermite.storage.MergeRequest.merge_mode:type_name -> ermite.storage.MergeMode701+ 8, // 4: ermite.storage.MergeRequest.updated_at:type_name -> google.protobuf.Timestamp
632- 2, // 5: ermite.storage.Comment.side:type_name -> ermite.storage.DiffSide702+ 0, // 5: ermite.storage.MergeRequest.merge_mode:type_name -> ermite.storage.MergeMode
633- 7, // 6: ermite.storage.Comment.created_at:type_name -> google.protobuf.Timestamp703+ 3, // 6: ermite.storage.Comment.side:type_name -> ermite.storage.DiffSide
634- 7, // [7:7] is the sub-list for method output_type704+ 8, // 7: ermite.storage.Comment.created_at:type_name -> google.protobuf.Timestamp
635- 7, // [7:7] is the sub-list for method input_type705+ 8, // [8:8] is the sub-list for method output_type
636- 7, // [7:7] is the sub-list for extension type_name706+ 8, // [8:8] is the sub-list for method input_type
637- 7, // [7:7] is the sub-list for extension extendee707+ 8, // [8:8] is the sub-list for extension type_name
638- 0, // [0:7] is the sub-list for field type_name708+ 8, // [8:8] is the sub-list for extension extendee
709+ 0, // [0:8] is the sub-list for field type_name
639 }710 }
640 711
641 func init() { file_ermite_storage_merge_request_proto_init() }712 func init() { file_ermite_storage_merge_request_proto_init() }
@@ -648,7 +719,7 @@ func file_ermite_storage_merge_request_proto_init() {
648 File: protoimpl.DescBuilder{719 File: protoimpl.DescBuilder{
649 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),720 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
650 RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_merge_request_proto_rawDesc), len(file_ermite_storage_merge_request_proto_rawDesc)),721 RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_merge_request_proto_rawDesc), len(file_ermite_storage_merge_request_proto_rawDesc)),
651- NumEnums: 3,722+ NumEnums: 4,
652 NumMessages: 4,723 NumMessages: 4,
653 NumExtensions: 0,724 NumExtensions: 0,
654 NumServices: 0,725 NumServices: 0,
M internal/adapters/kvstore/export_test.go +9 -0
@@ -25,3 +25,12 @@ func (c *CI) RawVersionForTest(ctx context.Context, id string) (uint64, bool) {
2525 item, ok := c.db.Get(ctx, runnerKey(id))
2626 return item.Version, ok
2727 }
28+
29+// CountKeysForTest counts the keys of the database under prefix.
30+func (s *Store) CountKeysForTest(ctx context.Context, prefix string) int {
31+ n := 0
32+ for range s.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
33+ n++
34+ }
35+ return n
36+}
@@ -25,3 +25,12 @@ func (c *CI) RawVersionForTest(ctx context.Context, id string) (uint64, bool) {
25 item, ok := c.db.Get(ctx, runnerKey(id))25 item, ok := c.db.Get(ctx, runnerKey(id))
26 return item.Version, ok26 return item.Version, ok
27 }27 }
28+
29+// CountKeysForTest counts the keys of the database under prefix.
30+func (s *Store) CountKeysForTest(ctx context.Context, prefix string) int {
31+ n := 0
32+ for range s.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
33+ n++
34+ }
35+ return n
36+}
A internal/adapters/kvstore/issues.go +396 -0
@@ -0,0 +1,396 @@
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+ "unicode/utf8"
13+
14+ kv "bnema.dev/ermite-kv"
15+ "google.golang.org/protobuf/proto"
16+
17+ storagepb "bnema.dev/ermite/gen/ermite/storage"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+// Key layout of the issues, like that of the merge requests. The numbers come
23+// from the sequence of the merge requests (mrseq/<repo>): an issue and a merge
24+// request never share one.
25+const (
26+ issuePrefix = "is/" // is/<repo>/<number:010d>
27+ issueCommentPrefix = "isc/" // isc/<repo>/<number:010d>/<id:010d>
28+ issueOpenPrefix = "isopen/" // isopen/<repo>/<number:010d>, written with the issue
29+)
30+
31+// Issues is the ermite-kv backed ports.IssueStore. It shares the database of
32+// the Store it comes from.
33+type Issues struct {
34+ db *kv.DB
35+}
36+
37+var _ ports.IssueStore = (*Issues)(nil)
38+
39+// Issues returns the issue store over the same database. It is valid until the
40+// Store is closed.
41+func (s *Store) Issues() *Issues { return &Issues{db: s.db} }
42+
43+func issueRepoPrefix(repo domain.RepoName) string { return issuePrefix + repo.String() + "/" }
44+func issueKey(repo domain.RepoName, n int64) []byte {
45+ return []byte(issueRepoPrefix(repo) + fmt.Sprintf(numberFormat, n))
46+}
47+func issueCommentsPrefix(repo domain.RepoName, n int64) string {
48+ return issueCommentPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n) + "/"
49+}
50+func issueCommentKey(repo domain.RepoName, n, id int64) []byte {
51+ return []byte(issueCommentsPrefix(repo, n) + fmt.Sprintf(numberFormat, id))
52+}
53+func issueOpenRepoPrefix(repo domain.RepoName) string { return issueOpenPrefix + repo.String() + "/" }
54+func issueOpenKey(repo domain.RepoName, n int64) []byte {
55+ return []byte(issueOpenRepoPrefix(repo) + fmt.Sprintf(numberFormat, n))
56+}
57+
58+// indexOpenIssue adds to b the write that keeps the index of the open issues in
59+// step with the issue: the key is put when it is open, deleted otherwise
60+// (deleting a key that is not there is not an error).
61+func indexOpenIssue(b *kv.Batch, issue domain.Issue) {
62+ if issue.IsOpen() {
63+ b.Put(issueOpenKey(issue.Repo, issue.Number), []byte{1})
64+ return
65+ }
66+ b.Delete(issueOpenKey(issue.Repo, issue.Number))
67+}
68+
69+// issueKeys lists every key of the snapshot that belongs to the repository's
70+// issues.
71+func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
72+ var keys [][]byte
73+ for _, p := range []string{
74+ issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo),
75+ } {
76+ for k := range snap.Prefix(ctx, []byte(p)) {
77+ keys = append(keys, slices.Clone(k))
78+ }
79+ }
80+ return keys
81+}
82+
83+// Create takes the next number of the repository from mrseq/<repo>, the
84+// sequence of the merge requests, and stores the issue under it; see
85+// createNumbered. A missing repository gives domain.ErrNotFound.
86+//
87+//hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
88+func (i *Issues) Create(ctx context.Context, issue domain.Issue) (domain.Issue, error) {
89+ err := createNumbered(ctx, i.db, issue.Repo, func(b *kv.Batch, n int64) error {
90+ issue.Number = n
91+ val, err := encodeIssue(issue)
92+ if err != nil {
93+ return err
94+ }
95+ b.RequireAbsent(issueKey(issue.Repo, n))
96+ b.Put(issueKey(issue.Repo, n), val)
97+ indexOpenIssue(b, issue)
98+ return nil
99+ })
100+ if err != nil {
101+ return domain.Issue{}, fmt.Errorf("create issue of %q: %w", issue.Repo, err)
102+ }
103+ return i.Get(ctx, issue.Repo, issue.Number)
104+}
105+
106+// Get reads is/<repo>/<number>.
107+func (i *Issues) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error) {
108+ if number < 1 {
109+ return domain.Issue{}, domain.ErrNotFound
110+ }
111+ item, ok := i.db.Get(ctx, issueKey(repo, number))
112+ if !ok {
113+ return domain.Issue{}, domain.ErrNotFound
114+ }
115+ return decodeIssue(repo, item)
116+}
117+
118+// List returns a page of the issues of the repository, highest number first. The
119+// page token is the last number of the previous page.
120+//
121+//hexcheck:ignore suspicious-business-logic-in-adapter pages through the keys of a prefix: a storage concern, no policy
122+func (i *Issues) List(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
123+ before := int64(0) // 0: from the highest
124+ if params.PageToken != "" {
125+ n, err := strconv.ParseInt(params.PageToken, 10, 64)
126+ if err != nil || n < 1 {
127+ return ports.IssuePage{}, fmt.Errorf("%w: page token", domain.ErrInvalid)
128+ }
129+ before = n
130+ }
131+ var all []domain.Issue
132+ for key, item := range i.db.Snapshot(ctx).Prefix(ctx, []byte(issueRepoPrefix(repo))) {
133+ issue, err := decodeIssue(repo, item)
134+ if err != nil {
135+ return ports.IssuePage{}, fmt.Errorf("decode %s: %w", key, err)
136+ }
137+ if params.State != "" && issue.State != params.State {
138+ continue
139+ }
140+ if before > 0 && issue.Number >= before {
141+ continue
142+ }
143+ all = append(all, issue)
144+ }
145+ slices.Reverse(all)
146+ var page ports.IssuePage
147+ if params.PageSize > 0 && len(all) > params.PageSize {
148+ all = all[:params.PageSize]
149+ page.NextPageToken = strconv.FormatInt(all[len(all)-1].Number, 10)
150+ }
151+ page.Issues = all
152+ return page, nil
153+}
154+
155+// ListOpen returns the open issues of the repository, highest number first,
156+// through the index: only the open ones are read.
157+//
158+//hexcheck:ignore suspicious-business-logic-in-adapter reads the open issues through their index: a storage concern, no policy
159+func (i *Issues) ListOpen(ctx context.Context, repo domain.RepoName) ([]domain.Issue, error) {
160+ var numbers []int64
161+ for key := range i.db.Snapshot(ctx).Prefix(ctx, []byte(issueOpenRepoPrefix(repo))) {
162+ if err := ctx.Err(); err != nil {
163+ return nil, err
164+ }
165+ n, err := strconv.ParseInt(string(key[strings.LastIndexByte(string(key), '/')+1:]), 10, 64)
166+ if err != nil {
167+ return nil, fmt.Errorf("index key %q: %w", key, err)
168+ }
169+ numbers = append(numbers, n)
170+ }
171+ slices.Sort(numbers)
172+ var out []domain.Issue
173+ for _, n := range slices.Backward(numbers) {
174+ issue, err := i.Get(ctx, repo, n)
175+ if errors.Is(err, domain.ErrNotFound) {
176+ continue // closed or deleted since the snapshot of the index
177+ }
178+ if err != nil {
179+ return nil, err
180+ }
181+ if issue.IsOpen() {
182+ out = append(out, issue)
183+ }
184+ }
185+ return out, nil
186+}
187+
188+// Update replaces the issue with a version precondition and moves its index key
189+// in the same batch.
190+//
191+//hexcheck:ignore suspicious-business-logic-in-adapter port method name only
192+func (i *Issues) Update(ctx context.Context, issue domain.Issue) error {
193+ val, err := encodeIssue(issue)
194+ if err != nil {
195+ return err
196+ }
197+ key := issueKey(issue.Repo, issue.Number)
198+ if _, ok := i.db.Get(ctx, key); !ok {
199+ return domain.ErrNotFound
200+ }
201+ if issue.Version == 0 {
202+ return fmt.Errorf("update issue %d of %q: read it first: %w", issue.Number, issue.Repo, domain.ErrConflict)
203+ }
204+ var b kv.Batch
205+ b.RequireVersion(key, issue.Version)
206+ b.Put(key, val)
207+ indexOpenIssue(&b, issue)
208+ if _, err := i.db.Apply(ctx, &b); err != nil {
209+ if errors.Is(err, kv.ErrConflict) {
210+ return domain.ErrConflict
211+ }
212+ return fmt.Errorf("update issue %d of %q: %w", issue.Number, issue.Repo, err)
213+ }
214+ return nil
215+}
216+
217+// ListComments returns the comments of the issue, oldest first.
218+func (i *Issues) ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error) {
219+ var out []domain.IssueComment
220+ for key, item := range i.db.Snapshot(ctx).Prefix(ctx, []byte(issueCommentsPrefix(repo, number))) {
221+ c, err := decodeIssueComment(item)
222+ if err != nil {
223+ return nil, fmt.Errorf("decode %s: %w", key, err)
224+ }
225+ out = append(out, c)
226+ }
227+ return out, nil
228+}
229+
230+// SaveComment writes the comment and the issue in one batch, each with its
231+// version precondition (absent for a new comment), and requires the repository
232+// to exist (see guardRepoKey): domain.ErrNotFound otherwise.
233+func (i *Issues) SaveComment(ctx context.Context, issue domain.Issue, c domain.IssueComment) error {
234+ issueVal, err := encodeIssue(issue)
235+ if err != nil {
236+ return err
237+ }
238+ cVal, err := encodeIssueComment(c)
239+ if err != nil {
240+ return err
241+ }
242+ if issue.Version == 0 {
243+ return fmt.Errorf("save comment on %d of %q: read the issue first: %w", issue.Number, issue.Repo, domain.ErrConflict)
244+ }
245+ var b kv.Batch
246+ if err := guardRepoKey(ctx, i.db, &b, issue.Repo); err != nil {
247+ return err
248+ }
249+ b.RequireVersion(issueKey(issue.Repo, issue.Number), issue.Version)
250+ b.Put(issueKey(issue.Repo, issue.Number), issueVal)
251+ indexOpenIssue(&b, issue)
252+ ckey := issueCommentKey(issue.Repo, issue.Number, c.ID)
253+ if c.Version == 0 {
254+ b.RequireAbsent(ckey)
255+ } else {
256+ b.RequireVersion(ckey, c.Version)
257+ }
258+ b.Put(ckey, cVal)
259+ if _, err := i.db.Apply(ctx, &b); err != nil {
260+ if errors.Is(err, kv.ErrConflict) {
261+ return domain.ErrConflict
262+ }
263+ return fmt.Errorf("save comment on %d of %q: %w", issue.Number, issue.Repo, err)
264+ }
265+ return nil
266+}
267+
268+func encodeIssue(issue domain.Issue) ([]byte, error) {
269+ if issue.Repo.IsZero() {
270+ return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
271+ }
272+ if !utf8.ValidString(issue.Author) {
273+ return nil, errors.New("issue author is not valid UTF-8")
274+ }
275+ state, err := encodeIssueState(issue.State)
276+ if err != nil {
277+ return nil, err
278+ }
279+ return mustMarshal(&storagepb.Issue{
280+ Number: issue.Number,
281+ Title: issue.Title.String(),
282+ Body: issue.Body.String(),
283+ State: state,
284+ Author: issue.Author,
285+ CreatedAt: encodeTime(issue.CreatedAt),
286+ UpdatedAt: encodeTime(issue.UpdatedAt),
287+ CommentSeq: issue.CommentSeq,
288+ }), nil
289+}
290+
291+//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
292+func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) {
293+ var pb storagepb.Issue
294+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
295+ return domain.Issue{}, fmt.Errorf("decode issue: %w", err)
296+ }
297+ fail := func(err error) (domain.Issue, error) {
298+ return domain.Issue{}, fmt.Errorf("decode issue %d of %q: %w", pb.GetNumber(), repo, err)
299+ }
300+ title, err := domain.ParseIssueTitle(pb.GetTitle())
301+ if err != nil {
302+ return fail(err)
303+ }
304+ body, err := domain.ParseIssueBody(pb.GetBody())
305+ if err != nil {
306+ return fail(err)
307+ }
308+ state, err := decodeIssueState(pb.GetState())
309+ if err != nil {
310+ return fail(err)
311+ }
312+ return domain.Issue{
313+ Repo: repo, Number: pb.GetNumber(), Title: title, Body: body, State: state,
314+ Author: pb.GetAuthor(),
315+ CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),
316+ CommentSeq: pb.GetCommentSeq(),
317+ Version: item.Version,
318+ }, nil
319+}
320+
321+func encodeIssueComment(c domain.IssueComment) ([]byte, error) {
322+ if !utf8.ValidString(c.Author) {
323+ return nil, errors.New("issue comment author is not valid UTF-8")
324+ }
325+ return mustMarshal(&storagepb.IssueComment{
326+ Id: c.ID,
327+ Author: c.Author,
328+ Text: c.Text.String(),
329+ CreatedAt: encodeTime(c.CreatedAt),
330+ }), nil
331+}
332+
333+//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
334+func decodeIssueComment(item kv.Item) (domain.IssueComment, error) {
335+ var pb storagepb.IssueComment
336+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
337+ return domain.IssueComment{}, fmt.Errorf("decode issue comment: %w", err)
338+ }
339+ text, err := domain.ParseIssueBody(pb.GetText())
340+ if err != nil {
341+ return domain.IssueComment{}, fmt.Errorf("decode issue comment %d: %w", pb.GetId(), err)
342+ }
343+ return domain.IssueComment{
344+ ID: pb.GetId(), Author: pb.GetAuthor(), Text: text,
345+ CreatedAt: decodeTime(pb.GetCreatedAt()), Version: item.Version,
346+ }, nil
347+}
348+
349+func encodeIssueState(s domain.IssueState) (storagepb.IssueState, error) {
350+ switch s {
351+ case domain.IssueStateOpen:
352+ return storagepb.IssueState_ISSUE_STATE_OPEN, nil
353+ case domain.IssueStateClosed:
354+ return storagepb.IssueState_ISSUE_STATE_CLOSED, nil
355+ case domain.IssueStatePending:
356+ return storagepb.IssueState_ISSUE_STATE_PENDING, nil
357+ }
358+ return 0, fmt.Errorf("%w: issue state %q", domain.ErrInvalid, s)
359+}
360+
361+func decodeIssueState(s storagepb.IssueState) (domain.IssueState, error) {
362+ switch s {
363+ case storagepb.IssueState_ISSUE_STATE_OPEN:
364+ return domain.IssueStateOpen, nil
365+ case storagepb.IssueState_ISSUE_STATE_CLOSED:
366+ return domain.IssueStateClosed, nil
367+ case storagepb.IssueState_ISSUE_STATE_PENDING:
368+ return domain.IssueStatePending, nil
369+ }
370+ return "", fmt.Errorf("unknown issue state %d", s)
371+}
372+
373+func encodeIssuesMode(m domain.IssuesMode) (storagepb.IssuesMode, error) {
374+ switch m {
375+ case "":
376+ return storagepb.IssuesMode_ISSUES_MODE_UNSPECIFIED, nil
377+ case domain.IssuesOff:
378+ return storagepb.IssuesMode_ISSUES_MODE_OFF, nil
379+ case domain.IssuesOwner:
380+ return storagepb.IssuesMode_ISSUES_MODE_OWNER, nil
381+ case domain.IssuesPublic:
382+ return storagepb.IssuesMode_ISSUES_MODE_PUBLIC, nil
383+ }
384+ return 0, fmt.Errorf("%w: issues mode %q in the settings", domain.ErrInvalid, m)
385+}
386+
387+// decodeIssuesMode reads an unspecified or unknown mode as off: the safe side.
388+func decodeIssuesMode(m storagepb.IssuesMode) domain.IssuesMode {
389+ switch m {
390+ case storagepb.IssuesMode_ISSUES_MODE_OWNER:
391+ return domain.IssuesOwner
392+ case storagepb.IssuesMode_ISSUES_MODE_PUBLIC:
393+ return domain.IssuesPublic
394+ }
395+ return domain.IssuesOff
396+}
@@ -0,0 +1,396 @@
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+ "unicode/utf8"
13+
14+ kv "bnema.dev/ermite-kv"
15+ "google.golang.org/protobuf/proto"
16+
17+ storagepb "bnema.dev/ermite/gen/ermite/storage"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+// Key layout of the issues, like that of the merge requests. The numbers come
23+// from the sequence of the merge requests (mrseq/<repo>): an issue and a merge
24+// request never share one.
25+const (
26+ issuePrefix = "is/" // is/<repo>/<number:010d>
27+ issueCommentPrefix = "isc/" // isc/<repo>/<number:010d>/<id:010d>
28+ issueOpenPrefix = "isopen/" // isopen/<repo>/<number:010d>, written with the issue
29+)
30+
31+// Issues is the ermite-kv backed ports.IssueStore. It shares the database of
32+// the Store it comes from.
33+type Issues struct {
34+ db *kv.DB
35+}
36+
37+var _ ports.IssueStore = (*Issues)(nil)
38+
39+// Issues returns the issue store over the same database. It is valid until the
40+// Store is closed.
41+func (s *Store) Issues() *Issues { return &Issues{db: s.db} }
42+
43+func issueRepoPrefix(repo domain.RepoName) string { return issuePrefix + repo.String() + "/" }
44+func issueKey(repo domain.RepoName, n int64) []byte {
45+ return []byte(issueRepoPrefix(repo) + fmt.Sprintf(numberFormat, n))
46+}
47+func issueCommentsPrefix(repo domain.RepoName, n int64) string {
48+ return issueCommentPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n) + "/"
49+}
50+func issueCommentKey(repo domain.RepoName, n, id int64) []byte {
51+ return []byte(issueCommentsPrefix(repo, n) + fmt.Sprintf(numberFormat, id))
52+}
53+func issueOpenRepoPrefix(repo domain.RepoName) string { return issueOpenPrefix + repo.String() + "/" }
54+func issueOpenKey(repo domain.RepoName, n int64) []byte {
55+ return []byte(issueOpenRepoPrefix(repo) + fmt.Sprintf(numberFormat, n))
56+}
57+
58+// indexOpenIssue adds to b the write that keeps the index of the open issues in
59+// step with the issue: the key is put when it is open, deleted otherwise
60+// (deleting a key that is not there is not an error).
61+func indexOpenIssue(b *kv.Batch, issue domain.Issue) {
62+ if issue.IsOpen() {
63+ b.Put(issueOpenKey(issue.Repo, issue.Number), []byte{1})
64+ return
65+ }
66+ b.Delete(issueOpenKey(issue.Repo, issue.Number))
67+}
68+
69+// issueKeys lists every key of the snapshot that belongs to the repository's
70+// issues.
71+func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
72+ var keys [][]byte
73+ for _, p := range []string{
74+ issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo),
75+ } {
76+ for k := range snap.Prefix(ctx, []byte(p)) {
77+ keys = append(keys, slices.Clone(k))
78+ }
79+ }
80+ return keys
81+}
82+
83+// Create takes the next number of the repository from mrseq/<repo>, the
84+// sequence of the merge requests, and stores the issue under it; see
85+// createNumbered. A missing repository gives domain.ErrNotFound.
86+//
87+//hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
88+func (i *Issues) Create(ctx context.Context, issue domain.Issue) (domain.Issue, error) {
89+ err := createNumbered(ctx, i.db, issue.Repo, func(b *kv.Batch, n int64) error {
90+ issue.Number = n
91+ val, err := encodeIssue(issue)
92+ if err != nil {
93+ return err
94+ }
95+ b.RequireAbsent(issueKey(issue.Repo, n))
96+ b.Put(issueKey(issue.Repo, n), val)
97+ indexOpenIssue(b, issue)
98+ return nil
99+ })
100+ if err != nil {
101+ return domain.Issue{}, fmt.Errorf("create issue of %q: %w", issue.Repo, err)
102+ }
103+ return i.Get(ctx, issue.Repo, issue.Number)
104+}
105+
106+// Get reads is/<repo>/<number>.
107+func (i *Issues) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error) {
108+ if number < 1 {
109+ return domain.Issue{}, domain.ErrNotFound
110+ }
111+ item, ok := i.db.Get(ctx, issueKey(repo, number))
112+ if !ok {
113+ return domain.Issue{}, domain.ErrNotFound
114+ }
115+ return decodeIssue(repo, item)
116+}
117+
118+// List returns a page of the issues of the repository, highest number first. The
119+// page token is the last number of the previous page.
120+//
121+//hexcheck:ignore suspicious-business-logic-in-adapter pages through the keys of a prefix: a storage concern, no policy
122+func (i *Issues) List(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
123+ before := int64(0) // 0: from the highest
124+ if params.PageToken != "" {
125+ n, err := strconv.ParseInt(params.PageToken, 10, 64)
126+ if err != nil || n < 1 {
127+ return ports.IssuePage{}, fmt.Errorf("%w: page token", domain.ErrInvalid)
128+ }
129+ before = n
130+ }
131+ var all []domain.Issue
132+ for key, item := range i.db.Snapshot(ctx).Prefix(ctx, []byte(issueRepoPrefix(repo))) {
133+ issue, err := decodeIssue(repo, item)
134+ if err != nil {
135+ return ports.IssuePage{}, fmt.Errorf("decode %s: %w", key, err)
136+ }
137+ if params.State != "" && issue.State != params.State {
138+ continue
139+ }
140+ if before > 0 && issue.Number >= before {
141+ continue
142+ }
143+ all = append(all, issue)
144+ }
145+ slices.Reverse(all)
146+ var page ports.IssuePage
147+ if params.PageSize > 0 && len(all) > params.PageSize {
148+ all = all[:params.PageSize]
149+ page.NextPageToken = strconv.FormatInt(all[len(all)-1].Number, 10)
150+ }
151+ page.Issues = all
152+ return page, nil
153+}
154+
155+// ListOpen returns the open issues of the repository, highest number first,
156+// through the index: only the open ones are read.
157+//
158+//hexcheck:ignore suspicious-business-logic-in-adapter reads the open issues through their index: a storage concern, no policy
159+func (i *Issues) ListOpen(ctx context.Context, repo domain.RepoName) ([]domain.Issue, error) {
160+ var numbers []int64
161+ for key := range i.db.Snapshot(ctx).Prefix(ctx, []byte(issueOpenRepoPrefix(repo))) {
162+ if err := ctx.Err(); err != nil {
163+ return nil, err
164+ }
165+ n, err := strconv.ParseInt(string(key[strings.LastIndexByte(string(key), '/')+1:]), 10, 64)
166+ if err != nil {
167+ return nil, fmt.Errorf("index key %q: %w", key, err)
168+ }
169+ numbers = append(numbers, n)
170+ }
171+ slices.Sort(numbers)
172+ var out []domain.Issue
173+ for _, n := range slices.Backward(numbers) {
174+ issue, err := i.Get(ctx, repo, n)
175+ if errors.Is(err, domain.ErrNotFound) {
176+ continue // closed or deleted since the snapshot of the index
177+ }
178+ if err != nil {
179+ return nil, err
180+ }
181+ if issue.IsOpen() {
182+ out = append(out, issue)
183+ }
184+ }
185+ return out, nil
186+}
187+
188+// Update replaces the issue with a version precondition and moves its index key
189+// in the same batch.
190+//
191+//hexcheck:ignore suspicious-business-logic-in-adapter port method name only
192+func (i *Issues) Update(ctx context.Context, issue domain.Issue) error {
193+ val, err := encodeIssue(issue)
194+ if err != nil {
195+ return err
196+ }
197+ key := issueKey(issue.Repo, issue.Number)
198+ if _, ok := i.db.Get(ctx, key); !ok {
199+ return domain.ErrNotFound
200+ }
201+ if issue.Version == 0 {
202+ return fmt.Errorf("update issue %d of %q: read it first: %w", issue.Number, issue.Repo, domain.ErrConflict)
203+ }
204+ var b kv.Batch
205+ b.RequireVersion(key, issue.Version)
206+ b.Put(key, val)
207+ indexOpenIssue(&b, issue)
208+ if _, err := i.db.Apply(ctx, &b); err != nil {
209+ if errors.Is(err, kv.ErrConflict) {
210+ return domain.ErrConflict
211+ }
212+ return fmt.Errorf("update issue %d of %q: %w", issue.Number, issue.Repo, err)
213+ }
214+ return nil
215+}
216+
217+// ListComments returns the comments of the issue, oldest first.
218+func (i *Issues) ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error) {
219+ var out []domain.IssueComment
220+ for key, item := range i.db.Snapshot(ctx).Prefix(ctx, []byte(issueCommentsPrefix(repo, number))) {
221+ c, err := decodeIssueComment(item)
222+ if err != nil {
223+ return nil, fmt.Errorf("decode %s: %w", key, err)
224+ }
225+ out = append(out, c)
226+ }
227+ return out, nil
228+}
229+
230+// SaveComment writes the comment and the issue in one batch, each with its
231+// version precondition (absent for a new comment), and requires the repository
232+// to exist (see guardRepoKey): domain.ErrNotFound otherwise.
233+func (i *Issues) SaveComment(ctx context.Context, issue domain.Issue, c domain.IssueComment) error {
234+ issueVal, err := encodeIssue(issue)
235+ if err != nil {
236+ return err
237+ }
238+ cVal, err := encodeIssueComment(c)
239+ if err != nil {
240+ return err
241+ }
242+ if issue.Version == 0 {
243+ return fmt.Errorf("save comment on %d of %q: read the issue first: %w", issue.Number, issue.Repo, domain.ErrConflict)
244+ }
245+ var b kv.Batch
246+ if err := guardRepoKey(ctx, i.db, &b, issue.Repo); err != nil {
247+ return err
248+ }
249+ b.RequireVersion(issueKey(issue.Repo, issue.Number), issue.Version)
250+ b.Put(issueKey(issue.Repo, issue.Number), issueVal)
251+ indexOpenIssue(&b, issue)
252+ ckey := issueCommentKey(issue.Repo, issue.Number, c.ID)
253+ if c.Version == 0 {
254+ b.RequireAbsent(ckey)
255+ } else {
256+ b.RequireVersion(ckey, c.Version)
257+ }
258+ b.Put(ckey, cVal)
259+ if _, err := i.db.Apply(ctx, &b); err != nil {
260+ if errors.Is(err, kv.ErrConflict) {
261+ return domain.ErrConflict
262+ }
263+ return fmt.Errorf("save comment on %d of %q: %w", issue.Number, issue.Repo, err)
264+ }
265+ return nil
266+}
267+
268+func encodeIssue(issue domain.Issue) ([]byte, error) {
269+ if issue.Repo.IsZero() {
270+ return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
271+ }
272+ if !utf8.ValidString(issue.Author) {
273+ return nil, errors.New("issue author is not valid UTF-8")
274+ }
275+ state, err := encodeIssueState(issue.State)
276+ if err != nil {
277+ return nil, err
278+ }
279+ return mustMarshal(&storagepb.Issue{
280+ Number: issue.Number,
281+ Title: issue.Title.String(),
282+ Body: issue.Body.String(),
283+ State: state,
284+ Author: issue.Author,
285+ CreatedAt: encodeTime(issue.CreatedAt),
286+ UpdatedAt: encodeTime(issue.UpdatedAt),
287+ CommentSeq: issue.CommentSeq,
288+ }), nil
289+}
290+
291+//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
292+func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) {
293+ var pb storagepb.Issue
294+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
295+ return domain.Issue{}, fmt.Errorf("decode issue: %w", err)
296+ }
297+ fail := func(err error) (domain.Issue, error) {
298+ return domain.Issue{}, fmt.Errorf("decode issue %d of %q: %w", pb.GetNumber(), repo, err)
299+ }
300+ title, err := domain.ParseIssueTitle(pb.GetTitle())
301+ if err != nil {
302+ return fail(err)
303+ }
304+ body, err := domain.ParseIssueBody(pb.GetBody())
305+ if err != nil {
306+ return fail(err)
307+ }
308+ state, err := decodeIssueState(pb.GetState())
309+ if err != nil {
310+ return fail(err)
311+ }
312+ return domain.Issue{
313+ Repo: repo, Number: pb.GetNumber(), Title: title, Body: body, State: state,
314+ Author: pb.GetAuthor(),
315+ CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),
316+ CommentSeq: pb.GetCommentSeq(),
317+ Version: item.Version,
318+ }, nil
319+}
320+
321+func encodeIssueComment(c domain.IssueComment) ([]byte, error) {
322+ if !utf8.ValidString(c.Author) {
323+ return nil, errors.New("issue comment author is not valid UTF-8")
324+ }
325+ return mustMarshal(&storagepb.IssueComment{
326+ Id: c.ID,
327+ Author: c.Author,
328+ Text: c.Text.String(),
329+ CreatedAt: encodeTime(c.CreatedAt),
330+ }), nil
331+}
332+
333+//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
334+func decodeIssueComment(item kv.Item) (domain.IssueComment, error) {
335+ var pb storagepb.IssueComment
336+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
337+ return domain.IssueComment{}, fmt.Errorf("decode issue comment: %w", err)
338+ }
339+ text, err := domain.ParseIssueBody(pb.GetText())
340+ if err != nil {
341+ return domain.IssueComment{}, fmt.Errorf("decode issue comment %d: %w", pb.GetId(), err)
342+ }
343+ return domain.IssueComment{
344+ ID: pb.GetId(), Author: pb.GetAuthor(), Text: text,
345+ CreatedAt: decodeTime(pb.GetCreatedAt()), Version: item.Version,
346+ }, nil
347+}
348+
349+func encodeIssueState(s domain.IssueState) (storagepb.IssueState, error) {
350+ switch s {
351+ case domain.IssueStateOpen:
352+ return storagepb.IssueState_ISSUE_STATE_OPEN, nil
353+ case domain.IssueStateClosed:
354+ return storagepb.IssueState_ISSUE_STATE_CLOSED, nil
355+ case domain.IssueStatePending:
356+ return storagepb.IssueState_ISSUE_STATE_PENDING, nil
357+ }
358+ return 0, fmt.Errorf("%w: issue state %q", domain.ErrInvalid, s)
359+}
360+
361+func decodeIssueState(s storagepb.IssueState) (domain.IssueState, error) {
362+ switch s {
363+ case storagepb.IssueState_ISSUE_STATE_OPEN:
364+ return domain.IssueStateOpen, nil
365+ case storagepb.IssueState_ISSUE_STATE_CLOSED:
366+ return domain.IssueStateClosed, nil
367+ case storagepb.IssueState_ISSUE_STATE_PENDING:
368+ return domain.IssueStatePending, nil
369+ }
370+ return "", fmt.Errorf("unknown issue state %d", s)
371+}
372+
373+func encodeIssuesMode(m domain.IssuesMode) (storagepb.IssuesMode, error) {
374+ switch m {
375+ case "":
376+ return storagepb.IssuesMode_ISSUES_MODE_UNSPECIFIED, nil
377+ case domain.IssuesOff:
378+ return storagepb.IssuesMode_ISSUES_MODE_OFF, nil
379+ case domain.IssuesOwner:
380+ return storagepb.IssuesMode_ISSUES_MODE_OWNER, nil
381+ case domain.IssuesPublic:
382+ return storagepb.IssuesMode_ISSUES_MODE_PUBLIC, nil
383+ }
384+ return 0, fmt.Errorf("%w: issues mode %q in the settings", domain.ErrInvalid, m)
385+}
386+
387+// decodeIssuesMode reads an unspecified or unknown mode as off: the safe side.
388+func decodeIssuesMode(m storagepb.IssuesMode) domain.IssuesMode {
389+ switch m {
390+ case storagepb.IssuesMode_ISSUES_MODE_OWNER:
391+ return domain.IssuesOwner
392+ case storagepb.IssuesMode_ISSUES_MODE_PUBLIC:
393+ return domain.IssuesPublic
394+ }
395+ return domain.IssuesOff
396+}
A internal/adapters/kvstore/issues_test.go +339 -0
@@ -0,0 +1,339 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "context"
7+ "sync"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ storagepb "bnema.dev/ermite/gen/ermite/storage"
14+ "bnema.dev/ermite/internal/adapters/kvstore"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+func newIssue(t *testing.T, repo domain.RepoName, title string) domain.Issue {
20+ t.Helper()
21+ ti, err := domain.ParseIssueTitle(title)
22+ require.NoError(t, err)
23+ body, err := domain.ParseIssueBody("what\nhappened")
24+ require.NoError(t, err)
25+ return domain.Issue{
26+ Repo: repo, Title: ti, Body: body, State: domain.IssueStateOpen,
27+ Author: "agent", CreatedAt: created, UpdatedAt: created,
28+ }
29+}
30+
31+func newIssueComment(t *testing.T, id int64, text string) domain.IssueComment {
32+ t.Helper()
33+ body, err := domain.ParseIssueBody(text)
34+ require.NoError(t, err)
35+ return domain.IssueComment{ID: id, Author: "agent", Text: body, CreatedAt: created}
36+}
37+
38+// countKeys counts the keys of the store under prefix.
39+func countKeys(t *testing.T, s *kvstore.Store, prefix string) int {
40+ t.Helper()
41+ return s.CountKeysForTest(context.Background(), prefix)
42+}
43+
44+func TestIssueRoundTripAndPaging(t *testing.T) {
45+ ctx := context.Background()
46+ s, _, n := repoWithMRs(t)
47+ is := s.Issues()
48+
49+ first, err := is.Create(ctx, newIssue(t, n, "one"))
50+ require.NoError(t, err)
51+ assert.Equal(t, int64(1), first.Number)
52+ assert.NotZero(t, first.Version)
53+ got, err := is.Get(ctx, n, 1)
54+ require.NoError(t, err)
55+ assert.Equal(t, first, got)
56+ _, err = is.Get(ctx, n, 2)
57+ assert.ErrorIs(t, err, domain.ErrNotFound)
58+
59+ for _, title := range []string{"two", "three", "four"} {
60+ _, err := is.Create(ctx, newIssue(t, n, title))
61+ require.NoError(t, err)
62+ }
63+ closed, err := is.Get(ctx, n, 2)
64+ require.NoError(t, err)
65+ closed.State = domain.IssueStateClosed
66+ require.NoError(t, is.Update(ctx, closed))
67+
68+ page, err := is.List(ctx, n, ports.ListIssuesParams{PageSize: 3})
69+ require.NoError(t, err)
70+ require.Len(t, page.Issues, 3)
71+ assert.Equal(t, int64(4), page.Issues[0].Number)
72+ require.NotEmpty(t, page.NextPageToken)
73+ page, err = is.List(ctx, n, ports.ListIssuesParams{PageSize: 3, PageToken: page.NextPageToken})
74+ require.NoError(t, err)
75+ require.Len(t, page.Issues, 1)
76+ assert.Equal(t, int64(1), page.Issues[0].Number)
77+ assert.Empty(t, page.NextPageToken)
78+
79+ page, err = is.List(ctx, n, ports.ListIssuesParams{State: domain.IssueStateClosed})
80+ require.NoError(t, err)
81+ require.Len(t, page.Issues, 1)
82+ assert.Equal(t, int64(2), page.Issues[0].Number)
83+
84+ _, err = is.List(ctx, n, ports.ListIssuesParams{PageToken: "x"})
85+ assert.ErrorIs(t, err, domain.ErrInvalid)
86+
87+ _, err = is.Create(ctx, newIssue(t, name(t, "ghost"), "x"))
88+ assert.ErrorIs(t, err, domain.ErrNotFound)
89+}
90+
91+func TestIssueUpdateNeedsTheVersionItWasReadAt(t *testing.T) {
92+ ctx := context.Background()
93+ s, _, n := repoWithMRs(t)
94+ is := s.Issues()
95+ first, err := is.Create(ctx, newIssue(t, n, "t"))
96+ require.NoError(t, err)
97+ a, b := first, first
98+ a.State = domain.IssueStateClosed
99+ require.NoError(t, is.Update(ctx, a))
100+ b.State = domain.IssueStateClosed
101+ assert.ErrorIs(t, is.Update(ctx, b), domain.ErrConflict)
102+}
103+
104+func TestIssueCommentsAndSaveCommentPreconditions(t *testing.T) {
105+ ctx := context.Background()
106+ s, _, n := repoWithMRs(t)
107+ is := s.Issues()
108+ issue, err := is.Create(ctx, newIssue(t, n, "t"))
109+ require.NoError(t, err)
110+
111+ issue.CommentSeq = 1
112+ require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 1, "first")))
113+ // The issue moved on: the same version is refused.
114+ issue.CommentSeq = 2
115+ assert.ErrorIs(t, is.SaveComment(ctx, issue, newIssueComment(t, 2, "second")), domain.ErrConflict)
116+ issue, err = is.Get(ctx, n, issue.Number)
117+ require.NoError(t, err)
118+ issue.CommentSeq = 2
119+ require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 2, "second")))
120+
121+ comments, err := is.ListComments(ctx, n, issue.Number)
122+ require.NoError(t, err)
123+ require.Len(t, comments, 2)
124+ assert.Equal(t, "first", comments[0].Text.String())
125+ assert.Equal(t, int64(2), comments[1].ID)
126+
127+ require.NoError(t, s.Delete(ctx, n))
128+ assert.ErrorIs(t, is.SaveComment(ctx, issue, newIssueComment(t, 3, "late")), domain.ErrNotFound)
129+}
130+
131+func TestConcurrentMergeRequestsAndIssuesNeverShareANumber(t *testing.T) {
132+ ctx := context.Background()
133+ s, mrs, n := repoWithMRs(t)
134+ is := s.Issues()
135+ const workers = 16
136+ var (
137+ wg sync.WaitGroup
138+ mu sync.Mutex
139+ numbers []int64
140+ )
141+ for w := range workers {
142+ wg.Go(func() {
143+ var number int64
144+ if w%2 == 0 {
145+ got, err := mrs.Create(ctx, newMR(t, n, "t"))
146+ if !assert.NoError(t, err) {
147+ return
148+ }
149+ number = got.Number
150+ } else {
151+ got, err := is.Create(ctx, newIssue(t, n, "t"))
152+ if !assert.NoError(t, err) {
153+ return
154+ }
155+ number = got.Number
156+ }
157+ mu.Lock()
158+ numbers = append(numbers, number)
159+ mu.Unlock()
160+ })
161+ }
162+ wg.Wait()
163+ want := make([]int64, workers)
164+ for i := range want {
165+ want[i] = int64(i + 1)
166+ }
167+ assert.ElementsMatch(t, want, numbers, "every number is used once, by an issue or a merge request")
168+}
169+
170+func TestDeleteDropsTheIssuesOfTheRepository(t *testing.T) {
171+ ctx := context.Background()
172+ s, _, n := repoWithMRs(t)
173+ sib := name(t, "proj-2")
174+ require.NoError(t, s.Create(ctx, domain.Repo{Name: sib, CreatedAt: created, Visibility: domain.VisibilityPublic}))
175+ is := s.Issues()
176+
177+ for _, repo := range []domain.RepoName{n, sib} {
178+ issue, err := is.Create(ctx, newIssue(t, repo, "t"))
179+ require.NoError(t, err)
180+ issue.CommentSeq = 1
181+ require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 1, "note")))
182+ }
183+ require.NoError(t, s.Delete(ctx, n))
184+
185+ page, err := is.List(ctx, n, ports.ListIssuesParams{})
186+ require.NoError(t, err)
187+ assert.Empty(t, page.Issues)
188+ comments, err := is.ListComments(ctx, n, 1)
189+ require.NoError(t, err)
190+ assert.Empty(t, comments)
191+ open, err := is.ListOpen(ctx, n)
192+ require.NoError(t, err)
193+ assert.Empty(t, open)
194+ assert.Zero(t, countKeys(t, s, "is/proj/"))
195+ assert.Zero(t, countKeys(t, s, "isc/proj/"))
196+ assert.Zero(t, countKeys(t, s, "isopen/proj/"))
197+
198+ open, err = is.ListOpen(ctx, sib)
199+ require.NoError(t, err)
200+ assert.Len(t, open, 1, "a repository whose name starts like the deleted one keeps its issues")
201+ comments, err = is.ListComments(ctx, sib, 1)
202+ require.NoError(t, err)
203+ assert.Len(t, comments, 1)
204+
205+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
206+ again, err := is.Create(ctx, newIssue(t, n, "fresh"))
207+ require.NoError(t, err)
208+ assert.Equal(t, int64(1), again.Number)
209+}
210+
211+func TestOpenIssueIndexFollowsUpdates(t *testing.T) {
212+ ctx := context.Background()
213+ s, _, n := repoWithMRs(t)
214+ is := s.Issues()
215+ a, err := is.Create(ctx, newIssue(t, n, "a"))
216+ require.NoError(t, err)
217+ b, err := is.Create(ctx, newIssue(t, n, "b"))
218+ require.NoError(t, err)
219+
220+ numbers := func() []int64 {
221+ open, err := is.ListOpen(ctx, n)
222+ require.NoError(t, err)
223+ var out []int64
224+ for _, i := range open {
225+ out = append(out, i.Number)
226+ }
227+ return out
228+ }
229+ assert.Equal(t, []int64{2, 1}, numbers())
230+ assert.Equal(t, 2, countKeys(t, s, "isopen/proj/"))
231+
232+ a.State = domain.IssueStateClosed
233+ require.NoError(t, is.Update(ctx, a))
234+ assert.Equal(t, []int64{2}, numbers())
235+ assert.Equal(t, 1, countKeys(t, s, "isopen/proj/"))
236+
237+ a, err = is.Get(ctx, n, a.Number)
238+ require.NoError(t, err)
239+ a.State = domain.IssueStateOpen
240+ require.NoError(t, is.Update(ctx, a))
241+ assert.Equal(t, []int64{2, 1}, numbers())
242+
243+ // A comment keeps the index as the issue says.
244+ b.State = domain.IssueStateClosed
245+ b.CommentSeq = 1
246+ require.NoError(t, is.SaveComment(ctx, b, newIssueComment(t, 1, "closing")))
247+ assert.Equal(t, []int64{1}, numbers())
248+ assert.Equal(t, 1, countKeys(t, s, "isopen/proj/"))
249+}
250+
251+func TestRepoSettingsKeepTheIssuesMode(t *testing.T) {
252+ ctx := context.Background()
253+ s := openStore(t, t.TempDir())
254+ n := name(t, "proj")
255+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
256+ got, err := s.Get(ctx, n)
257+ require.NoError(t, err)
258+ assert.Equal(t, domain.IssuesOff, got.Settings.Issues)
259+ for _, m := range []domain.IssuesMode{domain.IssuesOwner, domain.IssuesPublic, domain.IssuesOff} {
260+ got.Settings.Issues = m
261+ require.NoError(t, s.Update(ctx, got))
262+ again, err := s.Get(ctx, n)
263+ require.NoError(t, err)
264+ assert.Equal(t, m, again.Settings.Issues)
265+ }
266+ got.Settings.Issues = "everyone"
267+ assert.ErrorIs(t, s.Update(ctx, got), domain.ErrInvalid)
268+}
269+
270+// TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every scope
271+// before issue:write existed keep their owner rights; no other token changes.
272+func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
273+ ctx := context.Background()
274+ dir := t.TempDir()
275+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 3}))
276+
277+ all9 := []string{
278+ "repo:read", "repo:write", "repo:admin", "mr:write", "mr:merge",
279+ "ci:write", "secrets:write", "runner:register", "token:admin",
280+ }
281+ tokens := map[string]*storagepb.Token{
282+ "full": {Id: "full", Scopes: all9},
283+ "missing": {Id: "missing", Scopes: all9[:8]},
284+ "repos": {Id: "repos", Scopes: all9, Repos: []string{"proj"}},
285+ "prefix": {Id: "prefix", Scopes: all9, BranchPrefixes: []string{"agent/"}},
286+ "small": {Id: "small", Scopes: []string{"repo:read"}},
287+ "otherfull": {Id: "otherfull", Scopes: append([]string{"token:admin"}, all9[:8]...)},
288+ }
289+ for id, tok := range tokens {
290+ tok.Hash = make([]byte, 32)
291+ tok.Hash[0] = byte(len(id))
292+ putRaw(t, dir, "token/"+id, mustProto(t, tok))
293+ }
294+
295+ s, err := kvstore.Open(ctx, dir)
296+ require.NoError(t, err)
297+ t.Cleanup(func() { _ = s.Close(ctx) })
298+ get := func(id string) domain.Token {
299+ tok, err := s.Tokens().Get(ctx, id)
300+ require.NoError(t, err)
301+ return tok
302+ }
303+
304+ full := get("full")
305+ assert.Contains(t, full.Scopes, domain.ScopeIssueWrite)
306+ assert.True(t, domain.TokenPrincipal(full).HasOwnerRights())
307+ assert.True(t, domain.TokenPrincipal(get("otherfull")).HasOwnerRights(), "the order of the scopes does not matter")
308+
309+ assert.NotContains(t, get("missing").Scopes, domain.ScopeIssueWrite)
310+ assert.False(t, domain.TokenPrincipal(get("missing")).HasOwnerRights())
311+ assert.NotContains(t, get("repos").Scopes, domain.ScopeIssueWrite)
312+ assert.NotContains(t, get("prefix").Scopes, domain.ScopeIssueWrite)
313+ assert.Equal(t, []domain.Scope{domain.ScopeRepoRead}, get("small").Scopes)
314+ assert.Len(t, get("missing").Scopes, 8)
315+ assert.Len(t, get("repos").Scopes, 9)
316+ assert.Len(t, get("prefix").Scopes, 9)
317+ assert.Equal(t, []string{"agent/"}, get("prefix").BranchPrefixes)
318+ assert.Equal(t, "full", full.ID)
319+
320+ require.NoError(t, s.Close(ctx))
321+ assert.Equal(t, uint32(4), readSchema(t, dir))
322+
323+ // A second open changes nothing.
324+ s2, err := kvstore.Open(ctx, dir)
325+ require.NoError(t, err)
326+ tok, err := s2.Tokens().Get(ctx, "full")
327+ require.NoError(t, err)
328+ assert.Equal(t, full.Scopes, tok.Scopes)
329+ require.NoError(t, s2.Close(ctx))
330+}
331+
332+func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) {
333+ ctx := context.Background()
334+ dir := t.TempDir()
335+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1}))
336+ _, err := kvstore.Open(ctx, dir)
337+ require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
338+ assert.Equal(t, uint32(4), uint32(kvstore.SchemaVersion))
339+}
@@ -0,0 +1,339 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "context"
7+ "sync"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ storagepb "bnema.dev/ermite/gen/ermite/storage"
14+ "bnema.dev/ermite/internal/adapters/kvstore"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+func newIssue(t *testing.T, repo domain.RepoName, title string) domain.Issue {
20+ t.Helper()
21+ ti, err := domain.ParseIssueTitle(title)
22+ require.NoError(t, err)
23+ body, err := domain.ParseIssueBody("what\nhappened")
24+ require.NoError(t, err)
25+ return domain.Issue{
26+ Repo: repo, Title: ti, Body: body, State: domain.IssueStateOpen,
27+ Author: "agent", CreatedAt: created, UpdatedAt: created,
28+ }
29+}
30+
31+func newIssueComment(t *testing.T, id int64, text string) domain.IssueComment {
32+ t.Helper()
33+ body, err := domain.ParseIssueBody(text)
34+ require.NoError(t, err)
35+ return domain.IssueComment{ID: id, Author: "agent", Text: body, CreatedAt: created}
36+}
37+
38+// countKeys counts the keys of the store under prefix.
39+func countKeys(t *testing.T, s *kvstore.Store, prefix string) int {
40+ t.Helper()
41+ return s.CountKeysForTest(context.Background(), prefix)
42+}
43+
44+func TestIssueRoundTripAndPaging(t *testing.T) {
45+ ctx := context.Background()
46+ s, _, n := repoWithMRs(t)
47+ is := s.Issues()
48+
49+ first, err := is.Create(ctx, newIssue(t, n, "one"))
50+ require.NoError(t, err)
51+ assert.Equal(t, int64(1), first.Number)
52+ assert.NotZero(t, first.Version)
53+ got, err := is.Get(ctx, n, 1)
54+ require.NoError(t, err)
55+ assert.Equal(t, first, got)
56+ _, err = is.Get(ctx, n, 2)
57+ assert.ErrorIs(t, err, domain.ErrNotFound)
58+
59+ for _, title := range []string{"two", "three", "four"} {
60+ _, err := is.Create(ctx, newIssue(t, n, title))
61+ require.NoError(t, err)
62+ }
63+ closed, err := is.Get(ctx, n, 2)
64+ require.NoError(t, err)
65+ closed.State = domain.IssueStateClosed
66+ require.NoError(t, is.Update(ctx, closed))
67+
68+ page, err := is.List(ctx, n, ports.ListIssuesParams{PageSize: 3})
69+ require.NoError(t, err)
70+ require.Len(t, page.Issues, 3)
71+ assert.Equal(t, int64(4), page.Issues[0].Number)
72+ require.NotEmpty(t, page.NextPageToken)
73+ page, err = is.List(ctx, n, ports.ListIssuesParams{PageSize: 3, PageToken: page.NextPageToken})
74+ require.NoError(t, err)
75+ require.Len(t, page.Issues, 1)
76+ assert.Equal(t, int64(1), page.Issues[0].Number)
77+ assert.Empty(t, page.NextPageToken)
78+
79+ page, err = is.List(ctx, n, ports.ListIssuesParams{State: domain.IssueStateClosed})
80+ require.NoError(t, err)
81+ require.Len(t, page.Issues, 1)
82+ assert.Equal(t, int64(2), page.Issues[0].Number)
83+
84+ _, err = is.List(ctx, n, ports.ListIssuesParams{PageToken: "x"})
85+ assert.ErrorIs(t, err, domain.ErrInvalid)
86+
87+ _, err = is.Create(ctx, newIssue(t, name(t, "ghost"), "x"))
88+ assert.ErrorIs(t, err, domain.ErrNotFound)
89+}
90+
91+func TestIssueUpdateNeedsTheVersionItWasReadAt(t *testing.T) {
92+ ctx := context.Background()
93+ s, _, n := repoWithMRs(t)
94+ is := s.Issues()
95+ first, err := is.Create(ctx, newIssue(t, n, "t"))
96+ require.NoError(t, err)
97+ a, b := first, first
98+ a.State = domain.IssueStateClosed
99+ require.NoError(t, is.Update(ctx, a))
100+ b.State = domain.IssueStateClosed
101+ assert.ErrorIs(t, is.Update(ctx, b), domain.ErrConflict)
102+}
103+
104+func TestIssueCommentsAndSaveCommentPreconditions(t *testing.T) {
105+ ctx := context.Background()
106+ s, _, n := repoWithMRs(t)
107+ is := s.Issues()
108+ issue, err := is.Create(ctx, newIssue(t, n, "t"))
109+ require.NoError(t, err)
110+
111+ issue.CommentSeq = 1
112+ require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 1, "first")))
113+ // The issue moved on: the same version is refused.
114+ issue.CommentSeq = 2
115+ assert.ErrorIs(t, is.SaveComment(ctx, issue, newIssueComment(t, 2, "second")), domain.ErrConflict)
116+ issue, err = is.Get(ctx, n, issue.Number)
117+ require.NoError(t, err)
118+ issue.CommentSeq = 2
119+ require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 2, "second")))
120+
121+ comments, err := is.ListComments(ctx, n, issue.Number)
122+ require.NoError(t, err)
123+ require.Len(t, comments, 2)
124+ assert.Equal(t, "first", comments[0].Text.String())
125+ assert.Equal(t, int64(2), comments[1].ID)
126+
127+ require.NoError(t, s.Delete(ctx, n))
128+ assert.ErrorIs(t, is.SaveComment(ctx, issue, newIssueComment(t, 3, "late")), domain.ErrNotFound)
129+}
130+
131+func TestConcurrentMergeRequestsAndIssuesNeverShareANumber(t *testing.T) {
132+ ctx := context.Background()
133+ s, mrs, n := repoWithMRs(t)
134+ is := s.Issues()
135+ const workers = 16
136+ var (
137+ wg sync.WaitGroup
138+ mu sync.Mutex
139+ numbers []int64
140+ )
141+ for w := range workers {
142+ wg.Go(func() {
143+ var number int64
144+ if w%2 == 0 {
145+ got, err := mrs.Create(ctx, newMR(t, n, "t"))
146+ if !assert.NoError(t, err) {
147+ return
148+ }
149+ number = got.Number
150+ } else {
151+ got, err := is.Create(ctx, newIssue(t, n, "t"))
152+ if !assert.NoError(t, err) {
153+ return
154+ }
155+ number = got.Number
156+ }
157+ mu.Lock()
158+ numbers = append(numbers, number)
159+ mu.Unlock()
160+ })
161+ }
162+ wg.Wait()
163+ want := make([]int64, workers)
164+ for i := range want {
165+ want[i] = int64(i + 1)
166+ }
167+ assert.ElementsMatch(t, want, numbers, "every number is used once, by an issue or a merge request")
168+}
169+
170+func TestDeleteDropsTheIssuesOfTheRepository(t *testing.T) {
171+ ctx := context.Background()
172+ s, _, n := repoWithMRs(t)
173+ sib := name(t, "proj-2")
174+ require.NoError(t, s.Create(ctx, domain.Repo{Name: sib, CreatedAt: created, Visibility: domain.VisibilityPublic}))
175+ is := s.Issues()
176+
177+ for _, repo := range []domain.RepoName{n, sib} {
178+ issue, err := is.Create(ctx, newIssue(t, repo, "t"))
179+ require.NoError(t, err)
180+ issue.CommentSeq = 1
181+ require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 1, "note")))
182+ }
183+ require.NoError(t, s.Delete(ctx, n))
184+
185+ page, err := is.List(ctx, n, ports.ListIssuesParams{})
186+ require.NoError(t, err)
187+ assert.Empty(t, page.Issues)
188+ comments, err := is.ListComments(ctx, n, 1)
189+ require.NoError(t, err)
190+ assert.Empty(t, comments)
191+ open, err := is.ListOpen(ctx, n)
192+ require.NoError(t, err)
193+ assert.Empty(t, open)
194+ assert.Zero(t, countKeys(t, s, "is/proj/"))
195+ assert.Zero(t, countKeys(t, s, "isc/proj/"))
196+ assert.Zero(t, countKeys(t, s, "isopen/proj/"))
197+
198+ open, err = is.ListOpen(ctx, sib)
199+ require.NoError(t, err)
200+ assert.Len(t, open, 1, "a repository whose name starts like the deleted one keeps its issues")
201+ comments, err = is.ListComments(ctx, sib, 1)
202+ require.NoError(t, err)
203+ assert.Len(t, comments, 1)
204+
205+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
206+ again, err := is.Create(ctx, newIssue(t, n, "fresh"))
207+ require.NoError(t, err)
208+ assert.Equal(t, int64(1), again.Number)
209+}
210+
211+func TestOpenIssueIndexFollowsUpdates(t *testing.T) {
212+ ctx := context.Background()
213+ s, _, n := repoWithMRs(t)
214+ is := s.Issues()
215+ a, err := is.Create(ctx, newIssue(t, n, "a"))
216+ require.NoError(t, err)
217+ b, err := is.Create(ctx, newIssue(t, n, "b"))
218+ require.NoError(t, err)
219+
220+ numbers := func() []int64 {
221+ open, err := is.ListOpen(ctx, n)
222+ require.NoError(t, err)
223+ var out []int64
224+ for _, i := range open {
225+ out = append(out, i.Number)
226+ }
227+ return out
228+ }
229+ assert.Equal(t, []int64{2, 1}, numbers())
230+ assert.Equal(t, 2, countKeys(t, s, "isopen/proj/"))
231+
232+ a.State = domain.IssueStateClosed
233+ require.NoError(t, is.Update(ctx, a))
234+ assert.Equal(t, []int64{2}, numbers())
235+ assert.Equal(t, 1, countKeys(t, s, "isopen/proj/"))
236+
237+ a, err = is.Get(ctx, n, a.Number)
238+ require.NoError(t, err)
239+ a.State = domain.IssueStateOpen
240+ require.NoError(t, is.Update(ctx, a))
241+ assert.Equal(t, []int64{2, 1}, numbers())
242+
243+ // A comment keeps the index as the issue says.
244+ b.State = domain.IssueStateClosed
245+ b.CommentSeq = 1
246+ require.NoError(t, is.SaveComment(ctx, b, newIssueComment(t, 1, "closing")))
247+ assert.Equal(t, []int64{1}, numbers())
248+ assert.Equal(t, 1, countKeys(t, s, "isopen/proj/"))
249+}
250+
251+func TestRepoSettingsKeepTheIssuesMode(t *testing.T) {
252+ ctx := context.Background()
253+ s := openStore(t, t.TempDir())
254+ n := name(t, "proj")
255+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
256+ got, err := s.Get(ctx, n)
257+ require.NoError(t, err)
258+ assert.Equal(t, domain.IssuesOff, got.Settings.Issues)
259+ for _, m := range []domain.IssuesMode{domain.IssuesOwner, domain.IssuesPublic, domain.IssuesOff} {
260+ got.Settings.Issues = m
261+ require.NoError(t, s.Update(ctx, got))
262+ again, err := s.Get(ctx, n)
263+ require.NoError(t, err)
264+ assert.Equal(t, m, again.Settings.Issues)
265+ }
266+ got.Settings.Issues = "everyone"
267+ assert.ErrorIs(t, s.Update(ctx, got), domain.ErrInvalid)
268+}
269+
270+// TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every scope
271+// before issue:write existed keep their owner rights; no other token changes.
272+func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
273+ ctx := context.Background()
274+ dir := t.TempDir()
275+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 3}))
276+
277+ all9 := []string{
278+ "repo:read", "repo:write", "repo:admin", "mr:write", "mr:merge",
279+ "ci:write", "secrets:write", "runner:register", "token:admin",
280+ }
281+ tokens := map[string]*storagepb.Token{
282+ "full": {Id: "full", Scopes: all9},
283+ "missing": {Id: "missing", Scopes: all9[:8]},
284+ "repos": {Id: "repos", Scopes: all9, Repos: []string{"proj"}},
285+ "prefix": {Id: "prefix", Scopes: all9, BranchPrefixes: []string{"agent/"}},
286+ "small": {Id: "small", Scopes: []string{"repo:read"}},
287+ "otherfull": {Id: "otherfull", Scopes: append([]string{"token:admin"}, all9[:8]...)},
288+ }
289+ for id, tok := range tokens {
290+ tok.Hash = make([]byte, 32)
291+ tok.Hash[0] = byte(len(id))
292+ putRaw(t, dir, "token/"+id, mustProto(t, tok))
293+ }
294+
295+ s, err := kvstore.Open(ctx, dir)
296+ require.NoError(t, err)
297+ t.Cleanup(func() { _ = s.Close(ctx) })
298+ get := func(id string) domain.Token {
299+ tok, err := s.Tokens().Get(ctx, id)
300+ require.NoError(t, err)
301+ return tok
302+ }
303+
304+ full := get("full")
305+ assert.Contains(t, full.Scopes, domain.ScopeIssueWrite)
306+ assert.True(t, domain.TokenPrincipal(full).HasOwnerRights())
307+ assert.True(t, domain.TokenPrincipal(get("otherfull")).HasOwnerRights(), "the order of the scopes does not matter")
308+
309+ assert.NotContains(t, get("missing").Scopes, domain.ScopeIssueWrite)
310+ assert.False(t, domain.TokenPrincipal(get("missing")).HasOwnerRights())
311+ assert.NotContains(t, get("repos").Scopes, domain.ScopeIssueWrite)
312+ assert.NotContains(t, get("prefix").Scopes, domain.ScopeIssueWrite)
313+ assert.Equal(t, []domain.Scope{domain.ScopeRepoRead}, get("small").Scopes)
314+ assert.Len(t, get("missing").Scopes, 8)
315+ assert.Len(t, get("repos").Scopes, 9)
316+ assert.Len(t, get("prefix").Scopes, 9)
317+ assert.Equal(t, []string{"agent/"}, get("prefix").BranchPrefixes)
318+ assert.Equal(t, "full", full.ID)
319+
320+ require.NoError(t, s.Close(ctx))
321+ assert.Equal(t, uint32(4), readSchema(t, dir))
322+
323+ // A second open changes nothing.
324+ s2, err := kvstore.Open(ctx, dir)
325+ require.NoError(t, err)
326+ tok, err := s2.Tokens().Get(ctx, "full")
327+ require.NoError(t, err)
328+ assert.Equal(t, full.Scopes, tok.Scopes)
329+ require.NoError(t, s2.Close(ctx))
330+}
331+
332+func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) {
333+ ctx := context.Background()
334+ dir := t.TempDir()
335+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1}))
336+ _, err := kvstore.Open(ctx, dir)
337+ require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
338+ assert.Equal(t, uint32(4), uint32(kvstore.SchemaVersion))
339+}
M internal/adapters/kvstore/mergerequests.go +47 -24
@@ -198,47 +198,65 @@ func (m *MergeRequests) guardRepo(ctx context.Context, b *kv.Batch, repo domain.
198198 //
199199 //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
200200 func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) {
201+ err := createNumbered(ctx, m.db, mr.Repo, func(b *kv.Batch, n int64) error {
202+ mr.Number = n
203+ val, err := encodeMergeRequest(mr)
204+ if err != nil {
205+ return err
206+ }
207+ b.RequireAbsent(mrKey(mr.Repo, n))
208+ b.Put(mrKey(mr.Repo, n), val)
209+ indexOpen(b, nil, mr)
210+ return nil
211+ })
212+ if err != nil {
213+ return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, err)
214+ }
215+ return m.Get(ctx, mr.Repo, mr.Number)
216+}
217+
218+// createNumbered takes the next number of the repository from mrseq/<repo>,
219+// shared by the merge requests and the issues, and lets add put the record
220+// under it into a batch that requires the sequence to be unchanged since it was
221+// read (absent for the first number) and the repository to be unchanged (see
222+// guardRepoKey): two concurrent creations, of either kind, never share a
223+// number. It retries when the batch conflicts; a missing repository gives
224+// domain.ErrNotFound.
225+func createNumbered(ctx context.Context, db *kv.DB, repo domain.RepoName, add func(b *kv.Batch, number int64) error) error {
201226 for range createTries {
202- repoItem, repoOK := m.db.Get(ctx, repoKey(mr.Repo))
227+ repoItem, repoOK := db.Get(ctx, repoKey(repo))
203228 if !repoOK {
204- return domain.MergeRequest{}, domain.ErrNotFound
229+ return domain.ErrNotFound
205230 }
206231 var last int64
207- item, ok := m.db.Get(ctx, seqKey(mr.Repo))
232+ item, ok := db.Get(ctx, seqKey(repo))
208233 if ok {
209234 var seq storagepb.Sequence
210235 if err := proto.Unmarshal(item.Value, &seq); err != nil {
211- return domain.MergeRequest{}, fmt.Errorf("decode merge request sequence of %q: %w", mr.Repo, err)
236+ return fmt.Errorf("decode sequence of %q: %w", repo, err)
212237 }
213238 last = seq.GetLast()
214239 }
215- mr.Number = last + 1
216- val, err := encodeMergeRequest(mr)
217- if err != nil {
218- return domain.MergeRequest{}, err
219- }
240+ number := last + 1
220241 var b kv.Batch
221- b.RequireVersion(repoKey(mr.Repo), repoItem.Version)
222- b.Put(repoKey(mr.Repo), repoItem.Value) // see guardRepo
242+ b.RequireVersion(repoKey(repo), repoItem.Version)
243+ b.Put(repoKey(repo), repoItem.Value) // see guardRepoKey
223244 if ok {
224- b.RequireVersion(seqKey(mr.Repo), item.Version)
245+ b.RequireVersion(seqKey(repo), item.Version)
225246 } else {
226- b.RequireAbsent(seqKey(mr.Repo))
247+ b.RequireAbsent(seqKey(repo))
227248 }
228- b.Put(seqKey(mr.Repo), mustMarshal(&storagepb.Sequence{Last: mr.Number}))
229- b.RequireAbsent(mrKey(mr.Repo, mr.Number))
230- b.Put(mrKey(mr.Repo, mr.Number), val)
231- indexOpen(&b, nil, mr)
232- _, err = m.db.Apply(ctx, &b)
249+ b.Put(seqKey(repo), mustMarshal(&storagepb.Sequence{Last: number}))
250+ if err := add(&b, number); err != nil {
251+ return err
252+ }
253+ _, err := db.Apply(ctx, &b)
233254 if errors.Is(err, kv.ErrConflict) {
234255 continue
235256 }
236- if err != nil {
237- return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, err)
238- }
239- return m.Get(ctx, mr.Repo, mr.Number)
257+ return err
240258 }
241- return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, domain.ErrConflict)
259+ return domain.ErrConflict
242260 }
243261
244262 // Get reads mr/<repo>/<number>.
@@ -683,6 +701,11 @@ func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {
683701 }
684702 pb.AllowedMergeModes = append(pb.AllowedMergeModes, mode)
685703 }
704+ issues, err := encodeIssuesMode(s.Issues)
705+ if err != nil {
706+ return nil, err
707+ }
708+ pb.Issues = issues
686709 return pb, nil
687710 }
688711
@@ -707,5 +730,5 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings {
707730 if err != nil {
708731 return domain.DefaultRepoSettings()
709732 }
710- return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock()}
733+ return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues())}
711734 }
@@ -198,47 +198,65 @@ func (m *MergeRequests) guardRepo(ctx context.Context, b *kv.Batch, repo domain.
198 //198 //
199 //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy199 //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
200 func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) {200 func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) {
201+ err := createNumbered(ctx, m.db, mr.Repo, func(b *kv.Batch, n int64) error {
202+ mr.Number = n
203+ val, err := encodeMergeRequest(mr)
204+ if err != nil {
205+ return err
206+ }
207+ b.RequireAbsent(mrKey(mr.Repo, n))
208+ b.Put(mrKey(mr.Repo, n), val)
209+ indexOpen(b, nil, mr)
210+ return nil
211+ })
212+ if err != nil {
213+ return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, err)
214+ }
215+ return m.Get(ctx, mr.Repo, mr.Number)
216+}
217+
218+// createNumbered takes the next number of the repository from mrseq/<repo>,
219+// shared by the merge requests and the issues, and lets add put the record
220+// under it into a batch that requires the sequence to be unchanged since it was
221+// read (absent for the first number) and the repository to be unchanged (see
222+// guardRepoKey): two concurrent creations, of either kind, never share a
223+// number. It retries when the batch conflicts; a missing repository gives
224+// domain.ErrNotFound.
225+func createNumbered(ctx context.Context, db *kv.DB, repo domain.RepoName, add func(b *kv.Batch, number int64) error) error {
201 for range createTries {226 for range createTries {
202- repoItem, repoOK := m.db.Get(ctx, repoKey(mr.Repo))227+ repoItem, repoOK := db.Get(ctx, repoKey(repo))
203 if !repoOK {228 if !repoOK {
204- return domain.MergeRequest{}, domain.ErrNotFound229+ return domain.ErrNotFound
205 }230 }
206 var last int64231 var last int64
207- item, ok := m.db.Get(ctx, seqKey(mr.Repo))232+ item, ok := db.Get(ctx, seqKey(repo))
208 if ok {233 if ok {
209 var seq storagepb.Sequence234 var seq storagepb.Sequence
210 if err := proto.Unmarshal(item.Value, &seq); err != nil {235 if err := proto.Unmarshal(item.Value, &seq); err != nil {
211- return domain.MergeRequest{}, fmt.Errorf("decode merge request sequence of %q: %w", mr.Repo, err)236+ return fmt.Errorf("decode sequence of %q: %w", repo, err)
212 }237 }
213 last = seq.GetLast()238 last = seq.GetLast()
214 }239 }
215- mr.Number = last + 1240+ number := last + 1
216- val, err := encodeMergeRequest(mr)
217- if err != nil {
218- return domain.MergeRequest{}, err
219- }
220 var b kv.Batch241 var b kv.Batch
221- b.RequireVersion(repoKey(mr.Repo), repoItem.Version)242+ b.RequireVersion(repoKey(repo), repoItem.Version)
222- b.Put(repoKey(mr.Repo), repoItem.Value) // see guardRepo243+ b.Put(repoKey(repo), repoItem.Value) // see guardRepoKey
223 if ok {244 if ok {
224- b.RequireVersion(seqKey(mr.Repo), item.Version)245+ b.RequireVersion(seqKey(repo), item.Version)
225 } else {246 } else {
226- b.RequireAbsent(seqKey(mr.Repo))247+ b.RequireAbsent(seqKey(repo))
227 }248 }
228- b.Put(seqKey(mr.Repo), mustMarshal(&storagepb.Sequence{Last: mr.Number}))249+ b.Put(seqKey(repo), mustMarshal(&storagepb.Sequence{Last: number}))
229- b.RequireAbsent(mrKey(mr.Repo, mr.Number))250+ if err := add(&b, number); err != nil {
230- b.Put(mrKey(mr.Repo, mr.Number), val)251+ return err
231- indexOpen(&b, nil, mr)252+ }
232- _, err = m.db.Apply(ctx, &b)253+ _, err := db.Apply(ctx, &b)
233 if errors.Is(err, kv.ErrConflict) {254 if errors.Is(err, kv.ErrConflict) {
234 continue255 continue
235 }256 }
236- if err != nil {257+ return err
237- return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, err)
238- }
239- return m.Get(ctx, mr.Repo, mr.Number)
240 }258 }
241- return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, domain.ErrConflict)259+ return domain.ErrConflict
242 }260 }
243 261
244 // Get reads mr/<repo>/<number>.262 // Get reads mr/<repo>/<number>.
@@ -683,6 +701,11 @@ func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {
683 }701 }
684 pb.AllowedMergeModes = append(pb.AllowedMergeModes, mode)702 pb.AllowedMergeModes = append(pb.AllowedMergeModes, mode)
685 }703 }
704+ issues, err := encodeIssuesMode(s.Issues)
705+ if err != nil {
706+ return nil, err
707+ }
708+ pb.Issues = issues
686 return pb, nil709 return pb, nil
687 }710 }
688 711
@@ -707,5 +730,5 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings {
707 if err != nil {730 if err != nil {
708 return domain.DefaultRepoSettings()731 return domain.DefaultRepoSettings()
709 }732 }
710- return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock()}733+ return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues())}
711 }734 }
M internal/adapters/kvstore/mergerequests_test.go +1 -1
@@ -441,7 +441,7 @@ func TestRepoSettings(t *testing.T) {
441441 require.NoError(t, err)
442442 assert.Equal(t, domain.DefaultRepoSettings(), got.Settings)
443443
444- got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false}
444+ got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false, Issues: domain.IssuesOwner}
445445 require.NoError(t, s.Update(ctx, got))
446446 again, err := s.Get(ctx, n)
447447 require.NoError(t, err)
@@ -441,7 +441,7 @@ func TestRepoSettings(t *testing.T) {
441 require.NoError(t, err)441 require.NoError(t, err)
442 assert.Equal(t, domain.DefaultRepoSettings(), got.Settings)442 assert.Equal(t, domain.DefaultRepoSettings(), got.Settings)
443 443
444- got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false}444+ got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false, Issues: domain.IssuesOwner}
445 require.NoError(t, s.Update(ctx, got))445 require.NoError(t, s.Update(ctx, got))
446 again, err := s.Get(ctx, n)446 again, err := s.Get(ctx, n)
447 require.NoError(t, err)447 require.NoError(t, err)
M internal/adapters/kvstore/repos.go +2 -1
@@ -144,7 +144,8 @@ func (s *Store) Delete(ctx context.Context, name domain.RepoName) error {
144144 for range deleteTries {
145145 snap := s.db.Snapshot(ctx)
146146 repoItem, repoOK := snap.Get(ctx, repoKey(name))
147- keys := append(mergeRequestKeys(ctx, snap, name), ciKeys(ctx, snap, name)...)
147+ keys := append(mergeRequestKeys(ctx, snap, name), issueKeys(ctx, snap, name)...)
148+ keys = append(keys, ciKeys(ctx, snap, name)...)
148149 keys = append(keys, settingsKeys(ctx, snap, name)...)
149150 for len(keys) > deleteChunk {
150151 var b kv.Batch
@@ -144,7 +144,8 @@ func (s *Store) Delete(ctx context.Context, name domain.RepoName) error {
144 for range deleteTries {144 for range deleteTries {
145 snap := s.db.Snapshot(ctx)145 snap := s.db.Snapshot(ctx)
146 repoItem, repoOK := snap.Get(ctx, repoKey(name))146 repoItem, repoOK := snap.Get(ctx, repoKey(name))
147- keys := append(mergeRequestKeys(ctx, snap, name), ciKeys(ctx, snap, name)...)147+ keys := append(mergeRequestKeys(ctx, snap, name), issueKeys(ctx, snap, name)...)
148+ keys = append(keys, ciKeys(ctx, snap, name)...)
148 keys = append(keys, settingsKeys(ctx, snap, name)...)149 keys = append(keys, settingsKeys(ctx, snap, name)...)
149 for len(keys) > deleteChunk {150 for len(keys) > deleteChunk {
150 var b kv.Batch151 var b kv.Batch
M internal/adapters/kvstore/store.go +59 -2
@@ -8,12 +8,14 @@ import (
88 "context"
99 "errors"
1010 "fmt"
11+ "slices"
1112
1213 kv "bnema.dev/ermite-kv"
1314 "github.com/bnema/zerowrap"
1415 "google.golang.org/protobuf/proto"
1516
1617 storagepb "bnema.dev/ermite/gen/ermite/storage"
18+ "bnema.dev/ermite/internal/domain"
1719 "bnema.dev/ermite/internal/ports"
1820 )
1921
@@ -27,8 +29,14 @@ import (
2729 // (CIRun.head_sha: it would attach their checks to the merge commit, where
2830 // nothing looks for them), and the index of the open merge requests by source
2931 // branch (mropen/: an older build would not keep it up to date; the upgrade
30-// builds it once, see BuildOpenIndex).
31-const SchemaVersion = 3
32+// builds it once, see BuildOpenIndex). Version 4 adds the issues (is/, isc/,
33+// isopen/, numbered from the same mrseq/ sequence as the merge requests, and
34+// RepoSettings.issues): an older build would not delete them with their
35+// repository, nor keep the numbers apart, and it would misread the issue:write
36+// scope, so it must refuse the data. The upgrade also adds issue:write to the
37+// stored tokens that held all nine earlier scopes with no repository list and
38+// no branch prefix, so that they keep the owner rights (see upgradeTokens).
39+const SchemaVersion = 4
3240
3341 const schemaKey = "meta/schema"
3442
@@ -125,8 +133,15 @@ func (s *Store) checkSchema(ctx context.Context) error {
125133 if sv.GetVersion() < SchemaVersion {
126134 // Older data stays readable as it is (the changes are additive);
127135 // the new version is recorded so that older builds refuse it.
136+ // The tokens are upgraded in the same batch, so that a crash
137+ // leaves the old version and the upgrade runs again.
128138 var b kv.Batch
129139 b.RequireVersion(key, item.Version)
140+ if sv.GetVersion() < 4 {
141+ if err := s.upgradeTokens(ctx, &b); err != nil {
142+ return err
143+ }
144+ }
130145 b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: SchemaVersion}))
131146 _, err := s.db.Apply(ctx, &b)
132147 if errors.Is(err, kv.ErrConflict) {
@@ -151,3 +166,45 @@ func mustMarshal(m proto.Message) []byte {
151166 }
152167 return b
153168 }
169+
170+// preIssueScopes are the nine scopes that existed before schema 4.
171+var preIssueScopes = []domain.Scope{
172+ domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeRepoAdmin,
173+ domain.ScopeMRWrite, domain.ScopeMRMerge,
174+ domain.ScopeCIWrite, domain.ScopeSecretsWrite, domain.ScopeRunnerRegister,
175+ domain.ScopeTokenAdmin,
176+}
177+
178+// upgradeTokens adds to b the schema 4 upgrade of the tokens: a token that
179+// holds all of preIssueScopes, limited to no repository and no branch prefix,
180+// had the owner rights, which AllScopes now needs issue:write for, so it gets
181+// the scope (this is no escalation). Every other token is left as it is. The
182+// batch requires each token to be at the version it was read at.
183+//
184+//hexcheck:ignore suspicious-business-logic-in-adapter one-time storage upgrade of the stored tokens: a storage concern, no policy
185+func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {
186+ for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) {
187+ pb := &storagepb.Token{}
188+ if err := proto.Unmarshal(item.Value, pb); err != nil {
189+ return fmt.Errorf("upgrade token %s: %w", key, err)
190+ }
191+ if len(pb.GetRepos()) > 0 || len(pb.GetBranchPrefixes()) > 0 ||
192+ slices.Contains(pb.GetScopes(), domain.ScopeIssueWrite.String()) {
193+ continue
194+ }
195+ full := true
196+ for _, sc := range preIssueScopes {
197+ if !slices.Contains(pb.GetScopes(), sc.String()) {
198+ full = false
199+ break
200+ }
201+ }
202+ if !full {
203+ continue
204+ }
205+ pb.Scopes = append(pb.Scopes, domain.ScopeIssueWrite.String())
206+ b.RequireVersion(slices.Clone(key), item.Version)
207+ b.Put(slices.Clone(key), mustMarshal(pb))
208+ }
209+ return nil
210+}
@@ -8,12 +8,14 @@ import (
8 "context"8 "context"
9 "errors"9 "errors"
10 "fmt"10 "fmt"
11+ "slices"
11 12
12 kv "bnema.dev/ermite-kv"13 kv "bnema.dev/ermite-kv"
13 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
14 "google.golang.org/protobuf/proto"15 "google.golang.org/protobuf/proto"
15 16
16 storagepb "bnema.dev/ermite/gen/ermite/storage"17 storagepb "bnema.dev/ermite/gen/ermite/storage"
18+ "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"19 "bnema.dev/ermite/internal/ports"
18 )20 )
19 21
@@ -27,8 +29,14 @@ import (
27 // (CIRun.head_sha: it would attach their checks to the merge commit, where29 // (CIRun.head_sha: it would attach their checks to the merge commit, where
28 // nothing looks for them), and the index of the open merge requests by source30 // nothing looks for them), and the index of the open merge requests by source
29 // branch (mropen/: an older build would not keep it up to date; the upgrade31 // branch (mropen/: an older build would not keep it up to date; the upgrade
30-// builds it once, see BuildOpenIndex).32+// builds it once, see BuildOpenIndex). Version 4 adds the issues (is/, isc/,
31-const SchemaVersion = 333+// isopen/, numbered from the same mrseq/ sequence as the merge requests, and
34+// RepoSettings.issues): an older build would not delete them with their
35+// repository, nor keep the numbers apart, and it would misread the issue:write
36+// scope, so it must refuse the data. The upgrade also adds issue:write to the
37+// stored tokens that held all nine earlier scopes with no repository list and
38+// no branch prefix, so that they keep the owner rights (see upgradeTokens).
39+const SchemaVersion = 4
32 40
33 const schemaKey = "meta/schema"41 const schemaKey = "meta/schema"
34 42
@@ -125,8 +133,15 @@ func (s *Store) checkSchema(ctx context.Context) error {
125 if sv.GetVersion() < SchemaVersion {133 if sv.GetVersion() < SchemaVersion {
126 // Older data stays readable as it is (the changes are additive);134 // Older data stays readable as it is (the changes are additive);
127 // the new version is recorded so that older builds refuse it.135 // the new version is recorded so that older builds refuse it.
136+ // The tokens are upgraded in the same batch, so that a crash
137+ // leaves the old version and the upgrade runs again.
128 var b kv.Batch138 var b kv.Batch
129 b.RequireVersion(key, item.Version)139 b.RequireVersion(key, item.Version)
140+ if sv.GetVersion() < 4 {
141+ if err := s.upgradeTokens(ctx, &b); err != nil {
142+ return err
143+ }
144+ }
130 b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: SchemaVersion}))145 b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: SchemaVersion}))
131 _, err := s.db.Apply(ctx, &b)146 _, err := s.db.Apply(ctx, &b)
132 if errors.Is(err, kv.ErrConflict) {147 if errors.Is(err, kv.ErrConflict) {
@@ -151,3 +166,45 @@ func mustMarshal(m proto.Message) []byte {
151 }166 }
152 return b167 return b
153 }168 }
169+
170+// preIssueScopes are the nine scopes that existed before schema 4.
171+var preIssueScopes = []domain.Scope{
172+ domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeRepoAdmin,
173+ domain.ScopeMRWrite, domain.ScopeMRMerge,
174+ domain.ScopeCIWrite, domain.ScopeSecretsWrite, domain.ScopeRunnerRegister,
175+ domain.ScopeTokenAdmin,
176+}
177+
178+// upgradeTokens adds to b the schema 4 upgrade of the tokens: a token that
179+// holds all of preIssueScopes, limited to no repository and no branch prefix,
180+// had the owner rights, which AllScopes now needs issue:write for, so it gets
181+// the scope (this is no escalation). Every other token is left as it is. The
182+// batch requires each token to be at the version it was read at.
183+//
184+//hexcheck:ignore suspicious-business-logic-in-adapter one-time storage upgrade of the stored tokens: a storage concern, no policy
185+func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {
186+ for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) {
187+ pb := &storagepb.Token{}
188+ if err := proto.Unmarshal(item.Value, pb); err != nil {
189+ return fmt.Errorf("upgrade token %s: %w", key, err)
190+ }
191+ if len(pb.GetRepos()) > 0 || len(pb.GetBranchPrefixes()) > 0 ||
192+ slices.Contains(pb.GetScopes(), domain.ScopeIssueWrite.String()) {
193+ continue
194+ }
195+ full := true
196+ for _, sc := range preIssueScopes {
197+ if !slices.Contains(pb.GetScopes(), sc.String()) {
198+ full = false
199+ break
200+ }
201+ }
202+ if !full {
203+ continue
204+ }
205+ pb.Scopes = append(pb.Scopes, domain.ScopeIssueWrite.String())
206+ b.RequireVersion(slices.Clone(key), item.Version)
207+ b.Put(slices.Clone(key), mustMarshal(pb))
208+ }
209+ return nil
210+}
M internal/adapters/kvstore/store_test.go +4 -4
@@ -230,7 +230,7 @@ func TestSchema(t *testing.T) {
230230 assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
231231 })
232232
233- t.Run("version 2 is upgraded to 3, its runs kept", func(t *testing.T) {
233+ t.Run("version 2 is upgraded to the current one, its runs kept", func(t *testing.T) {
234234 dir := t.TempDir()
235235 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))
236236 putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
@@ -261,10 +261,10 @@ func TestSchema(t *testing.T) {
261261 assert.Equal(t, "cccccccccccccccccccccccccccccccccccccccc", pr.HeadSha)
262262 assert.Equal(t, pr.HeadSha, pr.CheckSha(), "the checks of a pull_request run are on the head")
263263 require.NoError(t, s.Close(ctx))
264- assert.Equal(t, uint32(3), readSchema(t, dir))
264+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
265265 })
266266
267- t.Run("version 2 is upgraded to 3, its data kept", func(t *testing.T) {
267+ t.Run("version 2 is upgraded to the current one, its data kept", func(t *testing.T) {
268268 dir := t.TempDir()
269269 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))
270270 putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
@@ -273,7 +273,7 @@ func TestSchema(t *testing.T) {
273273 _, err = s.Get(ctx, name(t, "old"))
274274 require.NoError(t, err)
275275 require.NoError(t, s.Close(ctx))
276- assert.Equal(t, uint32(3), readSchema(t, dir))
276+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
277277 })
278278
279279 // Downgrade guard: once a build with reusable workflows has opened the data
@@ -230,7 +230,7 @@ func TestSchema(t *testing.T) {
230 assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))230 assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
231 })231 })
232 232
233- t.Run("version 2 is upgraded to 3, its runs kept", func(t *testing.T) {233+ t.Run("version 2 is upgraded to the current one, its runs kept", func(t *testing.T) {
234 dir := t.TempDir()234 dir := t.TempDir()
235 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))235 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))
236 putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))236 putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
@@ -261,10 +261,10 @@ func TestSchema(t *testing.T) {
261 assert.Equal(t, "cccccccccccccccccccccccccccccccccccccccc", pr.HeadSha)261 assert.Equal(t, "cccccccccccccccccccccccccccccccccccccccc", pr.HeadSha)
262 assert.Equal(t, pr.HeadSha, pr.CheckSha(), "the checks of a pull_request run are on the head")262 assert.Equal(t, pr.HeadSha, pr.CheckSha(), "the checks of a pull_request run are on the head")
263 require.NoError(t, s.Close(ctx))263 require.NoError(t, s.Close(ctx))
264- assert.Equal(t, uint32(3), readSchema(t, dir))264+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
265 })265 })
266 266
267- t.Run("version 2 is upgraded to 3, its data kept", func(t *testing.T) {267+ t.Run("version 2 is upgraded to the current one, its data kept", func(t *testing.T) {
268 dir := t.TempDir()268 dir := t.TempDir()
269 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))269 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))
270 putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))270 putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
@@ -273,7 +273,7 @@ func TestSchema(t *testing.T) {
273 _, err = s.Get(ctx, name(t, "old"))273 _, err = s.Get(ctx, name(t, "old"))
274 require.NoError(t, err)274 require.NoError(t, err)
275 require.NoError(t, s.Close(ctx))275 require.NoError(t, s.Close(ctx))
276- assert.Equal(t, uint32(3), readSchema(t, dir))276+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
277 })277 })
278 278
279 // Downgrade guard: once a build with reusable workflows has opened the data279 // Downgrade guard: once a build with reusable workflows has opened the data
M internal/ports/dto.go +18 -0
@@ -198,6 +198,24 @@ type MergeRequestPage struct {
198198 NextPageToken string
199199 }
200200
201+// ListIssuesParams selects a page of issues from an IssueStore.
202+type ListIssuesParams struct {
203+ // State restricts the result; empty means every state.
204+ State domain.IssueState
205+ // PageSize is the most issues returned; 0 or less means no limit. The
206+ // use case chooses it.
207+ PageSize int
208+ // PageToken is the NextPageToken of the previous page, or empty.
209+ PageToken string
210+}
211+
212+// IssuePage is a page of issues, highest number first.
213+type IssuePage struct {
214+ Issues []domain.Issue
215+ // NextPageToken is empty on the last page.
216+ NextPageToken string
217+}
218+
201219 // MergeRequestDetail is a merge request with its review data.
202220 type MergeRequestDetail struct {
203221 MergeRequest domain.MergeRequest
@@ -198,6 +198,24 @@ type MergeRequestPage struct {
198 NextPageToken string198 NextPageToken string
199 }199 }
200 200
201+// ListIssuesParams selects a page of issues from an IssueStore.
202+type ListIssuesParams struct {
203+ // State restricts the result; empty means every state.
204+ State domain.IssueState
205+ // PageSize is the most issues returned; 0 or less means no limit. The
206+ // use case chooses it.
207+ PageSize int
208+ // PageToken is the NextPageToken of the previous page, or empty.
209+ PageToken string
210+}
211+
212+// IssuePage is a page of issues, highest number first.
213+type IssuePage struct {
214+ Issues []domain.Issue
215+ // NextPageToken is empty on the last page.
216+ NextPageToken string
217+}
218+
201 // MergeRequestDetail is a merge request with its review data.219 // MergeRequestDetail is a merge request with its review data.
202 type MergeRequestDetail struct {220 type MergeRequestDetail struct {
203 MergeRequest domain.MergeRequest221 MergeRequest domain.MergeRequest
A internal/ports/mocks/IssueStore.go +521 -0
@@ -0,0 +1,521 @@
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+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewIssueStore creates a new instance of IssueStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewIssueStore(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *IssueStore {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &IssueStore{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// IssueStore is an autogenerated mock type for the IssueStore type
39+type IssueStore struct {
40+ mock.Mock
41+}
42+
43+type IssueStore_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *IssueStore) EXPECT() *IssueStore_Expecter {
48+ return &IssueStore_Expecter{mock: &_m.Mock}
49+}
50+
51+// Create provides a mock function for the type IssueStore
52+func (_mock *IssueStore) Create(ctx context.Context, issue domain.Issue) (domain.Issue, error) {
53+ ret := _mock.Called(ctx, issue)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Create")
57+ }
58+
59+ var r0 domain.Issue
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue) (domain.Issue, error)); ok {
62+ return returnFunc(ctx, issue)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue) domain.Issue); ok {
65+ r0 = returnFunc(ctx, issue)
66+ } else {
67+ r0 = ret.Get(0).(domain.Issue)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Issue) error); ok {
70+ r1 = returnFunc(ctx, issue)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// IssueStore_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create'
78+type IssueStore_Create_Call struct {
79+ *mock.Call
80+}
81+
82+// Create is a helper method to define mock.On call
83+// - ctx context.Context
84+// - issue domain.Issue
85+func (_e *IssueStore_Expecter) Create(ctx any, issue any) *IssueStore_Create_Call {
86+ return &IssueStore_Create_Call{Call: _e.mock.On("Create", ctx, issue)}
87+}
88+
89+func (_c *IssueStore_Create_Call) Run(run func(ctx context.Context, issue domain.Issue)) *IssueStore_Create_Call {
90+ _c.Call.Run(func(args mock.Arguments) {
91+ var arg0 context.Context
92+ if args[0] != nil {
93+ arg0 = args[0].(context.Context)
94+ }
95+ var arg1 domain.Issue
96+ if args[1] != nil {
97+ arg1 = args[1].(domain.Issue)
98+ }
99+ run(
100+ arg0,
101+ arg1,
102+ )
103+ })
104+ return _c
105+}
106+
107+func (_c *IssueStore_Create_Call) Return(issue1 domain.Issue, err error) *IssueStore_Create_Call {
108+ _c.Call.Return(issue1, err)
109+ return _c
110+}
111+
112+func (_c *IssueStore_Create_Call) RunAndReturn(run func(ctx context.Context, issue domain.Issue) (domain.Issue, error)) *IssueStore_Create_Call {
113+ _c.Call.Return(run)
114+ return _c
115+}
116+
117+// Get provides a mock function for the type IssueStore
118+func (_mock *IssueStore) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error) {
119+ ret := _mock.Called(ctx, repo, number)
120+
121+ if len(ret) == 0 {
122+ panic("no return value specified for Get")
123+ }
124+
125+ var r0 domain.Issue
126+ var r1 error
127+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) (domain.Issue, error)); ok {
128+ return returnFunc(ctx, repo, number)
129+ }
130+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) domain.Issue); ok {
131+ r0 = returnFunc(ctx, repo, number)
132+ } else {
133+ r0 = ret.Get(0).(domain.Issue)
134+ }
135+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
136+ r1 = returnFunc(ctx, repo, number)
137+ } else {
138+ r1 = ret.Error(1)
139+ }
140+ return r0, r1
141+}
142+
143+// IssueStore_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
144+type IssueStore_Get_Call struct {
145+ *mock.Call
146+}
147+
148+// Get is a helper method to define mock.On call
149+// - ctx context.Context
150+// - repo domain.RepoName
151+// - number int64
152+func (_e *IssueStore_Expecter) Get(ctx any, repo any, number any) *IssueStore_Get_Call {
153+ return &IssueStore_Get_Call{Call: _e.mock.On("Get", ctx, repo, number)}
154+}
155+
156+func (_c *IssueStore_Get_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *IssueStore_Get_Call {
157+ _c.Call.Run(func(args mock.Arguments) {
158+ var arg0 context.Context
159+ if args[0] != nil {
160+ arg0 = args[0].(context.Context)
161+ }
162+ var arg1 domain.RepoName
163+ if args[1] != nil {
164+ arg1 = args[1].(domain.RepoName)
165+ }
166+ var arg2 int64
167+ if args[2] != nil {
168+ arg2 = args[2].(int64)
169+ }
170+ run(
171+ arg0,
172+ arg1,
173+ arg2,
174+ )
175+ })
176+ return _c
177+}
178+
179+func (_c *IssueStore_Get_Call) Return(issue domain.Issue, err error) *IssueStore_Get_Call {
180+ _c.Call.Return(issue, err)
181+ return _c
182+}
183+
184+func (_c *IssueStore_Get_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error)) *IssueStore_Get_Call {
185+ _c.Call.Return(run)
186+ return _c
187+}
188+
189+// List provides a mock function for the type IssueStore
190+func (_mock *IssueStore) List(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
191+ ret := _mock.Called(ctx, repo, params)
192+
193+ if len(ret) == 0 {
194+ panic("no return value specified for List")
195+ }
196+
197+ var r0 ports.IssuePage
198+ var r1 error
199+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.ListIssuesParams) (ports.IssuePage, error)); ok {
200+ return returnFunc(ctx, repo, params)
201+ }
202+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.ListIssuesParams) ports.IssuePage); ok {
203+ r0 = returnFunc(ctx, repo, params)
204+ } else {
205+ r0 = ret.Get(0).(ports.IssuePage)
206+ }
207+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, ports.ListIssuesParams) error); ok {
208+ r1 = returnFunc(ctx, repo, params)
209+ } else {
210+ r1 = ret.Error(1)
211+ }
212+ return r0, r1
213+}
214+
215+// IssueStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
216+type IssueStore_List_Call struct {
217+ *mock.Call
218+}
219+
220+// List is a helper method to define mock.On call
221+// - ctx context.Context
222+// - repo domain.RepoName
223+// - params ports.ListIssuesParams
224+func (_e *IssueStore_Expecter) List(ctx any, repo any, params any) *IssueStore_List_Call {
225+ return &IssueStore_List_Call{Call: _e.mock.On("List", ctx, repo, params)}
226+}
227+
228+func (_c *IssueStore_List_Call) Run(run func(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams)) *IssueStore_List_Call {
229+ _c.Call.Run(func(args mock.Arguments) {
230+ var arg0 context.Context
231+ if args[0] != nil {
232+ arg0 = args[0].(context.Context)
233+ }
234+ var arg1 domain.RepoName
235+ if args[1] != nil {
236+ arg1 = args[1].(domain.RepoName)
237+ }
238+ var arg2 ports.ListIssuesParams
239+ if args[2] != nil {
240+ arg2 = args[2].(ports.ListIssuesParams)
241+ }
242+ run(
243+ arg0,
244+ arg1,
245+ arg2,
246+ )
247+ })
248+ return _c
249+}
250+
251+func (_c *IssueStore_List_Call) Return(issuePage ports.IssuePage, err error) *IssueStore_List_Call {
252+ _c.Call.Return(issuePage, err)
253+ return _c
254+}
255+
256+func (_c *IssueStore_List_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error)) *IssueStore_List_Call {
257+ _c.Call.Return(run)
258+ return _c
259+}
260+
261+// ListComments provides a mock function for the type IssueStore
262+func (_mock *IssueStore) ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error) {
263+ ret := _mock.Called(ctx, repo, number)
264+
265+ if len(ret) == 0 {
266+ panic("no return value specified for ListComments")
267+ }
268+
269+ var r0 []domain.IssueComment
270+ var r1 error
271+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) ([]domain.IssueComment, error)); ok {
272+ return returnFunc(ctx, repo, number)
273+ }
274+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) []domain.IssueComment); ok {
275+ r0 = returnFunc(ctx, repo, number)
276+ } else {
277+ if ret.Get(0) != nil {
278+ r0 = ret.Get(0).([]domain.IssueComment)
279+ }
280+ }
281+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
282+ r1 = returnFunc(ctx, repo, number)
283+ } else {
284+ r1 = ret.Error(1)
285+ }
286+ return r0, r1
287+}
288+
289+// IssueStore_ListComments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListComments'
290+type IssueStore_ListComments_Call struct {
291+ *mock.Call
292+}
293+
294+// ListComments is a helper method to define mock.On call
295+// - ctx context.Context
296+// - repo domain.RepoName
297+// - number int64
298+func (_e *IssueStore_Expecter) ListComments(ctx any, repo any, number any) *IssueStore_ListComments_Call {
299+ return &IssueStore_ListComments_Call{Call: _e.mock.On("ListComments", ctx, repo, number)}
300+}
301+
302+func (_c *IssueStore_ListComments_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *IssueStore_ListComments_Call {
303+ _c.Call.Run(func(args mock.Arguments) {
304+ var arg0 context.Context
305+ if args[0] != nil {
306+ arg0 = args[0].(context.Context)
307+ }
308+ var arg1 domain.RepoName
309+ if args[1] != nil {
310+ arg1 = args[1].(domain.RepoName)
311+ }
312+ var arg2 int64
313+ if args[2] != nil {
314+ arg2 = args[2].(int64)
315+ }
316+ run(
317+ arg0,
318+ arg1,
319+ arg2,
320+ )
321+ })
322+ return _c
323+}
324+
325+func (_c *IssueStore_ListComments_Call) Return(issueComments []domain.IssueComment, err error) *IssueStore_ListComments_Call {
326+ _c.Call.Return(issueComments, err)
327+ return _c
328+}
329+
330+func (_c *IssueStore_ListComments_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error)) *IssueStore_ListComments_Call {
331+ _c.Call.Return(run)
332+ return _c
333+}
334+
335+// ListOpen provides a mock function for the type IssueStore
336+func (_mock *IssueStore) ListOpen(ctx context.Context, repo domain.RepoName) ([]domain.Issue, error) {
337+ ret := _mock.Called(ctx, repo)
338+
339+ if len(ret) == 0 {
340+ panic("no return value specified for ListOpen")
341+ }
342+
343+ var r0 []domain.Issue
344+ var r1 error
345+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) ([]domain.Issue, error)); ok {
346+ return returnFunc(ctx, repo)
347+ }
348+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) []domain.Issue); ok {
349+ r0 = returnFunc(ctx, repo)
350+ } else {
351+ if ret.Get(0) != nil {
352+ r0 = ret.Get(0).([]domain.Issue)
353+ }
354+ }
355+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok {
356+ r1 = returnFunc(ctx, repo)
357+ } else {
358+ r1 = ret.Error(1)
359+ }
360+ return r0, r1
361+}
362+
363+// IssueStore_ListOpen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListOpen'
364+type IssueStore_ListOpen_Call struct {
365+ *mock.Call
366+}
367+
368+// ListOpen is a helper method to define mock.On call
369+// - ctx context.Context
370+// - repo domain.RepoName
371+func (_e *IssueStore_Expecter) ListOpen(ctx any, repo any) *IssueStore_ListOpen_Call {
372+ return &IssueStore_ListOpen_Call{Call: _e.mock.On("ListOpen", ctx, repo)}
373+}
374+
375+func (_c *IssueStore_ListOpen_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *IssueStore_ListOpen_Call {
376+ _c.Call.Run(func(args mock.Arguments) {
377+ var arg0 context.Context
378+ if args[0] != nil {
379+ arg0 = args[0].(context.Context)
380+ }
381+ var arg1 domain.RepoName
382+ if args[1] != nil {
383+ arg1 = args[1].(domain.RepoName)
384+ }
385+ run(
386+ arg0,
387+ arg1,
388+ )
389+ })
390+ return _c
391+}
392+
393+func (_c *IssueStore_ListOpen_Call) Return(issues []domain.Issue, err error) *IssueStore_ListOpen_Call {
394+ _c.Call.Return(issues, err)
395+ return _c
396+}
397+
398+func (_c *IssueStore_ListOpen_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) ([]domain.Issue, error)) *IssueStore_ListOpen_Call {
399+ _c.Call.Return(run)
400+ return _c
401+}
402+
403+// SaveComment provides a mock function for the type IssueStore
404+func (_mock *IssueStore) SaveComment(ctx context.Context, issue domain.Issue, c domain.IssueComment) error {
405+ ret := _mock.Called(ctx, issue, c)
406+
407+ if len(ret) == 0 {
408+ panic("no return value specified for SaveComment")
409+ }
410+
411+ var r0 error
412+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue, domain.IssueComment) error); ok {
413+ r0 = returnFunc(ctx, issue, c)
414+ } else {
415+ r0 = ret.Error(0)
416+ }
417+ return r0
418+}
419+
420+// IssueStore_SaveComment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveComment'
421+type IssueStore_SaveComment_Call struct {
422+ *mock.Call
423+}
424+
425+// SaveComment is a helper method to define mock.On call
426+// - ctx context.Context
427+// - issue domain.Issue
428+// - c domain.IssueComment
429+func (_e *IssueStore_Expecter) SaveComment(ctx any, issue any, c any) *IssueStore_SaveComment_Call {
430+ return &IssueStore_SaveComment_Call{Call: _e.mock.On("SaveComment", ctx, issue, c)}
431+}
432+
433+func (_c *IssueStore_SaveComment_Call) Run(run func(ctx context.Context, issue domain.Issue, c domain.IssueComment)) *IssueStore_SaveComment_Call {
434+ _c.Call.Run(func(args mock.Arguments) {
435+ var arg0 context.Context
436+ if args[0] != nil {
437+ arg0 = args[0].(context.Context)
438+ }
439+ var arg1 domain.Issue
440+ if args[1] != nil {
441+ arg1 = args[1].(domain.Issue)
442+ }
443+ var arg2 domain.IssueComment
444+ if args[2] != nil {
445+ arg2 = args[2].(domain.IssueComment)
446+ }
447+ run(
448+ arg0,
449+ arg1,
450+ arg2,
451+ )
452+ })
453+ return _c
454+}
455+
456+func (_c *IssueStore_SaveComment_Call) Return(err error) *IssueStore_SaveComment_Call {
457+ _c.Call.Return(err)
458+ return _c
459+}
460+
461+func (_c *IssueStore_SaveComment_Call) RunAndReturn(run func(ctx context.Context, issue domain.Issue, c domain.IssueComment) error) *IssueStore_SaveComment_Call {
462+ _c.Call.Return(run)
463+ return _c
464+}
465+
466+// Update provides a mock function for the type IssueStore
467+func (_mock *IssueStore) Update(ctx context.Context, issue domain.Issue) error {
468+ ret := _mock.Called(ctx, issue)
469+
470+ if len(ret) == 0 {
471+ panic("no return value specified for Update")
472+ }
473+
474+ var r0 error
475+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue) error); ok {
476+ r0 = returnFunc(ctx, issue)
477+ } else {
478+ r0 = ret.Error(0)
479+ }
480+ return r0
481+}
482+
483+// IssueStore_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update'
484+type IssueStore_Update_Call struct {
485+ *mock.Call
486+}
487+
488+// Update is a helper method to define mock.On call
489+// - ctx context.Context
490+// - issue domain.Issue
491+func (_e *IssueStore_Expecter) Update(ctx any, issue any) *IssueStore_Update_Call {
492+ return &IssueStore_Update_Call{Call: _e.mock.On("Update", ctx, issue)}
493+}
494+
495+func (_c *IssueStore_Update_Call) Run(run func(ctx context.Context, issue domain.Issue)) *IssueStore_Update_Call {
496+ _c.Call.Run(func(args mock.Arguments) {
497+ var arg0 context.Context
498+ if args[0] != nil {
499+ arg0 = args[0].(context.Context)
500+ }
501+ var arg1 domain.Issue
502+ if args[1] != nil {
503+ arg1 = args[1].(domain.Issue)
504+ }
505+ run(
506+ arg0,
507+ arg1,
508+ )
509+ })
510+ return _c
511+}
512+
513+func (_c *IssueStore_Update_Call) Return(err error) *IssueStore_Update_Call {
514+ _c.Call.Return(err)
515+ return _c
516+}
517+
518+func (_c *IssueStore_Update_Call) RunAndReturn(run func(ctx context.Context, issue domain.Issue) error) *IssueStore_Update_Call {
519+ _c.Call.Return(run)
520+ return _c
521+}
@@ -0,0 +1,521 @@
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+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewIssueStore creates a new instance of IssueStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewIssueStore(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *IssueStore {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &IssueStore{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// IssueStore is an autogenerated mock type for the IssueStore type
39+type IssueStore struct {
40+ mock.Mock
41+}
42+
43+type IssueStore_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *IssueStore) EXPECT() *IssueStore_Expecter {
48+ return &IssueStore_Expecter{mock: &_m.Mock}
49+}
50+
51+// Create provides a mock function for the type IssueStore
52+func (_mock *IssueStore) Create(ctx context.Context, issue domain.Issue) (domain.Issue, error) {
53+ ret := _mock.Called(ctx, issue)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Create")
57+ }
58+
59+ var r0 domain.Issue
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue) (domain.Issue, error)); ok {
62+ return returnFunc(ctx, issue)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue) domain.Issue); ok {
65+ r0 = returnFunc(ctx, issue)
66+ } else {
67+ r0 = ret.Get(0).(domain.Issue)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Issue) error); ok {
70+ r1 = returnFunc(ctx, issue)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// IssueStore_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create'
78+type IssueStore_Create_Call struct {
79+ *mock.Call
80+}
81+
82+// Create is a helper method to define mock.On call
83+// - ctx context.Context
84+// - issue domain.Issue
85+func (_e *IssueStore_Expecter) Create(ctx any, issue any) *IssueStore_Create_Call {
86+ return &IssueStore_Create_Call{Call: _e.mock.On("Create", ctx, issue)}
87+}
88+
89+func (_c *IssueStore_Create_Call) Run(run func(ctx context.Context, issue domain.Issue)) *IssueStore_Create_Call {
90+ _c.Call.Run(func(args mock.Arguments) {
91+ var arg0 context.Context
92+ if args[0] != nil {
93+ arg0 = args[0].(context.Context)
94+ }
95+ var arg1 domain.Issue
96+ if args[1] != nil {
97+ arg1 = args[1].(domain.Issue)
98+ }
99+ run(
100+ arg0,
101+ arg1,
102+ )
103+ })
104+ return _c
105+}
106+
107+func (_c *IssueStore_Create_Call) Return(issue1 domain.Issue, err error) *IssueStore_Create_Call {
108+ _c.Call.Return(issue1, err)
109+ return _c
110+}
111+
112+func (_c *IssueStore_Create_Call) RunAndReturn(run func(ctx context.Context, issue domain.Issue) (domain.Issue, error)) *IssueStore_Create_Call {
113+ _c.Call.Return(run)
114+ return _c
115+}
116+
117+// Get provides a mock function for the type IssueStore
118+func (_mock *IssueStore) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error) {
119+ ret := _mock.Called(ctx, repo, number)
120+
121+ if len(ret) == 0 {
122+ panic("no return value specified for Get")
123+ }
124+
125+ var r0 domain.Issue
126+ var r1 error
127+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) (domain.Issue, error)); ok {
128+ return returnFunc(ctx, repo, number)
129+ }
130+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) domain.Issue); ok {
131+ r0 = returnFunc(ctx, repo, number)
132+ } else {
133+ r0 = ret.Get(0).(domain.Issue)
134+ }
135+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
136+ r1 = returnFunc(ctx, repo, number)
137+ } else {
138+ r1 = ret.Error(1)
139+ }
140+ return r0, r1
141+}
142+
143+// IssueStore_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
144+type IssueStore_Get_Call struct {
145+ *mock.Call
146+}
147+
148+// Get is a helper method to define mock.On call
149+// - ctx context.Context
150+// - repo domain.RepoName
151+// - number int64
152+func (_e *IssueStore_Expecter) Get(ctx any, repo any, number any) *IssueStore_Get_Call {
153+ return &IssueStore_Get_Call{Call: _e.mock.On("Get", ctx, repo, number)}
154+}
155+
156+func (_c *IssueStore_Get_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *IssueStore_Get_Call {
157+ _c.Call.Run(func(args mock.Arguments) {
158+ var arg0 context.Context
159+ if args[0] != nil {
160+ arg0 = args[0].(context.Context)
161+ }
162+ var arg1 domain.RepoName
163+ if args[1] != nil {
164+ arg1 = args[1].(domain.RepoName)
165+ }
166+ var arg2 int64
167+ if args[2] != nil {
168+ arg2 = args[2].(int64)
169+ }
170+ run(
171+ arg0,
172+ arg1,
173+ arg2,
174+ )
175+ })
176+ return _c
177+}
178+
179+func (_c *IssueStore_Get_Call) Return(issue domain.Issue, err error) *IssueStore_Get_Call {
180+ _c.Call.Return(issue, err)
181+ return _c
182+}
183+
184+func (_c *IssueStore_Get_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error)) *IssueStore_Get_Call {
185+ _c.Call.Return(run)
186+ return _c
187+}
188+
189+// List provides a mock function for the type IssueStore
190+func (_mock *IssueStore) List(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
191+ ret := _mock.Called(ctx, repo, params)
192+
193+ if len(ret) == 0 {
194+ panic("no return value specified for List")
195+ }
196+
197+ var r0 ports.IssuePage
198+ var r1 error
199+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.ListIssuesParams) (ports.IssuePage, error)); ok {
200+ return returnFunc(ctx, repo, params)
201+ }
202+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.ListIssuesParams) ports.IssuePage); ok {
203+ r0 = returnFunc(ctx, repo, params)
204+ } else {
205+ r0 = ret.Get(0).(ports.IssuePage)
206+ }
207+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, ports.ListIssuesParams) error); ok {
208+ r1 = returnFunc(ctx, repo, params)
209+ } else {
210+ r1 = ret.Error(1)
211+ }
212+ return r0, r1
213+}
214+
215+// IssueStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
216+type IssueStore_List_Call struct {
217+ *mock.Call
218+}
219+
220+// List is a helper method to define mock.On call
221+// - ctx context.Context
222+// - repo domain.RepoName
223+// - params ports.ListIssuesParams
224+func (_e *IssueStore_Expecter) List(ctx any, repo any, params any) *IssueStore_List_Call {
225+ return &IssueStore_List_Call{Call: _e.mock.On("List", ctx, repo, params)}
226+}
227+
228+func (_c *IssueStore_List_Call) Run(run func(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams)) *IssueStore_List_Call {
229+ _c.Call.Run(func(args mock.Arguments) {
230+ var arg0 context.Context
231+ if args[0] != nil {
232+ arg0 = args[0].(context.Context)
233+ }
234+ var arg1 domain.RepoName
235+ if args[1] != nil {
236+ arg1 = args[1].(domain.RepoName)
237+ }
238+ var arg2 ports.ListIssuesParams
239+ if args[2] != nil {
240+ arg2 = args[2].(ports.ListIssuesParams)
241+ }
242+ run(
243+ arg0,
244+ arg1,
245+ arg2,
246+ )
247+ })
248+ return _c
249+}
250+
251+func (_c *IssueStore_List_Call) Return(issuePage ports.IssuePage, err error) *IssueStore_List_Call {
252+ _c.Call.Return(issuePage, err)
253+ return _c
254+}
255+
256+func (_c *IssueStore_List_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error)) *IssueStore_List_Call {
257+ _c.Call.Return(run)
258+ return _c
259+}
260+
261+// ListComments provides a mock function for the type IssueStore
262+func (_mock *IssueStore) ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error) {
263+ ret := _mock.Called(ctx, repo, number)
264+
265+ if len(ret) == 0 {
266+ panic("no return value specified for ListComments")
267+ }
268+
269+ var r0 []domain.IssueComment
270+ var r1 error
271+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) ([]domain.IssueComment, error)); ok {
272+ return returnFunc(ctx, repo, number)
273+ }
274+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) []domain.IssueComment); ok {
275+ r0 = returnFunc(ctx, repo, number)
276+ } else {
277+ if ret.Get(0) != nil {
278+ r0 = ret.Get(0).([]domain.IssueComment)
279+ }
280+ }
281+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
282+ r1 = returnFunc(ctx, repo, number)
283+ } else {
284+ r1 = ret.Error(1)
285+ }
286+ return r0, r1
287+}
288+
289+// IssueStore_ListComments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListComments'
290+type IssueStore_ListComments_Call struct {
291+ *mock.Call
292+}
293+
294+// ListComments is a helper method to define mock.On call
295+// - ctx context.Context
296+// - repo domain.RepoName
297+// - number int64
298+func (_e *IssueStore_Expecter) ListComments(ctx any, repo any, number any) *IssueStore_ListComments_Call {
299+ return &IssueStore_ListComments_Call{Call: _e.mock.On("ListComments", ctx, repo, number)}
300+}
301+
302+func (_c *IssueStore_ListComments_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *IssueStore_ListComments_Call {
303+ _c.Call.Run(func(args mock.Arguments) {
304+ var arg0 context.Context
305+ if args[0] != nil {
306+ arg0 = args[0].(context.Context)
307+ }
308+ var arg1 domain.RepoName
309+ if args[1] != nil {
310+ arg1 = args[1].(domain.RepoName)
311+ }
312+ var arg2 int64
313+ if args[2] != nil {
314+ arg2 = args[2].(int64)
315+ }
316+ run(
317+ arg0,
318+ arg1,
319+ arg2,
320+ )
321+ })
322+ return _c
323+}
324+
325+func (_c *IssueStore_ListComments_Call) Return(issueComments []domain.IssueComment, err error) *IssueStore_ListComments_Call {
326+ _c.Call.Return(issueComments, err)
327+ return _c
328+}
329+
330+func (_c *IssueStore_ListComments_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error)) *IssueStore_ListComments_Call {
331+ _c.Call.Return(run)
332+ return _c
333+}
334+
335+// ListOpen provides a mock function for the type IssueStore
336+func (_mock *IssueStore) ListOpen(ctx context.Context, repo domain.RepoName) ([]domain.Issue, error) {
337+ ret := _mock.Called(ctx, repo)
338+
339+ if len(ret) == 0 {
340+ panic("no return value specified for ListOpen")
341+ }
342+
343+ var r0 []domain.Issue
344+ var r1 error
345+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) ([]domain.Issue, error)); ok {
346+ return returnFunc(ctx, repo)
347+ }
348+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) []domain.Issue); ok {
349+ r0 = returnFunc(ctx, repo)
350+ } else {
351+ if ret.Get(0) != nil {
352+ r0 = ret.Get(0).([]domain.Issue)
353+ }
354+ }
355+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok {
356+ r1 = returnFunc(ctx, repo)
357+ } else {
358+ r1 = ret.Error(1)
359+ }
360+ return r0, r1
361+}
362+
363+// IssueStore_ListOpen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListOpen'
364+type IssueStore_ListOpen_Call struct {
365+ *mock.Call
366+}
367+
368+// ListOpen is a helper method to define mock.On call
369+// - ctx context.Context
370+// - repo domain.RepoName
371+func (_e *IssueStore_Expecter) ListOpen(ctx any, repo any) *IssueStore_ListOpen_Call {
372+ return &IssueStore_ListOpen_Call{Call: _e.mock.On("ListOpen", ctx, repo)}
373+}
374+
375+func (_c *IssueStore_ListOpen_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *IssueStore_ListOpen_Call {
376+ _c.Call.Run(func(args mock.Arguments) {
377+ var arg0 context.Context
378+ if args[0] != nil {
379+ arg0 = args[0].(context.Context)
380+ }
381+ var arg1 domain.RepoName
382+ if args[1] != nil {
383+ arg1 = args[1].(domain.RepoName)
384+ }
385+ run(
386+ arg0,
387+ arg1,
388+ )
389+ })
390+ return _c
391+}
392+
393+func (_c *IssueStore_ListOpen_Call) Return(issues []domain.Issue, err error) *IssueStore_ListOpen_Call {
394+ _c.Call.Return(issues, err)
395+ return _c
396+}
397+
398+func (_c *IssueStore_ListOpen_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) ([]domain.Issue, error)) *IssueStore_ListOpen_Call {
399+ _c.Call.Return(run)
400+ return _c
401+}
402+
403+// SaveComment provides a mock function for the type IssueStore
404+func (_mock *IssueStore) SaveComment(ctx context.Context, issue domain.Issue, c domain.IssueComment) error {
405+ ret := _mock.Called(ctx, issue, c)
406+
407+ if len(ret) == 0 {
408+ panic("no return value specified for SaveComment")
409+ }
410+
411+ var r0 error
412+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue, domain.IssueComment) error); ok {
413+ r0 = returnFunc(ctx, issue, c)
414+ } else {
415+ r0 = ret.Error(0)
416+ }
417+ return r0
418+}
419+
420+// IssueStore_SaveComment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveComment'
421+type IssueStore_SaveComment_Call struct {
422+ *mock.Call
423+}
424+
425+// SaveComment is a helper method to define mock.On call
426+// - ctx context.Context
427+// - issue domain.Issue
428+// - c domain.IssueComment
429+func (_e *IssueStore_Expecter) SaveComment(ctx any, issue any, c any) *IssueStore_SaveComment_Call {
430+ return &IssueStore_SaveComment_Call{Call: _e.mock.On("SaveComment", ctx, issue, c)}
431+}
432+
433+func (_c *IssueStore_SaveComment_Call) Run(run func(ctx context.Context, issue domain.Issue, c domain.IssueComment)) *IssueStore_SaveComment_Call {
434+ _c.Call.Run(func(args mock.Arguments) {
435+ var arg0 context.Context
436+ if args[0] != nil {
437+ arg0 = args[0].(context.Context)
438+ }
439+ var arg1 domain.Issue
440+ if args[1] != nil {
441+ arg1 = args[1].(domain.Issue)
442+ }
443+ var arg2 domain.IssueComment
444+ if args[2] != nil {
445+ arg2 = args[2].(domain.IssueComment)
446+ }
447+ run(
448+ arg0,
449+ arg1,
450+ arg2,
451+ )
452+ })
453+ return _c
454+}
455+
456+func (_c *IssueStore_SaveComment_Call) Return(err error) *IssueStore_SaveComment_Call {
457+ _c.Call.Return(err)
458+ return _c
459+}
460+
461+func (_c *IssueStore_SaveComment_Call) RunAndReturn(run func(ctx context.Context, issue domain.Issue, c domain.IssueComment) error) *IssueStore_SaveComment_Call {
462+ _c.Call.Return(run)
463+ return _c
464+}
465+
466+// Update provides a mock function for the type IssueStore
467+func (_mock *IssueStore) Update(ctx context.Context, issue domain.Issue) error {
468+ ret := _mock.Called(ctx, issue)
469+
470+ if len(ret) == 0 {
471+ panic("no return value specified for Update")
472+ }
473+
474+ var r0 error
475+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Issue) error); ok {
476+ r0 = returnFunc(ctx, issue)
477+ } else {
478+ r0 = ret.Error(0)
479+ }
480+ return r0
481+}
482+
483+// IssueStore_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update'
484+type IssueStore_Update_Call struct {
485+ *mock.Call
486+}
487+
488+// Update is a helper method to define mock.On call
489+// - ctx context.Context
490+// - issue domain.Issue
491+func (_e *IssueStore_Expecter) Update(ctx any, issue any) *IssueStore_Update_Call {
492+ return &IssueStore_Update_Call{Call: _e.mock.On("Update", ctx, issue)}
493+}
494+
495+func (_c *IssueStore_Update_Call) Run(run func(ctx context.Context, issue domain.Issue)) *IssueStore_Update_Call {
496+ _c.Call.Run(func(args mock.Arguments) {
497+ var arg0 context.Context
498+ if args[0] != nil {
499+ arg0 = args[0].(context.Context)
500+ }
501+ var arg1 domain.Issue
502+ if args[1] != nil {
503+ arg1 = args[1].(domain.Issue)
504+ }
505+ run(
506+ arg0,
507+ arg1,
508+ )
509+ })
510+ return _c
511+}
512+
513+func (_c *IssueStore_Update_Call) Return(err error) *IssueStore_Update_Call {
514+ _c.Call.Return(err)
515+ return _c
516+}
517+
518+func (_c *IssueStore_Update_Call) RunAndReturn(run func(ctx context.Context, issue domain.Issue) error) *IssueStore_Update_Call {
519+ _c.Call.Return(run)
520+ return _c
521+}
M internal/ports/ports.go +34 -0
@@ -241,6 +241,40 @@ type TokenStore interface {
241241 Touch(ctx context.Context, id string, at time.Time) error
242242 }
243243
244+// IssueStore keeps the issues of the repositories and their comments. Issues
245+// share the number sequence of the merge requests of their repository, so that
246+// "#n" names one record. Create and SaveComment return domain.ErrNotFound when
247+// the repository does not exist, so that deleting a repository leaves nothing
248+// for one created again under its name. *kvstore.Issues implements it.
249+type IssueStore interface {
250+ // Create stores the issue under the next number of its repository (the
251+ // one merge requests draw from), which it sets, and returns it. A number
252+ // is never given twice, to an issue or to a merge request, even by a
253+ // concurrent Create.
254+ Create(ctx context.Context, issue domain.Issue) (domain.Issue, error)
255+ // Get returns the issue, or domain.ErrNotFound. Its Version tells Update
256+ // which state it was read at.
257+ Get(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error)
258+ // List returns a page of the issues of the repository in the given state
259+ // (any when empty), highest number first.
260+ List(ctx context.Context, repo domain.RepoName, params ListIssuesParams) (IssuePage, error)
261+ // ListOpen returns the open issues of the repository, highest number
262+ // first, reading only the open ones (through an index).
263+ ListOpen(ctx context.Context, repo domain.RepoName) ([]domain.Issue, error)
264+ // Update replaces the stored issue and keeps the index of the open issues
265+ // in step. It returns domain.ErrNotFound if there is none and
266+ // domain.ErrConflict if another write changed it since issue was read.
267+ Update(ctx context.Context, issue domain.Issue) error
268+ // SaveComment writes a comment and the issue it belongs to in one batch: a
269+ // new comment (Version 0) must not exist yet, an existing one must be
270+ // unchanged since it was read, and so must issue. It returns
271+ // domain.ErrConflict otherwise. The caller sets issue.CommentSeq, the
272+ // comment's ID for a new comment, and issue.UpdatedAt.
273+ SaveComment(ctx context.Context, issue domain.Issue, c domain.IssueComment) error
274+ // ListComments returns the comments of the issue, oldest first.
275+ ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error)
276+}
277+
244278 // MergeRequestStore keeps the merge requests of the repositories, their
245279 // comments and the read position of the reviewer. A repository has no entry
246280 // until its first merge request. Create, SaveComment and SetRead return
@@ -241,6 +241,40 @@ type TokenStore interface {
241 Touch(ctx context.Context, id string, at time.Time) error241 Touch(ctx context.Context, id string, at time.Time) error
242 }242 }
243 243
244+// IssueStore keeps the issues of the repositories and their comments. Issues
245+// share the number sequence of the merge requests of their repository, so that
246+// "#n" names one record. Create and SaveComment return domain.ErrNotFound when
247+// the repository does not exist, so that deleting a repository leaves nothing
248+// for one created again under its name. *kvstore.Issues implements it.
249+type IssueStore interface {
250+ // Create stores the issue under the next number of its repository (the
251+ // one merge requests draw from), which it sets, and returns it. A number
252+ // is never given twice, to an issue or to a merge request, even by a
253+ // concurrent Create.
254+ Create(ctx context.Context, issue domain.Issue) (domain.Issue, error)
255+ // Get returns the issue, or domain.ErrNotFound. Its Version tells Update
256+ // which state it was read at.
257+ Get(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error)
258+ // List returns a page of the issues of the repository in the given state
259+ // (any when empty), highest number first.
260+ List(ctx context.Context, repo domain.RepoName, params ListIssuesParams) (IssuePage, error)
261+ // ListOpen returns the open issues of the repository, highest number
262+ // first, reading only the open ones (through an index).
263+ ListOpen(ctx context.Context, repo domain.RepoName) ([]domain.Issue, error)
264+ // Update replaces the stored issue and keeps the index of the open issues
265+ // in step. It returns domain.ErrNotFound if there is none and
266+ // domain.ErrConflict if another write changed it since issue was read.
267+ Update(ctx context.Context, issue domain.Issue) error
268+ // SaveComment writes a comment and the issue it belongs to in one batch: a
269+ // new comment (Version 0) must not exist yet, an existing one must be
270+ // unchanged since it was read, and so must issue. It returns
271+ // domain.ErrConflict otherwise. The caller sets issue.CommentSeq, the
272+ // comment's ID for a new comment, and issue.UpdatedAt.
273+ SaveComment(ctx context.Context, issue domain.Issue, c domain.IssueComment) error
274+ // ListComments returns the comments of the issue, oldest first.
275+ ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error)
276+}
277+
244 // MergeRequestStore keeps the merge requests of the repositories, their278 // MergeRequestStore keeps the merge requests of the repositories, their
245 // comments and the read position of the reviewer. A repository has no entry279 // comments and the read position of the reviewer. A repository has no entry
246 // until its first merge request. Create, SaveComment and SetRead return280 // until its first merge request. Create, SaveComment and SetRead return
A proto/ermite/storage/issue.proto +45 -0
@@ -0,0 +1,45 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Internal, unversioned storage schema of ermite: issues.
4+
5+syntax = "proto3";
6+
7+package ermite.storage;
8+
9+import "google/protobuf/timestamp.proto";
10+
11+option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb";
12+
13+// IssueState is where an issue is in its life.
14+enum IssueState {
15+ ISSUE_STATE_UNSPECIFIED = 0;
16+ ISSUE_STATE_OPEN = 1;
17+ ISSUE_STATE_CLOSED = 2;
18+ // Awaiting moderation. Not stored yet.
19+ ISSUE_STATE_PENDING = 3;
20+}
21+
22+// Issue is stored under the key "is/<repo>/<number:010d>". The numbers come
23+// from the sequence of the merge requests ("mrseq/<repo>"). The open ones are
24+// indexed under "isopen/<repo>/<number:010d>".
25+message Issue {
26+ int64 number = 1;
27+ string title = 2;
28+ string body = 3;
29+ IssueState state = 4;
30+ // Label of the token that opened it.
31+ string author = 5;
32+ google.protobuf.Timestamp created_at = 6;
33+ google.protobuf.Timestamp updated_at = 7;
34+ // Last comment id handed out for this issue.
35+ int64 comment_seq = 8;
36+}
37+
38+// IssueComment is stored under the key
39+// "isc/<repo>/<number:010d>/<id:010d>".
40+message IssueComment {
41+ int64 id = 1;
42+ string author = 2;
43+ string text = 3;
44+ google.protobuf.Timestamp created_at = 4;
45+}
@@ -0,0 +1,45 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Internal, unversioned storage schema of ermite: issues.
4+
5+syntax = "proto3";
6+
7+package ermite.storage;
8+
9+import "google/protobuf/timestamp.proto";
10+
11+option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb";
12+
13+// IssueState is where an issue is in its life.
14+enum IssueState {
15+ ISSUE_STATE_UNSPECIFIED = 0;
16+ ISSUE_STATE_OPEN = 1;
17+ ISSUE_STATE_CLOSED = 2;
18+ // Awaiting moderation. Not stored yet.
19+ ISSUE_STATE_PENDING = 3;
20+}
21+
22+// Issue is stored under the key "is/<repo>/<number:010d>". The numbers come
23+// from the sequence of the merge requests ("mrseq/<repo>"). The open ones are
24+// indexed under "isopen/<repo>/<number:010d>".
25+message Issue {
26+ int64 number = 1;
27+ string title = 2;
28+ string body = 3;
29+ IssueState state = 4;
30+ // Label of the token that opened it.
31+ string author = 5;
32+ google.protobuf.Timestamp created_at = 6;
33+ google.protobuf.Timestamp updated_at = 7;
34+ // Last comment id handed out for this issue.
35+ int64 comment_seq = 8;
36+}
37+
38+// IssueComment is stored under the key
39+// "isc/<repo>/<number:010d>/<id:010d>".
40+message IssueComment {
41+ int64 id = 1;
42+ string author = 2;
43+ string text = 3;
44+ google.protobuf.Timestamp created_at = 4;
45+}
M proto/ermite/storage/merge_request.proto +10 -0
@@ -22,6 +22,16 @@ message RepoSettings {
2222 // Never empty once stored.
2323 repeated MergeMode allowed_merge_modes = 1;
2424 bool failing_checks_block = 2;
25+ // Who may write issues; unspecified reads as off.
26+ IssuesMode issues = 3;
27+}
28+
29+// IssuesMode says who may write issues in a repository.
30+enum IssuesMode {
31+ ISSUES_MODE_UNSPECIFIED = 0;
32+ ISSUES_MODE_OFF = 1;
33+ ISSUES_MODE_OWNER = 2;
34+ ISSUES_MODE_PUBLIC = 3;
2535 }
2636
2737 // MergeRequestState is where a merge request is in its life.
@@ -22,6 +22,16 @@ message RepoSettings {
22 // Never empty once stored.22 // Never empty once stored.
23 repeated MergeMode allowed_merge_modes = 1;23 repeated MergeMode allowed_merge_modes = 1;
24 bool failing_checks_block = 2;24 bool failing_checks_block = 2;
25+ // Who may write issues; unspecified reads as off.
26+ IssuesMode issues = 3;
27+}
28+
29+// IssuesMode says who may write issues in a repository.
30+enum IssuesMode {
31+ ISSUES_MODE_UNSPECIFIED = 0;
32+ ISSUES_MODE_OFF = 1;
33+ ISSUES_MODE_OWNER = 2;
34+ ISSUES_MODE_PUBLIC = 3;
25 }35 }
26 36
27 // MergeRequestState is where a merge request is in its life.37 // MergeRequestState is where a merge request is in its life.

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