detached 858a2fb

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +1983 -15
A gen/ermite/storage/merge_request.pb.go +664 -0
@@ -0,0 +1,664 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Internal, unversioned storage schema of ermite: merge requests.
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/merge_request.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+// MergeMode is how a merge request is merged.
30+type MergeMode int32
31+
32+const (
33+ MergeMode_MERGE_MODE_UNSPECIFIED MergeMode = 0
34+ MergeMode_MERGE_MODE_FAST_FORWARD MergeMode = 1
35+ MergeMode_MERGE_MODE_SQUASH MergeMode = 2
36+)
37+
38+// Enum value maps for MergeMode.
39+var (
40+ MergeMode_name = map[int32]string{
41+ 0: "MERGE_MODE_UNSPECIFIED",
42+ 1: "MERGE_MODE_FAST_FORWARD",
43+ 2: "MERGE_MODE_SQUASH",
44+ }
45+ MergeMode_value = map[string]int32{
46+ "MERGE_MODE_UNSPECIFIED": 0,
47+ "MERGE_MODE_FAST_FORWARD": 1,
48+ "MERGE_MODE_SQUASH": 2,
49+ }
50+)
51+
52+func (x MergeMode) Enum() *MergeMode {
53+ p := new(MergeMode)
54+ *p = x
55+ return p
56+}
57+
58+func (x MergeMode) String() string {
59+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
60+}
61+
62+func (MergeMode) Descriptor() protoreflect.EnumDescriptor {
63+ return file_ermite_storage_merge_request_proto_enumTypes[0].Descriptor()
64+}
65+
66+func (MergeMode) Type() protoreflect.EnumType {
67+ return &file_ermite_storage_merge_request_proto_enumTypes[0]
68+}
69+
70+func (x MergeMode) Number() protoreflect.EnumNumber {
71+ return protoreflect.EnumNumber(x)
72+}
73+
74+// Deprecated: Use MergeMode.Descriptor instead.
75+func (MergeMode) EnumDescriptor() ([]byte, []int) {
76+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{0}
77+}
78+
79+// MergeRequestState is where a merge request is in its life.
80+type MergeRequestState int32
81+
82+const (
83+ MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED MergeRequestState = 0
84+ MergeRequestState_MERGE_REQUEST_STATE_OPEN MergeRequestState = 1
85+ MergeRequestState_MERGE_REQUEST_STATE_MERGED MergeRequestState = 2
86+ MergeRequestState_MERGE_REQUEST_STATE_CLOSED MergeRequestState = 3
87+)
88+
89+// Enum value maps for MergeRequestState.
90+var (
91+ MergeRequestState_name = map[int32]string{
92+ 0: "MERGE_REQUEST_STATE_UNSPECIFIED",
93+ 1: "MERGE_REQUEST_STATE_OPEN",
94+ 2: "MERGE_REQUEST_STATE_MERGED",
95+ 3: "MERGE_REQUEST_STATE_CLOSED",
96+ }
97+ MergeRequestState_value = map[string]int32{
98+ "MERGE_REQUEST_STATE_UNSPECIFIED": 0,
99+ "MERGE_REQUEST_STATE_OPEN": 1,
100+ "MERGE_REQUEST_STATE_MERGED": 2,
101+ "MERGE_REQUEST_STATE_CLOSED": 3,
102+ }
103+)
104+
105+func (x MergeRequestState) Enum() *MergeRequestState {
106+ p := new(MergeRequestState)
107+ *p = x
108+ return p
109+}
110+
111+func (x MergeRequestState) String() string {
112+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
113+}
114+
115+func (MergeRequestState) Descriptor() protoreflect.EnumDescriptor {
116+ return file_ermite_storage_merge_request_proto_enumTypes[1].Descriptor()
117+}
118+
119+func (MergeRequestState) Type() protoreflect.EnumType {
120+ return &file_ermite_storage_merge_request_proto_enumTypes[1]
121+}
122+
123+func (x MergeRequestState) Number() protoreflect.EnumNumber {
124+ return protoreflect.EnumNumber(x)
125+}
126+
127+// Deprecated: Use MergeRequestState.Descriptor instead.
128+func (MergeRequestState) EnumDescriptor() ([]byte, []int) {
129+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{1}
130+}
131+
132+// DiffSide is the side of a diff a comment is attached to.
133+type DiffSide int32
134+
135+const (
136+ // A general comment.
137+ DiffSide_DIFF_SIDE_UNSPECIFIED DiffSide = 0
138+ DiffSide_DIFF_SIDE_OLD DiffSide = 1
139+ DiffSide_DIFF_SIDE_NEW DiffSide = 2
140+)
141+
142+// Enum value maps for DiffSide.
143+var (
144+ DiffSide_name = map[int32]string{
145+ 0: "DIFF_SIDE_UNSPECIFIED",
146+ 1: "DIFF_SIDE_OLD",
147+ 2: "DIFF_SIDE_NEW",
148+ }
149+ DiffSide_value = map[string]int32{
150+ "DIFF_SIDE_UNSPECIFIED": 0,
151+ "DIFF_SIDE_OLD": 1,
152+ "DIFF_SIDE_NEW": 2,
153+ }
154+)
155+
156+func (x DiffSide) Enum() *DiffSide {
157+ p := new(DiffSide)
158+ *p = x
159+ return p
160+}
161+
162+func (x DiffSide) String() string {
163+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
164+}
165+
166+func (DiffSide) Descriptor() protoreflect.EnumDescriptor {
167+ return file_ermite_storage_merge_request_proto_enumTypes[2].Descriptor()
168+}
169+
170+func (DiffSide) Type() protoreflect.EnumType {
171+ return &file_ermite_storage_merge_request_proto_enumTypes[2]
172+}
173+
174+func (x DiffSide) Number() protoreflect.EnumNumber {
175+ return protoreflect.EnumNumber(x)
176+}
177+
178+// Deprecated: Use DiffSide.Descriptor instead.
179+func (DiffSide) EnumDescriptor() ([]byte, []int) {
180+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{2}
181+}
182+
183+// RepoSettings are the merge settings of a repository, stored inside Repo.
184+type RepoSettings struct {
185+ state protoimpl.MessageState `protogen:"open.v1"`
186+ // Never empty once stored.
187+ AllowedMergeModes []MergeMode `protobuf:"varint,1,rep,packed,name=allowed_merge_modes,json=allowedMergeModes,proto3,enum=ermite.storage.MergeMode" json:"allowed_merge_modes,omitempty"`
188+ FailingChecksBlock bool `protobuf:"varint,2,opt,name=failing_checks_block,json=failingChecksBlock,proto3" json:"failing_checks_block,omitempty"`
189+ unknownFields protoimpl.UnknownFields
190+ sizeCache protoimpl.SizeCache
191+}
192+
193+func (x *RepoSettings) Reset() {
194+ *x = RepoSettings{}
195+ mi := &file_ermite_storage_merge_request_proto_msgTypes[0]
196+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
197+ ms.StoreMessageInfo(mi)
198+}
199+
200+func (x *RepoSettings) String() string {
201+ return protoimpl.X.MessageStringOf(x)
202+}
203+
204+func (*RepoSettings) ProtoMessage() {}
205+
206+func (x *RepoSettings) ProtoReflect() protoreflect.Message {
207+ mi := &file_ermite_storage_merge_request_proto_msgTypes[0]
208+ if x != nil {
209+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
210+ if ms.LoadMessageInfo() == nil {
211+ ms.StoreMessageInfo(mi)
212+ }
213+ return ms
214+ }
215+ return mi.MessageOf(x)
216+}
217+
218+// Deprecated: Use RepoSettings.ProtoReflect.Descriptor instead.
219+func (*RepoSettings) Descriptor() ([]byte, []int) {
220+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{0}
221+}
222+
223+func (x *RepoSettings) GetAllowedMergeModes() []MergeMode {
224+ if x != nil {
225+ return x.AllowedMergeModes
226+ }
227+ return nil
228+}
229+
230+func (x *RepoSettings) GetFailingChecksBlock() bool {
231+ if x != nil {
232+ return x.FailingChecksBlock
233+ }
234+ return false
235+}
236+
237+// MergeRequest is stored under the key "mr/<repo>/<number:010d>".
238+type MergeRequest struct {
239+ state protoimpl.MessageState `protogen:"open.v1"`
240+ Number int64 `protobuf:"varint,1,opt,name=number,proto3" json:"number,omitempty"`
241+ Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
242+ Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"`
243+ SourceBranch string `protobuf:"bytes,4,opt,name=source_branch,json=sourceBranch,proto3" json:"source_branch,omitempty"`
244+ TargetBranch string `protobuf:"bytes,5,opt,name=target_branch,json=targetBranch,proto3" json:"target_branch,omitempty"`
245+ State MergeRequestState `protobuf:"varint,6,opt,name=state,proto3,enum=ermite.storage.MergeRequestState" json:"state,omitempty"`
246+ // Label of the token that opened it.
247+ Author string `protobuf:"bytes,7,opt,name=author,proto3" json:"author,omitempty"`
248+ CreatedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
249+ UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"`
250+ // Head of the source branch when the request was merged or closed; empty
251+ // while it is open (the head is read from Git then).
252+ HeadId string `protobuf:"bytes,10,opt,name=head_id,json=headId,proto3" json:"head_id,omitempty"`
253+ // Merge base of the target and the head when the request was merged or
254+ // closed, so that its diff stays readable after the merge.
255+ BaseId string `protobuf:"bytes,11,opt,name=base_id,json=baseId,proto3" json:"base_id,omitempty"`
256+ MergedCommitId string `protobuf:"bytes,12,opt,name=merged_commit_id,json=mergedCommitId,proto3" json:"merged_commit_id,omitempty"`
257+ MergeMode MergeMode `protobuf:"varint,13,opt,name=merge_mode,json=mergeMode,proto3,enum=ermite.storage.MergeMode" json:"merge_mode,omitempty"`
258+ // Last comment id handed out for this request.
259+ CommentSeq int64 `protobuf:"varint,14,opt,name=comment_seq,json=commentSeq,proto3" json:"comment_seq,omitempty"`
260+ unknownFields protoimpl.UnknownFields
261+ sizeCache protoimpl.SizeCache
262+}
263+
264+func (x *MergeRequest) Reset() {
265+ *x = MergeRequest{}
266+ mi := &file_ermite_storage_merge_request_proto_msgTypes[1]
267+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
268+ ms.StoreMessageInfo(mi)
269+}
270+
271+func (x *MergeRequest) String() string {
272+ return protoimpl.X.MessageStringOf(x)
273+}
274+
275+func (*MergeRequest) ProtoMessage() {}
276+
277+func (x *MergeRequest) ProtoReflect() protoreflect.Message {
278+ mi := &file_ermite_storage_merge_request_proto_msgTypes[1]
279+ if x != nil {
280+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
281+ if ms.LoadMessageInfo() == nil {
282+ ms.StoreMessageInfo(mi)
283+ }
284+ return ms
285+ }
286+ return mi.MessageOf(x)
287+}
288+
289+// Deprecated: Use MergeRequest.ProtoReflect.Descriptor instead.
290+func (*MergeRequest) Descriptor() ([]byte, []int) {
291+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{1}
292+}
293+
294+func (x *MergeRequest) GetNumber() int64 {
295+ if x != nil {
296+ return x.Number
297+ }
298+ return 0
299+}
300+
301+func (x *MergeRequest) GetTitle() string {
302+ if x != nil {
303+ return x.Title
304+ }
305+ return ""
306+}
307+
308+func (x *MergeRequest) GetDescription() string {
309+ if x != nil {
310+ return x.Description
311+ }
312+ return ""
313+}
314+
315+func (x *MergeRequest) GetSourceBranch() string {
316+ if x != nil {
317+ return x.SourceBranch
318+ }
319+ return ""
320+}
321+
322+func (x *MergeRequest) GetTargetBranch() string {
323+ if x != nil {
324+ return x.TargetBranch
325+ }
326+ return ""
327+}
328+
329+func (x *MergeRequest) GetState() MergeRequestState {
330+ if x != nil {
331+ return x.State
332+ }
333+ return MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED
334+}
335+
336+func (x *MergeRequest) GetAuthor() string {
337+ if x != nil {
338+ return x.Author
339+ }
340+ return ""
341+}
342+
343+func (x *MergeRequest) GetCreatedAt() *timestamppb.Timestamp {
344+ if x != nil {
345+ return x.CreatedAt
346+ }
347+ return nil
348+}
349+
350+func (x *MergeRequest) GetUpdatedAt() *timestamppb.Timestamp {
351+ if x != nil {
352+ return x.UpdatedAt
353+ }
354+ return nil
355+}
356+
357+func (x *MergeRequest) GetHeadId() string {
358+ if x != nil {
359+ return x.HeadId
360+ }
361+ return ""
362+}
363+
364+func (x *MergeRequest) GetBaseId() string {
365+ if x != nil {
366+ return x.BaseId
367+ }
368+ return ""
369+}
370+
371+func (x *MergeRequest) GetMergedCommitId() string {
372+ if x != nil {
373+ return x.MergedCommitId
374+ }
375+ return ""
376+}
377+
378+func (x *MergeRequest) GetMergeMode() MergeMode {
379+ if x != nil {
380+ return x.MergeMode
381+ }
382+ return MergeMode_MERGE_MODE_UNSPECIFIED
383+}
384+
385+func (x *MergeRequest) GetCommentSeq() int64 {
386+ if x != nil {
387+ return x.CommentSeq
388+ }
389+ return 0
390+}
391+
392+// Comment is stored under the key "mrc/<repo>/<number:010d>/<id:010d>".
393+type Comment struct {
394+ state protoimpl.MessageState `protogen:"open.v1"`
395+ Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
396+ Author string `protobuf:"bytes,2,opt,name=author,proto3" json:"author,omitempty"`
397+ Path string `protobuf:"bytes,3,opt,name=path,proto3" json:"path,omitempty"`
398+ Line int32 `protobuf:"varint,4,opt,name=line,proto3" json:"line,omitempty"`
399+ Side DiffSide `protobuf:"varint,5,opt,name=side,proto3,enum=ermite.storage.DiffSide" json:"side,omitempty"`
400+ CommitId string `protobuf:"bytes,6,opt,name=commit_id,json=commitId,proto3" json:"commit_id,omitempty"`
401+ Text string `protobuf:"bytes,7,opt,name=text,proto3" json:"text,omitempty"`
402+ Resolved bool `protobuf:"varint,8,opt,name=resolved,proto3" json:"resolved,omitempty"`
403+ CreatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
404+ unknownFields protoimpl.UnknownFields
405+ sizeCache protoimpl.SizeCache
406+}
407+
408+func (x *Comment) Reset() {
409+ *x = Comment{}
410+ mi := &file_ermite_storage_merge_request_proto_msgTypes[2]
411+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
412+ ms.StoreMessageInfo(mi)
413+}
414+
415+func (x *Comment) String() string {
416+ return protoimpl.X.MessageStringOf(x)
417+}
418+
419+func (*Comment) ProtoMessage() {}
420+
421+func (x *Comment) ProtoReflect() protoreflect.Message {
422+ mi := &file_ermite_storage_merge_request_proto_msgTypes[2]
423+ if x != nil {
424+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
425+ if ms.LoadMessageInfo() == nil {
426+ ms.StoreMessageInfo(mi)
427+ }
428+ return ms
429+ }
430+ return mi.MessageOf(x)
431+}
432+
433+// Deprecated: Use Comment.ProtoReflect.Descriptor instead.
434+func (*Comment) Descriptor() ([]byte, []int) {
435+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{2}
436+}
437+
438+func (x *Comment) GetId() int64 {
439+ if x != nil {
440+ return x.Id
441+ }
442+ return 0
443+}
444+
445+func (x *Comment) GetAuthor() string {
446+ if x != nil {
447+ return x.Author
448+ }
449+ return ""
450+}
451+
452+func (x *Comment) GetPath() string {
453+ if x != nil {
454+ return x.Path
455+ }
456+ return ""
457+}
458+
459+func (x *Comment) GetLine() int32 {
460+ if x != nil {
461+ return x.Line
462+ }
463+ return 0
464+}
465+
466+func (x *Comment) GetSide() DiffSide {
467+ if x != nil {
468+ return x.Side
469+ }
470+ return DiffSide_DIFF_SIDE_UNSPECIFIED
471+}
472+
473+func (x *Comment) GetCommitId() string {
474+ if x != nil {
475+ return x.CommitId
476+ }
477+ return ""
478+}
479+
480+func (x *Comment) GetText() string {
481+ if x != nil {
482+ return x.Text
483+ }
484+ return ""
485+}
486+
487+func (x *Comment) GetResolved() bool {
488+ if x != nil {
489+ return x.Resolved
490+ }
491+ return false
492+}
493+
494+func (x *Comment) GetCreatedAt() *timestamppb.Timestamp {
495+ if x != nil {
496+ return x.CreatedAt
497+ }
498+ return nil
499+}
500+
501+// Sequence is stored under the key "mrseq/<repo>": the last merge request
502+// number handed out for the repository.
503+type Sequence struct {
504+ state protoimpl.MessageState `protogen:"open.v1"`
505+ Last int64 `protobuf:"varint,1,opt,name=last,proto3" json:"last,omitempty"`
506+ unknownFields protoimpl.UnknownFields
507+ sizeCache protoimpl.SizeCache
508+}
509+
510+func (x *Sequence) Reset() {
511+ *x = Sequence{}
512+ mi := &file_ermite_storage_merge_request_proto_msgTypes[3]
513+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
514+ ms.StoreMessageInfo(mi)
515+}
516+
517+func (x *Sequence) String() string {
518+ return protoimpl.X.MessageStringOf(x)
519+}
520+
521+func (*Sequence) ProtoMessage() {}
522+
523+func (x *Sequence) ProtoReflect() protoreflect.Message {
524+ mi := &file_ermite_storage_merge_request_proto_msgTypes[3]
525+ if x != nil {
526+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
527+ if ms.LoadMessageInfo() == nil {
528+ ms.StoreMessageInfo(mi)
529+ }
530+ return ms
531+ }
532+ return mi.MessageOf(x)
533+}
534+
535+// Deprecated: Use Sequence.ProtoReflect.Descriptor instead.
536+func (*Sequence) Descriptor() ([]byte, []int) {
537+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{3}
538+}
539+
540+func (x *Sequence) GetLast() int64 {
541+ if x != nil {
542+ return x.Last
543+ }
544+ return 0
545+}
546+
547+var File_ermite_storage_merge_request_proto protoreflect.FileDescriptor
548+
549+const file_ermite_storage_merge_request_proto_rawDesc = "" +
550+ "\n" +
551+ "\"ermite/storage/merge_request.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\x8b\x01\n" +
552+ "\fRepoSettings\x12I\n" +
553+ "\x13allowed_merge_modes\x18\x01 \x03(\x0e2\x19.ermite.storage.MergeModeR\x11allowedMergeModes\x120\n" +
554+ "\x14failing_checks_block\x18\x02 \x01(\bR\x12failingChecksBlock\"\xa6\x04\n" +
555+ "\fMergeRequest\x12\x16\n" +
556+ "\x06number\x18\x01 \x01(\x03R\x06number\x12\x14\n" +
557+ "\x05title\x18\x02 \x01(\tR\x05title\x12 \n" +
558+ "\vdescription\x18\x03 \x01(\tR\vdescription\x12#\n" +
559+ "\rsource_branch\x18\x04 \x01(\tR\fsourceBranch\x12#\n" +
560+ "\rtarget_branch\x18\x05 \x01(\tR\ftargetBranch\x127\n" +
561+ "\x05state\x18\x06 \x01(\x0e2!.ermite.storage.MergeRequestStateR\x05state\x12\x16\n" +
562+ "\x06author\x18\a \x01(\tR\x06author\x129\n" +
563+ "\n" +
564+ "created_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" +
565+ "\n" +
566+ "updated_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12\x17\n" +
567+ "\ahead_id\x18\n" +
568+ " \x01(\tR\x06headId\x12\x17\n" +
569+ "\abase_id\x18\v \x01(\tR\x06baseId\x12(\n" +
570+ "\x10merged_commit_id\x18\f \x01(\tR\x0emergedCommitId\x128\n" +
571+ "\n" +
572+ "merge_mode\x18\r \x01(\x0e2\x19.ermite.storage.MergeModeR\tmergeMode\x12\x1f\n" +
573+ "\vcomment_seq\x18\x0e \x01(\x03R\n" +
574+ "commentSeq\"\x8f\x02\n" +
575+ "\aComment\x12\x0e\n" +
576+ "\x02id\x18\x01 \x01(\x03R\x02id\x12\x16\n" +
577+ "\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" +
578+ "\x04path\x18\x03 \x01(\tR\x04path\x12\x12\n" +
579+ "\x04line\x18\x04 \x01(\x05R\x04line\x12,\n" +
580+ "\x04side\x18\x05 \x01(\x0e2\x18.ermite.storage.DiffSideR\x04side\x12\x1b\n" +
581+ "\tcommit_id\x18\x06 \x01(\tR\bcommitId\x12\x12\n" +
582+ "\x04text\x18\a \x01(\tR\x04text\x12\x1a\n" +
583+ "\bresolved\x18\b \x01(\bR\bresolved\x129\n" +
584+ "\n" +
585+ "created_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\"\x1e\n" +
586+ "\bSequence\x12\x12\n" +
587+ "\x04last\x18\x01 \x01(\x03R\x04last*[\n" +
588+ "\tMergeMode\x12\x1a\n" +
589+ "\x16MERGE_MODE_UNSPECIFIED\x10\x00\x12\x1b\n" +
590+ "\x17MERGE_MODE_FAST_FORWARD\x10\x01\x12\x15\n" +
591+ "\x11MERGE_MODE_SQUASH\x10\x02*\x96\x01\n" +
592+ "\x11MergeRequestState\x12#\n" +
593+ "\x1fMERGE_REQUEST_STATE_UNSPECIFIED\x10\x00\x12\x1c\n" +
594+ "\x18MERGE_REQUEST_STATE_OPEN\x10\x01\x12\x1e\n" +
595+ "\x1aMERGE_REQUEST_STATE_MERGED\x10\x02\x12\x1e\n" +
596+ "\x1aMERGE_REQUEST_STATE_CLOSED\x10\x03*K\n" +
597+ "\bDiffSide\x12\x19\n" +
598+ "\x15DIFF_SIDE_UNSPECIFIED\x10\x00\x12\x11\n" +
599+ "\rDIFF_SIDE_OLD\x10\x01\x12\x11\n" +
600+ "\rDIFF_SIDE_NEW\x10\x02B/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3"
601+
602+var (
603+ file_ermite_storage_merge_request_proto_rawDescOnce sync.Once
604+ file_ermite_storage_merge_request_proto_rawDescData []byte
605+)
606+
607+func file_ermite_storage_merge_request_proto_rawDescGZIP() []byte {
608+ file_ermite_storage_merge_request_proto_rawDescOnce.Do(func() {
609+ file_ermite_storage_merge_request_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ermite_storage_merge_request_proto_rawDesc), len(file_ermite_storage_merge_request_proto_rawDesc)))
610+ })
611+ return file_ermite_storage_merge_request_proto_rawDescData
612+}
613+
614+var file_ermite_storage_merge_request_proto_enumTypes = make([]protoimpl.EnumInfo, 3)
615+var file_ermite_storage_merge_request_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
616+var file_ermite_storage_merge_request_proto_goTypes = []any{
617+ (MergeMode)(0), // 0: ermite.storage.MergeMode
618+ (MergeRequestState)(0), // 1: ermite.storage.MergeRequestState
619+ (DiffSide)(0), // 2: ermite.storage.DiffSide
620+ (*RepoSettings)(nil), // 3: ermite.storage.RepoSettings
621+ (*MergeRequest)(nil), // 4: ermite.storage.MergeRequest
622+ (*Comment)(nil), // 5: ermite.storage.Comment
623+ (*Sequence)(nil), // 6: ermite.storage.Sequence
624+ (*timestamppb.Timestamp)(nil), // 7: google.protobuf.Timestamp
625+}
626+var file_ermite_storage_merge_request_proto_depIdxs = []int32{
627+ 0, // 0: ermite.storage.RepoSettings.allowed_merge_modes:type_name -> ermite.storage.MergeMode
628+ 1, // 1: ermite.storage.MergeRequest.state:type_name -> ermite.storage.MergeRequestState
629+ 7, // 2: ermite.storage.MergeRequest.created_at:type_name -> google.protobuf.Timestamp
630+ 7, // 3: ermite.storage.MergeRequest.updated_at:type_name -> google.protobuf.Timestamp
631+ 0, // 4: ermite.storage.MergeRequest.merge_mode:type_name -> ermite.storage.MergeMode
632+ 2, // 5: ermite.storage.Comment.side:type_name -> ermite.storage.DiffSide
633+ 7, // 6: ermite.storage.Comment.created_at:type_name -> google.protobuf.Timestamp
634+ 7, // [7:7] is the sub-list for method output_type
635+ 7, // [7:7] is the sub-list for method input_type
636+ 7, // [7:7] is the sub-list for extension type_name
637+ 7, // [7:7] is the sub-list for extension extendee
638+ 0, // [0:7] is the sub-list for field type_name
639+}
640+
641+func init() { file_ermite_storage_merge_request_proto_init() }
642+func file_ermite_storage_merge_request_proto_init() {
643+ if File_ermite_storage_merge_request_proto != nil {
644+ return
645+ }
646+ type x struct{}
647+ out := protoimpl.TypeBuilder{
648+ File: protoimpl.DescBuilder{
649+ GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
650+ RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_merge_request_proto_rawDesc), len(file_ermite_storage_merge_request_proto_rawDesc)),
651+ NumEnums: 3,
652+ NumMessages: 4,
653+ NumExtensions: 0,
654+ NumServices: 0,
655+ },
656+ GoTypes: file_ermite_storage_merge_request_proto_goTypes,
657+ DependencyIndexes: file_ermite_storage_merge_request_proto_depIdxs,
658+ EnumInfos: file_ermite_storage_merge_request_proto_enumTypes,
659+ MessageInfos: file_ermite_storage_merge_request_proto_msgTypes,
660+ }.Build()
661+ File_ermite_storage_merge_request_proto = out.File
662+ file_ermite_storage_merge_request_proto_goTypes = nil
663+ file_ermite_storage_merge_request_proto_depIdxs = nil
664+}
@@ -0,0 +1,664 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Internal, unversioned storage schema of ermite: merge requests.
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/merge_request.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+// MergeMode is how a merge request is merged.
30+type MergeMode int32
31+
32+const (
33+ MergeMode_MERGE_MODE_UNSPECIFIED MergeMode = 0
34+ MergeMode_MERGE_MODE_FAST_FORWARD MergeMode = 1
35+ MergeMode_MERGE_MODE_SQUASH MergeMode = 2
36+)
37+
38+// Enum value maps for MergeMode.
39+var (
40+ MergeMode_name = map[int32]string{
41+ 0: "MERGE_MODE_UNSPECIFIED",
42+ 1: "MERGE_MODE_FAST_FORWARD",
43+ 2: "MERGE_MODE_SQUASH",
44+ }
45+ MergeMode_value = map[string]int32{
46+ "MERGE_MODE_UNSPECIFIED": 0,
47+ "MERGE_MODE_FAST_FORWARD": 1,
48+ "MERGE_MODE_SQUASH": 2,
49+ }
50+)
51+
52+func (x MergeMode) Enum() *MergeMode {
53+ p := new(MergeMode)
54+ *p = x
55+ return p
56+}
57+
58+func (x MergeMode) String() string {
59+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
60+}
61+
62+func (MergeMode) Descriptor() protoreflect.EnumDescriptor {
63+ return file_ermite_storage_merge_request_proto_enumTypes[0].Descriptor()
64+}
65+
66+func (MergeMode) Type() protoreflect.EnumType {
67+ return &file_ermite_storage_merge_request_proto_enumTypes[0]
68+}
69+
70+func (x MergeMode) Number() protoreflect.EnumNumber {
71+ return protoreflect.EnumNumber(x)
72+}
73+
74+// Deprecated: Use MergeMode.Descriptor instead.
75+func (MergeMode) EnumDescriptor() ([]byte, []int) {
76+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{0}
77+}
78+
79+// MergeRequestState is where a merge request is in its life.
80+type MergeRequestState int32
81+
82+const (
83+ MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED MergeRequestState = 0
84+ MergeRequestState_MERGE_REQUEST_STATE_OPEN MergeRequestState = 1
85+ MergeRequestState_MERGE_REQUEST_STATE_MERGED MergeRequestState = 2
86+ MergeRequestState_MERGE_REQUEST_STATE_CLOSED MergeRequestState = 3
87+)
88+
89+// Enum value maps for MergeRequestState.
90+var (
91+ MergeRequestState_name = map[int32]string{
92+ 0: "MERGE_REQUEST_STATE_UNSPECIFIED",
93+ 1: "MERGE_REQUEST_STATE_OPEN",
94+ 2: "MERGE_REQUEST_STATE_MERGED",
95+ 3: "MERGE_REQUEST_STATE_CLOSED",
96+ }
97+ MergeRequestState_value = map[string]int32{
98+ "MERGE_REQUEST_STATE_UNSPECIFIED": 0,
99+ "MERGE_REQUEST_STATE_OPEN": 1,
100+ "MERGE_REQUEST_STATE_MERGED": 2,
101+ "MERGE_REQUEST_STATE_CLOSED": 3,
102+ }
103+)
104+
105+func (x MergeRequestState) Enum() *MergeRequestState {
106+ p := new(MergeRequestState)
107+ *p = x
108+ return p
109+}
110+
111+func (x MergeRequestState) String() string {
112+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
113+}
114+
115+func (MergeRequestState) Descriptor() protoreflect.EnumDescriptor {
116+ return file_ermite_storage_merge_request_proto_enumTypes[1].Descriptor()
117+}
118+
119+func (MergeRequestState) Type() protoreflect.EnumType {
120+ return &file_ermite_storage_merge_request_proto_enumTypes[1]
121+}
122+
123+func (x MergeRequestState) Number() protoreflect.EnumNumber {
124+ return protoreflect.EnumNumber(x)
125+}
126+
127+// Deprecated: Use MergeRequestState.Descriptor instead.
128+func (MergeRequestState) EnumDescriptor() ([]byte, []int) {
129+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{1}
130+}
131+
132+// DiffSide is the side of a diff a comment is attached to.
133+type DiffSide int32
134+
135+const (
136+ // A general comment.
137+ DiffSide_DIFF_SIDE_UNSPECIFIED DiffSide = 0
138+ DiffSide_DIFF_SIDE_OLD DiffSide = 1
139+ DiffSide_DIFF_SIDE_NEW DiffSide = 2
140+)
141+
142+// Enum value maps for DiffSide.
143+var (
144+ DiffSide_name = map[int32]string{
145+ 0: "DIFF_SIDE_UNSPECIFIED",
146+ 1: "DIFF_SIDE_OLD",
147+ 2: "DIFF_SIDE_NEW",
148+ }
149+ DiffSide_value = map[string]int32{
150+ "DIFF_SIDE_UNSPECIFIED": 0,
151+ "DIFF_SIDE_OLD": 1,
152+ "DIFF_SIDE_NEW": 2,
153+ }
154+)
155+
156+func (x DiffSide) Enum() *DiffSide {
157+ p := new(DiffSide)
158+ *p = x
159+ return p
160+}
161+
162+func (x DiffSide) String() string {
163+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
164+}
165+
166+func (DiffSide) Descriptor() protoreflect.EnumDescriptor {
167+ return file_ermite_storage_merge_request_proto_enumTypes[2].Descriptor()
168+}
169+
170+func (DiffSide) Type() protoreflect.EnumType {
171+ return &file_ermite_storage_merge_request_proto_enumTypes[2]
172+}
173+
174+func (x DiffSide) Number() protoreflect.EnumNumber {
175+ return protoreflect.EnumNumber(x)
176+}
177+
178+// Deprecated: Use DiffSide.Descriptor instead.
179+func (DiffSide) EnumDescriptor() ([]byte, []int) {
180+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{2}
181+}
182+
183+// RepoSettings are the merge settings of a repository, stored inside Repo.
184+type RepoSettings struct {
185+ state protoimpl.MessageState `protogen:"open.v1"`
186+ // Never empty once stored.
187+ AllowedMergeModes []MergeMode `protobuf:"varint,1,rep,packed,name=allowed_merge_modes,json=allowedMergeModes,proto3,enum=ermite.storage.MergeMode" json:"allowed_merge_modes,omitempty"`
188+ FailingChecksBlock bool `protobuf:"varint,2,opt,name=failing_checks_block,json=failingChecksBlock,proto3" json:"failing_checks_block,omitempty"`
189+ unknownFields protoimpl.UnknownFields
190+ sizeCache protoimpl.SizeCache
191+}
192+
193+func (x *RepoSettings) Reset() {
194+ *x = RepoSettings{}
195+ mi := &file_ermite_storage_merge_request_proto_msgTypes[0]
196+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
197+ ms.StoreMessageInfo(mi)
198+}
199+
200+func (x *RepoSettings) String() string {
201+ return protoimpl.X.MessageStringOf(x)
202+}
203+
204+func (*RepoSettings) ProtoMessage() {}
205+
206+func (x *RepoSettings) ProtoReflect() protoreflect.Message {
207+ mi := &file_ermite_storage_merge_request_proto_msgTypes[0]
208+ if x != nil {
209+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
210+ if ms.LoadMessageInfo() == nil {
211+ ms.StoreMessageInfo(mi)
212+ }
213+ return ms
214+ }
215+ return mi.MessageOf(x)
216+}
217+
218+// Deprecated: Use RepoSettings.ProtoReflect.Descriptor instead.
219+func (*RepoSettings) Descriptor() ([]byte, []int) {
220+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{0}
221+}
222+
223+func (x *RepoSettings) GetAllowedMergeModes() []MergeMode {
224+ if x != nil {
225+ return x.AllowedMergeModes
226+ }
227+ return nil
228+}
229+
230+func (x *RepoSettings) GetFailingChecksBlock() bool {
231+ if x != nil {
232+ return x.FailingChecksBlock
233+ }
234+ return false
235+}
236+
237+// MergeRequest is stored under the key "mr/<repo>/<number:010d>".
238+type MergeRequest struct {
239+ state protoimpl.MessageState `protogen:"open.v1"`
240+ Number int64 `protobuf:"varint,1,opt,name=number,proto3" json:"number,omitempty"`
241+ Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
242+ Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"`
243+ SourceBranch string `protobuf:"bytes,4,opt,name=source_branch,json=sourceBranch,proto3" json:"source_branch,omitempty"`
244+ TargetBranch string `protobuf:"bytes,5,opt,name=target_branch,json=targetBranch,proto3" json:"target_branch,omitempty"`
245+ State MergeRequestState `protobuf:"varint,6,opt,name=state,proto3,enum=ermite.storage.MergeRequestState" json:"state,omitempty"`
246+ // Label of the token that opened it.
247+ Author string `protobuf:"bytes,7,opt,name=author,proto3" json:"author,omitempty"`
248+ CreatedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
249+ UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"`
250+ // Head of the source branch when the request was merged or closed; empty
251+ // while it is open (the head is read from Git then).
252+ HeadId string `protobuf:"bytes,10,opt,name=head_id,json=headId,proto3" json:"head_id,omitempty"`
253+ // Merge base of the target and the head when the request was merged or
254+ // closed, so that its diff stays readable after the merge.
255+ BaseId string `protobuf:"bytes,11,opt,name=base_id,json=baseId,proto3" json:"base_id,omitempty"`
256+ MergedCommitId string `protobuf:"bytes,12,opt,name=merged_commit_id,json=mergedCommitId,proto3" json:"merged_commit_id,omitempty"`
257+ MergeMode MergeMode `protobuf:"varint,13,opt,name=merge_mode,json=mergeMode,proto3,enum=ermite.storage.MergeMode" json:"merge_mode,omitempty"`
258+ // Last comment id handed out for this request.
259+ CommentSeq int64 `protobuf:"varint,14,opt,name=comment_seq,json=commentSeq,proto3" json:"comment_seq,omitempty"`
260+ unknownFields protoimpl.UnknownFields
261+ sizeCache protoimpl.SizeCache
262+}
263+
264+func (x *MergeRequest) Reset() {
265+ *x = MergeRequest{}
266+ mi := &file_ermite_storage_merge_request_proto_msgTypes[1]
267+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
268+ ms.StoreMessageInfo(mi)
269+}
270+
271+func (x *MergeRequest) String() string {
272+ return protoimpl.X.MessageStringOf(x)
273+}
274+
275+func (*MergeRequest) ProtoMessage() {}
276+
277+func (x *MergeRequest) ProtoReflect() protoreflect.Message {
278+ mi := &file_ermite_storage_merge_request_proto_msgTypes[1]
279+ if x != nil {
280+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
281+ if ms.LoadMessageInfo() == nil {
282+ ms.StoreMessageInfo(mi)
283+ }
284+ return ms
285+ }
286+ return mi.MessageOf(x)
287+}
288+
289+// Deprecated: Use MergeRequest.ProtoReflect.Descriptor instead.
290+func (*MergeRequest) Descriptor() ([]byte, []int) {
291+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{1}
292+}
293+
294+func (x *MergeRequest) GetNumber() int64 {
295+ if x != nil {
296+ return x.Number
297+ }
298+ return 0
299+}
300+
301+func (x *MergeRequest) GetTitle() string {
302+ if x != nil {
303+ return x.Title
304+ }
305+ return ""
306+}
307+
308+func (x *MergeRequest) GetDescription() string {
309+ if x != nil {
310+ return x.Description
311+ }
312+ return ""
313+}
314+
315+func (x *MergeRequest) GetSourceBranch() string {
316+ if x != nil {
317+ return x.SourceBranch
318+ }
319+ return ""
320+}
321+
322+func (x *MergeRequest) GetTargetBranch() string {
323+ if x != nil {
324+ return x.TargetBranch
325+ }
326+ return ""
327+}
328+
329+func (x *MergeRequest) GetState() MergeRequestState {
330+ if x != nil {
331+ return x.State
332+ }
333+ return MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED
334+}
335+
336+func (x *MergeRequest) GetAuthor() string {
337+ if x != nil {
338+ return x.Author
339+ }
340+ return ""
341+}
342+
343+func (x *MergeRequest) GetCreatedAt() *timestamppb.Timestamp {
344+ if x != nil {
345+ return x.CreatedAt
346+ }
347+ return nil
348+}
349+
350+func (x *MergeRequest) GetUpdatedAt() *timestamppb.Timestamp {
351+ if x != nil {
352+ return x.UpdatedAt
353+ }
354+ return nil
355+}
356+
357+func (x *MergeRequest) GetHeadId() string {
358+ if x != nil {
359+ return x.HeadId
360+ }
361+ return ""
362+}
363+
364+func (x *MergeRequest) GetBaseId() string {
365+ if x != nil {
366+ return x.BaseId
367+ }
368+ return ""
369+}
370+
371+func (x *MergeRequest) GetMergedCommitId() string {
372+ if x != nil {
373+ return x.MergedCommitId
374+ }
375+ return ""
376+}
377+
378+func (x *MergeRequest) GetMergeMode() MergeMode {
379+ if x != nil {
380+ return x.MergeMode
381+ }
382+ return MergeMode_MERGE_MODE_UNSPECIFIED
383+}
384+
385+func (x *MergeRequest) GetCommentSeq() int64 {
386+ if x != nil {
387+ return x.CommentSeq
388+ }
389+ return 0
390+}
391+
392+// Comment is stored under the key "mrc/<repo>/<number:010d>/<id:010d>".
393+type Comment struct {
394+ state protoimpl.MessageState `protogen:"open.v1"`
395+ Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
396+ Author string `protobuf:"bytes,2,opt,name=author,proto3" json:"author,omitempty"`
397+ Path string `protobuf:"bytes,3,opt,name=path,proto3" json:"path,omitempty"`
398+ Line int32 `protobuf:"varint,4,opt,name=line,proto3" json:"line,omitempty"`
399+ Side DiffSide `protobuf:"varint,5,opt,name=side,proto3,enum=ermite.storage.DiffSide" json:"side,omitempty"`
400+ CommitId string `protobuf:"bytes,6,opt,name=commit_id,json=commitId,proto3" json:"commit_id,omitempty"`
401+ Text string `protobuf:"bytes,7,opt,name=text,proto3" json:"text,omitempty"`
402+ Resolved bool `protobuf:"varint,8,opt,name=resolved,proto3" json:"resolved,omitempty"`
403+ CreatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
404+ unknownFields protoimpl.UnknownFields
405+ sizeCache protoimpl.SizeCache
406+}
407+
408+func (x *Comment) Reset() {
409+ *x = Comment{}
410+ mi := &file_ermite_storage_merge_request_proto_msgTypes[2]
411+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
412+ ms.StoreMessageInfo(mi)
413+}
414+
415+func (x *Comment) String() string {
416+ return protoimpl.X.MessageStringOf(x)
417+}
418+
419+func (*Comment) ProtoMessage() {}
420+
421+func (x *Comment) ProtoReflect() protoreflect.Message {
422+ mi := &file_ermite_storage_merge_request_proto_msgTypes[2]
423+ if x != nil {
424+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
425+ if ms.LoadMessageInfo() == nil {
426+ ms.StoreMessageInfo(mi)
427+ }
428+ return ms
429+ }
430+ return mi.MessageOf(x)
431+}
432+
433+// Deprecated: Use Comment.ProtoReflect.Descriptor instead.
434+func (*Comment) Descriptor() ([]byte, []int) {
435+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{2}
436+}
437+
438+func (x *Comment) GetId() int64 {
439+ if x != nil {
440+ return x.Id
441+ }
442+ return 0
443+}
444+
445+func (x *Comment) GetAuthor() string {
446+ if x != nil {
447+ return x.Author
448+ }
449+ return ""
450+}
451+
452+func (x *Comment) GetPath() string {
453+ if x != nil {
454+ return x.Path
455+ }
456+ return ""
457+}
458+
459+func (x *Comment) GetLine() int32 {
460+ if x != nil {
461+ return x.Line
462+ }
463+ return 0
464+}
465+
466+func (x *Comment) GetSide() DiffSide {
467+ if x != nil {
468+ return x.Side
469+ }
470+ return DiffSide_DIFF_SIDE_UNSPECIFIED
471+}
472+
473+func (x *Comment) GetCommitId() string {
474+ if x != nil {
475+ return x.CommitId
476+ }
477+ return ""
478+}
479+
480+func (x *Comment) GetText() string {
481+ if x != nil {
482+ return x.Text
483+ }
484+ return ""
485+}
486+
487+func (x *Comment) GetResolved() bool {
488+ if x != nil {
489+ return x.Resolved
490+ }
491+ return false
492+}
493+
494+func (x *Comment) GetCreatedAt() *timestamppb.Timestamp {
495+ if x != nil {
496+ return x.CreatedAt
497+ }
498+ return nil
499+}
500+
501+// Sequence is stored under the key "mrseq/<repo>": the last merge request
502+// number handed out for the repository.
503+type Sequence struct {
504+ state protoimpl.MessageState `protogen:"open.v1"`
505+ Last int64 `protobuf:"varint,1,opt,name=last,proto3" json:"last,omitempty"`
506+ unknownFields protoimpl.UnknownFields
507+ sizeCache protoimpl.SizeCache
508+}
509+
510+func (x *Sequence) Reset() {
511+ *x = Sequence{}
512+ mi := &file_ermite_storage_merge_request_proto_msgTypes[3]
513+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
514+ ms.StoreMessageInfo(mi)
515+}
516+
517+func (x *Sequence) String() string {
518+ return protoimpl.X.MessageStringOf(x)
519+}
520+
521+func (*Sequence) ProtoMessage() {}
522+
523+func (x *Sequence) ProtoReflect() protoreflect.Message {
524+ mi := &file_ermite_storage_merge_request_proto_msgTypes[3]
525+ if x != nil {
526+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
527+ if ms.LoadMessageInfo() == nil {
528+ ms.StoreMessageInfo(mi)
529+ }
530+ return ms
531+ }
532+ return mi.MessageOf(x)
533+}
534+
535+// Deprecated: Use Sequence.ProtoReflect.Descriptor instead.
536+func (*Sequence) Descriptor() ([]byte, []int) {
537+ return file_ermite_storage_merge_request_proto_rawDescGZIP(), []int{3}
538+}
539+
540+func (x *Sequence) GetLast() int64 {
541+ if x != nil {
542+ return x.Last
543+ }
544+ return 0
545+}
546+
547+var File_ermite_storage_merge_request_proto protoreflect.FileDescriptor
548+
549+const file_ermite_storage_merge_request_proto_rawDesc = "" +
550+ "\n" +
551+ "\"ermite/storage/merge_request.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\x8b\x01\n" +
552+ "\fRepoSettings\x12I\n" +
553+ "\x13allowed_merge_modes\x18\x01 \x03(\x0e2\x19.ermite.storage.MergeModeR\x11allowedMergeModes\x120\n" +
554+ "\x14failing_checks_block\x18\x02 \x01(\bR\x12failingChecksBlock\"\xa6\x04\n" +
555+ "\fMergeRequest\x12\x16\n" +
556+ "\x06number\x18\x01 \x01(\x03R\x06number\x12\x14\n" +
557+ "\x05title\x18\x02 \x01(\tR\x05title\x12 \n" +
558+ "\vdescription\x18\x03 \x01(\tR\vdescription\x12#\n" +
559+ "\rsource_branch\x18\x04 \x01(\tR\fsourceBranch\x12#\n" +
560+ "\rtarget_branch\x18\x05 \x01(\tR\ftargetBranch\x127\n" +
561+ "\x05state\x18\x06 \x01(\x0e2!.ermite.storage.MergeRequestStateR\x05state\x12\x16\n" +
562+ "\x06author\x18\a \x01(\tR\x06author\x129\n" +
563+ "\n" +
564+ "created_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" +
565+ "\n" +
566+ "updated_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12\x17\n" +
567+ "\ahead_id\x18\n" +
568+ " \x01(\tR\x06headId\x12\x17\n" +
569+ "\abase_id\x18\v \x01(\tR\x06baseId\x12(\n" +
570+ "\x10merged_commit_id\x18\f \x01(\tR\x0emergedCommitId\x128\n" +
571+ "\n" +
572+ "merge_mode\x18\r \x01(\x0e2\x19.ermite.storage.MergeModeR\tmergeMode\x12\x1f\n" +
573+ "\vcomment_seq\x18\x0e \x01(\x03R\n" +
574+ "commentSeq\"\x8f\x02\n" +
575+ "\aComment\x12\x0e\n" +
576+ "\x02id\x18\x01 \x01(\x03R\x02id\x12\x16\n" +
577+ "\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" +
578+ "\x04path\x18\x03 \x01(\tR\x04path\x12\x12\n" +
579+ "\x04line\x18\x04 \x01(\x05R\x04line\x12,\n" +
580+ "\x04side\x18\x05 \x01(\x0e2\x18.ermite.storage.DiffSideR\x04side\x12\x1b\n" +
581+ "\tcommit_id\x18\x06 \x01(\tR\bcommitId\x12\x12\n" +
582+ "\x04text\x18\a \x01(\tR\x04text\x12\x1a\n" +
583+ "\bresolved\x18\b \x01(\bR\bresolved\x129\n" +
584+ "\n" +
585+ "created_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\"\x1e\n" +
586+ "\bSequence\x12\x12\n" +
587+ "\x04last\x18\x01 \x01(\x03R\x04last*[\n" +
588+ "\tMergeMode\x12\x1a\n" +
589+ "\x16MERGE_MODE_UNSPECIFIED\x10\x00\x12\x1b\n" +
590+ "\x17MERGE_MODE_FAST_FORWARD\x10\x01\x12\x15\n" +
591+ "\x11MERGE_MODE_SQUASH\x10\x02*\x96\x01\n" +
592+ "\x11MergeRequestState\x12#\n" +
593+ "\x1fMERGE_REQUEST_STATE_UNSPECIFIED\x10\x00\x12\x1c\n" +
594+ "\x18MERGE_REQUEST_STATE_OPEN\x10\x01\x12\x1e\n" +
595+ "\x1aMERGE_REQUEST_STATE_MERGED\x10\x02\x12\x1e\n" +
596+ "\x1aMERGE_REQUEST_STATE_CLOSED\x10\x03*K\n" +
597+ "\bDiffSide\x12\x19\n" +
598+ "\x15DIFF_SIDE_UNSPECIFIED\x10\x00\x12\x11\n" +
599+ "\rDIFF_SIDE_OLD\x10\x01\x12\x11\n" +
600+ "\rDIFF_SIDE_NEW\x10\x02B/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3"
601+
602+var (
603+ file_ermite_storage_merge_request_proto_rawDescOnce sync.Once
604+ file_ermite_storage_merge_request_proto_rawDescData []byte
605+)
606+
607+func file_ermite_storage_merge_request_proto_rawDescGZIP() []byte {
608+ file_ermite_storage_merge_request_proto_rawDescOnce.Do(func() {
609+ file_ermite_storage_merge_request_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ermite_storage_merge_request_proto_rawDesc), len(file_ermite_storage_merge_request_proto_rawDesc)))
610+ })
611+ return file_ermite_storage_merge_request_proto_rawDescData
612+}
613+
614+var file_ermite_storage_merge_request_proto_enumTypes = make([]protoimpl.EnumInfo, 3)
615+var file_ermite_storage_merge_request_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
616+var file_ermite_storage_merge_request_proto_goTypes = []any{
617+ (MergeMode)(0), // 0: ermite.storage.MergeMode
618+ (MergeRequestState)(0), // 1: ermite.storage.MergeRequestState
619+ (DiffSide)(0), // 2: ermite.storage.DiffSide
620+ (*RepoSettings)(nil), // 3: ermite.storage.RepoSettings
621+ (*MergeRequest)(nil), // 4: ermite.storage.MergeRequest
622+ (*Comment)(nil), // 5: ermite.storage.Comment
623+ (*Sequence)(nil), // 6: ermite.storage.Sequence
624+ (*timestamppb.Timestamp)(nil), // 7: google.protobuf.Timestamp
625+}
626+var file_ermite_storage_merge_request_proto_depIdxs = []int32{
627+ 0, // 0: ermite.storage.RepoSettings.allowed_merge_modes:type_name -> ermite.storage.MergeMode
628+ 1, // 1: ermite.storage.MergeRequest.state:type_name -> ermite.storage.MergeRequestState
629+ 7, // 2: ermite.storage.MergeRequest.created_at:type_name -> google.protobuf.Timestamp
630+ 7, // 3: ermite.storage.MergeRequest.updated_at:type_name -> google.protobuf.Timestamp
631+ 0, // 4: ermite.storage.MergeRequest.merge_mode:type_name -> ermite.storage.MergeMode
632+ 2, // 5: ermite.storage.Comment.side:type_name -> ermite.storage.DiffSide
633+ 7, // 6: ermite.storage.Comment.created_at:type_name -> google.protobuf.Timestamp
634+ 7, // [7:7] is the sub-list for method output_type
635+ 7, // [7:7] is the sub-list for method input_type
636+ 7, // [7:7] is the sub-list for extension type_name
637+ 7, // [7:7] is the sub-list for extension extendee
638+ 0, // [0:7] is the sub-list for field type_name
639+}
640+
641+func init() { file_ermite_storage_merge_request_proto_init() }
642+func file_ermite_storage_merge_request_proto_init() {
643+ if File_ermite_storage_merge_request_proto != nil {
644+ return
645+ }
646+ type x struct{}
647+ out := protoimpl.TypeBuilder{
648+ File: protoimpl.DescBuilder{
649+ GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
650+ RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_merge_request_proto_rawDesc), len(file_ermite_storage_merge_request_proto_rawDesc)),
651+ NumEnums: 3,
652+ NumMessages: 4,
653+ NumExtensions: 0,
654+ NumServices: 0,
655+ },
656+ GoTypes: file_ermite_storage_merge_request_proto_goTypes,
657+ DependencyIndexes: file_ermite_storage_merge_request_proto_depIdxs,
658+ EnumInfos: file_ermite_storage_merge_request_proto_enumTypes,
659+ MessageInfos: file_ermite_storage_merge_request_proto_msgTypes,
660+ }.Build()
661+ File_ermite_storage_merge_request_proto = out.File
662+ file_ermite_storage_merge_request_proto_goTypes = nil
663+ file_ermite_storage_merge_request_proto_depIdxs = nil
664+}
M gen/ermite/storage/repo.pb.go +25 -11 generated file: collapsed
A gen/ermite/storage/signing.pb.go +143 -0 generated file: collapsed
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 {
9090 return nil
9191 }
9292
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.
94107 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+ }
95119 var b kv.Batch
120+ for _, k := range keys {
121+ b.Delete(k)
122+ }
96123 b.Delete(repoKey(name))
97124 if _, err := s.db.Apply(ctx, &b); err != nil {
98125 return fmt.Errorf("delete repository %q: %w", name, err)
@@ -111,12 +138,23 @@ func encodeRepo(r domain.Repo) ([]byte, error) {
111138 if err != nil {
112139 return nil, err
113140 }
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+ }
114151 pb := &storagepb.Repo{
115152 Name: r.Name.String(),
116153 Description: r.Description,
117154 Visibility: vis,
118155 CreatedAt: encodeTime(r.CreatedAt),
119156 PushedAt: encodeTime(r.PushedAt),
157+ Settings: pbSettings,
120158 }
121159 return mustMarshal(pb), nil
122160 }
@@ -136,6 +174,7 @@ func decodeRepo(val []byte) (domain.Repo, error) {
136174 Visibility: decodeVisibility(pb.GetVisibility()),
137175 CreatedAt: decodeTime(pb.GetCreatedAt()),
138176 PushedAt: decodeTime(pb.GetPushedAt()),
177+ Settings: decodeSettings(pb.GetSettings()),
139178 }, nil
140179 }
141180
@@ -90,9 +90,36 @@ func (s *Store) Update(ctx context.Context, repo domain.Repo) error {
90 return nil90 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.Batch119 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, err139 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), nil159 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 }, nil178 }, 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) {
4242 ctx := context.Background()
4343 s := openStore(t, t.TempDir())
4444
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()}
4747 require.NoError(t, s.Create(ctx, b))
4848 require.NoError(t, s.Create(ctx, a))
4949
@@ -129,7 +129,7 @@ func TestPersistsAcrossReopen(t *testing.T) {
129129 dir := t.TempDir()
130130 s, err := kvstore.Open(ctx, dir)
131131 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()}
133133 require.NoError(t, s.Create(ctx, r))
134134 require.NoError(t, s.Close(ctx))
135135
@@ -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";
66
77 package ermite.storage;
88
9+import "ermite/storage/merge_request.proto";
910 import "google/protobuf/timestamp.proto";
1011
1112 option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb";
@@ -26,6 +27,9 @@ message Repo {
2627 google.protobuf.Timestamp created_at = 4;
2728 // Unset until the first push.
2829 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;
2933 }
3034
3135 // 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+}

Keyboard shortcuts

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

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

Branches 4

Tags 45

↑↓ moveenter openesc close