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 generated file: collapsed
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 2

Tags 45

↑↓ moveenter openesc close