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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 66 | 66 | ||
| 67 | 67 | ## 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. | |
| @@ -66,4 +66,4 @@ The hook socket and scripts git calls on each push live in `$ERMITE_DATA_DIR/run | |||||
| 66 | 66 | ||||
| 67 | ## Storage schema | 67 | ## 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
| @@ -0,0 +1,343 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Internal, unversioned storage schema of ermite: issues. | |
| 4 | + | ||
| 5 | + | // Code generated by protoc-gen-go. DO NOT EDIT. | |
| 6 | + | // versions: | |
| 7 | + | // protoc-gen-go v1.36.12 | |
| 8 | + | // protoc (unknown) | |
| 9 | + | // source: ermite/storage/issue.proto | |
| 10 | + | ||
| 11 | + | package storagepb | |
| 12 | + | ||
| 13 | + | import ( | |
| 14 | + | protoreflect "google.golang.org/protobuf/reflect/protoreflect" | |
| 15 | + | protoimpl "google.golang.org/protobuf/runtime/protoimpl" | |
| 16 | + | timestamppb "google.golang.org/protobuf/types/known/timestamppb" | |
| 17 | + | reflect "reflect" | |
| 18 | + | sync "sync" | |
| 19 | + | unsafe "unsafe" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | const ( | |
| 23 | + | // Verify that this generated code is sufficiently up-to-date. | |
| 24 | + | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) | |
| 25 | + | // Verify that runtime/protoimpl is sufficiently up-to-date. | |
| 26 | + | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) | |
| 27 | + | ) | |
| 28 | + | ||
| 29 | + | // IssueState is where an issue is in its life. | |
| 30 | + | type IssueState int32 | |
| 31 | + | ||
| 32 | + | const ( | |
| 33 | + | IssueState_ISSUE_STATE_UNSPECIFIED IssueState = 0 | |
| 34 | + | IssueState_ISSUE_STATE_OPEN IssueState = 1 | |
| 35 | + | IssueState_ISSUE_STATE_CLOSED IssueState = 2 | |
| 36 | + | // Awaiting moderation. Not stored yet. | |
| 37 | + | IssueState_ISSUE_STATE_PENDING IssueState = 3 | |
| 38 | + | ) | |
| 39 | + | ||
| 40 | + | // Enum value maps for IssueState. | |
| 41 | + | var ( | |
| 42 | + | IssueState_name = map[int32]string{ | |
| 43 | + | 0: "ISSUE_STATE_UNSPECIFIED", | |
| 44 | + | 1: "ISSUE_STATE_OPEN", | |
| 45 | + | 2: "ISSUE_STATE_CLOSED", | |
| 46 | + | 3: "ISSUE_STATE_PENDING", | |
| 47 | + | } | |
| 48 | + | IssueState_value = map[string]int32{ | |
| 49 | + | "ISSUE_STATE_UNSPECIFIED": 0, | |
| 50 | + | "ISSUE_STATE_OPEN": 1, | |
| 51 | + | "ISSUE_STATE_CLOSED": 2, | |
| 52 | + | "ISSUE_STATE_PENDING": 3, | |
| 53 | + | } | |
| 54 | + | ) | |
| 55 | + | ||
| 56 | + | func (x IssueState) Enum() *IssueState { | |
| 57 | + | p := new(IssueState) | |
| 58 | + | *p = x | |
| 59 | + | return p | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func (x IssueState) String() string { | |
| 63 | + | return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func (IssueState) Descriptor() protoreflect.EnumDescriptor { | |
| 67 | + | return file_ermite_storage_issue_proto_enumTypes[0].Descriptor() | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (IssueState) Type() protoreflect.EnumType { | |
| 71 | + | return &file_ermite_storage_issue_proto_enumTypes[0] | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func (x IssueState) Number() protoreflect.EnumNumber { | |
| 75 | + | return protoreflect.EnumNumber(x) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // Deprecated: Use IssueState.Descriptor instead. | |
| 79 | + | func (IssueState) EnumDescriptor() ([]byte, []int) { | |
| 80 | + | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{0} | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // Issue is stored under the key "is/<repo>/<number:010d>". The numbers come | |
| 84 | + | // from the sequence of the merge requests ("mrseq/<repo>"). The open ones are | |
| 85 | + | // indexed under "isopen/<repo>/<number:010d>". | |
| 86 | + | type Issue struct { | |
| 87 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 88 | + | Number int64 `protobuf:"varint,1,opt,name=number,proto3" json:"number,omitempty"` | |
| 89 | + | Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` | |
| 90 | + | Body string `protobuf:"bytes,3,opt,name=body,proto3" json:"body,omitempty"` | |
| 91 | + | State IssueState `protobuf:"varint,4,opt,name=state,proto3,enum=ermite.storage.IssueState" json:"state,omitempty"` | |
| 92 | + | // Label of the token that opened it. | |
| 93 | + | Author string `protobuf:"bytes,5,opt,name=author,proto3" json:"author,omitempty"` | |
| 94 | + | CreatedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` | |
| 95 | + | UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` | |
| 96 | + | // Last comment id handed out for this issue. | |
| 97 | + | CommentSeq int64 `protobuf:"varint,8,opt,name=comment_seq,json=commentSeq,proto3" json:"comment_seq,omitempty"` | |
| 98 | + | unknownFields protoimpl.UnknownFields | |
| 99 | + | sizeCache protoimpl.SizeCache | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func (x *Issue) Reset() { | |
| 103 | + | *x = Issue{} | |
| 104 | + | mi := &file_ermite_storage_issue_proto_msgTypes[0] | |
| 105 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 106 | + | ms.StoreMessageInfo(mi) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func (x *Issue) String() string { | |
| 110 | + | return protoimpl.X.MessageStringOf(x) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (*Issue) ProtoMessage() {} | |
| 114 | + | ||
| 115 | + | func (x *Issue) ProtoReflect() protoreflect.Message { | |
| 116 | + | mi := &file_ermite_storage_issue_proto_msgTypes[0] | |
| 117 | + | if x != nil { | |
| 118 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 119 | + | if ms.LoadMessageInfo() == nil { | |
| 120 | + | ms.StoreMessageInfo(mi) | |
| 121 | + | } | |
| 122 | + | return ms | |
| 123 | + | } | |
| 124 | + | return mi.MessageOf(x) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // Deprecated: Use Issue.ProtoReflect.Descriptor instead. | |
| 128 | + | func (*Issue) Descriptor() ([]byte, []int) { | |
| 129 | + | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{0} | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func (x *Issue) GetNumber() int64 { | |
| 133 | + | if x != nil { | |
| 134 | + | return x.Number | |
| 135 | + | } | |
| 136 | + | return 0 | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func (x *Issue) GetTitle() string { | |
| 140 | + | if x != nil { | |
| 141 | + | return x.Title | |
| 142 | + | } | |
| 143 | + | return "" | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func (x *Issue) GetBody() string { | |
| 147 | + | if x != nil { | |
| 148 | + | return x.Body | |
| 149 | + | } | |
| 150 | + | return "" | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func (x *Issue) GetState() IssueState { | |
| 154 | + | if x != nil { | |
| 155 | + | return x.State | |
| 156 | + | } | |
| 157 | + | return IssueState_ISSUE_STATE_UNSPECIFIED | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func (x *Issue) GetAuthor() string { | |
| 161 | + | if x != nil { | |
| 162 | + | return x.Author | |
| 163 | + | } | |
| 164 | + | return "" | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func (x *Issue) GetCreatedAt() *timestamppb.Timestamp { | |
| 168 | + | if x != nil { | |
| 169 | + | return x.CreatedAt | |
| 170 | + | } | |
| 171 | + | return nil | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func (x *Issue) GetUpdatedAt() *timestamppb.Timestamp { | |
| 175 | + | if x != nil { | |
| 176 | + | return x.UpdatedAt | |
| 177 | + | } | |
| 178 | + | return nil | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func (x *Issue) GetCommentSeq() int64 { | |
| 182 | + | if x != nil { | |
| 183 | + | return x.CommentSeq | |
| 184 | + | } | |
| 185 | + | return 0 | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // IssueComment is stored under the key | |
| 189 | + | // "isc/<repo>/<number:010d>/<id:010d>". | |
| 190 | + | type IssueComment struct { | |
| 191 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 192 | + | Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` | |
| 193 | + | Author string `protobuf:"bytes,2,opt,name=author,proto3" json:"author,omitempty"` | |
| 194 | + | Text string `protobuf:"bytes,3,opt,name=text,proto3" json:"text,omitempty"` | |
| 195 | + | CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` | |
| 196 | + | unknownFields protoimpl.UnknownFields | |
| 197 | + | sizeCache protoimpl.SizeCache | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func (x *IssueComment) Reset() { | |
| 201 | + | *x = IssueComment{} | |
| 202 | + | mi := &file_ermite_storage_issue_proto_msgTypes[1] | |
| 203 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 204 | + | ms.StoreMessageInfo(mi) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func (x *IssueComment) String() string { | |
| 208 | + | return protoimpl.X.MessageStringOf(x) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func (*IssueComment) ProtoMessage() {} | |
| 212 | + | ||
| 213 | + | func (x *IssueComment) ProtoReflect() protoreflect.Message { | |
| 214 | + | mi := &file_ermite_storage_issue_proto_msgTypes[1] | |
| 215 | + | if x != nil { | |
| 216 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 217 | + | if ms.LoadMessageInfo() == nil { | |
| 218 | + | ms.StoreMessageInfo(mi) | |
| 219 | + | } | |
| 220 | + | return ms | |
| 221 | + | } | |
| 222 | + | return mi.MessageOf(x) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | // Deprecated: Use IssueComment.ProtoReflect.Descriptor instead. | |
| 226 | + | func (*IssueComment) Descriptor() ([]byte, []int) { | |
| 227 | + | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{1} | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func (x *IssueComment) GetId() int64 { | |
| 231 | + | if x != nil { | |
| 232 | + | return x.Id | |
| 233 | + | } | |
| 234 | + | return 0 | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func (x *IssueComment) GetAuthor() string { | |
| 238 | + | if x != nil { | |
| 239 | + | return x.Author | |
| 240 | + | } | |
| 241 | + | return "" | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | func (x *IssueComment) GetText() string { | |
| 245 | + | if x != nil { | |
| 246 | + | return x.Text | |
| 247 | + | } | |
| 248 | + | return "" | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func (x *IssueComment) GetCreatedAt() *timestamppb.Timestamp { | |
| 252 | + | if x != nil { | |
| 253 | + | return x.CreatedAt | |
| 254 | + | } | |
| 255 | + | return nil | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | var File_ermite_storage_issue_proto protoreflect.FileDescriptor | |
| 259 | + | ||
| 260 | + | const file_ermite_storage_issue_proto_rawDesc = "" + | |
| 261 | + | "\n" + | |
| 262 | + | "\x1aermite/storage/issue.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xaa\x02\n" + | |
| 263 | + | "\x05Issue\x12\x16\n" + | |
| 264 | + | "\x06number\x18\x01 \x01(\x03R\x06number\x12\x14\n" + | |
| 265 | + | "\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" + | |
| 266 | + | "\x04body\x18\x03 \x01(\tR\x04body\x120\n" + | |
| 267 | + | "\x05state\x18\x04 \x01(\x0e2\x1a.ermite.storage.IssueStateR\x05state\x12\x16\n" + | |
| 268 | + | "\x06author\x18\x05 \x01(\tR\x06author\x129\n" + | |
| 269 | + | "\n" + | |
| 270 | + | "created_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" + | |
| 271 | + | "\n" + | |
| 272 | + | "updated_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12\x1f\n" + | |
| 273 | + | "\vcomment_seq\x18\b \x01(\x03R\n" + | |
| 274 | + | "commentSeq\"\x85\x01\n" + | |
| 275 | + | "\fIssueComment\x12\x0e\n" + | |
| 276 | + | "\x02id\x18\x01 \x01(\x03R\x02id\x12\x16\n" + | |
| 277 | + | "\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" + | |
| 278 | + | "\x04text\x18\x03 \x01(\tR\x04text\x129\n" + | |
| 279 | + | "\n" + | |
| 280 | + | "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt*p\n" + | |
| 281 | + | "\n" + | |
| 282 | + | "IssueState\x12\x1b\n" + | |
| 283 | + | "\x17ISSUE_STATE_UNSPECIFIED\x10\x00\x12\x14\n" + | |
| 284 | + | "\x10ISSUE_STATE_OPEN\x10\x01\x12\x16\n" + | |
| 285 | + | "\x12ISSUE_STATE_CLOSED\x10\x02\x12\x17\n" + | |
| 286 | + | "\x13ISSUE_STATE_PENDING\x10\x03B/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3" | |
| 287 | + | ||
| 288 | + | var ( | |
| 289 | + | file_ermite_storage_issue_proto_rawDescOnce sync.Once | |
| 290 | + | file_ermite_storage_issue_proto_rawDescData []byte | |
| 291 | + | ) | |
| 292 | + | ||
| 293 | + | func file_ermite_storage_issue_proto_rawDescGZIP() []byte { | |
| 294 | + | file_ermite_storage_issue_proto_rawDescOnce.Do(func() { | |
| 295 | + | file_ermite_storage_issue_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ermite_storage_issue_proto_rawDesc), len(file_ermite_storage_issue_proto_rawDesc))) | |
| 296 | + | }) | |
| 297 | + | return file_ermite_storage_issue_proto_rawDescData | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | var file_ermite_storage_issue_proto_enumTypes = make([]protoimpl.EnumInfo, 1) | |
| 301 | + | var file_ermite_storage_issue_proto_msgTypes = make([]protoimpl.MessageInfo, 2) | |
| 302 | + | var file_ermite_storage_issue_proto_goTypes = []any{ | |
| 303 | + | (IssueState)(0), // 0: ermite.storage.IssueState | |
| 304 | + | (*Issue)(nil), // 1: ermite.storage.Issue | |
| 305 | + | (*IssueComment)(nil), // 2: ermite.storage.IssueComment | |
| 306 | + | (*timestamppb.Timestamp)(nil), // 3: google.protobuf.Timestamp | |
| 307 | + | } | |
| 308 | + | var file_ermite_storage_issue_proto_depIdxs = []int32{ | |
| 309 | + | 0, // 0: ermite.storage.Issue.state:type_name -> ermite.storage.IssueState | |
| 310 | + | 3, // 1: ermite.storage.Issue.created_at:type_name -> google.protobuf.Timestamp | |
| 311 | + | 3, // 2: ermite.storage.Issue.updated_at:type_name -> google.protobuf.Timestamp | |
| 312 | + | 3, // 3: ermite.storage.IssueComment.created_at:type_name -> google.protobuf.Timestamp | |
| 313 | + | 4, // [4:4] is the sub-list for method output_type | |
| 314 | + | 4, // [4:4] is the sub-list for method input_type | |
| 315 | + | 4, // [4:4] is the sub-list for extension type_name | |
| 316 | + | 4, // [4:4] is the sub-list for extension extendee | |
| 317 | + | 0, // [0:4] is the sub-list for field type_name | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | func init() { file_ermite_storage_issue_proto_init() } | |
| 321 | + | func file_ermite_storage_issue_proto_init() { | |
| 322 | + | if File_ermite_storage_issue_proto != nil { | |
| 323 | + | return | |
| 324 | + | } | |
| 325 | + | type x struct{} | |
| 326 | + | out := protoimpl.TypeBuilder{ | |
| 327 | + | File: protoimpl.DescBuilder{ | |
| 328 | + | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), | |
| 329 | + | RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_issue_proto_rawDesc), len(file_ermite_storage_issue_proto_rawDesc)), | |
| 330 | + | NumEnums: 1, | |
| 331 | + | NumMessages: 2, | |
| 332 | + | NumExtensions: 0, | |
| 333 | + | NumServices: 0, | |
| 334 | + | }, | |
| 335 | + | GoTypes: file_ermite_storage_issue_proto_goTypes, | |
| 336 | + | DependencyIndexes: file_ermite_storage_issue_proto_depIdxs, | |
| 337 | + | EnumInfos: file_ermite_storage_issue_proto_enumTypes, | |
| 338 | + | MessageInfos: file_ermite_storage_issue_proto_msgTypes, | |
| 339 | + | }.Build() | |
| 340 | + | File_ermite_storage_issue_proto = out.File | |
| 341 | + | file_ermite_storage_issue_proto_goTypes = nil | |
| 342 | + | file_ermite_storage_issue_proto_depIdxs = nil | |
| 343 | + | } | |
| @@ -0,0 +1,343 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | // Internal, unversioned storage schema of ermite: issues. | |||
| 4 | + | ||||
| 5 | + | // Code generated by protoc-gen-go. DO NOT EDIT. | |||
| 6 | + | // versions: | |||
| 7 | + | // protoc-gen-go v1.36.12 | |||
| 8 | + | // protoc (unknown) | |||
| 9 | + | // source: ermite/storage/issue.proto | |||
| 10 | + | ||||
| 11 | + | package storagepb | |||
| 12 | + | ||||
| 13 | + | import ( | |||
| 14 | + | protoreflect "google.golang.org/protobuf/reflect/protoreflect" | |||
| 15 | + | protoimpl "google.golang.org/protobuf/runtime/protoimpl" | |||
| 16 | + | timestamppb "google.golang.org/protobuf/types/known/timestamppb" | |||
| 17 | + | reflect "reflect" | |||
| 18 | + | sync "sync" | |||
| 19 | + | unsafe "unsafe" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | const ( | |||
| 23 | + | // Verify that this generated code is sufficiently up-to-date. | |||
| 24 | + | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) | |||
| 25 | + | // Verify that runtime/protoimpl is sufficiently up-to-date. | |||
| 26 | + | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) | |||
| 27 | + | ) | |||
| 28 | + | ||||
| 29 | + | // IssueState is where an issue is in its life. | |||
| 30 | + | type IssueState int32 | |||
| 31 | + | ||||
| 32 | + | const ( | |||
| 33 | + | IssueState_ISSUE_STATE_UNSPECIFIED IssueState = 0 | |||
| 34 | + | IssueState_ISSUE_STATE_OPEN IssueState = 1 | |||
| 35 | + | IssueState_ISSUE_STATE_CLOSED IssueState = 2 | |||
| 36 | + | // Awaiting moderation. Not stored yet. | |||
| 37 | + | IssueState_ISSUE_STATE_PENDING IssueState = 3 | |||
| 38 | + | ) | |||
| 39 | + | ||||
| 40 | + | // Enum value maps for IssueState. | |||
| 41 | + | var ( | |||
| 42 | + | IssueState_name = map[int32]string{ | |||
| 43 | + | 0: "ISSUE_STATE_UNSPECIFIED", | |||
| 44 | + | 1: "ISSUE_STATE_OPEN", | |||
| 45 | + | 2: "ISSUE_STATE_CLOSED", | |||
| 46 | + | 3: "ISSUE_STATE_PENDING", | |||
| 47 | + | } | |||
| 48 | + | IssueState_value = map[string]int32{ | |||
| 49 | + | "ISSUE_STATE_UNSPECIFIED": 0, | |||
| 50 | + | "ISSUE_STATE_OPEN": 1, | |||
| 51 | + | "ISSUE_STATE_CLOSED": 2, | |||
| 52 | + | "ISSUE_STATE_PENDING": 3, | |||
| 53 | + | } | |||
| 54 | + | ) | |||
| 55 | + | ||||
| 56 | + | func (x IssueState) Enum() *IssueState { | |||
| 57 | + | p := new(IssueState) | |||
| 58 | + | *p = x | |||
| 59 | + | return p | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func (x IssueState) String() string { | |||
| 63 | + | return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func (IssueState) Descriptor() protoreflect.EnumDescriptor { | |||
| 67 | + | return file_ermite_storage_issue_proto_enumTypes[0].Descriptor() | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func (IssueState) Type() protoreflect.EnumType { | |||
| 71 | + | return &file_ermite_storage_issue_proto_enumTypes[0] | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func (x IssueState) Number() protoreflect.EnumNumber { | |||
| 75 | + | return protoreflect.EnumNumber(x) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // Deprecated: Use IssueState.Descriptor instead. | |||
| 79 | + | func (IssueState) EnumDescriptor() ([]byte, []int) { | |||
| 80 | + | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{0} | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // Issue is stored under the key "is/<repo>/<number:010d>". The numbers come | |||
| 84 | + | // from the sequence of the merge requests ("mrseq/<repo>"). The open ones are | |||
| 85 | + | // indexed under "isopen/<repo>/<number:010d>". | |||
| 86 | + | type Issue struct { | |||
| 87 | + | state protoimpl.MessageState `protogen:"open.v1"` | |||
| 88 | + | Number int64 `protobuf:"varint,1,opt,name=number,proto3" json:"number,omitempty"` | |||
| 89 | + | Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` | |||
| 90 | + | Body string `protobuf:"bytes,3,opt,name=body,proto3" json:"body,omitempty"` | |||
| 91 | + | State IssueState `protobuf:"varint,4,opt,name=state,proto3,enum=ermite.storage.IssueState" json:"state,omitempty"` | |||
| 92 | + | // Label of the token that opened it. | |||
| 93 | + | Author string `protobuf:"bytes,5,opt,name=author,proto3" json:"author,omitempty"` | |||
| 94 | + | CreatedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` | |||
| 95 | + | UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` | |||
| 96 | + | // Last comment id handed out for this issue. | |||
| 97 | + | CommentSeq int64 `protobuf:"varint,8,opt,name=comment_seq,json=commentSeq,proto3" json:"comment_seq,omitempty"` | |||
| 98 | + | unknownFields protoimpl.UnknownFields | |||
| 99 | + | sizeCache protoimpl.SizeCache | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func (x *Issue) Reset() { | |||
| 103 | + | *x = Issue{} | |||
| 104 | + | mi := &file_ermite_storage_issue_proto_msgTypes[0] | |||
| 105 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 106 | + | ms.StoreMessageInfo(mi) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func (x *Issue) String() string { | |||
| 110 | + | return protoimpl.X.MessageStringOf(x) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func (*Issue) ProtoMessage() {} | |||
| 114 | + | ||||
| 115 | + | func (x *Issue) ProtoReflect() protoreflect.Message { | |||
| 116 | + | mi := &file_ermite_storage_issue_proto_msgTypes[0] | |||
| 117 | + | if x != nil { | |||
| 118 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 119 | + | if ms.LoadMessageInfo() == nil { | |||
| 120 | + | ms.StoreMessageInfo(mi) | |||
| 121 | + | } | |||
| 122 | + | return ms | |||
| 123 | + | } | |||
| 124 | + | return mi.MessageOf(x) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // Deprecated: Use Issue.ProtoReflect.Descriptor instead. | |||
| 128 | + | func (*Issue) Descriptor() ([]byte, []int) { | |||
| 129 | + | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{0} | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func (x *Issue) GetNumber() int64 { | |||
| 133 | + | if x != nil { | |||
| 134 | + | return x.Number | |||
| 135 | + | } | |||
| 136 | + | return 0 | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func (x *Issue) GetTitle() string { | |||
| 140 | + | if x != nil { | |||
| 141 | + | return x.Title | |||
| 142 | + | } | |||
| 143 | + | return "" | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func (x *Issue) GetBody() string { | |||
| 147 | + | if x != nil { | |||
| 148 | + | return x.Body | |||
| 149 | + | } | |||
| 150 | + | return "" | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func (x *Issue) GetState() IssueState { | |||
| 154 | + | if x != nil { | |||
| 155 | + | return x.State | |||
| 156 | + | } | |||
| 157 | + | return IssueState_ISSUE_STATE_UNSPECIFIED | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func (x *Issue) GetAuthor() string { | |||
| 161 | + | if x != nil { | |||
| 162 | + | return x.Author | |||
| 163 | + | } | |||
| 164 | + | return "" | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func (x *Issue) GetCreatedAt() *timestamppb.Timestamp { | |||
| 168 | + | if x != nil { | |||
| 169 | + | return x.CreatedAt | |||
| 170 | + | } | |||
| 171 | + | return nil | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func (x *Issue) GetUpdatedAt() *timestamppb.Timestamp { | |||
| 175 | + | if x != nil { | |||
| 176 | + | return x.UpdatedAt | |||
| 177 | + | } | |||
| 178 | + | return nil | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func (x *Issue) GetCommentSeq() int64 { | |||
| 182 | + | if x != nil { | |||
| 183 | + | return x.CommentSeq | |||
| 184 | + | } | |||
| 185 | + | return 0 | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // IssueComment is stored under the key | |||
| 189 | + | // "isc/<repo>/<number:010d>/<id:010d>". | |||
| 190 | + | type IssueComment struct { | |||
| 191 | + | state protoimpl.MessageState `protogen:"open.v1"` | |||
| 192 | + | Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` | |||
| 193 | + | Author string `protobuf:"bytes,2,opt,name=author,proto3" json:"author,omitempty"` | |||
| 194 | + | Text string `protobuf:"bytes,3,opt,name=text,proto3" json:"text,omitempty"` | |||
| 195 | + | CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` | |||
| 196 | + | unknownFields protoimpl.UnknownFields | |||
| 197 | + | sizeCache protoimpl.SizeCache | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func (x *IssueComment) Reset() { | |||
| 201 | + | *x = IssueComment{} | |||
| 202 | + | mi := &file_ermite_storage_issue_proto_msgTypes[1] | |||
| 203 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 204 | + | ms.StoreMessageInfo(mi) | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | func (x *IssueComment) String() string { | |||
| 208 | + | return protoimpl.X.MessageStringOf(x) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func (*IssueComment) ProtoMessage() {} | |||
| 212 | + | ||||
| 213 | + | func (x *IssueComment) ProtoReflect() protoreflect.Message { | |||
| 214 | + | mi := &file_ermite_storage_issue_proto_msgTypes[1] | |||
| 215 | + | if x != nil { | |||
| 216 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 217 | + | if ms.LoadMessageInfo() == nil { | |||
| 218 | + | ms.StoreMessageInfo(mi) | |||
| 219 | + | } | |||
| 220 | + | return ms | |||
| 221 | + | } | |||
| 222 | + | return mi.MessageOf(x) | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | // Deprecated: Use IssueComment.ProtoReflect.Descriptor instead. | |||
| 226 | + | func (*IssueComment) Descriptor() ([]byte, []int) { | |||
| 227 | + | return file_ermite_storage_issue_proto_rawDescGZIP(), []int{1} | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func (x *IssueComment) GetId() int64 { | |||
| 231 | + | if x != nil { | |||
| 232 | + | return x.Id | |||
| 233 | + | } | |||
| 234 | + | return 0 | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | func (x *IssueComment) GetAuthor() string { | |||
| 238 | + | if x != nil { | |||
| 239 | + | return x.Author | |||
| 240 | + | } | |||
| 241 | + | return "" | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | func (x *IssueComment) GetText() string { | |||
| 245 | + | if x != nil { | |||
| 246 | + | return x.Text | |||
| 247 | + | } | |||
| 248 | + | return "" | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | func (x *IssueComment) GetCreatedAt() *timestamppb.Timestamp { | |||
| 252 | + | if x != nil { | |||
| 253 | + | return x.CreatedAt | |||
| 254 | + | } | |||
| 255 | + | return nil | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | var File_ermite_storage_issue_proto protoreflect.FileDescriptor | |||
| 259 | + | ||||
| 260 | + | const file_ermite_storage_issue_proto_rawDesc = "" + | |||
| 261 | + | "\n" + | |||
| 262 | + | "\x1aermite/storage/issue.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xaa\x02\n" + | |||
| 263 | + | "\x05Issue\x12\x16\n" + | |||
| 264 | + | "\x06number\x18\x01 \x01(\x03R\x06number\x12\x14\n" + | |||
| 265 | + | "\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" + | |||
| 266 | + | "\x04body\x18\x03 \x01(\tR\x04body\x120\n" + | |||
| 267 | + | "\x05state\x18\x04 \x01(\x0e2\x1a.ermite.storage.IssueStateR\x05state\x12\x16\n" + | |||
| 268 | + | "\x06author\x18\x05 \x01(\tR\x06author\x129\n" + | |||
| 269 | + | "\n" + | |||
| 270 | + | "created_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" + | |||
| 271 | + | "\n" + | |||
| 272 | + | "updated_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12\x1f\n" + | |||
| 273 | + | "\vcomment_seq\x18\b \x01(\x03R\n" + | |||
| 274 | + | "commentSeq\"\x85\x01\n" + | |||
| 275 | + | "\fIssueComment\x12\x0e\n" + | |||
| 276 | + | "\x02id\x18\x01 \x01(\x03R\x02id\x12\x16\n" + | |||
| 277 | + | "\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" + | |||
| 278 | + | "\x04text\x18\x03 \x01(\tR\x04text\x129\n" + | |||
| 279 | + | "\n" + | |||
| 280 | + | "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt*p\n" + | |||
| 281 | + | "\n" + | |||
| 282 | + | "IssueState\x12\x1b\n" + | |||
| 283 | + | "\x17ISSUE_STATE_UNSPECIFIED\x10\x00\x12\x14\n" + | |||
| 284 | + | "\x10ISSUE_STATE_OPEN\x10\x01\x12\x16\n" + | |||
| 285 | + | "\x12ISSUE_STATE_CLOSED\x10\x02\x12\x17\n" + | |||
| 286 | + | "\x13ISSUE_STATE_PENDING\x10\x03B/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3" | |||
| 287 | + | ||||
| 288 | + | var ( | |||
| 289 | + | file_ermite_storage_issue_proto_rawDescOnce sync.Once | |||
| 290 | + | file_ermite_storage_issue_proto_rawDescData []byte | |||
| 291 | + | ) | |||
| 292 | + | ||||
| 293 | + | func file_ermite_storage_issue_proto_rawDescGZIP() []byte { | |||
| 294 | + | file_ermite_storage_issue_proto_rawDescOnce.Do(func() { | |||
| 295 | + | file_ermite_storage_issue_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ermite_storage_issue_proto_rawDesc), len(file_ermite_storage_issue_proto_rawDesc))) | |||
| 296 | + | }) | |||
| 297 | + | return file_ermite_storage_issue_proto_rawDescData | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | var file_ermite_storage_issue_proto_enumTypes = make([]protoimpl.EnumInfo, 1) | |||
| 301 | + | var file_ermite_storage_issue_proto_msgTypes = make([]protoimpl.MessageInfo, 2) | |||
| 302 | + | var file_ermite_storage_issue_proto_goTypes = []any{ | |||
| 303 | + | (IssueState)(0), // 0: ermite.storage.IssueState | |||
| 304 | + | (*Issue)(nil), // 1: ermite.storage.Issue | |||
| 305 | + | (*IssueComment)(nil), // 2: ermite.storage.IssueComment | |||
| 306 | + | (*timestamppb.Timestamp)(nil), // 3: google.protobuf.Timestamp | |||
| 307 | + | } | |||
| 308 | + | var file_ermite_storage_issue_proto_depIdxs = []int32{ | |||
| 309 | + | 0, // 0: ermite.storage.Issue.state:type_name -> ermite.storage.IssueState | |||
| 310 | + | 3, // 1: ermite.storage.Issue.created_at:type_name -> google.protobuf.Timestamp | |||
| 311 | + | 3, // 2: ermite.storage.Issue.updated_at:type_name -> google.protobuf.Timestamp | |||
| 312 | + | 3, // 3: ermite.storage.IssueComment.created_at:type_name -> google.protobuf.Timestamp | |||
| 313 | + | 4, // [4:4] is the sub-list for method output_type | |||
| 314 | + | 4, // [4:4] is the sub-list for method input_type | |||
| 315 | + | 4, // [4:4] is the sub-list for extension type_name | |||
| 316 | + | 4, // [4:4] is the sub-list for extension extendee | |||
| 317 | + | 0, // [0:4] is the sub-list for field type_name | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | func init() { file_ermite_storage_issue_proto_init() } | |||
| 321 | + | func file_ermite_storage_issue_proto_init() { | |||
| 322 | + | if File_ermite_storage_issue_proto != nil { | |||
| 323 | + | return | |||
| 324 | + | } | |||
| 325 | + | type x struct{} | |||
| 326 | + | out := protoimpl.TypeBuilder{ | |||
| 327 | + | File: protoimpl.DescBuilder{ | |||
| 328 | + | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), | |||
| 329 | + | RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_issue_proto_rawDesc), len(file_ermite_storage_issue_proto_rawDesc)), | |||
| 330 | + | NumEnums: 1, | |||
| 331 | + | NumMessages: 2, | |||
| 332 | + | NumExtensions: 0, | |||
| 333 | + | NumServices: 0, | |||
| 334 | + | }, | |||
| 335 | + | GoTypes: file_ermite_storage_issue_proto_goTypes, | |||
| 336 | + | DependencyIndexes: file_ermite_storage_issue_proto_depIdxs, | |||
| 337 | + | EnumInfos: file_ermite_storage_issue_proto_enumTypes, | |||
| 338 | + | MessageInfos: file_ermite_storage_issue_proto_msgTypes, | |||
| 339 | + | }.Build() | |||
| 340 | + | File_ermite_storage_issue_proto = out.File | |||
| 341 | + | file_ermite_storage_issue_proto_goTypes = nil | |||
| 342 | + | file_ermite_storage_issue_proto_depIdxs = nil | |||
| 343 | + | } | |||
M gen/ermite/storage/merge_request.pb.go +102 -31 generated file: collapsed
M internal/adapters/kvstore/export_test.go +9 -0
| @@ -25,3 +25,12 @@ func (c *CI) RawVersionForTest(ctx context.Context, id string) (uint64, bool) { | |||
| 25 | 25 | item, ok := c.db.Get(ctx, runnerKey(id)) | |
| 26 | 26 | 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 | + | } | |
| @@ -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, ok | 26 | 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. | |||
| 198 | 198 | // | |
| 199 | 199 | //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy | |
| 200 | 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 | 226 | for range createTries { | |
| 202 | - | repoItem, repoOK := m.db.Get(ctx, repoKey(mr.Repo)) | |
| 227 | + | repoItem, repoOK := db.Get(ctx, repoKey(repo)) | |
| 203 | 228 | if !repoOK { | |
| 204 | - | return domain.MergeRequest{}, domain.ErrNotFound | |
| 229 | + | return domain.ErrNotFound | |
| 205 | 230 | } | |
| 206 | 231 | var last int64 | |
| 207 | - | item, ok := m.db.Get(ctx, seqKey(mr.Repo)) | |
| 232 | + | item, ok := db.Get(ctx, seqKey(repo)) | |
| 208 | 233 | if ok { | |
| 209 | 234 | var seq storagepb.Sequence | |
| 210 | 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 | 238 | last = seq.GetLast() | |
| 214 | 239 | } | |
| 215 | - | mr.Number = last + 1 | |
| 216 | - | val, err := encodeMergeRequest(mr) | |
| 217 | - | if err != nil { | |
| 218 | - | return domain.MergeRequest{}, err | |
| 219 | - | } | |
| 240 | + | number := last + 1 | |
| 220 | 241 | 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 | |
| 223 | 244 | if ok { | |
| 224 | - | b.RequireVersion(seqKey(mr.Repo), item.Version) | |
| 245 | + | b.RequireVersion(seqKey(repo), item.Version) | |
| 225 | 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})) | |
| 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) | |
| 233 | 254 | if errors.Is(err, kv.ErrConflict) { | |
| 234 | 255 | continue | |
| 235 | 256 | } | |
| 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 | |
| 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 | 262 | // Get reads mr/<repo>/<number>. | |
| @@ -683,6 +701,11 @@ func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) { | |||
| 683 | 701 | } | |
| 684 | 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 | 709 | return pb, nil | |
| 687 | 710 | } | |
| 688 | 711 | ||
| @@ -707,5 +730,5 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings { | |||
| 707 | 730 | if err != nil { | |
| 708 | 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 | } | |
| @@ -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 policy | 199 | //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.ErrNotFound | 229 | + | return domain.ErrNotFound |
| 205 | } | 230 | } | ||
| 206 | var last int64 | 231 | 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.Sequence | 234 | 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 + 1 | 240 | + | number := last + 1 |
| 216 | - | val, err := encodeMergeRequest(mr) | |||
| 217 | - | if err != nil { | |||
| 218 | - | return domain.MergeRequest{}, err | |||
| 219 | - | } | |||
| 220 | var b kv.Batch | 241 | 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 guardRepo | 243 | + | 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 | continue | 255 | 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, nil | 709 | 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) { | |||
| 441 | 441 | require.NoError(t, err) | |
| 442 | 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 | 445 | require.NoError(t, s.Update(ctx, got)) | |
| 446 | 446 | again, err := s.Get(ctx, n) | |
| 447 | 447 | 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 { | |||
| 144 | 144 | for range deleteTries { | |
| 145 | 145 | snap := s.db.Snapshot(ctx) | |
| 146 | 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 | 149 | keys = append(keys, settingsKeys(ctx, snap, name)...) | |
| 149 | 150 | for len(keys) > deleteChunk { | |
| 150 | 151 | 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.Batch | 151 | var b kv.Batch | ||
M internal/adapters/kvstore/store.go +59 -2
| @@ -8,12 +8,14 @@ import ( | |||
| 8 | 8 | "context" | |
| 9 | 9 | "errors" | |
| 10 | 10 | "fmt" | |
| 11 | + | "slices" | |
| 11 | 12 | ||
| 12 | 13 | kv "bnema.dev/ermite-kv" | |
| 13 | 14 | "github.com/bnema/zerowrap" | |
| 14 | 15 | "google.golang.org/protobuf/proto" | |
| 15 | 16 | ||
| 16 | 17 | storagepb "bnema.dev/ermite/gen/ermite/storage" | |
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | 19 | "bnema.dev/ermite/internal/ports" | |
| 18 | 20 | ) | |
| 19 | 21 | ||
| @@ -27,8 +29,14 @@ import ( | |||
| 27 | 29 | // (CIRun.head_sha: it would attach their checks to the merge commit, where | |
| 28 | 30 | // nothing looks for them), and the index of the open merge requests by source | |
| 29 | 31 | // 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 | |
| 32 | 40 | ||
| 33 | 41 | const schemaKey = "meta/schema" | |
| 34 | 42 | ||
| @@ -125,8 +133,15 @@ func (s *Store) checkSchema(ctx context.Context) error { | |||
| 125 | 133 | if sv.GetVersion() < SchemaVersion { | |
| 126 | 134 | // Older data stays readable as it is (the changes are additive); | |
| 127 | 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 | 138 | var b kv.Batch | |
| 129 | 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 | 145 | b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: SchemaVersion})) | |
| 131 | 146 | _, err := s.db.Apply(ctx, &b) | |
| 132 | 147 | if errors.Is(err, kv.ErrConflict) { | |
| @@ -151,3 +166,45 @@ func mustMarshal(m proto.Message) []byte { | |||
| 151 | 166 | } | |
| 152 | 167 | 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 | + | } | |
| @@ -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, where | 29 | // (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 source | 30 | // 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 upgrade | 31 | // 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 = 3 | 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 | |||
| 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.Batch | 138 | 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 b | 167 | 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) { | |||
| 230 | 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 | 234 | dir := t.TempDir() | |
| 235 | 235 | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2})) | |
| 236 | 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 | 261 | assert.Equal(t, "cccccccccccccccccccccccccccccccccccccccc", pr.HeadSha) | |
| 262 | 262 | assert.Equal(t, pr.HeadSha, pr.CheckSha(), "the checks of a pull_request run are on the head") | |
| 263 | 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 | 268 | dir := t.TempDir() | |
| 269 | 269 | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2})) | |
| 270 | 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 | 273 | _, err = s.Get(ctx, name(t, "old")) | |
| 274 | 274 | require.NoError(t, err) | |
| 275 | 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 | 279 | // 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 data | 279 | // 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 { | |||
| 198 | 198 | 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 | 219 | // MergeRequestDetail is a merge request with its review data. | |
| 202 | 220 | type MergeRequestDetail struct { | |
| 203 | 221 | MergeRequest domain.MergeRequest | |
| @@ -198,6 +198,24 @@ type MergeRequestPage struct { | |||||
| 198 | NextPageToken string | 198 | 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.MergeRequest | 221 | 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 { | |||
| 241 | 241 | 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 | 278 | // MergeRequestStore keeps the merge requests of the repositories, their | |
| 245 | 279 | // comments and the read position of the reviewer. A repository has no entry | |
| 246 | 280 | // 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) error | 241 | 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, their | 278 | // MergeRequestStore keeps the merge requests of the repositories, their | ||
| 245 | // comments and the read position of the reviewer. A repository has no entry | 279 | // comments and the read position of the reviewer. A repository has no entry | ||
| 246 | // until its first merge request. Create, SaveComment and SetRead return | 280 | // 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 { | |||
| 22 | 22 | // Never empty once stored. | |
| 23 | 23 | repeated MergeMode allowed_merge_modes = 1; | |
| 24 | 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 | 37 | // 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. | ||