feat(kvstore): store merge requests, repository settings and the signing key
Keys mr/<repo>/<n:010d>, mrc/<repo>/<n>/<id>, mrread/<repo>/<n>, mrseq/<repo> (next number taken under RequireVersion) and signing/key. Repository settings are stored explicitly with the repository; one stored before merge requests existed reads as the defaults. Deleting a repository drops its merge requests, comments, read positions and sequence with it: like the repository metadata they are not kept in the trash, and a repository created again under the same name starts at !1.
- commit
- 858a2fbc9b3c9996e06532bc81e614ecdf6a9469
- author
- brice · · 2026-10-10 16:49 UTC
- parent
- b60fb76
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +1983 -15
A gen/ermite/storage/merge_request.pb.go +664 -0 generated file: collapsed
M gen/ermite/storage/repo.pb.go +25 -11 generated file: collapsed
A gen/ermite/storage/signing.pb.go +143 -0
| @@ -0,0 +1,143 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Internal, unversioned storage schema of ermite: the commit signing key. | |
| 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/signing.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 | + | // SigningKey is stored under the key "signing/key". The seed is the private | |
| 30 | + | // key: it must never be logged. | |
| 31 | + | type SigningKey struct { | |
| 32 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 33 | + | // The 32-byte seed of an ed25519 key. | |
| 34 | + | Seed []byte `protobuf:"bytes,1,opt,name=seed,proto3" json:"seed,omitempty"` | |
| 35 | + | CreatedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` | |
| 36 | + | unknownFields protoimpl.UnknownFields | |
| 37 | + | sizeCache protoimpl.SizeCache | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func (x *SigningKey) Reset() { | |
| 41 | + | *x = SigningKey{} | |
| 42 | + | mi := &file_ermite_storage_signing_proto_msgTypes[0] | |
| 43 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 44 | + | ms.StoreMessageInfo(mi) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (x *SigningKey) String() string { | |
| 48 | + | return protoimpl.X.MessageStringOf(x) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func (*SigningKey) ProtoMessage() {} | |
| 52 | + | ||
| 53 | + | func (x *SigningKey) ProtoReflect() protoreflect.Message { | |
| 54 | + | mi := &file_ermite_storage_signing_proto_msgTypes[0] | |
| 55 | + | if x != nil { | |
| 56 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 57 | + | if ms.LoadMessageInfo() == nil { | |
| 58 | + | ms.StoreMessageInfo(mi) | |
| 59 | + | } | |
| 60 | + | return ms | |
| 61 | + | } | |
| 62 | + | return mi.MessageOf(x) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // Deprecated: Use SigningKey.ProtoReflect.Descriptor instead. | |
| 66 | + | func (*SigningKey) Descriptor() ([]byte, []int) { | |
| 67 | + | return file_ermite_storage_signing_proto_rawDescGZIP(), []int{0} | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (x *SigningKey) GetSeed() []byte { | |
| 71 | + | if x != nil { | |
| 72 | + | return x.Seed | |
| 73 | + | } | |
| 74 | + | return nil | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (x *SigningKey) GetCreatedAt() *timestamppb.Timestamp { | |
| 78 | + | if x != nil { | |
| 79 | + | return x.CreatedAt | |
| 80 | + | } | |
| 81 | + | return nil | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | var File_ermite_storage_signing_proto protoreflect.FileDescriptor | |
| 85 | + | ||
| 86 | + | const file_ermite_storage_signing_proto_rawDesc = "" + | |
| 87 | + | "\n" + | |
| 88 | + | "\x1cermite/storage/signing.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"[\n" + | |
| 89 | + | "\n" + | |
| 90 | + | "SigningKey\x12\x12\n" + | |
| 91 | + | "\x04seed\x18\x01 \x01(\fR\x04seed\x129\n" + | |
| 92 | + | "\n" + | |
| 93 | + | "created_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAtB/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3" | |
| 94 | + | ||
| 95 | + | var ( | |
| 96 | + | file_ermite_storage_signing_proto_rawDescOnce sync.Once | |
| 97 | + | file_ermite_storage_signing_proto_rawDescData []byte | |
| 98 | + | ) | |
| 99 | + | ||
| 100 | + | func file_ermite_storage_signing_proto_rawDescGZIP() []byte { | |
| 101 | + | file_ermite_storage_signing_proto_rawDescOnce.Do(func() { | |
| 102 | + | file_ermite_storage_signing_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ermite_storage_signing_proto_rawDesc), len(file_ermite_storage_signing_proto_rawDesc))) | |
| 103 | + | }) | |
| 104 | + | return file_ermite_storage_signing_proto_rawDescData | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | var file_ermite_storage_signing_proto_msgTypes = make([]protoimpl.MessageInfo, 1) | |
| 108 | + | var file_ermite_storage_signing_proto_goTypes = []any{ | |
| 109 | + | (*SigningKey)(nil), // 0: ermite.storage.SigningKey | |
| 110 | + | (*timestamppb.Timestamp)(nil), // 1: google.protobuf.Timestamp | |
| 111 | + | } | |
| 112 | + | var file_ermite_storage_signing_proto_depIdxs = []int32{ | |
| 113 | + | 1, // 0: ermite.storage.SigningKey.created_at:type_name -> google.protobuf.Timestamp | |
| 114 | + | 1, // [1:1] is the sub-list for method output_type | |
| 115 | + | 1, // [1:1] is the sub-list for method input_type | |
| 116 | + | 1, // [1:1] is the sub-list for extension type_name | |
| 117 | + | 1, // [1:1] is the sub-list for extension extendee | |
| 118 | + | 0, // [0:1] is the sub-list for field type_name | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func init() { file_ermite_storage_signing_proto_init() } | |
| 122 | + | func file_ermite_storage_signing_proto_init() { | |
| 123 | + | if File_ermite_storage_signing_proto != nil { | |
| 124 | + | return | |
| 125 | + | } | |
| 126 | + | type x struct{} | |
| 127 | + | out := protoimpl.TypeBuilder{ | |
| 128 | + | File: protoimpl.DescBuilder{ | |
| 129 | + | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), | |
| 130 | + | RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_signing_proto_rawDesc), len(file_ermite_storage_signing_proto_rawDesc)), | |
| 131 | + | NumEnums: 0, | |
| 132 | + | NumMessages: 1, | |
| 133 | + | NumExtensions: 0, | |
| 134 | + | NumServices: 0, | |
| 135 | + | }, | |
| 136 | + | GoTypes: file_ermite_storage_signing_proto_goTypes, | |
| 137 | + | DependencyIndexes: file_ermite_storage_signing_proto_depIdxs, | |
| 138 | + | MessageInfos: file_ermite_storage_signing_proto_msgTypes, | |
| 139 | + | }.Build() | |
| 140 | + | File_ermite_storage_signing_proto = out.File | |
| 141 | + | file_ermite_storage_signing_proto_goTypes = nil | |
| 142 | + | file_ermite_storage_signing_proto_depIdxs = nil | |
| 143 | + | } | |
| @@ -0,0 +1,143 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | // Internal, unversioned storage schema of ermite: the commit signing key. | |||
| 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/signing.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 | + | // SigningKey is stored under the key "signing/key". The seed is the private | |||
| 30 | + | // key: it must never be logged. | |||
| 31 | + | type SigningKey struct { | |||
| 32 | + | state protoimpl.MessageState `protogen:"open.v1"` | |||
| 33 | + | // The 32-byte seed of an ed25519 key. | |||
| 34 | + | Seed []byte `protobuf:"bytes,1,opt,name=seed,proto3" json:"seed,omitempty"` | |||
| 35 | + | CreatedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` | |||
| 36 | + | unknownFields protoimpl.UnknownFields | |||
| 37 | + | sizeCache protoimpl.SizeCache | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func (x *SigningKey) Reset() { | |||
| 41 | + | *x = SigningKey{} | |||
| 42 | + | mi := &file_ermite_storage_signing_proto_msgTypes[0] | |||
| 43 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 44 | + | ms.StoreMessageInfo(mi) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (x *SigningKey) String() string { | |||
| 48 | + | return protoimpl.X.MessageStringOf(x) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func (*SigningKey) ProtoMessage() {} | |||
| 52 | + | ||||
| 53 | + | func (x *SigningKey) ProtoReflect() protoreflect.Message { | |||
| 54 | + | mi := &file_ermite_storage_signing_proto_msgTypes[0] | |||
| 55 | + | if x != nil { | |||
| 56 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |||
| 57 | + | if ms.LoadMessageInfo() == nil { | |||
| 58 | + | ms.StoreMessageInfo(mi) | |||
| 59 | + | } | |||
| 60 | + | return ms | |||
| 61 | + | } | |||
| 62 | + | return mi.MessageOf(x) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // Deprecated: Use SigningKey.ProtoReflect.Descriptor instead. | |||
| 66 | + | func (*SigningKey) Descriptor() ([]byte, []int) { | |||
| 67 | + | return file_ermite_storage_signing_proto_rawDescGZIP(), []int{0} | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func (x *SigningKey) GetSeed() []byte { | |||
| 71 | + | if x != nil { | |||
| 72 | + | return x.Seed | |||
| 73 | + | } | |||
| 74 | + | return nil | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func (x *SigningKey) GetCreatedAt() *timestamppb.Timestamp { | |||
| 78 | + | if x != nil { | |||
| 79 | + | return x.CreatedAt | |||
| 80 | + | } | |||
| 81 | + | return nil | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | var File_ermite_storage_signing_proto protoreflect.FileDescriptor | |||
| 85 | + | ||||
| 86 | + | const file_ermite_storage_signing_proto_rawDesc = "" + | |||
| 87 | + | "\n" + | |||
| 88 | + | "\x1cermite/storage/signing.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"[\n" + | |||
| 89 | + | "\n" + | |||
| 90 | + | "SigningKey\x12\x12\n" + | |||
| 91 | + | "\x04seed\x18\x01 \x01(\fR\x04seed\x129\n" + | |||
| 92 | + | "\n" + | |||
| 93 | + | "created_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAtB/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3" | |||
| 94 | + | ||||
| 95 | + | var ( | |||
| 96 | + | file_ermite_storage_signing_proto_rawDescOnce sync.Once | |||
| 97 | + | file_ermite_storage_signing_proto_rawDescData []byte | |||
| 98 | + | ) | |||
| 99 | + | ||||
| 100 | + | func file_ermite_storage_signing_proto_rawDescGZIP() []byte { | |||
| 101 | + | file_ermite_storage_signing_proto_rawDescOnce.Do(func() { | |||
| 102 | + | file_ermite_storage_signing_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ermite_storage_signing_proto_rawDesc), len(file_ermite_storage_signing_proto_rawDesc))) | |||
| 103 | + | }) | |||
| 104 | + | return file_ermite_storage_signing_proto_rawDescData | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | var file_ermite_storage_signing_proto_msgTypes = make([]protoimpl.MessageInfo, 1) | |||
| 108 | + | var file_ermite_storage_signing_proto_goTypes = []any{ | |||
| 109 | + | (*SigningKey)(nil), // 0: ermite.storage.SigningKey | |||
| 110 | + | (*timestamppb.Timestamp)(nil), // 1: google.protobuf.Timestamp | |||
| 111 | + | } | |||
| 112 | + | var file_ermite_storage_signing_proto_depIdxs = []int32{ | |||
| 113 | + | 1, // 0: ermite.storage.SigningKey.created_at:type_name -> google.protobuf.Timestamp | |||
| 114 | + | 1, // [1:1] is the sub-list for method output_type | |||
| 115 | + | 1, // [1:1] is the sub-list for method input_type | |||
| 116 | + | 1, // [1:1] is the sub-list for extension type_name | |||
| 117 | + | 1, // [1:1] is the sub-list for extension extendee | |||
| 118 | + | 0, // [0:1] is the sub-list for field type_name | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func init() { file_ermite_storage_signing_proto_init() } | |||
| 122 | + | func file_ermite_storage_signing_proto_init() { | |||
| 123 | + | if File_ermite_storage_signing_proto != nil { | |||
| 124 | + | return | |||
| 125 | + | } | |||
| 126 | + | type x struct{} | |||
| 127 | + | out := protoimpl.TypeBuilder{ | |||
| 128 | + | File: protoimpl.DescBuilder{ | |||
| 129 | + | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), | |||
| 130 | + | RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_signing_proto_rawDesc), len(file_ermite_storage_signing_proto_rawDesc)), | |||
| 131 | + | NumEnums: 0, | |||
| 132 | + | NumMessages: 1, | |||
| 133 | + | NumExtensions: 0, | |||
| 134 | + | NumServices: 0, | |||
| 135 | + | }, | |||
| 136 | + | GoTypes: file_ermite_storage_signing_proto_goTypes, | |||
| 137 | + | DependencyIndexes: file_ermite_storage_signing_proto_depIdxs, | |||
| 138 | + | MessageInfos: file_ermite_storage_signing_proto_msgTypes, | |||
| 139 | + | }.Build() | |||
| 140 | + | File_ermite_storage_signing_proto = out.File | |||
| 141 | + | file_ermite_storage_signing_proto_goTypes = nil | |||
| 142 | + | file_ermite_storage_signing_proto_depIdxs = nil | |||
| 143 | + | } | |||
A internal/adapters/kvstore/mergerequests.go +483 -0
| @@ -0,0 +1,483 @@ | |||
| 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 | + | "unicode/utf8" | |
| 11 | + | ||
| 12 | + | kv "bnema.dev/ermite-kv" | |
| 13 | + | "google.golang.org/protobuf/proto" | |
| 14 | + | ||
| 15 | + | storagepb "bnema.dev/ermite/gen/ermite/storage" | |
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // Key layout of the merge requests. Repository names hold no '/', so a prefix | |
| 21 | + | // ending in "/" never matches another repository; numbers and comment ids are | |
| 22 | + | // zero-padded so that keys sort numerically. | |
| 23 | + | const ( | |
| 24 | + | mrPrefix = "mr/" // mr/<repo>/<number:010d> | |
| 25 | + | commentPrefix = "mrc/" // mrc/<repo>/<number:010d>/<id:010d> | |
| 26 | + | readPrefix = "mrread/" // mrread/<repo>/<number:010d> | |
| 27 | + | seqPrefix = "mrseq/" // mrseq/<repo> | |
| 28 | + | numberFormat = "%010d" | |
| 29 | + | createTries = 10 | |
| 30 | + | ) | |
| 31 | + | ||
| 32 | + | // MergeRequests is the ermite-kv backed ports.MergeRequestStore. It shares the | |
| 33 | + | // database of the Store it comes from. | |
| 34 | + | type MergeRequests struct { | |
| 35 | + | db *kv.DB | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | var _ ports.MergeRequestStore = (*MergeRequests)(nil) | |
| 39 | + | ||
| 40 | + | // MergeRequests returns the merge request store over the same database. It is | |
| 41 | + | // valid until the Store is closed. | |
| 42 | + | func (s *Store) MergeRequests() *MergeRequests { return &MergeRequests{db: s.db} } | |
| 43 | + | ||
| 44 | + | func mrRepoPrefix(repo domain.RepoName) string { return mrPrefix + repo.String() + "/" } | |
| 45 | + | func mrKey(repo domain.RepoName, n int64) []byte { | |
| 46 | + | return []byte(mrRepoPrefix(repo) + fmt.Sprintf(numberFormat, n)) | |
| 47 | + | } | |
| 48 | + | func commentsPrefix(repo domain.RepoName, n int64) string { | |
| 49 | + | return commentPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n) + "/" | |
| 50 | + | } | |
| 51 | + | func commentKey(repo domain.RepoName, n, id int64) []byte { | |
| 52 | + | return []byte(commentsPrefix(repo, n) + fmt.Sprintf(numberFormat, id)) | |
| 53 | + | } | |
| 54 | + | func readKey(repo domain.RepoName, n int64) []byte { | |
| 55 | + | return []byte(readPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n)) | |
| 56 | + | } | |
| 57 | + | func seqKey(repo domain.RepoName) []byte { return []byte(seqPrefix + repo.String()) } | |
| 58 | + | ||
| 59 | + | // mergeRequestKeys lists every key that belongs to the repository's merge | |
| 60 | + | // requests. | |
| 61 | + | func (s *Store) mergeRequestKeys(ctx context.Context, repo domain.RepoName) [][]byte { | |
| 62 | + | snap := s.db.Snapshot(ctx) | |
| 63 | + | var keys [][]byte | |
| 64 | + | for _, p := range []string{mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/"} { | |
| 65 | + | for k := range snap.Prefix(ctx, []byte(p)) { | |
| 66 | + | keys = append(keys, slices.Clone(k)) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | if _, ok := snap.Get(ctx, seqKey(repo)); ok { | |
| 70 | + | keys = append(keys, seqKey(repo)) | |
| 71 | + | } | |
| 72 | + | return keys | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // Create takes the next number of the repository from mrseq/<repo> and stores | |
| 76 | + | // the merge request under it, in one batch that requires the sequence to be | |
| 77 | + | // unchanged since it was read: two concurrent creations never share a number. | |
| 78 | + | // The batch also requires the repository to be unchanged since it was read, so | |
| 79 | + | // that a repository deleted meanwhile is not left with a stray merge request | |
| 80 | + | // (a push recording pushed_at makes it retry). A missing repository gives | |
| 81 | + | // domain.ErrNotFound. | |
| 82 | + | // | |
| 83 | + | //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy | |
| 84 | + | func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) { | |
| 85 | + | for range createTries { | |
| 86 | + | repoItem, repoOK := m.db.Get(ctx, repoKey(mr.Repo)) | |
| 87 | + | if !repoOK { | |
| 88 | + | return domain.MergeRequest{}, domain.ErrNotFound | |
| 89 | + | } | |
| 90 | + | var last int64 | |
| 91 | + | item, ok := m.db.Get(ctx, seqKey(mr.Repo)) | |
| 92 | + | if ok { | |
| 93 | + | var seq storagepb.Sequence | |
| 94 | + | if err := proto.Unmarshal(item.Value, &seq); err != nil { | |
| 95 | + | return domain.MergeRequest{}, fmt.Errorf("decode merge request sequence of %q: %w", mr.Repo, err) | |
| 96 | + | } | |
| 97 | + | last = seq.GetLast() | |
| 98 | + | } | |
| 99 | + | mr.Number = last + 1 | |
| 100 | + | val, err := encodeMergeRequest(mr) | |
| 101 | + | if err != nil { | |
| 102 | + | return domain.MergeRequest{}, err | |
| 103 | + | } | |
| 104 | + | var b kv.Batch | |
| 105 | + | b.RequireVersion(repoKey(mr.Repo), repoItem.Version) | |
| 106 | + | if ok { | |
| 107 | + | b.RequireVersion(seqKey(mr.Repo), item.Version) | |
| 108 | + | } else { | |
| 109 | + | b.RequireAbsent(seqKey(mr.Repo)) | |
| 110 | + | } | |
| 111 | + | b.Put(seqKey(mr.Repo), mustMarshal(&storagepb.Sequence{Last: mr.Number})) | |
| 112 | + | b.RequireAbsent(mrKey(mr.Repo, mr.Number)) | |
| 113 | + | b.Put(mrKey(mr.Repo, mr.Number), val) | |
| 114 | + | _, err = m.db.Apply(ctx, &b) | |
| 115 | + | if errors.Is(err, kv.ErrConflict) { | |
| 116 | + | continue | |
| 117 | + | } | |
| 118 | + | if err != nil { | |
| 119 | + | return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, err) | |
| 120 | + | } | |
| 121 | + | return m.Get(ctx, mr.Repo, mr.Number) | |
| 122 | + | } | |
| 123 | + | return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, domain.ErrConflict) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // Get reads mr/<repo>/<number>. | |
| 127 | + | func (m *MergeRequests) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.MergeRequest, error) { | |
| 128 | + | if number < 1 { | |
| 129 | + | return domain.MergeRequest{}, domain.ErrNotFound | |
| 130 | + | } | |
| 131 | + | item, ok := m.db.Get(ctx, mrKey(repo, number)) | |
| 132 | + | if !ok { | |
| 133 | + | return domain.MergeRequest{}, domain.ErrNotFound | |
| 134 | + | } | |
| 135 | + | return decodeMergeRequest(repo, item) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // List returns the merge requests of the repository, highest number first. | |
| 139 | + | func (m *MergeRequests) List(ctx context.Context, repo domain.RepoName) ([]domain.MergeRequest, error) { | |
| 140 | + | var out []domain.MergeRequest | |
| 141 | + | for key, item := range m.db.Snapshot(ctx).Prefix(ctx, []byte(mrRepoPrefix(repo))) { | |
| 142 | + | mr, err := decodeMergeRequest(repo, item) | |
| 143 | + | if err != nil { | |
| 144 | + | return nil, fmt.Errorf("decode %s: %w", key, err) | |
| 145 | + | } | |
| 146 | + | out = append(out, mr) | |
| 147 | + | } | |
| 148 | + | slices.Reverse(out) | |
| 149 | + | return out, nil | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // Update replaces the merge request with a version precondition. | |
| 153 | + | // | |
| 154 | + | //hexcheck:ignore suspicious-business-logic-in-adapter port method name only | |
| 155 | + | func (m *MergeRequests) Update(ctx context.Context, mr domain.MergeRequest) error { | |
| 156 | + | val, err := encodeMergeRequest(mr) | |
| 157 | + | if err != nil { | |
| 158 | + | return err | |
| 159 | + | } | |
| 160 | + | key := mrKey(mr.Repo, mr.Number) | |
| 161 | + | if _, ok := m.db.Get(ctx, key); !ok { | |
| 162 | + | return domain.ErrNotFound | |
| 163 | + | } | |
| 164 | + | if mr.Version == 0 { | |
| 165 | + | return fmt.Errorf("update merge request %d of %q: read it first: %w", mr.Number, mr.Repo, domain.ErrConflict) | |
| 166 | + | } | |
| 167 | + | var b kv.Batch | |
| 168 | + | b.RequireVersion(key, mr.Version) | |
| 169 | + | b.Put(key, val) | |
| 170 | + | if _, err := m.db.Apply(ctx, &b); err != nil { | |
| 171 | + | if errors.Is(err, kv.ErrConflict) { | |
| 172 | + | return domain.ErrConflict | |
| 173 | + | } | |
| 174 | + | return fmt.Errorf("update merge request %d of %q: %w", mr.Number, mr.Repo, err) | |
| 175 | + | } | |
| 176 | + | return nil | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // ListComments returns the comments of the merge request, oldest first. | |
| 180 | + | func (m *MergeRequests) ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.Comment, error) { | |
| 181 | + | var out []domain.Comment | |
| 182 | + | for key, item := range m.db.Snapshot(ctx).Prefix(ctx, []byte(commentsPrefix(repo, number))) { | |
| 183 | + | c, err := decodeComment(item) | |
| 184 | + | if err != nil { | |
| 185 | + | return nil, fmt.Errorf("decode %s: %w", key, err) | |
| 186 | + | } | |
| 187 | + | out = append(out, c) | |
| 188 | + | } | |
| 189 | + | return out, nil | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // GetComment reads one comment. | |
| 193 | + | func (m *MergeRequests) GetComment(ctx context.Context, repo domain.RepoName, number, id int64) (domain.Comment, error) { | |
| 194 | + | if number < 1 || id < 1 { | |
| 195 | + | return domain.Comment{}, domain.ErrNotFound | |
| 196 | + | } | |
| 197 | + | item, ok := m.db.Get(ctx, commentKey(repo, number, id)) | |
| 198 | + | if !ok { | |
| 199 | + | return domain.Comment{}, domain.ErrNotFound | |
| 200 | + | } | |
| 201 | + | return decodeComment(item) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // SaveComment writes the comment and the merge request in one batch, each with | |
| 205 | + | // its version precondition (absent for a new comment). | |
| 206 | + | func (m *MergeRequests) SaveComment(ctx context.Context, mr domain.MergeRequest, c domain.Comment) error { | |
| 207 | + | mrVal, err := encodeMergeRequest(mr) | |
| 208 | + | if err != nil { | |
| 209 | + | return err | |
| 210 | + | } | |
| 211 | + | cVal, err := encodeComment(c) | |
| 212 | + | if err != nil { | |
| 213 | + | return err | |
| 214 | + | } | |
| 215 | + | if mr.Version == 0 { | |
| 216 | + | return fmt.Errorf("save comment on %d of %q: read the merge request first: %w", mr.Number, mr.Repo, domain.ErrConflict) | |
| 217 | + | } | |
| 218 | + | var b kv.Batch | |
| 219 | + | b.RequireVersion(mrKey(mr.Repo, mr.Number), mr.Version) | |
| 220 | + | b.Put(mrKey(mr.Repo, mr.Number), mrVal) | |
| 221 | + | if c.Version == 0 { | |
| 222 | + | b.RequireAbsent(commentKey(mr.Repo, mr.Number, c.ID)) | |
| 223 | + | } else { | |
| 224 | + | b.RequireVersion(commentKey(mr.Repo, mr.Number, c.ID), c.Version) | |
| 225 | + | } | |
| 226 | + | b.Put(commentKey(mr.Repo, mr.Number, c.ID), cVal) | |
| 227 | + | if _, err := m.db.Apply(ctx, &b); err != nil { | |
| 228 | + | if errors.Is(err, kv.ErrConflict) { | |
| 229 | + | return domain.ErrConflict | |
| 230 | + | } | |
| 231 | + | return fmt.Errorf("save comment on %d of %q: %w", mr.Number, mr.Repo, err) | |
| 232 | + | } | |
| 233 | + | return nil | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | // GetRead reads mrread/<repo>/<number>: the id of a commit, or nothing. | |
| 237 | + | func (m *MergeRequests) GetRead(ctx context.Context, repo domain.RepoName, number int64) (domain.ObjectID, error) { | |
| 238 | + | item, ok := m.db.Get(ctx, readKey(repo, number)) | |
| 239 | + | if !ok { | |
| 240 | + | return domain.ObjectID{}, nil | |
| 241 | + | } | |
| 242 | + | id, err := domain.ParseObjectID(string(item.Value)) | |
| 243 | + | if err != nil { | |
| 244 | + | return domain.ObjectID{}, fmt.Errorf("decode read position of %d in %q: %w", number, repo, err) | |
| 245 | + | } | |
| 246 | + | return id, nil | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // SetRead writes mrread/<repo>/<number>. | |
| 250 | + | func (m *MergeRequests) SetRead(ctx context.Context, repo domain.RepoName, number int64, head domain.ObjectID) error { | |
| 251 | + | if head.IsZero() { | |
| 252 | + | return fmt.Errorf("%w: empty read position", domain.ErrInvalidObjectID) | |
| 253 | + | } | |
| 254 | + | var b kv.Batch | |
| 255 | + | b.Put(readKey(repo, number), []byte(head.String())) | |
| 256 | + | if _, err := m.db.Apply(ctx, &b); err != nil { | |
| 257 | + | return fmt.Errorf("record read position of %d in %q: %w", number, repo, err) | |
| 258 | + | } | |
| 259 | + | return nil | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) { | |
| 263 | + | if mr.Repo.IsZero() { | |
| 264 | + | return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName) | |
| 265 | + | } | |
| 266 | + | if !utf8.ValidString(mr.Author) { | |
| 267 | + | return nil, errors.New("merge request author is not valid UTF-8") | |
| 268 | + | } | |
| 269 | + | state, err := encodeState(mr.State) | |
| 270 | + | if err != nil { | |
| 271 | + | return nil, err | |
| 272 | + | } | |
| 273 | + | mode, err := encodeMode(mr.MergeMode) | |
| 274 | + | if err != nil { | |
| 275 | + | return nil, err | |
| 276 | + | } | |
| 277 | + | return mustMarshal(&storagepb.MergeRequest{ | |
| 278 | + | Number: mr.Number, | |
| 279 | + | Title: mr.Title.String(), | |
| 280 | + | Description: mr.Description.String(), | |
| 281 | + | SourceBranch: mr.Source, | |
| 282 | + | TargetBranch: mr.Target, | |
| 283 | + | State: state, | |
| 284 | + | Author: mr.Author, | |
| 285 | + | CreatedAt: encodeTime(mr.CreatedAt), | |
| 286 | + | UpdatedAt: encodeTime(mr.UpdatedAt), | |
| 287 | + | HeadId: mr.HeadID.String(), | |
| 288 | + | BaseId: mr.BaseID.String(), | |
| 289 | + | MergedCommitId: mr.MergedCommitID.String(), | |
| 290 | + | MergeMode: mode, | |
| 291 | + | CommentSeq: mr.CommentSeq, | |
| 292 | + | }), nil | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | //hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy | |
| 296 | + | func decodeMergeRequest(repo domain.RepoName, item kv.Item) (domain.MergeRequest, error) { | |
| 297 | + | var pb storagepb.MergeRequest | |
| 298 | + | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |
| 299 | + | return domain.MergeRequest{}, fmt.Errorf("decode merge request: %w", err) | |
| 300 | + | } | |
| 301 | + | fail := func(err error) (domain.MergeRequest, error) { | |
| 302 | + | return domain.MergeRequest{}, fmt.Errorf("decode merge request %d of %q: %w", pb.GetNumber(), repo, err) | |
| 303 | + | } | |
| 304 | + | title, err := domain.ParseMRTitle(pb.GetTitle()) | |
| 305 | + | if err != nil { | |
| 306 | + | return fail(err) | |
| 307 | + | } | |
| 308 | + | desc, err := domain.ParsePlainText(pb.GetDescription()) | |
| 309 | + | if err != nil { | |
| 310 | + | return fail(err) | |
| 311 | + | } | |
| 312 | + | state, err := decodeState(pb.GetState()) | |
| 313 | + | if err != nil { | |
| 314 | + | return fail(err) | |
| 315 | + | } | |
| 316 | + | mr := domain.MergeRequest{ | |
| 317 | + | Repo: repo, Number: pb.GetNumber(), Title: title, Description: desc, | |
| 318 | + | Source: pb.GetSourceBranch(), Target: pb.GetTargetBranch(), State: state, | |
| 319 | + | Author: pb.GetAuthor(), | |
| 320 | + | CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()), | |
| 321 | + | MergeMode: decodeMode(pb.GetMergeMode()), | |
| 322 | + | CommentSeq: pb.GetCommentSeq(), | |
| 323 | + | Version: item.Version, | |
| 324 | + | } | |
| 325 | + | for _, f := range []struct { | |
| 326 | + | dst *domain.ObjectID | |
| 327 | + | src string | |
| 328 | + | }{{&mr.HeadID, pb.GetHeadId()}, {&mr.BaseID, pb.GetBaseId()}, {&mr.MergedCommitID, pb.GetMergedCommitId()}} { | |
| 329 | + | if f.src == "" { | |
| 330 | + | continue | |
| 331 | + | } | |
| 332 | + | if *f.dst, err = domain.ParseObjectID(f.src); err != nil { | |
| 333 | + | return fail(err) | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | return mr, nil | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | func encodeComment(c domain.Comment) ([]byte, error) { | |
| 340 | + | side, err := encodeSide(c.Side) | |
| 341 | + | if err != nil { | |
| 342 | + | return nil, err | |
| 343 | + | } | |
| 344 | + | return mustMarshal(&storagepb.Comment{ | |
| 345 | + | Id: c.ID, | |
| 346 | + | Author: c.Author, | |
| 347 | + | Path: c.Path, | |
| 348 | + | Line: int32(c.Line), //nolint:gosec // validated against the diff, far below 2^31 | |
| 349 | + | Side: side, | |
| 350 | + | CommitId: c.CommitID.String(), | |
| 351 | + | Text: c.Text.String(), | |
| 352 | + | Resolved: c.Resolved, | |
| 353 | + | CreatedAt: encodeTime(c.CreatedAt), | |
| 354 | + | }), nil | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | //hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy | |
| 358 | + | func decodeComment(item kv.Item) (domain.Comment, error) { | |
| 359 | + | var pb storagepb.Comment | |
| 360 | + | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |
| 361 | + | return domain.Comment{}, fmt.Errorf("decode comment: %w", err) | |
| 362 | + | } | |
| 363 | + | text, err := domain.ParsePlainText(pb.GetText()) | |
| 364 | + | if err != nil { | |
| 365 | + | return domain.Comment{}, fmt.Errorf("decode comment %d: %w", pb.GetId(), err) | |
| 366 | + | } | |
| 367 | + | c := domain.Comment{ | |
| 368 | + | ID: pb.GetId(), Author: pb.GetAuthor(), Path: pb.GetPath(), Line: int(pb.GetLine()), | |
| 369 | + | Side: decodeSide(pb.GetSide()), Text: text, Resolved: pb.GetResolved(), | |
| 370 | + | CreatedAt: decodeTime(pb.GetCreatedAt()), Version: item.Version, | |
| 371 | + | } | |
| 372 | + | if pb.GetCommitId() != "" { | |
| 373 | + | if c.CommitID, err = domain.ParseObjectID(pb.GetCommitId()); err != nil { | |
| 374 | + | return domain.Comment{}, fmt.Errorf("decode comment %d: %w", c.ID, err) | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | return c, nil | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | func encodeState(s domain.MRState) (storagepb.MergeRequestState, error) { | |
| 381 | + | switch s { | |
| 382 | + | case domain.MRStateOpen: | |
| 383 | + | return storagepb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, nil | |
| 384 | + | case domain.MRStateMerged: | |
| 385 | + | return storagepb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, nil | |
| 386 | + | case domain.MRStateClosed: | |
| 387 | + | return storagepb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, nil | |
| 388 | + | } | |
| 389 | + | return 0, fmt.Errorf("%w: merge request state %q", domain.ErrInvalid, s) | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | func decodeState(s storagepb.MergeRequestState) (domain.MRState, error) { | |
| 393 | + | switch s { | |
| 394 | + | case storagepb.MergeRequestState_MERGE_REQUEST_STATE_OPEN: | |
| 395 | + | return domain.MRStateOpen, nil | |
| 396 | + | case storagepb.MergeRequestState_MERGE_REQUEST_STATE_MERGED: | |
| 397 | + | return domain.MRStateMerged, nil | |
| 398 | + | case storagepb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED: | |
| 399 | + | return domain.MRStateClosed, nil | |
| 400 | + | } | |
| 401 | + | return "", fmt.Errorf("unknown merge request state %d", s) | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | func encodeMode(m domain.MergeMode) (storagepb.MergeMode, error) { | |
| 405 | + | switch m { | |
| 406 | + | case "": | |
| 407 | + | return storagepb.MergeMode_MERGE_MODE_UNSPECIFIED, nil | |
| 408 | + | case domain.MergeModeFastForward: | |
| 409 | + | return storagepb.MergeMode_MERGE_MODE_FAST_FORWARD, nil | |
| 410 | + | case domain.MergeModeSquash: | |
| 411 | + | return storagepb.MergeMode_MERGE_MODE_SQUASH, nil | |
| 412 | + | } | |
| 413 | + | return 0, fmt.Errorf("%w: merge mode %q", domain.ErrInvalid, m) | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | // decodeMode maps an unknown mode to none: it only labels a merged request. | |
| 417 | + | func decodeMode(m storagepb.MergeMode) domain.MergeMode { | |
| 418 | + | switch m { | |
| 419 | + | case storagepb.MergeMode_MERGE_MODE_FAST_FORWARD: | |
| 420 | + | return domain.MergeModeFastForward | |
| 421 | + | case storagepb.MergeMode_MERGE_MODE_SQUASH: | |
| 422 | + | return domain.MergeModeSquash | |
| 423 | + | } | |
| 424 | + | return "" | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | func encodeSide(s domain.DiffSide) (storagepb.DiffSide, error) { | |
| 428 | + | switch s { | |
| 429 | + | case domain.SideNone: | |
| 430 | + | return storagepb.DiffSide_DIFF_SIDE_UNSPECIFIED, nil | |
| 431 | + | case domain.SideOld: | |
| 432 | + | return storagepb.DiffSide_DIFF_SIDE_OLD, nil | |
| 433 | + | case domain.SideNew: | |
| 434 | + | return storagepb.DiffSide_DIFF_SIDE_NEW, nil | |
| 435 | + | } | |
| 436 | + | return 0, fmt.Errorf("%w: diff side %q", domain.ErrInvalid, s) | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | func decodeSide(s storagepb.DiffSide) domain.DiffSide { | |
| 440 | + | switch s { | |
| 441 | + | case storagepb.DiffSide_DIFF_SIDE_OLD: | |
| 442 | + | return domain.SideOld | |
| 443 | + | case storagepb.DiffSide_DIFF_SIDE_NEW: | |
| 444 | + | return domain.SideNew | |
| 445 | + | } | |
| 446 | + | return domain.SideNone | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) { | |
| 450 | + | pb := &storagepb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock} | |
| 451 | + | for _, m := range s.AllowedMergeModes { | |
| 452 | + | mode, err := encodeMode(m) | |
| 453 | + | if err != nil || mode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED { | |
| 454 | + | return nil, fmt.Errorf("%w: merge mode %q in the settings", domain.ErrInvalid, m) | |
| 455 | + | } | |
| 456 | + | pb.AllowedMergeModes = append(pb.AllowedMergeModes, mode) | |
| 457 | + | } | |
| 458 | + | return pb, nil | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | // decodeSettings returns the defaults for a repository stored before merge | |
| 462 | + | // requests existed, and for settings left with no mode that this build knows. | |
| 463 | + | // | |
| 464 | + | //hexcheck:ignore suspicious-business-logic-in-adapter schema decoding with the documented defaults | |
| 465 | + | func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings { | |
| 466 | + | if pb == nil { | |
| 467 | + | return domain.DefaultRepoSettings() | |
| 468 | + | } | |
| 469 | + | var modes []domain.MergeMode | |
| 470 | + | for _, m := range pb.GetAllowedMergeModes() { | |
| 471 | + | if mode := decodeMode(m); mode != "" { | |
| 472 | + | modes = append(modes, mode) | |
| 473 | + | } | |
| 474 | + | } | |
| 475 | + | if len(modes) == 0 { | |
| 476 | + | return domain.DefaultRepoSettings() | |
| 477 | + | } | |
| 478 | + | normalized, err := domain.NormalizeMergeModes(modes) | |
| 479 | + | if err != nil { | |
| 480 | + | return domain.DefaultRepoSettings() | |
| 481 | + | } | |
| 482 | + | return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock()} | |
| 483 | + | } | |
| @@ -0,0 +1,483 @@ | |||||
| 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 | + | "unicode/utf8" | |||
| 11 | + | ||||
| 12 | + | kv "bnema.dev/ermite-kv" | |||
| 13 | + | "google.golang.org/protobuf/proto" | |||
| 14 | + | ||||
| 15 | + | storagepb "bnema.dev/ermite/gen/ermite/storage" | |||
| 16 | + | "bnema.dev/ermite/internal/domain" | |||
| 17 | + | "bnema.dev/ermite/internal/ports" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // Key layout of the merge requests. Repository names hold no '/', so a prefix | |||
| 21 | + | // ending in "/" never matches another repository; numbers and comment ids are | |||
| 22 | + | // zero-padded so that keys sort numerically. | |||
| 23 | + | const ( | |||
| 24 | + | mrPrefix = "mr/" // mr/<repo>/<number:010d> | |||
| 25 | + | commentPrefix = "mrc/" // mrc/<repo>/<number:010d>/<id:010d> | |||
| 26 | + | readPrefix = "mrread/" // mrread/<repo>/<number:010d> | |||
| 27 | + | seqPrefix = "mrseq/" // mrseq/<repo> | |||
| 28 | + | numberFormat = "%010d" | |||
| 29 | + | createTries = 10 | |||
| 30 | + | ) | |||
| 31 | + | ||||
| 32 | + | // MergeRequests is the ermite-kv backed ports.MergeRequestStore. It shares the | |||
| 33 | + | // database of the Store it comes from. | |||
| 34 | + | type MergeRequests struct { | |||
| 35 | + | db *kv.DB | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | var _ ports.MergeRequestStore = (*MergeRequests)(nil) | |||
| 39 | + | ||||
| 40 | + | // MergeRequests returns the merge request store over the same database. It is | |||
| 41 | + | // valid until the Store is closed. | |||
| 42 | + | func (s *Store) MergeRequests() *MergeRequests { return &MergeRequests{db: s.db} } | |||
| 43 | + | ||||
| 44 | + | func mrRepoPrefix(repo domain.RepoName) string { return mrPrefix + repo.String() + "/" } | |||
| 45 | + | func mrKey(repo domain.RepoName, n int64) []byte { | |||
| 46 | + | return []byte(mrRepoPrefix(repo) + fmt.Sprintf(numberFormat, n)) | |||
| 47 | + | } | |||
| 48 | + | func commentsPrefix(repo domain.RepoName, n int64) string { | |||
| 49 | + | return commentPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n) + "/" | |||
| 50 | + | } | |||
| 51 | + | func commentKey(repo domain.RepoName, n, id int64) []byte { | |||
| 52 | + | return []byte(commentsPrefix(repo, n) + fmt.Sprintf(numberFormat, id)) | |||
| 53 | + | } | |||
| 54 | + | func readKey(repo domain.RepoName, n int64) []byte { | |||
| 55 | + | return []byte(readPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n)) | |||
| 56 | + | } | |||
| 57 | + | func seqKey(repo domain.RepoName) []byte { return []byte(seqPrefix + repo.String()) } | |||
| 58 | + | ||||
| 59 | + | // mergeRequestKeys lists every key that belongs to the repository's merge | |||
| 60 | + | // requests. | |||
| 61 | + | func (s *Store) mergeRequestKeys(ctx context.Context, repo domain.RepoName) [][]byte { | |||
| 62 | + | snap := s.db.Snapshot(ctx) | |||
| 63 | + | var keys [][]byte | |||
| 64 | + | for _, p := range []string{mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/"} { | |||
| 65 | + | for k := range snap.Prefix(ctx, []byte(p)) { | |||
| 66 | + | keys = append(keys, slices.Clone(k)) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | if _, ok := snap.Get(ctx, seqKey(repo)); ok { | |||
| 70 | + | keys = append(keys, seqKey(repo)) | |||
| 71 | + | } | |||
| 72 | + | return keys | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // Create takes the next number of the repository from mrseq/<repo> and stores | |||
| 76 | + | // the merge request under it, in one batch that requires the sequence to be | |||
| 77 | + | // unchanged since it was read: two concurrent creations never share a number. | |||
| 78 | + | // The batch also requires the repository to be unchanged since it was read, so | |||
| 79 | + | // that a repository deleted meanwhile is not left with a stray merge request | |||
| 80 | + | // (a push recording pushed_at makes it retry). A missing repository gives | |||
| 81 | + | // domain.ErrNotFound. | |||
| 82 | + | // | |||
| 83 | + | //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy | |||
| 84 | + | func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) { | |||
| 85 | + | for range createTries { | |||
| 86 | + | repoItem, repoOK := m.db.Get(ctx, repoKey(mr.Repo)) | |||
| 87 | + | if !repoOK { | |||
| 88 | + | return domain.MergeRequest{}, domain.ErrNotFound | |||
| 89 | + | } | |||
| 90 | + | var last int64 | |||
| 91 | + | item, ok := m.db.Get(ctx, seqKey(mr.Repo)) | |||
| 92 | + | if ok { | |||
| 93 | + | var seq storagepb.Sequence | |||
| 94 | + | if err := proto.Unmarshal(item.Value, &seq); err != nil { | |||
| 95 | + | return domain.MergeRequest{}, fmt.Errorf("decode merge request sequence of %q: %w", mr.Repo, err) | |||
| 96 | + | } | |||
| 97 | + | last = seq.GetLast() | |||
| 98 | + | } | |||
| 99 | + | mr.Number = last + 1 | |||
| 100 | + | val, err := encodeMergeRequest(mr) | |||
| 101 | + | if err != nil { | |||
| 102 | + | return domain.MergeRequest{}, err | |||
| 103 | + | } | |||
| 104 | + | var b kv.Batch | |||
| 105 | + | b.RequireVersion(repoKey(mr.Repo), repoItem.Version) | |||
| 106 | + | if ok { | |||
| 107 | + | b.RequireVersion(seqKey(mr.Repo), item.Version) | |||
| 108 | + | } else { | |||
| 109 | + | b.RequireAbsent(seqKey(mr.Repo)) | |||
| 110 | + | } | |||
| 111 | + | b.Put(seqKey(mr.Repo), mustMarshal(&storagepb.Sequence{Last: mr.Number})) | |||
| 112 | + | b.RequireAbsent(mrKey(mr.Repo, mr.Number)) | |||
| 113 | + | b.Put(mrKey(mr.Repo, mr.Number), val) | |||
| 114 | + | _, err = m.db.Apply(ctx, &b) | |||
| 115 | + | if errors.Is(err, kv.ErrConflict) { | |||
| 116 | + | continue | |||
| 117 | + | } | |||
| 118 | + | if err != nil { | |||
| 119 | + | return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, err) | |||
| 120 | + | } | |||
| 121 | + | return m.Get(ctx, mr.Repo, mr.Number) | |||
| 122 | + | } | |||
| 123 | + | return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, domain.ErrConflict) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // Get reads mr/<repo>/<number>. | |||
| 127 | + | func (m *MergeRequests) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.MergeRequest, error) { | |||
| 128 | + | if number < 1 { | |||
| 129 | + | return domain.MergeRequest{}, domain.ErrNotFound | |||
| 130 | + | } | |||
| 131 | + | item, ok := m.db.Get(ctx, mrKey(repo, number)) | |||
| 132 | + | if !ok { | |||
| 133 | + | return domain.MergeRequest{}, domain.ErrNotFound | |||
| 134 | + | } | |||
| 135 | + | return decodeMergeRequest(repo, item) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // List returns the merge requests of the repository, highest number first. | |||
| 139 | + | func (m *MergeRequests) List(ctx context.Context, repo domain.RepoName) ([]domain.MergeRequest, error) { | |||
| 140 | + | var out []domain.MergeRequest | |||
| 141 | + | for key, item := range m.db.Snapshot(ctx).Prefix(ctx, []byte(mrRepoPrefix(repo))) { | |||
| 142 | + | mr, err := decodeMergeRequest(repo, item) | |||
| 143 | + | if err != nil { | |||
| 144 | + | return nil, fmt.Errorf("decode %s: %w", key, err) | |||
| 145 | + | } | |||
| 146 | + | out = append(out, mr) | |||
| 147 | + | } | |||
| 148 | + | slices.Reverse(out) | |||
| 149 | + | return out, nil | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // Update replaces the merge request with a version precondition. | |||
| 153 | + | // | |||
| 154 | + | //hexcheck:ignore suspicious-business-logic-in-adapter port method name only | |||
| 155 | + | func (m *MergeRequests) Update(ctx context.Context, mr domain.MergeRequest) error { | |||
| 156 | + | val, err := encodeMergeRequest(mr) | |||
| 157 | + | if err != nil { | |||
| 158 | + | return err | |||
| 159 | + | } | |||
| 160 | + | key := mrKey(mr.Repo, mr.Number) | |||
| 161 | + | if _, ok := m.db.Get(ctx, key); !ok { | |||
| 162 | + | return domain.ErrNotFound | |||
| 163 | + | } | |||
| 164 | + | if mr.Version == 0 { | |||
| 165 | + | return fmt.Errorf("update merge request %d of %q: read it first: %w", mr.Number, mr.Repo, domain.ErrConflict) | |||
| 166 | + | } | |||
| 167 | + | var b kv.Batch | |||
| 168 | + | b.RequireVersion(key, mr.Version) | |||
| 169 | + | b.Put(key, val) | |||
| 170 | + | if _, err := m.db.Apply(ctx, &b); err != nil { | |||
| 171 | + | if errors.Is(err, kv.ErrConflict) { | |||
| 172 | + | return domain.ErrConflict | |||
| 173 | + | } | |||
| 174 | + | return fmt.Errorf("update merge request %d of %q: %w", mr.Number, mr.Repo, err) | |||
| 175 | + | } | |||
| 176 | + | return nil | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | // ListComments returns the comments of the merge request, oldest first. | |||
| 180 | + | func (m *MergeRequests) ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.Comment, error) { | |||
| 181 | + | var out []domain.Comment | |||
| 182 | + | for key, item := range m.db.Snapshot(ctx).Prefix(ctx, []byte(commentsPrefix(repo, number))) { | |||
| 183 | + | c, err := decodeComment(item) | |||
| 184 | + | if err != nil { | |||
| 185 | + | return nil, fmt.Errorf("decode %s: %w", key, err) | |||
| 186 | + | } | |||
| 187 | + | out = append(out, c) | |||
| 188 | + | } | |||
| 189 | + | return out, nil | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // GetComment reads one comment. | |||
| 193 | + | func (m *MergeRequests) GetComment(ctx context.Context, repo domain.RepoName, number, id int64) (domain.Comment, error) { | |||
| 194 | + | if number < 1 || id < 1 { | |||
| 195 | + | return domain.Comment{}, domain.ErrNotFound | |||
| 196 | + | } | |||
| 197 | + | item, ok := m.db.Get(ctx, commentKey(repo, number, id)) | |||
| 198 | + | if !ok { | |||
| 199 | + | return domain.Comment{}, domain.ErrNotFound | |||
| 200 | + | } | |||
| 201 | + | return decodeComment(item) | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // SaveComment writes the comment and the merge request in one batch, each with | |||
| 205 | + | // its version precondition (absent for a new comment). | |||
| 206 | + | func (m *MergeRequests) SaveComment(ctx context.Context, mr domain.MergeRequest, c domain.Comment) error { | |||
| 207 | + | mrVal, err := encodeMergeRequest(mr) | |||
| 208 | + | if err != nil { | |||
| 209 | + | return err | |||
| 210 | + | } | |||
| 211 | + | cVal, err := encodeComment(c) | |||
| 212 | + | if err != nil { | |||
| 213 | + | return err | |||
| 214 | + | } | |||
| 215 | + | if mr.Version == 0 { | |||
| 216 | + | return fmt.Errorf("save comment on %d of %q: read the merge request first: %w", mr.Number, mr.Repo, domain.ErrConflict) | |||
| 217 | + | } | |||
| 218 | + | var b kv.Batch | |||
| 219 | + | b.RequireVersion(mrKey(mr.Repo, mr.Number), mr.Version) | |||
| 220 | + | b.Put(mrKey(mr.Repo, mr.Number), mrVal) | |||
| 221 | + | if c.Version == 0 { | |||
| 222 | + | b.RequireAbsent(commentKey(mr.Repo, mr.Number, c.ID)) | |||
| 223 | + | } else { | |||
| 224 | + | b.RequireVersion(commentKey(mr.Repo, mr.Number, c.ID), c.Version) | |||
| 225 | + | } | |||
| 226 | + | b.Put(commentKey(mr.Repo, mr.Number, c.ID), cVal) | |||
| 227 | + | if _, err := m.db.Apply(ctx, &b); err != nil { | |||
| 228 | + | if errors.Is(err, kv.ErrConflict) { | |||
| 229 | + | return domain.ErrConflict | |||
| 230 | + | } | |||
| 231 | + | return fmt.Errorf("save comment on %d of %q: %w", mr.Number, mr.Repo, err) | |||
| 232 | + | } | |||
| 233 | + | return nil | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | // GetRead reads mrread/<repo>/<number>: the id of a commit, or nothing. | |||
| 237 | + | func (m *MergeRequests) GetRead(ctx context.Context, repo domain.RepoName, number int64) (domain.ObjectID, error) { | |||
| 238 | + | item, ok := m.db.Get(ctx, readKey(repo, number)) | |||
| 239 | + | if !ok { | |||
| 240 | + | return domain.ObjectID{}, nil | |||
| 241 | + | } | |||
| 242 | + | id, err := domain.ParseObjectID(string(item.Value)) | |||
| 243 | + | if err != nil { | |||
| 244 | + | return domain.ObjectID{}, fmt.Errorf("decode read position of %d in %q: %w", number, repo, err) | |||
| 245 | + | } | |||
| 246 | + | return id, nil | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // SetRead writes mrread/<repo>/<number>. | |||
| 250 | + | func (m *MergeRequests) SetRead(ctx context.Context, repo domain.RepoName, number int64, head domain.ObjectID) error { | |||
| 251 | + | if head.IsZero() { | |||
| 252 | + | return fmt.Errorf("%w: empty read position", domain.ErrInvalidObjectID) | |||
| 253 | + | } | |||
| 254 | + | var b kv.Batch | |||
| 255 | + | b.Put(readKey(repo, number), []byte(head.String())) | |||
| 256 | + | if _, err := m.db.Apply(ctx, &b); err != nil { | |||
| 257 | + | return fmt.Errorf("record read position of %d in %q: %w", number, repo, err) | |||
| 258 | + | } | |||
| 259 | + | return nil | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) { | |||
| 263 | + | if mr.Repo.IsZero() { | |||
| 264 | + | return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName) | |||
| 265 | + | } | |||
| 266 | + | if !utf8.ValidString(mr.Author) { | |||
| 267 | + | return nil, errors.New("merge request author is not valid UTF-8") | |||
| 268 | + | } | |||
| 269 | + | state, err := encodeState(mr.State) | |||
| 270 | + | if err != nil { | |||
| 271 | + | return nil, err | |||
| 272 | + | } | |||
| 273 | + | mode, err := encodeMode(mr.MergeMode) | |||
| 274 | + | if err != nil { | |||
| 275 | + | return nil, err | |||
| 276 | + | } | |||
| 277 | + | return mustMarshal(&storagepb.MergeRequest{ | |||
| 278 | + | Number: mr.Number, | |||
| 279 | + | Title: mr.Title.String(), | |||
| 280 | + | Description: mr.Description.String(), | |||
| 281 | + | SourceBranch: mr.Source, | |||
| 282 | + | TargetBranch: mr.Target, | |||
| 283 | + | State: state, | |||
| 284 | + | Author: mr.Author, | |||
| 285 | + | CreatedAt: encodeTime(mr.CreatedAt), | |||
| 286 | + | UpdatedAt: encodeTime(mr.UpdatedAt), | |||
| 287 | + | HeadId: mr.HeadID.String(), | |||
| 288 | + | BaseId: mr.BaseID.String(), | |||
| 289 | + | MergedCommitId: mr.MergedCommitID.String(), | |||
| 290 | + | MergeMode: mode, | |||
| 291 | + | CommentSeq: mr.CommentSeq, | |||
| 292 | + | }), nil | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | //hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy | |||
| 296 | + | func decodeMergeRequest(repo domain.RepoName, item kv.Item) (domain.MergeRequest, error) { | |||
| 297 | + | var pb storagepb.MergeRequest | |||
| 298 | + | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |||
| 299 | + | return domain.MergeRequest{}, fmt.Errorf("decode merge request: %w", err) | |||
| 300 | + | } | |||
| 301 | + | fail := func(err error) (domain.MergeRequest, error) { | |||
| 302 | + | return domain.MergeRequest{}, fmt.Errorf("decode merge request %d of %q: %w", pb.GetNumber(), repo, err) | |||
| 303 | + | } | |||
| 304 | + | title, err := domain.ParseMRTitle(pb.GetTitle()) | |||
| 305 | + | if err != nil { | |||
| 306 | + | return fail(err) | |||
| 307 | + | } | |||
| 308 | + | desc, err := domain.ParsePlainText(pb.GetDescription()) | |||
| 309 | + | if err != nil { | |||
| 310 | + | return fail(err) | |||
| 311 | + | } | |||
| 312 | + | state, err := decodeState(pb.GetState()) | |||
| 313 | + | if err != nil { | |||
| 314 | + | return fail(err) | |||
| 315 | + | } | |||
| 316 | + | mr := domain.MergeRequest{ | |||
| 317 | + | Repo: repo, Number: pb.GetNumber(), Title: title, Description: desc, | |||
| 318 | + | Source: pb.GetSourceBranch(), Target: pb.GetTargetBranch(), State: state, | |||
| 319 | + | Author: pb.GetAuthor(), | |||
| 320 | + | CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()), | |||
| 321 | + | MergeMode: decodeMode(pb.GetMergeMode()), | |||
| 322 | + | CommentSeq: pb.GetCommentSeq(), | |||
| 323 | + | Version: item.Version, | |||
| 324 | + | } | |||
| 325 | + | for _, f := range []struct { | |||
| 326 | + | dst *domain.ObjectID | |||
| 327 | + | src string | |||
| 328 | + | }{{&mr.HeadID, pb.GetHeadId()}, {&mr.BaseID, pb.GetBaseId()}, {&mr.MergedCommitID, pb.GetMergedCommitId()}} { | |||
| 329 | + | if f.src == "" { | |||
| 330 | + | continue | |||
| 331 | + | } | |||
| 332 | + | if *f.dst, err = domain.ParseObjectID(f.src); err != nil { | |||
| 333 | + | return fail(err) | |||
| 334 | + | } | |||
| 335 | + | } | |||
| 336 | + | return mr, nil | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | func encodeComment(c domain.Comment) ([]byte, error) { | |||
| 340 | + | side, err := encodeSide(c.Side) | |||
| 341 | + | if err != nil { | |||
| 342 | + | return nil, err | |||
| 343 | + | } | |||
| 344 | + | return mustMarshal(&storagepb.Comment{ | |||
| 345 | + | Id: c.ID, | |||
| 346 | + | Author: c.Author, | |||
| 347 | + | Path: c.Path, | |||
| 348 | + | Line: int32(c.Line), //nolint:gosec // validated against the diff, far below 2^31 | |||
| 349 | + | Side: side, | |||
| 350 | + | CommitId: c.CommitID.String(), | |||
| 351 | + | Text: c.Text.String(), | |||
| 352 | + | Resolved: c.Resolved, | |||
| 353 | + | CreatedAt: encodeTime(c.CreatedAt), | |||
| 354 | + | }), nil | |||
| 355 | + | } | |||
| 356 | + | ||||
| 357 | + | //hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy | |||
| 358 | + | func decodeComment(item kv.Item) (domain.Comment, error) { | |||
| 359 | + | var pb storagepb.Comment | |||
| 360 | + | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |||
| 361 | + | return domain.Comment{}, fmt.Errorf("decode comment: %w", err) | |||
| 362 | + | } | |||
| 363 | + | text, err := domain.ParsePlainText(pb.GetText()) | |||
| 364 | + | if err != nil { | |||
| 365 | + | return domain.Comment{}, fmt.Errorf("decode comment %d: %w", pb.GetId(), err) | |||
| 366 | + | } | |||
| 367 | + | c := domain.Comment{ | |||
| 368 | + | ID: pb.GetId(), Author: pb.GetAuthor(), Path: pb.GetPath(), Line: int(pb.GetLine()), | |||
| 369 | + | Side: decodeSide(pb.GetSide()), Text: text, Resolved: pb.GetResolved(), | |||
| 370 | + | CreatedAt: decodeTime(pb.GetCreatedAt()), Version: item.Version, | |||
| 371 | + | } | |||
| 372 | + | if pb.GetCommitId() != "" { | |||
| 373 | + | if c.CommitID, err = domain.ParseObjectID(pb.GetCommitId()); err != nil { | |||
| 374 | + | return domain.Comment{}, fmt.Errorf("decode comment %d: %w", c.ID, err) | |||
| 375 | + | } | |||
| 376 | + | } | |||
| 377 | + | return c, nil | |||
| 378 | + | } | |||
| 379 | + | ||||
| 380 | + | func encodeState(s domain.MRState) (storagepb.MergeRequestState, error) { | |||
| 381 | + | switch s { | |||
| 382 | + | case domain.MRStateOpen: | |||
| 383 | + | return storagepb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, nil | |||
| 384 | + | case domain.MRStateMerged: | |||
| 385 | + | return storagepb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, nil | |||
| 386 | + | case domain.MRStateClosed: | |||
| 387 | + | return storagepb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, nil | |||
| 388 | + | } | |||
| 389 | + | return 0, fmt.Errorf("%w: merge request state %q", domain.ErrInvalid, s) | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | func decodeState(s storagepb.MergeRequestState) (domain.MRState, error) { | |||
| 393 | + | switch s { | |||
| 394 | + | case storagepb.MergeRequestState_MERGE_REQUEST_STATE_OPEN: | |||
| 395 | + | return domain.MRStateOpen, nil | |||
| 396 | + | case storagepb.MergeRequestState_MERGE_REQUEST_STATE_MERGED: | |||
| 397 | + | return domain.MRStateMerged, nil | |||
| 398 | + | case storagepb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED: | |||
| 399 | + | return domain.MRStateClosed, nil | |||
| 400 | + | } | |||
| 401 | + | return "", fmt.Errorf("unknown merge request state %d", s) | |||
| 402 | + | } | |||
| 403 | + | ||||
| 404 | + | func encodeMode(m domain.MergeMode) (storagepb.MergeMode, error) { | |||
| 405 | + | switch m { | |||
| 406 | + | case "": | |||
| 407 | + | return storagepb.MergeMode_MERGE_MODE_UNSPECIFIED, nil | |||
| 408 | + | case domain.MergeModeFastForward: | |||
| 409 | + | return storagepb.MergeMode_MERGE_MODE_FAST_FORWARD, nil | |||
| 410 | + | case domain.MergeModeSquash: | |||
| 411 | + | return storagepb.MergeMode_MERGE_MODE_SQUASH, nil | |||
| 412 | + | } | |||
| 413 | + | return 0, fmt.Errorf("%w: merge mode %q", domain.ErrInvalid, m) | |||
| 414 | + | } | |||
| 415 | + | ||||
| 416 | + | // decodeMode maps an unknown mode to none: it only labels a merged request. | |||
| 417 | + | func decodeMode(m storagepb.MergeMode) domain.MergeMode { | |||
| 418 | + | switch m { | |||
| 419 | + | case storagepb.MergeMode_MERGE_MODE_FAST_FORWARD: | |||
| 420 | + | return domain.MergeModeFastForward | |||
| 421 | + | case storagepb.MergeMode_MERGE_MODE_SQUASH: | |||
| 422 | + | return domain.MergeModeSquash | |||
| 423 | + | } | |||
| 424 | + | return "" | |||
| 425 | + | } | |||
| 426 | + | ||||
| 427 | + | func encodeSide(s domain.DiffSide) (storagepb.DiffSide, error) { | |||
| 428 | + | switch s { | |||
| 429 | + | case domain.SideNone: | |||
| 430 | + | return storagepb.DiffSide_DIFF_SIDE_UNSPECIFIED, nil | |||
| 431 | + | case domain.SideOld: | |||
| 432 | + | return storagepb.DiffSide_DIFF_SIDE_OLD, nil | |||
| 433 | + | case domain.SideNew: | |||
| 434 | + | return storagepb.DiffSide_DIFF_SIDE_NEW, nil | |||
| 435 | + | } | |||
| 436 | + | return 0, fmt.Errorf("%w: diff side %q", domain.ErrInvalid, s) | |||
| 437 | + | } | |||
| 438 | + | ||||
| 439 | + | func decodeSide(s storagepb.DiffSide) domain.DiffSide { | |||
| 440 | + | switch s { | |||
| 441 | + | case storagepb.DiffSide_DIFF_SIDE_OLD: | |||
| 442 | + | return domain.SideOld | |||
| 443 | + | case storagepb.DiffSide_DIFF_SIDE_NEW: | |||
| 444 | + | return domain.SideNew | |||
| 445 | + | } | |||
| 446 | + | return domain.SideNone | |||
| 447 | + | } | |||
| 448 | + | ||||
| 449 | + | func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) { | |||
| 450 | + | pb := &storagepb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock} | |||
| 451 | + | for _, m := range s.AllowedMergeModes { | |||
| 452 | + | mode, err := encodeMode(m) | |||
| 453 | + | if err != nil || mode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED { | |||
| 454 | + | return nil, fmt.Errorf("%w: merge mode %q in the settings", domain.ErrInvalid, m) | |||
| 455 | + | } | |||
| 456 | + | pb.AllowedMergeModes = append(pb.AllowedMergeModes, mode) | |||
| 457 | + | } | |||
| 458 | + | return pb, nil | |||
| 459 | + | } | |||
| 460 | + | ||||
| 461 | + | // decodeSettings returns the defaults for a repository stored before merge | |||
| 462 | + | // requests existed, and for settings left with no mode that this build knows. | |||
| 463 | + | // | |||
| 464 | + | //hexcheck:ignore suspicious-business-logic-in-adapter schema decoding with the documented defaults | |||
| 465 | + | func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings { | |||
| 466 | + | if pb == nil { | |||
| 467 | + | return domain.DefaultRepoSettings() | |||
| 468 | + | } | |||
| 469 | + | var modes []domain.MergeMode | |||
| 470 | + | for _, m := range pb.GetAllowedMergeModes() { | |||
| 471 | + | if mode := decodeMode(m); mode != "" { | |||
| 472 | + | modes = append(modes, mode) | |||
| 473 | + | } | |||
| 474 | + | } | |||
| 475 | + | if len(modes) == 0 { | |||
| 476 | + | return domain.DefaultRepoSettings() | |||
| 477 | + | } | |||
| 478 | + | normalized, err := domain.NormalizeMergeModes(modes) | |||
| 479 | + | if err != nil { | |||
| 480 | + | return domain.DefaultRepoSettings() | |||
| 481 | + | } | |||
| 482 | + | return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock()} | |||
| 483 | + | } | |||
A internal/adapters/kvstore/mergerequests_test.go +455 -0
| @@ -0,0 +1,455 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package kvstore_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "strings" | |
| 8 | + | "sync" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | "google.golang.org/protobuf/proto" | |
| 15 | + | ||
| 16 | + | kv "bnema.dev/ermite-kv" | |
| 17 | + | ||
| 18 | + | storagepb "bnema.dev/ermite/gen/ermite/storage" | |
| 19 | + | "bnema.dev/ermite/internal/adapters/kvstore" | |
| 20 | + | "bnema.dev/ermite/internal/domain" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | func newMR(t *testing.T, repo domain.RepoName, title string) domain.MergeRequest { | |
| 24 | + | t.Helper() | |
| 25 | + | ti, err := domain.ParseMRTitle(title) | |
| 26 | + | require.NoError(t, err) | |
| 27 | + | desc, err := domain.ParsePlainText("why\nand how") | |
| 28 | + | require.NoError(t, err) | |
| 29 | + | return domain.MergeRequest{ | |
| 30 | + | Repo: repo, Title: ti, Description: desc, Source: "feat/x", Target: "main", | |
| 31 | + | State: domain.MRStateOpen, Author: "agent", CreatedAt: created, UpdatedAt: created, | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func oid(c byte) domain.ObjectID { | |
| 36 | + | id, err := domain.ParseObjectID(strings.Repeat(string(c), 40)) | |
| 37 | + | if err != nil { | |
| 38 | + | panic(err) | |
| 39 | + | } | |
| 40 | + | return id | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // repoWithMRs opens a store holding repository "proj". | |
| 44 | + | func repoWithMRs(t *testing.T) (*kvstore.Store, *kvstore.MergeRequests, domain.RepoName) { | |
| 45 | + | t.Helper() | |
| 46 | + | s := openStore(t, t.TempDir()) | |
| 47 | + | n := name(t, "proj") | |
| 48 | + | require.NoError(t, s.Create(context.Background(), domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: created})) | |
| 49 | + | return s, s.MergeRequests(), n | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestMergeRequestNumbersAreSequentialPerRepository(t *testing.T) { | |
| 53 | + | ctx := context.Background() | |
| 54 | + | s, mrs, n := repoWithMRs(t) | |
| 55 | + | other := name(t, "other") | |
| 56 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic})) | |
| 57 | + | ||
| 58 | + | for want := int64(1); want <= 3; want++ { | |
| 59 | + | got, err := mrs.Create(ctx, newMR(t, n, "t")) | |
| 60 | + | require.NoError(t, err) | |
| 61 | + | assert.Equal(t, want, got.Number) | |
| 62 | + | } | |
| 63 | + | got, err := mrs.Create(ctx, newMR(t, other, "t")) | |
| 64 | + | require.NoError(t, err) | |
| 65 | + | assert.Equal(t, int64(1), got.Number, "each repository counts on its own") | |
| 66 | + | ||
| 67 | + | _, err = mrs.Create(ctx, newMR(t, name(t, "ghost"), "t")) | |
| 68 | + | assert.ErrorIs(t, err, domain.ErrNotFound, "no merge request for a missing repository") | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestMergeRequestRoundTrip(t *testing.T) { | |
| 72 | + | ctx := context.Background() | |
| 73 | + | _, mrs, n := repoWithMRs(t) | |
| 74 | + | in := newMR(t, n, "Ajoute « ça »") | |
| 75 | + | in.HeadID, in.BaseID, in.MergedCommitID = oid('a'), oid('b'), oid('c') | |
| 76 | + | in.MergeMode = domain.MergeModeSquash | |
| 77 | + | in.State = domain.MRStateMerged | |
| 78 | + | in.CommentSeq = 4 | |
| 79 | + | created1, err := mrs.Create(ctx, in) | |
| 80 | + | require.NoError(t, err) | |
| 81 | + | ||
| 82 | + | got, err := mrs.Get(ctx, n, created1.Number) | |
| 83 | + | require.NoError(t, err) | |
| 84 | + | assert.NotZero(t, got.Version) | |
| 85 | + | in.Number, in.Version = 1, got.Version | |
| 86 | + | assert.Equal(t, in, got) | |
| 87 | + | ||
| 88 | + | _, err = mrs.Get(ctx, n, 2) | |
| 89 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 90 | + | _, err = mrs.Get(ctx, n, 0) | |
| 91 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 92 | + | _, err = mrs.Get(ctx, n, -3) | |
| 93 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestMergeRequestListIsNewestFirstAndPerRepository(t *testing.T) { | |
| 97 | + | ctx := context.Background() | |
| 98 | + | s, mrs, n := repoWithMRs(t) | |
| 99 | + | // "proj-2" shares the prefix "proj": its keys must not leak into proj's. | |
| 100 | + | sib := name(t, "proj-2") | |
| 101 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: sib, CreatedAt: created, Visibility: domain.VisibilityPublic})) | |
| 102 | + | for range 12 { // beyond 10: numbers sort numerically, not as text | |
| 103 | + | _, err := mrs.Create(ctx, newMR(t, n, "t")) | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | } | |
| 106 | + | _, err := mrs.Create(ctx, newMR(t, sib, "sibling")) | |
| 107 | + | require.NoError(t, err) | |
| 108 | + | ||
| 109 | + | list, err := mrs.List(ctx, n) | |
| 110 | + | require.NoError(t, err) | |
| 111 | + | require.Len(t, list, 12) | |
| 112 | + | for i, mr := range list { | |
| 113 | + | assert.Equal(t, int64(12-i), mr.Number) | |
| 114 | + | assert.Equal(t, n, mr.Repo) | |
| 115 | + | } | |
| 116 | + | list, err = mrs.List(ctx, name(t, "none")) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | assert.Empty(t, list) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func TestConcurrentCreateNeverSharesANumber(t *testing.T) { | |
| 122 | + | ctx := context.Background() | |
| 123 | + | _, mrs, n := repoWithMRs(t) | |
| 124 | + | const workers = 8 | |
| 125 | + | var ( | |
| 126 | + | wg sync.WaitGroup | |
| 127 | + | mu sync.Mutex | |
| 128 | + | numbers []int64 | |
| 129 | + | ) | |
| 130 | + | for range workers { | |
| 131 | + | wg.Go(func() { | |
| 132 | + | got, err := mrs.Create(ctx, newMR(t, n, "t")) | |
| 133 | + | if assert.NoError(t, err) { | |
| 134 | + | mu.Lock() | |
| 135 | + | numbers = append(numbers, got.Number) | |
| 136 | + | mu.Unlock() | |
| 137 | + | } | |
| 138 | + | }) | |
| 139 | + | } | |
| 140 | + | wg.Wait() | |
| 141 | + | assert.ElementsMatch(t, []int64{1, 2, 3, 4, 5, 6, 7, 8}, numbers) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func TestMergeRequestUpdateNeedsTheVersionItWasReadAt(t *testing.T) { | |
| 145 | + | ctx := context.Background() | |
| 146 | + | _, mrs, n := repoWithMRs(t) | |
| 147 | + | first, err := mrs.Create(ctx, newMR(t, n, "t")) | |
| 148 | + | require.NoError(t, err) | |
| 149 | + | ||
| 150 | + | a, err := mrs.Get(ctx, n, first.Number) | |
| 151 | + | require.NoError(t, err) | |
| 152 | + | b := a | |
| 153 | + | a.State = domain.MRStateClosed | |
| 154 | + | require.NoError(t, mrs.Update(ctx, a)) | |
| 155 | + | ||
| 156 | + | b.State = domain.MRStateMerged | |
| 157 | + | assert.ErrorIs(t, mrs.Update(ctx, b), domain.ErrConflict, "b was read before a wrote") | |
| 158 | + | ||
| 159 | + | got, err := mrs.Get(ctx, n, first.Number) | |
| 160 | + | require.NoError(t, err) | |
| 161 | + | assert.Equal(t, domain.MRStateClosed, got.State) | |
| 162 | + | ||
| 163 | + | gone := got | |
| 164 | + | gone.Number = 99 | |
| 165 | + | assert.ErrorIs(t, mrs.Update(ctx, gone), domain.ErrNotFound) | |
| 166 | + | fresh := newMR(t, n, "never read") | |
| 167 | + | fresh.Number = first.Number | |
| 168 | + | assert.ErrorIs(t, mrs.Update(ctx, fresh), domain.ErrConflict, "an unread request has no version") | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func newComment(t *testing.T, id int64, text string) domain.Comment { | |
| 172 | + | t.Helper() | |
| 173 | + | pt, err := domain.ParsePlainText(text) | |
| 174 | + | require.NoError(t, err) | |
| 175 | + | return domain.Comment{ | |
| 176 | + | ID: id, Author: "root", Path: "a.txt", Line: 3, Side: domain.SideNew, | |
| 177 | + | CommitID: oid('d'), Text: pt, CreatedAt: created.Add(time.Duration(id) * time.Minute), | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestComments(t *testing.T) { | |
| 182 | + | ctx := context.Background() | |
| 183 | + | _, mrs, n := repoWithMRs(t) | |
| 184 | + | first, err := mrs.Create(ctx, newMR(t, n, "t")) | |
| 185 | + | require.NoError(t, err) | |
| 186 | + | ||
| 187 | + | // Twelve comments: ids sort numerically too. | |
| 188 | + | for id := int64(1); id <= 12; id++ { | |
| 189 | + | mr, err := mrs.Get(ctx, n, first.Number) | |
| 190 | + | require.NoError(t, err) | |
| 191 | + | mr.CommentSeq = id | |
| 192 | + | c := newComment(t, id, "note") | |
| 193 | + | if id == 2 { | |
| 194 | + | c.Path, c.Line, c.Side = "", 0, domain.SideNone | |
| 195 | + | } | |
| 196 | + | require.NoError(t, mrs.SaveComment(ctx, mr, c)) | |
| 197 | + | } | |
| 198 | + | list, err := mrs.ListComments(ctx, n, first.Number) | |
| 199 | + | require.NoError(t, err) | |
| 200 | + | require.Len(t, list, 12) | |
| 201 | + | for i, c := range list { | |
| 202 | + | assert.Equal(t, int64(i+1), c.ID, "oldest first") | |
| 203 | + | } | |
| 204 | + | assert.True(t, list[1].IsGeneral()) | |
| 205 | + | assert.Equal(t, domain.SideNone, list[1].Side) | |
| 206 | + | assert.Equal(t, domain.SideNew, list[0].Side) | |
| 207 | + | ||
| 208 | + | mr, err := mrs.Get(ctx, n, first.Number) | |
| 209 | + | require.NoError(t, err) | |
| 210 | + | assert.Equal(t, int64(12), mr.CommentSeq) | |
| 211 | + | ||
| 212 | + | c, err := mrs.GetComment(ctx, n, first.Number, 3) | |
| 213 | + | require.NoError(t, err) | |
| 214 | + | assert.NotZero(t, c.Version) | |
| 215 | + | assert.Equal(t, "note", c.Text.String()) | |
| 216 | + | assert.Equal(t, oid('d'), c.CommitID) | |
| 217 | + | for _, id := range []int64{0, 13, -1} { | |
| 218 | + | _, err = mrs.GetComment(ctx, n, first.Number, id) | |
| 219 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // Another merge request's comments are not listed. | |
| 223 | + | second, err := mrs.Create(ctx, newMR(t, n, "t2")) | |
| 224 | + | require.NoError(t, err) | |
| 225 | + | list, err = mrs.ListComments(ctx, n, second.Number) | |
| 226 | + | require.NoError(t, err) | |
| 227 | + | assert.Empty(t, list) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func TestSaveCommentPreconditions(t *testing.T) { | |
| 231 | + | ctx := context.Background() | |
| 232 | + | _, mrs, n := repoWithMRs(t) | |
| 233 | + | first, err := mrs.Create(ctx, newMR(t, n, "t")) | |
| 234 | + | require.NoError(t, err) | |
| 235 | + | mr, err := mrs.Get(ctx, n, first.Number) | |
| 236 | + | require.NoError(t, err) | |
| 237 | + | ||
| 238 | + | mr.CommentSeq = 1 | |
| 239 | + | require.NoError(t, mrs.SaveComment(ctx, mr, newComment(t, 1, "one"))) | |
| 240 | + | ||
| 241 | + | // The same id again, from the same stale read: the request moved on. | |
| 242 | + | assert.ErrorIs(t, mrs.SaveComment(ctx, mr, newComment(t, 1, "dup")), domain.ErrConflict) | |
| 243 | + | ||
| 244 | + | // A fresh read of the request but a new comment with an existing id. | |
| 245 | + | mr, err = mrs.Get(ctx, n, first.Number) | |
| 246 | + | require.NoError(t, err) | |
| 247 | + | assert.ErrorIs(t, mrs.SaveComment(ctx, mr, newComment(t, 1, "dup")), domain.ErrConflict, "a new comment must not overwrite") | |
| 248 | + | ||
| 249 | + | // Resolving needs the comment version. | |
| 250 | + | c, err := mrs.GetComment(ctx, n, first.Number, 1) | |
| 251 | + | require.NoError(t, err) | |
| 252 | + | stale := c | |
| 253 | + | c.Resolved = true | |
| 254 | + | require.NoError(t, mrs.SaveComment(ctx, mr, c)) | |
| 255 | + | mr, err = mrs.Get(ctx, n, first.Number) | |
| 256 | + | require.NoError(t, err) | |
| 257 | + | stale.Resolved = false | |
| 258 | + | assert.ErrorIs(t, mrs.SaveComment(ctx, mr, stale), domain.ErrConflict) | |
| 259 | + | ||
| 260 | + | // An unread request cannot be saved with. | |
| 261 | + | assert.ErrorIs(t, mrs.SaveComment(ctx, newMR(t, n, "x"), newComment(t, 2, "n")), domain.ErrConflict) | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func TestReadPosition(t *testing.T) { | |
| 265 | + | ctx := context.Background() | |
| 266 | + | _, mrs, n := repoWithMRs(t) | |
| 267 | + | got, err := mrs.GetRead(ctx, n, 1) | |
| 268 | + | require.NoError(t, err) | |
| 269 | + | assert.True(t, got.IsZero()) | |
| 270 | + | ||
| 271 | + | require.NoError(t, mrs.SetRead(ctx, n, 1, oid('a'))) | |
| 272 | + | require.NoError(t, mrs.SetRead(ctx, n, 1, oid('b'))) | |
| 273 | + | got, err = mrs.GetRead(ctx, n, 1) | |
| 274 | + | require.NoError(t, err) | |
| 275 | + | assert.Equal(t, oid('b'), got) | |
| 276 | + | got, err = mrs.GetRead(ctx, n, 2) | |
| 277 | + | require.NoError(t, err) | |
| 278 | + | assert.True(t, got.IsZero()) | |
| 279 | + | assert.Error(t, mrs.SetRead(ctx, n, 1, domain.ObjectID{})) | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | // TestDeleteDropsTheMergeRequestsOfTheRepository: deleting a repository drops | |
| 283 | + | // its merge requests, comments, read positions and number sequence, and only | |
| 284 | + | // its own; a repository created again under the name starts at !1. | |
| 285 | + | func TestDeleteDropsTheMergeRequestsOfTheRepository(t *testing.T) { | |
| 286 | + | ctx := context.Background() | |
| 287 | + | s, mrs, n := repoWithMRs(t) | |
| 288 | + | sib := name(t, "proj-2") | |
| 289 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: sib, CreatedAt: created, Visibility: domain.VisibilityPublic})) | |
| 290 | + | ||
| 291 | + | for _, repo := range []domain.RepoName{n, sib} { | |
| 292 | + | mr, err := mrs.Create(ctx, newMR(t, repo, "t")) | |
| 293 | + | require.NoError(t, err) | |
| 294 | + | mr.CommentSeq = 1 | |
| 295 | + | require.NoError(t, mrs.SaveComment(ctx, mr, newComment(t, 1, "note"))) | |
| 296 | + | require.NoError(t, mrs.SetRead(ctx, repo, mr.Number, oid('a'))) | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | require.NoError(t, s.Delete(ctx, n)) | |
| 300 | + | ||
| 301 | + | list, err := mrs.List(ctx, n) | |
| 302 | + | require.NoError(t, err) | |
| 303 | + | assert.Empty(t, list) | |
| 304 | + | comments, err := mrs.ListComments(ctx, n, 1) | |
| 305 | + | require.NoError(t, err) | |
| 306 | + | assert.Empty(t, comments) | |
| 307 | + | read, err := mrs.GetRead(ctx, n, 1) | |
| 308 | + | require.NoError(t, err) | |
| 309 | + | assert.True(t, read.IsZero()) | |
| 310 | + | ||
| 311 | + | list, err = mrs.List(ctx, sib) | |
| 312 | + | require.NoError(t, err) | |
| 313 | + | require.Len(t, list, 1, "a repository whose name starts like the deleted one keeps its requests") | |
| 314 | + | comments, err = mrs.ListComments(ctx, sib, 1) | |
| 315 | + | require.NoError(t, err) | |
| 316 | + | assert.Len(t, comments, 1) | |
| 317 | + | ||
| 318 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate})) | |
| 319 | + | again, err := mrs.Create(ctx, newMR(t, n, "fresh")) | |
| 320 | + | require.NoError(t, err) | |
| 321 | + | assert.Equal(t, int64(1), again.Number) | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | func TestDeleteManyMergeRequests(t *testing.T) { | |
| 325 | + | ctx := context.Background() | |
| 326 | + | s, mrs, n := repoWithMRs(t) | |
| 327 | + | for range 1100 { | |
| 328 | + | _, err := mrs.Create(ctx, newMR(t, n, "t")) | |
| 329 | + | require.NoError(t, err) | |
| 330 | + | } | |
| 331 | + | require.NoError(t, s.Delete(ctx, n)) | |
| 332 | + | list, err := mrs.List(ctx, n) | |
| 333 | + | require.NoError(t, err) | |
| 334 | + | assert.Empty(t, list) | |
| 335 | + | _, err = s.Get(ctx, n) | |
| 336 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | func TestRepoSettings(t *testing.T) { | |
| 340 | + | ctx := context.Background() | |
| 341 | + | s := openStore(t, t.TempDir()) | |
| 342 | + | n := name(t, "proj") | |
| 343 | + | ||
| 344 | + | // Created without settings: they are stored explicitly, with the defaults. | |
| 345 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate})) | |
| 346 | + | got, err := s.Get(ctx, n) | |
| 347 | + | require.NoError(t, err) | |
| 348 | + | assert.Equal(t, domain.DefaultRepoSettings(), got.Settings) | |
| 349 | + | ||
| 350 | + | got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false} | |
| 351 | + | require.NoError(t, s.Update(ctx, got)) | |
| 352 | + | again, err := s.Get(ctx, n) | |
| 353 | + | require.NoError(t, err) | |
| 354 | + | assert.Equal(t, got.Settings, again.Settings) | |
| 355 | + | ||
| 356 | + | got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{"rebase"}} | |
| 357 | + | assert.ErrorIs(t, s.Update(ctx, got), domain.ErrInvalid) | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | // TestRepoWithoutStoredSettingsReadsTheDefaults: a repository stored before | |
| 361 | + | // merge requests existed has no settings field. | |
| 362 | + | func TestRepoWithoutStoredSettingsReadsTheDefaults(t *testing.T) { | |
| 363 | + | ctx := context.Background() | |
| 364 | + | dir := t.TempDir() | |
| 365 | + | s, err := kvstore.Open(ctx, dir) | |
| 366 | + | require.NoError(t, err) | |
| 367 | + | require.NoError(t, s.Close(ctx)) | |
| 368 | + | ||
| 369 | + | // Write a legacy value straight into the database. | |
| 370 | + | db, err := kv.Open(ctx, dir, kv.Options{}) | |
| 371 | + | require.NoError(t, err) | |
| 372 | + | legacy, err := proto.Marshal(&storagepb.Repo{Name: "legacy", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}) | |
| 373 | + | require.NoError(t, err) | |
| 374 | + | var b kv.Batch | |
| 375 | + | b.Put([]byte("repo/legacy"), legacy) | |
| 376 | + | _, err = db.Apply(ctx, &b) | |
| 377 | + | require.NoError(t, err) | |
| 378 | + | require.NoError(t, db.Close(ctx)) | |
| 379 | + | ||
| 380 | + | s = openStore(t, dir) | |
| 381 | + | got, err := s.Get(ctx, name(t, "legacy")) | |
| 382 | + | require.NoError(t, err) | |
| 383 | + | assert.Equal(t, domain.DefaultRepoSettings(), got.Settings) | |
| 384 | + | list, err := s.List(ctx) | |
| 385 | + | require.NoError(t, err) | |
| 386 | + | require.Len(t, list, 1) | |
| 387 | + | assert.Equal(t, domain.DefaultRepoSettings(), list[0].Settings) | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | func TestSigningSeed(t *testing.T) { | |
| 391 | + | ctx := context.Background() | |
| 392 | + | dir := t.TempDir() | |
| 393 | + | s := openStore(t, dir) | |
| 394 | + | calls := 0 | |
| 395 | + | gen := func() ([]byte, error) { | |
| 396 | + | calls++ | |
| 397 | + | return []byte(strings.Repeat(string(rune('a'+calls)), kvstore.SigningSeedLen)), nil | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | first, err := s.SigningSeed(ctx, gen, created) | |
| 401 | + | require.NoError(t, err) | |
| 402 | + | require.Len(t, first, kvstore.SigningSeedLen) | |
| 403 | + | second, err := s.SigningSeed(ctx, gen, created) | |
| 404 | + | require.NoError(t, err) | |
| 405 | + | assert.Equal(t, first, second, "generated once") | |
| 406 | + | assert.Equal(t, 1, calls) | |
| 407 | + | ||
| 408 | + | // It survives a restart. | |
| 409 | + | require.NoError(t, s.Close(ctx)) | |
| 410 | + | s2 := openStore(t, dir) | |
| 411 | + | third, err := s2.SigningSeed(ctx, gen, created) | |
| 412 | + | require.NoError(t, err) | |
| 413 | + | assert.Equal(t, first, third) | |
| 414 | + | assert.Equal(t, 1, calls) | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | func TestSigningSeedConcurrentFirstStart(t *testing.T) { | |
| 418 | + | ctx := context.Background() | |
| 419 | + | s := openStore(t, t.TempDir()) | |
| 420 | + | var ( | |
| 421 | + | wg sync.WaitGroup | |
| 422 | + | mu sync.Mutex | |
| 423 | + | seeds [][]byte | |
| 424 | + | n byte | |
| 425 | + | ) | |
| 426 | + | for range 6 { | |
| 427 | + | wg.Go(func() { | |
| 428 | + | seed, err := s.SigningSeed(ctx, func() ([]byte, error) { | |
| 429 | + | mu.Lock() | |
| 430 | + | defer mu.Unlock() | |
| 431 | + | n++ | |
| 432 | + | return []byte(strings.Repeat(string(rune('A'+n)), kvstore.SigningSeedLen)), nil | |
| 433 | + | }, created) | |
| 434 | + | if assert.NoError(t, err) { | |
| 435 | + | mu.Lock() | |
| 436 | + | seeds = append(seeds, seed) | |
| 437 | + | mu.Unlock() | |
| 438 | + | } | |
| 439 | + | }) | |
| 440 | + | } | |
| 441 | + | wg.Wait() | |
| 442 | + | for _, seed := range seeds { | |
| 443 | + | assert.Equal(t, seeds[0], seed, "every caller gets the winner's seed") | |
| 444 | + | } | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | func TestSigningSeedRejectsBadGenerators(t *testing.T) { | |
| 448 | + | ctx := context.Background() | |
| 449 | + | s := openStore(t, t.TempDir()) | |
| 450 | + | _, err := s.SigningSeed(ctx, func() ([]byte, error) { return []byte("short"), nil }, created) | |
| 451 | + | require.Error(t, err) | |
| 452 | + | assert.NotContains(t, err.Error(), "short") | |
| 453 | + | _, err = s.SigningSeed(ctx, func() ([]byte, error) { return nil, assert.AnError }, created) | |
| 454 | + | require.ErrorIs(t, err, assert.AnError) | |
| 455 | + | } | |
| @@ -0,0 +1,455 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package kvstore_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "strings" | |||
| 8 | + | "sync" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/stretchr/testify/assert" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | "google.golang.org/protobuf/proto" | |||
| 15 | + | ||||
| 16 | + | kv "bnema.dev/ermite-kv" | |||
| 17 | + | ||||
| 18 | + | storagepb "bnema.dev/ermite/gen/ermite/storage" | |||
| 19 | + | "bnema.dev/ermite/internal/adapters/kvstore" | |||
| 20 | + | "bnema.dev/ermite/internal/domain" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | func newMR(t *testing.T, repo domain.RepoName, title string) domain.MergeRequest { | |||
| 24 | + | t.Helper() | |||
| 25 | + | ti, err := domain.ParseMRTitle(title) | |||
| 26 | + | require.NoError(t, err) | |||
| 27 | + | desc, err := domain.ParsePlainText("why\nand how") | |||
| 28 | + | require.NoError(t, err) | |||
| 29 | + | return domain.MergeRequest{ | |||
| 30 | + | Repo: repo, Title: ti, Description: desc, Source: "feat/x", Target: "main", | |||
| 31 | + | State: domain.MRStateOpen, Author: "agent", CreatedAt: created, UpdatedAt: created, | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func oid(c byte) domain.ObjectID { | |||
| 36 | + | id, err := domain.ParseObjectID(strings.Repeat(string(c), 40)) | |||
| 37 | + | if err != nil { | |||
| 38 | + | panic(err) | |||
| 39 | + | } | |||
| 40 | + | return id | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // repoWithMRs opens a store holding repository "proj". | |||
| 44 | + | func repoWithMRs(t *testing.T) (*kvstore.Store, *kvstore.MergeRequests, domain.RepoName) { | |||
| 45 | + | t.Helper() | |||
| 46 | + | s := openStore(t, t.TempDir()) | |||
| 47 | + | n := name(t, "proj") | |||
| 48 | + | require.NoError(t, s.Create(context.Background(), domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: created})) | |||
| 49 | + | return s, s.MergeRequests(), n | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestMergeRequestNumbersAreSequentialPerRepository(t *testing.T) { | |||
| 53 | + | ctx := context.Background() | |||
| 54 | + | s, mrs, n := repoWithMRs(t) | |||
| 55 | + | other := name(t, "other") | |||
| 56 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic})) | |||
| 57 | + | ||||
| 58 | + | for want := int64(1); want <= 3; want++ { | |||
| 59 | + | got, err := mrs.Create(ctx, newMR(t, n, "t")) | |||
| 60 | + | require.NoError(t, err) | |||
| 61 | + | assert.Equal(t, want, got.Number) | |||
| 62 | + | } | |||
| 63 | + | got, err := mrs.Create(ctx, newMR(t, other, "t")) | |||
| 64 | + | require.NoError(t, err) | |||
| 65 | + | assert.Equal(t, int64(1), got.Number, "each repository counts on its own") | |||
| 66 | + | ||||
| 67 | + | _, err = mrs.Create(ctx, newMR(t, name(t, "ghost"), "t")) | |||
| 68 | + | assert.ErrorIs(t, err, domain.ErrNotFound, "no merge request for a missing repository") | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestMergeRequestRoundTrip(t *testing.T) { | |||
| 72 | + | ctx := context.Background() | |||
| 73 | + | _, mrs, n := repoWithMRs(t) | |||
| 74 | + | in := newMR(t, n, "Ajoute « ça »") | |||
| 75 | + | in.HeadID, in.BaseID, in.MergedCommitID = oid('a'), oid('b'), oid('c') | |||
| 76 | + | in.MergeMode = domain.MergeModeSquash | |||
| 77 | + | in.State = domain.MRStateMerged | |||
| 78 | + | in.CommentSeq = 4 | |||
| 79 | + | created1, err := mrs.Create(ctx, in) | |||
| 80 | + | require.NoError(t, err) | |||
| 81 | + | ||||
| 82 | + | got, err := mrs.Get(ctx, n, created1.Number) | |||
| 83 | + | require.NoError(t, err) | |||
| 84 | + | assert.NotZero(t, got.Version) | |||
| 85 | + | in.Number, in.Version = 1, got.Version | |||
| 86 | + | assert.Equal(t, in, got) | |||
| 87 | + | ||||
| 88 | + | _, err = mrs.Get(ctx, n, 2) | |||
| 89 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 90 | + | _, err = mrs.Get(ctx, n, 0) | |||
| 91 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 92 | + | _, err = mrs.Get(ctx, n, -3) | |||
| 93 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestMergeRequestListIsNewestFirstAndPerRepository(t *testing.T) { | |||
| 97 | + | ctx := context.Background() | |||
| 98 | + | s, mrs, n := repoWithMRs(t) | |||
| 99 | + | // "proj-2" shares the prefix "proj": its keys must not leak into proj's. | |||
| 100 | + | sib := name(t, "proj-2") | |||
| 101 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: sib, CreatedAt: created, Visibility: domain.VisibilityPublic})) | |||
| 102 | + | for range 12 { // beyond 10: numbers sort numerically, not as text | |||
| 103 | + | _, err := mrs.Create(ctx, newMR(t, n, "t")) | |||
| 104 | + | require.NoError(t, err) | |||
| 105 | + | } | |||
| 106 | + | _, err := mrs.Create(ctx, newMR(t, sib, "sibling")) | |||
| 107 | + | require.NoError(t, err) | |||
| 108 | + | ||||
| 109 | + | list, err := mrs.List(ctx, n) | |||
| 110 | + | require.NoError(t, err) | |||
| 111 | + | require.Len(t, list, 12) | |||
| 112 | + | for i, mr := range list { | |||
| 113 | + | assert.Equal(t, int64(12-i), mr.Number) | |||
| 114 | + | assert.Equal(t, n, mr.Repo) | |||
| 115 | + | } | |||
| 116 | + | list, err = mrs.List(ctx, name(t, "none")) | |||
| 117 | + | require.NoError(t, err) | |||
| 118 | + | assert.Empty(t, list) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func TestConcurrentCreateNeverSharesANumber(t *testing.T) { | |||
| 122 | + | ctx := context.Background() | |||
| 123 | + | _, mrs, n := repoWithMRs(t) | |||
| 124 | + | const workers = 8 | |||
| 125 | + | var ( | |||
| 126 | + | wg sync.WaitGroup | |||
| 127 | + | mu sync.Mutex | |||
| 128 | + | numbers []int64 | |||
| 129 | + | ) | |||
| 130 | + | for range workers { | |||
| 131 | + | wg.Go(func() { | |||
| 132 | + | got, err := mrs.Create(ctx, newMR(t, n, "t")) | |||
| 133 | + | if assert.NoError(t, err) { | |||
| 134 | + | mu.Lock() | |||
| 135 | + | numbers = append(numbers, got.Number) | |||
| 136 | + | mu.Unlock() | |||
| 137 | + | } | |||
| 138 | + | }) | |||
| 139 | + | } | |||
| 140 | + | wg.Wait() | |||
| 141 | + | assert.ElementsMatch(t, []int64{1, 2, 3, 4, 5, 6, 7, 8}, numbers) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func TestMergeRequestUpdateNeedsTheVersionItWasReadAt(t *testing.T) { | |||
| 145 | + | ctx := context.Background() | |||
| 146 | + | _, mrs, n := repoWithMRs(t) | |||
| 147 | + | first, err := mrs.Create(ctx, newMR(t, n, "t")) | |||
| 148 | + | require.NoError(t, err) | |||
| 149 | + | ||||
| 150 | + | a, err := mrs.Get(ctx, n, first.Number) | |||
| 151 | + | require.NoError(t, err) | |||
| 152 | + | b := a | |||
| 153 | + | a.State = domain.MRStateClosed | |||
| 154 | + | require.NoError(t, mrs.Update(ctx, a)) | |||
| 155 | + | ||||
| 156 | + | b.State = domain.MRStateMerged | |||
| 157 | + | assert.ErrorIs(t, mrs.Update(ctx, b), domain.ErrConflict, "b was read before a wrote") | |||
| 158 | + | ||||
| 159 | + | got, err := mrs.Get(ctx, n, first.Number) | |||
| 160 | + | require.NoError(t, err) | |||
| 161 | + | assert.Equal(t, domain.MRStateClosed, got.State) | |||
| 162 | + | ||||
| 163 | + | gone := got | |||
| 164 | + | gone.Number = 99 | |||
| 165 | + | assert.ErrorIs(t, mrs.Update(ctx, gone), domain.ErrNotFound) | |||
| 166 | + | fresh := newMR(t, n, "never read") | |||
| 167 | + | fresh.Number = first.Number | |||
| 168 | + | assert.ErrorIs(t, mrs.Update(ctx, fresh), domain.ErrConflict, "an unread request has no version") | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func newComment(t *testing.T, id int64, text string) domain.Comment { | |||
| 172 | + | t.Helper() | |||
| 173 | + | pt, err := domain.ParsePlainText(text) | |||
| 174 | + | require.NoError(t, err) | |||
| 175 | + | return domain.Comment{ | |||
| 176 | + | ID: id, Author: "root", Path: "a.txt", Line: 3, Side: domain.SideNew, | |||
| 177 | + | CommitID: oid('d'), Text: pt, CreatedAt: created.Add(time.Duration(id) * time.Minute), | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func TestComments(t *testing.T) { | |||
| 182 | + | ctx := context.Background() | |||
| 183 | + | _, mrs, n := repoWithMRs(t) | |||
| 184 | + | first, err := mrs.Create(ctx, newMR(t, n, "t")) | |||
| 185 | + | require.NoError(t, err) | |||
| 186 | + | ||||
| 187 | + | // Twelve comments: ids sort numerically too. | |||
| 188 | + | for id := int64(1); id <= 12; id++ { | |||
| 189 | + | mr, err := mrs.Get(ctx, n, first.Number) | |||
| 190 | + | require.NoError(t, err) | |||
| 191 | + | mr.CommentSeq = id | |||
| 192 | + | c := newComment(t, id, "note") | |||
| 193 | + | if id == 2 { | |||
| 194 | + | c.Path, c.Line, c.Side = "", 0, domain.SideNone | |||
| 195 | + | } | |||
| 196 | + | require.NoError(t, mrs.SaveComment(ctx, mr, c)) | |||
| 197 | + | } | |||
| 198 | + | list, err := mrs.ListComments(ctx, n, first.Number) | |||
| 199 | + | require.NoError(t, err) | |||
| 200 | + | require.Len(t, list, 12) | |||
| 201 | + | for i, c := range list { | |||
| 202 | + | assert.Equal(t, int64(i+1), c.ID, "oldest first") | |||
| 203 | + | } | |||
| 204 | + | assert.True(t, list[1].IsGeneral()) | |||
| 205 | + | assert.Equal(t, domain.SideNone, list[1].Side) | |||
| 206 | + | assert.Equal(t, domain.SideNew, list[0].Side) | |||
| 207 | + | ||||
| 208 | + | mr, err := mrs.Get(ctx, n, first.Number) | |||
| 209 | + | require.NoError(t, err) | |||
| 210 | + | assert.Equal(t, int64(12), mr.CommentSeq) | |||
| 211 | + | ||||
| 212 | + | c, err := mrs.GetComment(ctx, n, first.Number, 3) | |||
| 213 | + | require.NoError(t, err) | |||
| 214 | + | assert.NotZero(t, c.Version) | |||
| 215 | + | assert.Equal(t, "note", c.Text.String()) | |||
| 216 | + | assert.Equal(t, oid('d'), c.CommitID) | |||
| 217 | + | for _, id := range []int64{0, 13, -1} { | |||
| 218 | + | _, err = mrs.GetComment(ctx, n, first.Number, id) | |||
| 219 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | // Another merge request's comments are not listed. | |||
| 223 | + | second, err := mrs.Create(ctx, newMR(t, n, "t2")) | |||
| 224 | + | require.NoError(t, err) | |||
| 225 | + | list, err = mrs.ListComments(ctx, n, second.Number) | |||
| 226 | + | require.NoError(t, err) | |||
| 227 | + | assert.Empty(t, list) | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func TestSaveCommentPreconditions(t *testing.T) { | |||
| 231 | + | ctx := context.Background() | |||
| 232 | + | _, mrs, n := repoWithMRs(t) | |||
| 233 | + | first, err := mrs.Create(ctx, newMR(t, n, "t")) | |||
| 234 | + | require.NoError(t, err) | |||
| 235 | + | mr, err := mrs.Get(ctx, n, first.Number) | |||
| 236 | + | require.NoError(t, err) | |||
| 237 | + | ||||
| 238 | + | mr.CommentSeq = 1 | |||
| 239 | + | require.NoError(t, mrs.SaveComment(ctx, mr, newComment(t, 1, "one"))) | |||
| 240 | + | ||||
| 241 | + | // The same id again, from the same stale read: the request moved on. | |||
| 242 | + | assert.ErrorIs(t, mrs.SaveComment(ctx, mr, newComment(t, 1, "dup")), domain.ErrConflict) | |||
| 243 | + | ||||
| 244 | + | // A fresh read of the request but a new comment with an existing id. | |||
| 245 | + | mr, err = mrs.Get(ctx, n, first.Number) | |||
| 246 | + | require.NoError(t, err) | |||
| 247 | + | assert.ErrorIs(t, mrs.SaveComment(ctx, mr, newComment(t, 1, "dup")), domain.ErrConflict, "a new comment must not overwrite") | |||
| 248 | + | ||||
| 249 | + | // Resolving needs the comment version. | |||
| 250 | + | c, err := mrs.GetComment(ctx, n, first.Number, 1) | |||
| 251 | + | require.NoError(t, err) | |||
| 252 | + | stale := c | |||
| 253 | + | c.Resolved = true | |||
| 254 | + | require.NoError(t, mrs.SaveComment(ctx, mr, c)) | |||
| 255 | + | mr, err = mrs.Get(ctx, n, first.Number) | |||
| 256 | + | require.NoError(t, err) | |||
| 257 | + | stale.Resolved = false | |||
| 258 | + | assert.ErrorIs(t, mrs.SaveComment(ctx, mr, stale), domain.ErrConflict) | |||
| 259 | + | ||||
| 260 | + | // An unread request cannot be saved with. | |||
| 261 | + | assert.ErrorIs(t, mrs.SaveComment(ctx, newMR(t, n, "x"), newComment(t, 2, "n")), domain.ErrConflict) | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func TestReadPosition(t *testing.T) { | |||
| 265 | + | ctx := context.Background() | |||
| 266 | + | _, mrs, n := repoWithMRs(t) | |||
| 267 | + | got, err := mrs.GetRead(ctx, n, 1) | |||
| 268 | + | require.NoError(t, err) | |||
| 269 | + | assert.True(t, got.IsZero()) | |||
| 270 | + | ||||
| 271 | + | require.NoError(t, mrs.SetRead(ctx, n, 1, oid('a'))) | |||
| 272 | + | require.NoError(t, mrs.SetRead(ctx, n, 1, oid('b'))) | |||
| 273 | + | got, err = mrs.GetRead(ctx, n, 1) | |||
| 274 | + | require.NoError(t, err) | |||
| 275 | + | assert.Equal(t, oid('b'), got) | |||
| 276 | + | got, err = mrs.GetRead(ctx, n, 2) | |||
| 277 | + | require.NoError(t, err) | |||
| 278 | + | assert.True(t, got.IsZero()) | |||
| 279 | + | assert.Error(t, mrs.SetRead(ctx, n, 1, domain.ObjectID{})) | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | // TestDeleteDropsTheMergeRequestsOfTheRepository: deleting a repository drops | |||
| 283 | + | // its merge requests, comments, read positions and number sequence, and only | |||
| 284 | + | // its own; a repository created again under the name starts at !1. | |||
| 285 | + | func TestDeleteDropsTheMergeRequestsOfTheRepository(t *testing.T) { | |||
| 286 | + | ctx := context.Background() | |||
| 287 | + | s, mrs, n := repoWithMRs(t) | |||
| 288 | + | sib := name(t, "proj-2") | |||
| 289 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: sib, CreatedAt: created, Visibility: domain.VisibilityPublic})) | |||
| 290 | + | ||||
| 291 | + | for _, repo := range []domain.RepoName{n, sib} { | |||
| 292 | + | mr, err := mrs.Create(ctx, newMR(t, repo, "t")) | |||
| 293 | + | require.NoError(t, err) | |||
| 294 | + | mr.CommentSeq = 1 | |||
| 295 | + | require.NoError(t, mrs.SaveComment(ctx, mr, newComment(t, 1, "note"))) | |||
| 296 | + | require.NoError(t, mrs.SetRead(ctx, repo, mr.Number, oid('a'))) | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | require.NoError(t, s.Delete(ctx, n)) | |||
| 300 | + | ||||
| 301 | + | list, err := mrs.List(ctx, n) | |||
| 302 | + | require.NoError(t, err) | |||
| 303 | + | assert.Empty(t, list) | |||
| 304 | + | comments, err := mrs.ListComments(ctx, n, 1) | |||
| 305 | + | require.NoError(t, err) | |||
| 306 | + | assert.Empty(t, comments) | |||
| 307 | + | read, err := mrs.GetRead(ctx, n, 1) | |||
| 308 | + | require.NoError(t, err) | |||
| 309 | + | assert.True(t, read.IsZero()) | |||
| 310 | + | ||||
| 311 | + | list, err = mrs.List(ctx, sib) | |||
| 312 | + | require.NoError(t, err) | |||
| 313 | + | require.Len(t, list, 1, "a repository whose name starts like the deleted one keeps its requests") | |||
| 314 | + | comments, err = mrs.ListComments(ctx, sib, 1) | |||
| 315 | + | require.NoError(t, err) | |||
| 316 | + | assert.Len(t, comments, 1) | |||
| 317 | + | ||||
| 318 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate})) | |||
| 319 | + | again, err := mrs.Create(ctx, newMR(t, n, "fresh")) | |||
| 320 | + | require.NoError(t, err) | |||
| 321 | + | assert.Equal(t, int64(1), again.Number) | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | func TestDeleteManyMergeRequests(t *testing.T) { | |||
| 325 | + | ctx := context.Background() | |||
| 326 | + | s, mrs, n := repoWithMRs(t) | |||
| 327 | + | for range 1100 { | |||
| 328 | + | _, err := mrs.Create(ctx, newMR(t, n, "t")) | |||
| 329 | + | require.NoError(t, err) | |||
| 330 | + | } | |||
| 331 | + | require.NoError(t, s.Delete(ctx, n)) | |||
| 332 | + | list, err := mrs.List(ctx, n) | |||
| 333 | + | require.NoError(t, err) | |||
| 334 | + | assert.Empty(t, list) | |||
| 335 | + | _, err = s.Get(ctx, n) | |||
| 336 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | func TestRepoSettings(t *testing.T) { | |||
| 340 | + | ctx := context.Background() | |||
| 341 | + | s := openStore(t, t.TempDir()) | |||
| 342 | + | n := name(t, "proj") | |||
| 343 | + | ||||
| 344 | + | // Created without settings: they are stored explicitly, with the defaults. | |||
| 345 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate})) | |||
| 346 | + | got, err := s.Get(ctx, n) | |||
| 347 | + | require.NoError(t, err) | |||
| 348 | + | assert.Equal(t, domain.DefaultRepoSettings(), got.Settings) | |||
| 349 | + | ||||
| 350 | + | got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false} | |||
| 351 | + | require.NoError(t, s.Update(ctx, got)) | |||
| 352 | + | again, err := s.Get(ctx, n) | |||
| 353 | + | require.NoError(t, err) | |||
| 354 | + | assert.Equal(t, got.Settings, again.Settings) | |||
| 355 | + | ||||
| 356 | + | got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{"rebase"}} | |||
| 357 | + | assert.ErrorIs(t, s.Update(ctx, got), domain.ErrInvalid) | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | // TestRepoWithoutStoredSettingsReadsTheDefaults: a repository stored before | |||
| 361 | + | // merge requests existed has no settings field. | |||
| 362 | + | func TestRepoWithoutStoredSettingsReadsTheDefaults(t *testing.T) { | |||
| 363 | + | ctx := context.Background() | |||
| 364 | + | dir := t.TempDir() | |||
| 365 | + | s, err := kvstore.Open(ctx, dir) | |||
| 366 | + | require.NoError(t, err) | |||
| 367 | + | require.NoError(t, s.Close(ctx)) | |||
| 368 | + | ||||
| 369 | + | // Write a legacy value straight into the database. | |||
| 370 | + | db, err := kv.Open(ctx, dir, kv.Options{}) | |||
| 371 | + | require.NoError(t, err) | |||
| 372 | + | legacy, err := proto.Marshal(&storagepb.Repo{Name: "legacy", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}) | |||
| 373 | + | require.NoError(t, err) | |||
| 374 | + | var b kv.Batch | |||
| 375 | + | b.Put([]byte("repo/legacy"), legacy) | |||
| 376 | + | _, err = db.Apply(ctx, &b) | |||
| 377 | + | require.NoError(t, err) | |||
| 378 | + | require.NoError(t, db.Close(ctx)) | |||
| 379 | + | ||||
| 380 | + | s = openStore(t, dir) | |||
| 381 | + | got, err := s.Get(ctx, name(t, "legacy")) | |||
| 382 | + | require.NoError(t, err) | |||
| 383 | + | assert.Equal(t, domain.DefaultRepoSettings(), got.Settings) | |||
| 384 | + | list, err := s.List(ctx) | |||
| 385 | + | require.NoError(t, err) | |||
| 386 | + | require.Len(t, list, 1) | |||
| 387 | + | assert.Equal(t, domain.DefaultRepoSettings(), list[0].Settings) | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | func TestSigningSeed(t *testing.T) { | |||
| 391 | + | ctx := context.Background() | |||
| 392 | + | dir := t.TempDir() | |||
| 393 | + | s := openStore(t, dir) | |||
| 394 | + | calls := 0 | |||
| 395 | + | gen := func() ([]byte, error) { | |||
| 396 | + | calls++ | |||
| 397 | + | return []byte(strings.Repeat(string(rune('a'+calls)), kvstore.SigningSeedLen)), nil | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | first, err := s.SigningSeed(ctx, gen, created) | |||
| 401 | + | require.NoError(t, err) | |||
| 402 | + | require.Len(t, first, kvstore.SigningSeedLen) | |||
| 403 | + | second, err := s.SigningSeed(ctx, gen, created) | |||
| 404 | + | require.NoError(t, err) | |||
| 405 | + | assert.Equal(t, first, second, "generated once") | |||
| 406 | + | assert.Equal(t, 1, calls) | |||
| 407 | + | ||||
| 408 | + | // It survives a restart. | |||
| 409 | + | require.NoError(t, s.Close(ctx)) | |||
| 410 | + | s2 := openStore(t, dir) | |||
| 411 | + | third, err := s2.SigningSeed(ctx, gen, created) | |||
| 412 | + | require.NoError(t, err) | |||
| 413 | + | assert.Equal(t, first, third) | |||
| 414 | + | assert.Equal(t, 1, calls) | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | func TestSigningSeedConcurrentFirstStart(t *testing.T) { | |||
| 418 | + | ctx := context.Background() | |||
| 419 | + | s := openStore(t, t.TempDir()) | |||
| 420 | + | var ( | |||
| 421 | + | wg sync.WaitGroup | |||
| 422 | + | mu sync.Mutex | |||
| 423 | + | seeds [][]byte | |||
| 424 | + | n byte | |||
| 425 | + | ) | |||
| 426 | + | for range 6 { | |||
| 427 | + | wg.Go(func() { | |||
| 428 | + | seed, err := s.SigningSeed(ctx, func() ([]byte, error) { | |||
| 429 | + | mu.Lock() | |||
| 430 | + | defer mu.Unlock() | |||
| 431 | + | n++ | |||
| 432 | + | return []byte(strings.Repeat(string(rune('A'+n)), kvstore.SigningSeedLen)), nil | |||
| 433 | + | }, created) | |||
| 434 | + | if assert.NoError(t, err) { | |||
| 435 | + | mu.Lock() | |||
| 436 | + | seeds = append(seeds, seed) | |||
| 437 | + | mu.Unlock() | |||
| 438 | + | } | |||
| 439 | + | }) | |||
| 440 | + | } | |||
| 441 | + | wg.Wait() | |||
| 442 | + | for _, seed := range seeds { | |||
| 443 | + | assert.Equal(t, seeds[0], seed, "every caller gets the winner's seed") | |||
| 444 | + | } | |||
| 445 | + | } | |||
| 446 | + | ||||
| 447 | + | func TestSigningSeedRejectsBadGenerators(t *testing.T) { | |||
| 448 | + | ctx := context.Background() | |||
| 449 | + | s := openStore(t, t.TempDir()) | |||
| 450 | + | _, err := s.SigningSeed(ctx, func() ([]byte, error) { return []byte("short"), nil }, created) | |||
| 451 | + | require.Error(t, err) | |||
| 452 | + | assert.NotContains(t, err.Error(), "short") | |||
| 453 | + | _, err = s.SigningSeed(ctx, func() ([]byte, error) { return nil, assert.AnError }, created) | |||
| 454 | + | require.ErrorIs(t, err, assert.AnError) | |||
| 455 | + | } | |||
M internal/adapters/kvstore/repos.go +40 -1
| @@ -90,9 +90,36 @@ func (s *Store) Update(ctx context.Context, repo domain.Repo) error { | |||
| 90 | 90 | return nil | |
| 91 | 91 | } | |
| 92 | 92 | ||
| 93 | - | // Delete removes the repository; a missing one is not an error. | |
| 93 | + | // deleteChunk is how many merge request keys one batch deletes before the | |
| 94 | + | // last one, which also removes the repository. | |
| 95 | + | const deleteChunk = 1000 | |
| 96 | + | ||
| 97 | + | // Delete removes the repository and everything keyed to it: its merge | |
| 98 | + | // requests, their comments and read positions, and its number sequence. The | |
| 99 | + | // Git data is moved to the trash by the caller; the merge requests are not | |
| 100 | + | // kept anywhere, like the repository metadata, so a repository created again | |
| 101 | + | // under the same name starts clean, with its numbers starting at 1. A missing | |
| 102 | + | // repository is not an error. | |
| 103 | + | // | |
| 104 | + | // The merge request keys go first, in chunks, and the repository key goes with | |
| 105 | + | // the last chunk: a delete that fails half way leaves the repository, which | |
| 106 | + | // the caller can delete again. | |
| 94 | 107 | func (s *Store) Delete(ctx context.Context, name domain.RepoName) error { | |
| 108 | + | keys := s.mergeRequestKeys(ctx, name) | |
| 109 | + | for len(keys) > deleteChunk { | |
| 110 | + | var b kv.Batch | |
| 111 | + | for _, k := range keys[:deleteChunk] { | |
| 112 | + | b.Delete(k) | |
| 113 | + | } | |
| 114 | + | if _, err := s.db.Apply(ctx, &b); err != nil { | |
| 115 | + | return fmt.Errorf("delete merge requests of %q: %w", name, err) | |
| 116 | + | } | |
| 117 | + | keys = keys[deleteChunk:] | |
| 118 | + | } | |
| 95 | 119 | var b kv.Batch | |
| 120 | + | for _, k := range keys { | |
| 121 | + | b.Delete(k) | |
| 122 | + | } | |
| 96 | 123 | b.Delete(repoKey(name)) | |
| 97 | 124 | if _, err := s.db.Apply(ctx, &b); err != nil { | |
| 98 | 125 | return fmt.Errorf("delete repository %q: %w", name, err) | |
| @@ -111,12 +138,23 @@ func encodeRepo(r domain.Repo) ([]byte, error) { | |||
| 111 | 138 | if err != nil { | |
| 112 | 139 | return nil, err | |
| 113 | 140 | } | |
| 141 | + | settings := r.Settings | |
| 142 | + | if settings.IsZero() { | |
| 143 | + | // Settings are always stored explicitly, so that a later change of | |
| 144 | + | // the defaults never alters a stored repository. | |
| 145 | + | settings = domain.DefaultRepoSettings() | |
| 146 | + | } | |
| 147 | + | pbSettings, err := encodeSettings(settings) | |
| 148 | + | if err != nil { | |
| 149 | + | return nil, err | |
| 150 | + | } | |
| 114 | 151 | pb := &storagepb.Repo{ | |
| 115 | 152 | Name: r.Name.String(), | |
| 116 | 153 | Description: r.Description, | |
| 117 | 154 | Visibility: vis, | |
| 118 | 155 | CreatedAt: encodeTime(r.CreatedAt), | |
| 119 | 156 | PushedAt: encodeTime(r.PushedAt), | |
| 157 | + | Settings: pbSettings, | |
| 120 | 158 | } | |
| 121 | 159 | return mustMarshal(pb), nil | |
| 122 | 160 | } | |
| @@ -136,6 +174,7 @@ func decodeRepo(val []byte) (domain.Repo, error) { | |||
| 136 | 174 | Visibility: decodeVisibility(pb.GetVisibility()), | |
| 137 | 175 | CreatedAt: decodeTime(pb.GetCreatedAt()), | |
| 138 | 176 | PushedAt: decodeTime(pb.GetPushedAt()), | |
| 177 | + | Settings: decodeSettings(pb.GetSettings()), | |
| 139 | 178 | }, nil | |
| 140 | 179 | } | |
| 141 | 180 | ||
| @@ -90,9 +90,36 @@ func (s *Store) Update(ctx context.Context, repo domain.Repo) error { | |||||
| 90 | return nil | 90 | return nil | ||
| 91 | } | 91 | } | ||
| 92 | 92 | ||||
| 93 | - | // Delete removes the repository; a missing one is not an error. | 93 | + | // deleteChunk is how many merge request keys one batch deletes before the |
| 94 | + | // last one, which also removes the repository. | |||
| 95 | + | const deleteChunk = 1000 | |||
| 96 | + | ||||
| 97 | + | // Delete removes the repository and everything keyed to it: its merge | |||
| 98 | + | // requests, their comments and read positions, and its number sequence. The | |||
| 99 | + | // Git data is moved to the trash by the caller; the merge requests are not | |||
| 100 | + | // kept anywhere, like the repository metadata, so a repository created again | |||
| 101 | + | // under the same name starts clean, with its numbers starting at 1. A missing | |||
| 102 | + | // repository is not an error. | |||
| 103 | + | // | |||
| 104 | + | // The merge request keys go first, in chunks, and the repository key goes with | |||
| 105 | + | // the last chunk: a delete that fails half way leaves the repository, which | |||
| 106 | + | // the caller can delete again. | |||
| 94 | func (s *Store) Delete(ctx context.Context, name domain.RepoName) error { | 107 | func (s *Store) Delete(ctx context.Context, name domain.RepoName) error { | ||
| 108 | + | keys := s.mergeRequestKeys(ctx, name) | |||
| 109 | + | for len(keys) > deleteChunk { | |||
| 110 | + | var b kv.Batch | |||
| 111 | + | for _, k := range keys[:deleteChunk] { | |||
| 112 | + | b.Delete(k) | |||
| 113 | + | } | |||
| 114 | + | if _, err := s.db.Apply(ctx, &b); err != nil { | |||
| 115 | + | return fmt.Errorf("delete merge requests of %q: %w", name, err) | |||
| 116 | + | } | |||
| 117 | + | keys = keys[deleteChunk:] | |||
| 118 | + | } | |||
| 95 | var b kv.Batch | 119 | var b kv.Batch | ||
| 120 | + | for _, k := range keys { | |||
| 121 | + | b.Delete(k) | |||
| 122 | + | } | |||
| 96 | b.Delete(repoKey(name)) | 123 | b.Delete(repoKey(name)) | ||
| 97 | if _, err := s.db.Apply(ctx, &b); err != nil { | 124 | if _, err := s.db.Apply(ctx, &b); err != nil { | ||
| 98 | return fmt.Errorf("delete repository %q: %w", name, err) | 125 | return fmt.Errorf("delete repository %q: %w", name, err) | ||
| @@ -111,12 +138,23 @@ func encodeRepo(r domain.Repo) ([]byte, error) { | |||||
| 111 | if err != nil { | 138 | if err != nil { | ||
| 112 | return nil, err | 139 | return nil, err | ||
| 113 | } | 140 | } | ||
| 141 | + | settings := r.Settings | |||
| 142 | + | if settings.IsZero() { | |||
| 143 | + | // Settings are always stored explicitly, so that a later change of | |||
| 144 | + | // the defaults never alters a stored repository. | |||
| 145 | + | settings = domain.DefaultRepoSettings() | |||
| 146 | + | } | |||
| 147 | + | pbSettings, err := encodeSettings(settings) | |||
| 148 | + | if err != nil { | |||
| 149 | + | return nil, err | |||
| 150 | + | } | |||
| 114 | pb := &storagepb.Repo{ | 151 | pb := &storagepb.Repo{ | ||
| 115 | Name: r.Name.String(), | 152 | Name: r.Name.String(), | ||
| 116 | Description: r.Description, | 153 | Description: r.Description, | ||
| 117 | Visibility: vis, | 154 | Visibility: vis, | ||
| 118 | CreatedAt: encodeTime(r.CreatedAt), | 155 | CreatedAt: encodeTime(r.CreatedAt), | ||
| 119 | PushedAt: encodeTime(r.PushedAt), | 156 | PushedAt: encodeTime(r.PushedAt), | ||
| 157 | + | Settings: pbSettings, | |||
| 120 | } | 158 | } | ||
| 121 | return mustMarshal(pb), nil | 159 | return mustMarshal(pb), nil | ||
| 122 | } | 160 | } | ||
| @@ -136,6 +174,7 @@ func decodeRepo(val []byte) (domain.Repo, error) { | |||||
| 136 | Visibility: decodeVisibility(pb.GetVisibility()), | 174 | Visibility: decodeVisibility(pb.GetVisibility()), | ||
| 137 | CreatedAt: decodeTime(pb.GetCreatedAt()), | 175 | CreatedAt: decodeTime(pb.GetCreatedAt()), | ||
| 138 | PushedAt: decodeTime(pb.GetPushedAt()), | 176 | PushedAt: decodeTime(pb.GetPushedAt()), | ||
| 177 | + | Settings: decodeSettings(pb.GetSettings()), | |||
| 139 | }, nil | 178 | }, nil | ||
| 140 | } | 179 | } | ||
| 141 | 180 | ||||
A internal/adapters/kvstore/signing.go +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package kvstore | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | kv "bnema.dev/ermite-kv" | |
| 12 | + | "google.golang.org/protobuf/proto" | |
| 13 | + | ||
| 14 | + | storagepb "bnema.dev/ermite/gen/ermite/storage" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | const signingKeyKey = "signing/key" | |
| 18 | + | ||
| 19 | + | // SigningSeedLen is the length of the stored seed of the signing key. | |
| 20 | + | const SigningSeedLen = 32 | |
| 21 | + | ||
| 22 | + | // SigningSeed returns the 32-byte seed of the commit signing key (ADR 0022). | |
| 23 | + | // When none is stored, it calls generate, stores what it returns and returns | |
| 24 | + | // that; of two concurrent first calls one wins and both return its seed. The | |
| 25 | + | // seed is the private key: neither it nor an error carrying it is logged. | |
| 26 | + | func (s *Store) SigningSeed(ctx context.Context, generate func() ([]byte, error), now time.Time) ([]byte, error) { | |
| 27 | + | key := []byte(signingKeyKey) | |
| 28 | + | for range 2 { | |
| 29 | + | if item, ok := s.db.Get(ctx, key); ok { | |
| 30 | + | return decodeSigningSeed(item.Value) | |
| 31 | + | } | |
| 32 | + | seed, err := generate() | |
| 33 | + | if err != nil { | |
| 34 | + | return nil, fmt.Errorf("generate the signing key: %w", err) | |
| 35 | + | } | |
| 36 | + | if len(seed) != SigningSeedLen { | |
| 37 | + | return nil, fmt.Errorf("generate the signing key: seed has %d bytes, want %d", len(seed), SigningSeedLen) | |
| 38 | + | } | |
| 39 | + | var b kv.Batch | |
| 40 | + | b.RequireAbsent(key) | |
| 41 | + | b.Put(key, mustMarshal(&storagepb.SigningKey{Seed: seed, CreatedAt: encodeTime(now)})) | |
| 42 | + | _, err = s.db.Apply(ctx, &b) | |
| 43 | + | if errors.Is(err, kv.ErrConflict) { | |
| 44 | + | continue // written meanwhile: read it | |
| 45 | + | } | |
| 46 | + | if err != nil { | |
| 47 | + | return nil, fmt.Errorf("store the signing key: %w", err) | |
| 48 | + | } | |
| 49 | + | return seed, nil | |
| 50 | + | } | |
| 51 | + | return nil, errors.New("store the signing key: concurrent initialization did not settle") | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func decodeSigningSeed(val []byte) ([]byte, error) { | |
| 55 | + | var pb storagepb.SigningKey | |
| 56 | + | if err := proto.Unmarshal(val, &pb); err != nil { | |
| 57 | + | return nil, fmt.Errorf("decode the signing key: %w", err) | |
| 58 | + | } | |
| 59 | + | if len(pb.GetSeed()) != SigningSeedLen { | |
| 60 | + | return nil, fmt.Errorf("decode the signing key: seed has %d bytes, want %d", len(pb.GetSeed()), SigningSeedLen) | |
| 61 | + | } | |
| 62 | + | return pb.GetSeed(), nil | |
| 63 | + | } | |
| @@ -0,0 +1,63 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package kvstore | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | kv "bnema.dev/ermite-kv" | |||
| 12 | + | "google.golang.org/protobuf/proto" | |||
| 13 | + | ||||
| 14 | + | storagepb "bnema.dev/ermite/gen/ermite/storage" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | const signingKeyKey = "signing/key" | |||
| 18 | + | ||||
| 19 | + | // SigningSeedLen is the length of the stored seed of the signing key. | |||
| 20 | + | const SigningSeedLen = 32 | |||
| 21 | + | ||||
| 22 | + | // SigningSeed returns the 32-byte seed of the commit signing key (ADR 0022). | |||
| 23 | + | // When none is stored, it calls generate, stores what it returns and returns | |||
| 24 | + | // that; of two concurrent first calls one wins and both return its seed. The | |||
| 25 | + | // seed is the private key: neither it nor an error carrying it is logged. | |||
| 26 | + | func (s *Store) SigningSeed(ctx context.Context, generate func() ([]byte, error), now time.Time) ([]byte, error) { | |||
| 27 | + | key := []byte(signingKeyKey) | |||
| 28 | + | for range 2 { | |||
| 29 | + | if item, ok := s.db.Get(ctx, key); ok { | |||
| 30 | + | return decodeSigningSeed(item.Value) | |||
| 31 | + | } | |||
| 32 | + | seed, err := generate() | |||
| 33 | + | if err != nil { | |||
| 34 | + | return nil, fmt.Errorf("generate the signing key: %w", err) | |||
| 35 | + | } | |||
| 36 | + | if len(seed) != SigningSeedLen { | |||
| 37 | + | return nil, fmt.Errorf("generate the signing key: seed has %d bytes, want %d", len(seed), SigningSeedLen) | |||
| 38 | + | } | |||
| 39 | + | var b kv.Batch | |||
| 40 | + | b.RequireAbsent(key) | |||
| 41 | + | b.Put(key, mustMarshal(&storagepb.SigningKey{Seed: seed, CreatedAt: encodeTime(now)})) | |||
| 42 | + | _, err = s.db.Apply(ctx, &b) | |||
| 43 | + | if errors.Is(err, kv.ErrConflict) { | |||
| 44 | + | continue // written meanwhile: read it | |||
| 45 | + | } | |||
| 46 | + | if err != nil { | |||
| 47 | + | return nil, fmt.Errorf("store the signing key: %w", err) | |||
| 48 | + | } | |||
| 49 | + | return seed, nil | |||
| 50 | + | } | |||
| 51 | + | return nil, errors.New("store the signing key: concurrent initialization did not settle") | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func decodeSigningSeed(val []byte) ([]byte, error) { | |||
| 55 | + | var pb storagepb.SigningKey | |||
| 56 | + | if err := proto.Unmarshal(val, &pb); err != nil { | |||
| 57 | + | return nil, fmt.Errorf("decode the signing key: %w", err) | |||
| 58 | + | } | |||
| 59 | + | if len(pb.GetSeed()) != SigningSeedLen { | |||
| 60 | + | return nil, fmt.Errorf("decode the signing key: seed has %d bytes, want %d", len(pb.GetSeed()), SigningSeedLen) | |||
| 61 | + | } | |||
| 62 | + | return pb.GetSeed(), nil | |||
| 63 | + | } | |||
M internal/adapters/kvstore/store_test.go +3 -3
| @@ -42,8 +42,8 @@ func TestCreateGetListUpdateDelete(t *testing.T) { | |||
| 42 | 42 | ctx := context.Background() | |
| 43 | 43 | s := openStore(t, t.TempDir()) | |
| 44 | 44 | ||
| 45 | - | b := domain.Repo{Name: name(t, "b"), Description: "second", Visibility: domain.VisibilityPublic, CreatedAt: created} | |
| 46 | - | a := domain.Repo{Name: name(t, "a"), Visibility: domain.VisibilityPrivate, CreatedAt: created, PushedAt: created.Add(time.Hour)} | |
| 45 | + | b := domain.Repo{Name: name(t, "b"), Description: "second", Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()} | |
| 46 | + | a := domain.Repo{Name: name(t, "a"), Visibility: domain.VisibilityPrivate, CreatedAt: created, PushedAt: created.Add(time.Hour), Settings: domain.DefaultRepoSettings()} | |
| 47 | 47 | require.NoError(t, s.Create(ctx, b)) | |
| 48 | 48 | require.NoError(t, s.Create(ctx, a)) | |
| 49 | 49 | ||
| @@ -129,7 +129,7 @@ func TestPersistsAcrossReopen(t *testing.T) { | |||
| 129 | 129 | dir := t.TempDir() | |
| 130 | 130 | s, err := kvstore.Open(ctx, dir) | |
| 131 | 131 | require.NoError(t, err) | |
| 132 | - | r := domain.Repo{Name: name(t, "keep"), Visibility: domain.VisibilityPublic, CreatedAt: created} | |
| 132 | + | r := domain.Repo{Name: name(t, "keep"), Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()} | |
| 133 | 133 | require.NoError(t, s.Create(ctx, r)) | |
| 134 | 134 | require.NoError(t, s.Close(ctx)) | |
| 135 | 135 | ||
| @@ -42,8 +42,8 @@ func TestCreateGetListUpdateDelete(t *testing.T) { | |||||
| 42 | ctx := context.Background() | 42 | ctx := context.Background() | ||
| 43 | s := openStore(t, t.TempDir()) | 43 | s := openStore(t, t.TempDir()) | ||
| 44 | 44 | ||||
| 45 | - | b := domain.Repo{Name: name(t, "b"), Description: "second", Visibility: domain.VisibilityPublic, CreatedAt: created} | 45 | + | b := domain.Repo{Name: name(t, "b"), Description: "second", Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()} |
| 46 | - | a := domain.Repo{Name: name(t, "a"), Visibility: domain.VisibilityPrivate, CreatedAt: created, PushedAt: created.Add(time.Hour)} | 46 | + | a := domain.Repo{Name: name(t, "a"), Visibility: domain.VisibilityPrivate, CreatedAt: created, PushedAt: created.Add(time.Hour), Settings: domain.DefaultRepoSettings()} |
| 47 | require.NoError(t, s.Create(ctx, b)) | 47 | require.NoError(t, s.Create(ctx, b)) | ||
| 48 | require.NoError(t, s.Create(ctx, a)) | 48 | require.NoError(t, s.Create(ctx, a)) | ||
| 49 | 49 | ||||
| @@ -129,7 +129,7 @@ func TestPersistsAcrossReopen(t *testing.T) { | |||||
| 129 | dir := t.TempDir() | 129 | dir := t.TempDir() | ||
| 130 | s, err := kvstore.Open(ctx, dir) | 130 | s, err := kvstore.Open(ctx, dir) | ||
| 131 | require.NoError(t, err) | 131 | require.NoError(t, err) | ||
| 132 | - | r := domain.Repo{Name: name(t, "keep"), Visibility: domain.VisibilityPublic, CreatedAt: created} | 132 | + | r := domain.Repo{Name: name(t, "keep"), Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()} |
| 133 | require.NoError(t, s.Create(ctx, r)) | 133 | require.NoError(t, s.Create(ctx, r)) | ||
| 134 | require.NoError(t, s.Close(ctx)) | 134 | require.NoError(t, s.Close(ctx)) | ||
| 135 | 135 | ||||
A proto/ermite/storage/merge_request.proto +84 -0
| @@ -0,0 +1,84 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Internal, unversioned storage schema of ermite: merge requests. | |
| 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 | + | // MergeMode is how a merge request is merged. | |
| 14 | + | enum MergeMode { | |
| 15 | + | MERGE_MODE_UNSPECIFIED = 0; | |
| 16 | + | MERGE_MODE_FAST_FORWARD = 1; | |
| 17 | + | MERGE_MODE_SQUASH = 2; | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // RepoSettings are the merge settings of a repository, stored inside Repo. | |
| 21 | + | message RepoSettings { | |
| 22 | + | // Never empty once stored. | |
| 23 | + | repeated MergeMode allowed_merge_modes = 1; | |
| 24 | + | bool failing_checks_block = 2; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // MergeRequestState is where a merge request is in its life. | |
| 28 | + | enum MergeRequestState { | |
| 29 | + | MERGE_REQUEST_STATE_UNSPECIFIED = 0; | |
| 30 | + | MERGE_REQUEST_STATE_OPEN = 1; | |
| 31 | + | MERGE_REQUEST_STATE_MERGED = 2; | |
| 32 | + | MERGE_REQUEST_STATE_CLOSED = 3; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // DiffSide is the side of a diff a comment is attached to. | |
| 36 | + | enum DiffSide { | |
| 37 | + | // A general comment. | |
| 38 | + | DIFF_SIDE_UNSPECIFIED = 0; | |
| 39 | + | DIFF_SIDE_OLD = 1; | |
| 40 | + | DIFF_SIDE_NEW = 2; | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // MergeRequest is stored under the key "mr/<repo>/<number:010d>". | |
| 44 | + | message MergeRequest { | |
| 45 | + | int64 number = 1; | |
| 46 | + | string title = 2; | |
| 47 | + | string description = 3; | |
| 48 | + | string source_branch = 4; | |
| 49 | + | string target_branch = 5; | |
| 50 | + | MergeRequestState state = 6; | |
| 51 | + | // Label of the token that opened it. | |
| 52 | + | string author = 7; | |
| 53 | + | google.protobuf.Timestamp created_at = 8; | |
| 54 | + | google.protobuf.Timestamp updated_at = 9; | |
| 55 | + | // Head of the source branch when the request was merged or closed; empty | |
| 56 | + | // while it is open (the head is read from Git then). | |
| 57 | + | string head_id = 10; | |
| 58 | + | // Merge base of the target and the head when the request was merged or | |
| 59 | + | // closed, so that its diff stays readable after the merge. | |
| 60 | + | string base_id = 11; | |
| 61 | + | string merged_commit_id = 12; | |
| 62 | + | MergeMode merge_mode = 13; | |
| 63 | + | // Last comment id handed out for this request. | |
| 64 | + | int64 comment_seq = 14; | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Comment is stored under the key "mrc/<repo>/<number:010d>/<id:010d>". | |
| 68 | + | message Comment { | |
| 69 | + | int64 id = 1; | |
| 70 | + | string author = 2; | |
| 71 | + | string path = 3; | |
| 72 | + | int32 line = 4; | |
| 73 | + | DiffSide side = 5; | |
| 74 | + | string commit_id = 6; | |
| 75 | + | string text = 7; | |
| 76 | + | bool resolved = 8; | |
| 77 | + | google.protobuf.Timestamp created_at = 9; | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // Sequence is stored under the key "mrseq/<repo>": the last merge request | |
| 81 | + | // number handed out for the repository. | |
| 82 | + | message Sequence { | |
| 83 | + | int64 last = 1; | |
| 84 | + | } | |
| @@ -0,0 +1,84 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | // Internal, unversioned storage schema of ermite: merge requests. | |||
| 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 | + | // MergeMode is how a merge request is merged. | |||
| 14 | + | enum MergeMode { | |||
| 15 | + | MERGE_MODE_UNSPECIFIED = 0; | |||
| 16 | + | MERGE_MODE_FAST_FORWARD = 1; | |||
| 17 | + | MERGE_MODE_SQUASH = 2; | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // RepoSettings are the merge settings of a repository, stored inside Repo. | |||
| 21 | + | message RepoSettings { | |||
| 22 | + | // Never empty once stored. | |||
| 23 | + | repeated MergeMode allowed_merge_modes = 1; | |||
| 24 | + | bool failing_checks_block = 2; | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // MergeRequestState is where a merge request is in its life. | |||
| 28 | + | enum MergeRequestState { | |||
| 29 | + | MERGE_REQUEST_STATE_UNSPECIFIED = 0; | |||
| 30 | + | MERGE_REQUEST_STATE_OPEN = 1; | |||
| 31 | + | MERGE_REQUEST_STATE_MERGED = 2; | |||
| 32 | + | MERGE_REQUEST_STATE_CLOSED = 3; | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // DiffSide is the side of a diff a comment is attached to. | |||
| 36 | + | enum DiffSide { | |||
| 37 | + | // A general comment. | |||
| 38 | + | DIFF_SIDE_UNSPECIFIED = 0; | |||
| 39 | + | DIFF_SIDE_OLD = 1; | |||
| 40 | + | DIFF_SIDE_NEW = 2; | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // MergeRequest is stored under the key "mr/<repo>/<number:010d>". | |||
| 44 | + | message MergeRequest { | |||
| 45 | + | int64 number = 1; | |||
| 46 | + | string title = 2; | |||
| 47 | + | string description = 3; | |||
| 48 | + | string source_branch = 4; | |||
| 49 | + | string target_branch = 5; | |||
| 50 | + | MergeRequestState state = 6; | |||
| 51 | + | // Label of the token that opened it. | |||
| 52 | + | string author = 7; | |||
| 53 | + | google.protobuf.Timestamp created_at = 8; | |||
| 54 | + | google.protobuf.Timestamp updated_at = 9; | |||
| 55 | + | // Head of the source branch when the request was merged or closed; empty | |||
| 56 | + | // while it is open (the head is read from Git then). | |||
| 57 | + | string head_id = 10; | |||
| 58 | + | // Merge base of the target and the head when the request was merged or | |||
| 59 | + | // closed, so that its diff stays readable after the merge. | |||
| 60 | + | string base_id = 11; | |||
| 61 | + | string merged_commit_id = 12; | |||
| 62 | + | MergeMode merge_mode = 13; | |||
| 63 | + | // Last comment id handed out for this request. | |||
| 64 | + | int64 comment_seq = 14; | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // Comment is stored under the key "mrc/<repo>/<number:010d>/<id:010d>". | |||
| 68 | + | message Comment { | |||
| 69 | + | int64 id = 1; | |||
| 70 | + | string author = 2; | |||
| 71 | + | string path = 3; | |||
| 72 | + | int32 line = 4; | |||
| 73 | + | DiffSide side = 5; | |||
| 74 | + | string commit_id = 6; | |||
| 75 | + | string text = 7; | |||
| 76 | + | bool resolved = 8; | |||
| 77 | + | google.protobuf.Timestamp created_at = 9; | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // Sequence is stored under the key "mrseq/<repo>": the last merge request | |||
| 81 | + | // number handed out for the repository. | |||
| 82 | + | message Sequence { | |||
| 83 | + | int64 last = 1; | |||
| 84 | + | } | |||
M proto/ermite/storage/repo.proto +4 -0
| @@ -6,6 +6,7 @@ syntax = "proto3"; | |||
| 6 | 6 | ||
| 7 | 7 | package ermite.storage; | |
| 8 | 8 | ||
| 9 | + | import "ermite/storage/merge_request.proto"; | |
| 9 | 10 | import "google/protobuf/timestamp.proto"; | |
| 10 | 11 | ||
| 11 | 12 | option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb"; | |
| @@ -26,6 +27,9 @@ message Repo { | |||
| 26 | 27 | google.protobuf.Timestamp created_at = 4; | |
| 27 | 28 | // Unset until the first push. | |
| 28 | 29 | google.protobuf.Timestamp pushed_at = 5; | |
| 30 | + | // Unset on repositories stored before merge requests: they read as the | |
| 31 | + | // default settings. | |
| 32 | + | RepoSettings settings = 6; | |
| 29 | 33 | } | |
| 30 | 34 | ||
| 31 | 35 | // SchemaVersion is stored under the key "meta/schema". | |
| @@ -6,6 +6,7 @@ syntax = "proto3"; | |||||
| 6 | 6 | ||||
| 7 | package ermite.storage; | 7 | package ermite.storage; | ||
| 8 | 8 | ||||
| 9 | + | import "ermite/storage/merge_request.proto"; | |||
| 9 | import "google/protobuf/timestamp.proto"; | 10 | import "google/protobuf/timestamp.proto"; | ||
| 10 | 11 | ||||
| 11 | option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb"; | 12 | option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb"; | ||
| @@ -26,6 +27,9 @@ message Repo { | |||||
| 26 | google.protobuf.Timestamp created_at = 4; | 27 | google.protobuf.Timestamp created_at = 4; | ||
| 27 | // Unset until the first push. | 28 | // Unset until the first push. | ||
| 28 | google.protobuf.Timestamp pushed_at = 5; | 29 | google.protobuf.Timestamp pushed_at = 5; | ||
| 30 | + | // Unset on repositories stored before merge requests: they read as the | |||
| 31 | + | // default settings. | |||
| 32 | + | RepoSettings settings = 6; | |||
| 29 | } | 33 | } | ||
| 30 | 34 | ||||
| 31 | // SchemaVersion is stored under the key "meta/schema". | 35 | // SchemaVersion is stored under the key "meta/schema". | ||
A proto/ermite/storage/signing.proto +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Internal, unversioned storage schema of ermite: the commit signing key. | |
| 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 | + | // SigningKey is stored under the key "signing/key". The seed is the private | |
| 14 | + | // key: it must never be logged. | |
| 15 | + | message SigningKey { | |
| 16 | + | // The 32-byte seed of an ed25519 key. | |
| 17 | + | bytes seed = 1; | |
| 18 | + | google.protobuf.Timestamp created_at = 2; | |
| 19 | + | } | |
| @@ -0,0 +1,19 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | // Internal, unversioned storage schema of ermite: the commit signing key. | |||
| 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 | + | // SigningKey is stored under the key "signing/key". The seed is the private | |||
| 14 | + | // key: it must never be logged. | |||
| 15 | + | message SigningKey { | |||
| 16 | + | // The 32-byte seed of an ed25519 key. | |||
| 17 | + | bytes seed = 1; | |||
| 18 | + | google.protobuf.Timestamp created_at = 2; | |||
| 19 | + | } | |||