detached fda3c2b

mergerequests_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package kvstore_test
4
5import (
6	"context"
7	"errors"
8	"strings"
9	"sync"
10	"testing"
11	"time"
12
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/require"
15	"google.golang.org/protobuf/proto"
16
17	kv "bnema.dev/ermite-kv"
18
19	storagepb "bnema.dev/ermite/gen/ermite/storage"
20	"bnema.dev/ermite/internal/adapters/kvstore"
21	"bnema.dev/ermite/internal/domain"
22)
23
24func newMR(t *testing.T, repo domain.RepoName, title string) domain.MergeRequest {
25	t.Helper()
26	ti, err := domain.ParseMRTitle(title)
27	require.NoError(t, err)
28	desc, err := domain.ParsePlainText("why\nand how")
29	require.NoError(t, err)
30	return domain.MergeRequest{
31		Repo: repo, Title: ti, Description: desc, Source: "feat/x", Target: "main",
32		State: domain.MRStateOpen, Author: "agent", CreatedAt: created, UpdatedAt: created,
33	}
34}
35
36func oid(c byte) domain.ObjectID {
37	id, err := domain.ParseObjectID(strings.Repeat(string(c), 40))
38	if err != nil {
39		panic(err)
40	}
41	return id
42}
43
44// repoWithMRs opens a store holding repository "proj".
45func repoWithMRs(t *testing.T) (*kvstore.Store, *kvstore.MergeRequests, domain.RepoName) {
46	t.Helper()
47	s := openStore(t, t.TempDir())
48	n := name(t, "proj")
49	require.NoError(t, s.Create(context.Background(), domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: created}))
50	return s, s.MergeRequests(), n
51}
52
53func TestMergeRequestNumbersAreSequentialPerRepository(t *testing.T) {
54	ctx := context.Background()
55	s, mrs, n := repoWithMRs(t)
56	other := name(t, "other")
57	require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic}))
58
59	for want := int64(1); want <= 3; want++ {
60		got, err := mrs.Create(ctx, newMR(t, n, "t"))
61		require.NoError(t, err)
62		assert.Equal(t, want, got.Number)
63	}
64	got, err := mrs.Create(ctx, newMR(t, other, "t"))
65	require.NoError(t, err)
66	assert.Equal(t, int64(1), got.Number, "each repository counts on its own")
67
68	_, err = mrs.Create(ctx, newMR(t, name(t, "ghost"), "t"))
69	assert.ErrorIs(t, err, domain.ErrNotFound, "no merge request for a missing repository")
70}
71
72func TestMergeRequestRoundTrip(t *testing.T) {
73	ctx := context.Background()
74	_, mrs, n := repoWithMRs(t)
75	in := newMR(t, n, "Ajoute « ça »")
76	in.HeadID, in.BaseID, in.MergedCommitID = oid('a'), oid('b'), oid('c')
77	in.MergeMode = domain.MergeModeSquash
78	in.State = domain.MRStateMerged
79	in.CommentSeq = 4
80	created1, err := mrs.Create(ctx, in)
81	require.NoError(t, err)
82
83	got, err := mrs.Get(ctx, n, created1.Number)
84	require.NoError(t, err)
85	assert.NotZero(t, got.Version)
86	in.Number, in.Version = 1, got.Version
87	assert.Equal(t, in, got)
88
89	_, err = mrs.Get(ctx, n, 2)
90	assert.ErrorIs(t, err, domain.ErrNotFound)
91	_, err = mrs.Get(ctx, n, 0)
92	assert.ErrorIs(t, err, domain.ErrNotFound)
93	_, err = mrs.Get(ctx, n, -3)
94	assert.ErrorIs(t, err, domain.ErrNotFound)
95}
96
97func TestMergeRequestAutoMergeRoundTrip(t *testing.T) {
98	ctx := context.Background()
99	_, mrs, n := repoWithMRs(t)
100
101	set := newMR(t, n, "Auto")
102	set.AutoMerge = &domain.AutoMerge{
103		Mode: domain.MergeModeSquash, HeadID: oid('d'),
104		EnabledBy:    domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "tok1"},
105		EnablerLabel: "ci-bot", EnabledAt: created,
106	}
107	created1, err := mrs.Create(ctx, set)
108	require.NoError(t, err)
109	got, err := mrs.Get(ctx, n, created1.Number)
110	require.NoError(t, err)
111	require.NotNil(t, got.AutoMerge)
112	assert.Equal(t, *set.AutoMerge, *got.AutoMerge)
113	assert.False(t, got.AutoMerged)
114
115	unset := newMR(t, n, "Merged by auto")
116	unset.State = domain.MRStateMerged
117	unset.AutoMerged = true
118	created2, err := mrs.Create(ctx, unset)
119	require.NoError(t, err)
120	got, err = mrs.Get(ctx, n, created2.Number)
121	require.NoError(t, err)
122	assert.Nil(t, got.AutoMerge)
123	assert.True(t, got.AutoMerged)
124}
125
126func TestMergeRequestListIsNewestFirstAndPerRepository(t *testing.T) {
127	ctx := context.Background()
128	s, mrs, n := repoWithMRs(t)
129	// "proj-2" shares the prefix "proj": its keys must not leak into proj's.
130	sib := name(t, "proj-2")
131	require.NoError(t, s.Create(ctx, domain.Repo{Name: sib, CreatedAt: created, Visibility: domain.VisibilityPublic}))
132	for range 12 { // beyond 10: numbers sort numerically, not as text
133		_, err := mrs.Create(ctx, newMR(t, n, "t"))
134		require.NoError(t, err)
135	}
136	_, err := mrs.Create(ctx, newMR(t, sib, "sibling"))
137	require.NoError(t, err)
138
139	list, err := mrs.List(ctx, n)
140	require.NoError(t, err)
141	require.Len(t, list, 12)
142	for i, mr := range list {
143		assert.Equal(t, int64(12-i), mr.Number)
144		assert.Equal(t, n, mr.Repo)
145	}
146	list, err = mrs.List(ctx, name(t, "none"))
147	require.NoError(t, err)
148	assert.Empty(t, list)
149}
150
151func TestConcurrentCreateNeverSharesANumber(t *testing.T) {
152	ctx := context.Background()
153	_, mrs, n := repoWithMRs(t)
154	const workers = 8
155	var (
156		wg      sync.WaitGroup
157		mu      sync.Mutex
158		numbers []int64
159	)
160	for range workers {
161		wg.Go(func() {
162			got, err := mrs.Create(ctx, newMR(t, n, "t"))
163			if assert.NoError(t, err) {
164				mu.Lock()
165				numbers = append(numbers, got.Number)
166				mu.Unlock()
167			}
168		})
169	}
170	wg.Wait()
171	assert.ElementsMatch(t, []int64{1, 2, 3, 4, 5, 6, 7, 8}, numbers)
172}
173
174func TestMergeRequestUpdateNeedsTheVersionItWasReadAt(t *testing.T) {
175	ctx := context.Background()
176	_, mrs, n := repoWithMRs(t)
177	first, err := mrs.Create(ctx, newMR(t, n, "t"))
178	require.NoError(t, err)
179
180	a, err := mrs.Get(ctx, n, first.Number)
181	require.NoError(t, err)
182	b := a
183	a.State = domain.MRStateClosed
184	require.NoError(t, mrs.Update(ctx, a))
185
186	b.State = domain.MRStateMerged
187	assert.ErrorIs(t, mrs.Update(ctx, b), domain.ErrConflict, "b was read before a wrote")
188
189	got, err := mrs.Get(ctx, n, first.Number)
190	require.NoError(t, err)
191	assert.Equal(t, domain.MRStateClosed, got.State)
192
193	gone := got
194	gone.Number = 99
195	assert.ErrorIs(t, mrs.Update(ctx, gone), domain.ErrNotFound)
196	fresh := newMR(t, n, "never read")
197	fresh.Number = first.Number
198	assert.ErrorIs(t, mrs.Update(ctx, fresh), domain.ErrConflict, "an unread request has no version")
199}
200
201func newComment(t *testing.T, id int64, text string) domain.Comment {
202	t.Helper()
203	pt, err := domain.ParsePlainText(text)
204	require.NoError(t, err)
205	return domain.Comment{
206		ID: id, Author: "root", Path: "a.txt", Line: 3, Side: domain.SideNew,
207		CommitID: oid('d'), Text: pt, CreatedAt: created.Add(time.Duration(id) * time.Minute),
208	}
209}
210
211func TestComments(t *testing.T) {
212	ctx := context.Background()
213	_, mrs, n := repoWithMRs(t)
214	first, err := mrs.Create(ctx, newMR(t, n, "t"))
215	require.NoError(t, err)
216
217	// Twelve comments: ids sort numerically too.
218	for id := int64(1); id <= 12; id++ {
219		mr, err := mrs.Get(ctx, n, first.Number)
220		require.NoError(t, err)
221		mr.CommentSeq = id
222		c := newComment(t, id, "note")
223		if id == 2 {
224			c.Path, c.Line, c.Side = "", 0, domain.SideNone
225		}
226		require.NoError(t, mrs.SaveComment(ctx, mr, c))
227	}
228	list, err := mrs.ListComments(ctx, n, first.Number)
229	require.NoError(t, err)
230	require.Len(t, list, 12)
231	for i, c := range list {
232		assert.Equal(t, int64(i+1), c.ID, "oldest first")
233	}
234	assert.True(t, list[1].IsGeneral())
235	assert.Equal(t, domain.SideNone, list[1].Side)
236	assert.Equal(t, domain.SideNew, list[0].Side)
237
238	mr, err := mrs.Get(ctx, n, first.Number)
239	require.NoError(t, err)
240	assert.Equal(t, int64(12), mr.CommentSeq)
241
242	c, err := mrs.GetComment(ctx, n, first.Number, 3)
243	require.NoError(t, err)
244	assert.NotZero(t, c.Version)
245	assert.Equal(t, "note", c.Text.String())
246	assert.Equal(t, oid('d'), c.CommitID)
247	for _, id := range []int64{0, 13, -1} {
248		_, err = mrs.GetComment(ctx, n, first.Number, id)
249		assert.ErrorIs(t, err, domain.ErrNotFound)
250	}
251
252	// Another merge request's comments are not listed.
253	second, err := mrs.Create(ctx, newMR(t, n, "t2"))
254	require.NoError(t, err)
255	list, err = mrs.ListComments(ctx, n, second.Number)
256	require.NoError(t, err)
257	assert.Empty(t, list)
258}
259
260func TestSaveCommentPreconditions(t *testing.T) {
261	ctx := context.Background()
262	_, mrs, n := repoWithMRs(t)
263	first, err := mrs.Create(ctx, newMR(t, n, "t"))
264	require.NoError(t, err)
265	mr, err := mrs.Get(ctx, n, first.Number)
266	require.NoError(t, err)
267
268	mr.CommentSeq = 1
269	require.NoError(t, mrs.SaveComment(ctx, mr, newComment(t, 1, "one")))
270
271	// The same id again, from the same stale read: the request moved on.
272	assert.ErrorIs(t, mrs.SaveComment(ctx, mr, newComment(t, 1, "dup")), domain.ErrConflict)
273
274	// A fresh read of the request but a new comment with an existing id.
275	mr, err = mrs.Get(ctx, n, first.Number)
276	require.NoError(t, err)
277	assert.ErrorIs(t, mrs.SaveComment(ctx, mr, newComment(t, 1, "dup")), domain.ErrConflict, "a new comment must not overwrite")
278
279	// Resolving needs the comment version.
280	c, err := mrs.GetComment(ctx, n, first.Number, 1)
281	require.NoError(t, err)
282	stale := c
283	c.Resolved = true
284	require.NoError(t, mrs.SaveComment(ctx, mr, c))
285	mr, err = mrs.Get(ctx, n, first.Number)
286	require.NoError(t, err)
287	stale.Resolved = false
288	assert.ErrorIs(t, mrs.SaveComment(ctx, mr, stale), domain.ErrConflict)
289
290	// An unread request cannot be saved with.
291	assert.ErrorIs(t, mrs.SaveComment(ctx, newMR(t, n, "x"), newComment(t, 2, "n")), domain.ErrConflict)
292}
293
294func TestReadPosition(t *testing.T) {
295	ctx := context.Background()
296	_, mrs, n := repoWithMRs(t)
297	got, err := mrs.GetRead(ctx, n, 1)
298	require.NoError(t, err)
299	assert.True(t, got.IsZero())
300
301	require.NoError(t, mrs.SetRead(ctx, n, 1, oid('a')))
302	require.NoError(t, mrs.SetRead(ctx, n, 1, oid('b')))
303	got, err = mrs.GetRead(ctx, n, 1)
304	require.NoError(t, err)
305	assert.Equal(t, oid('b'), got)
306	got, err = mrs.GetRead(ctx, n, 2)
307	require.NoError(t, err)
308	assert.True(t, got.IsZero())
309	assert.Error(t, mrs.SetRead(ctx, n, 1, domain.ObjectID{}))
310}
311
312// TestDeleteDropsTheMergeRequestsOfTheRepository: deleting a repository drops
313// its merge requests, comments, read positions and number sequence, and only
314// its own; a repository created again under the name starts at !1.
315func TestDeleteDropsTheMergeRequestsOfTheRepository(t *testing.T) {
316	ctx := context.Background()
317	s, mrs, n := repoWithMRs(t)
318	sib := name(t, "proj-2")
319	require.NoError(t, s.Create(ctx, domain.Repo{Name: sib, CreatedAt: created, Visibility: domain.VisibilityPublic}))
320
321	for _, repo := range []domain.RepoName{n, sib} {
322		mr, err := mrs.Create(ctx, newMR(t, repo, "t"))
323		require.NoError(t, err)
324		mr.CommentSeq = 1
325		require.NoError(t, mrs.SaveComment(ctx, mr, newComment(t, 1, "note")))
326		require.NoError(t, mrs.SetRead(ctx, repo, mr.Number, oid('a')))
327	}
328
329	require.NoError(t, s.Delete(ctx, n))
330
331	list, err := mrs.List(ctx, n)
332	require.NoError(t, err)
333	assert.Empty(t, list)
334	comments, err := mrs.ListComments(ctx, n, 1)
335	require.NoError(t, err)
336	assert.Empty(t, comments)
337	read, err := mrs.GetRead(ctx, n, 1)
338	require.NoError(t, err)
339	assert.True(t, read.IsZero())
340
341	list, err = mrs.List(ctx, sib)
342	require.NoError(t, err)
343	require.Len(t, list, 1, "a repository whose name starts like the deleted one keeps its requests")
344	comments, err = mrs.ListComments(ctx, sib, 1)
345	require.NoError(t, err)
346	assert.Len(t, comments, 1)
347
348	require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
349	again, err := mrs.Create(ctx, newMR(t, n, "fresh"))
350	require.NoError(t, err)
351	assert.Equal(t, int64(1), again.Number)
352}
353
354// TestWritesAfterDeleteLeaveNothing: a comment or a read position written for
355// a repository that was deleted meanwhile is refused, so that a repository
356// created again under the name does not inherit it (GO-002).
357func TestWritesAfterDeleteLeaveNothing(t *testing.T) {
358	ctx := context.Background()
359	s, mrs, n := repoWithMRs(t)
360	mr, err := mrs.Create(ctx, newMR(t, n, "t"))
361	require.NoError(t, err)
362
363	require.NoError(t, s.Delete(ctx, n))
364
365	mr.CommentSeq = 1
366	assert.ErrorIs(t, mrs.SaveComment(ctx, mr, newComment(t, 1, "late")), domain.ErrNotFound)
367	assert.ErrorIs(t, mrs.SetRead(ctx, n, mr.Number, oid('a')), domain.ErrNotFound)
368	_, err = mrs.Create(ctx, newMR(t, n, "late"))
369	assert.ErrorIs(t, err, domain.ErrNotFound)
370
371	require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPublic}))
372	assertNoMergeRequestKeys(t, mrs, n)
373}
374
375func assertNoMergeRequestKeys(t *testing.T, mrs *kvstore.MergeRequests, n domain.RepoName) {
376	t.Helper()
377	ctx := context.Background()
378	list, err := mrs.List(ctx, n)
379	require.NoError(t, err)
380	assert.Empty(t, list, "merge requests")
381	comments, err := mrs.ListComments(ctx, n, 1)
382	require.NoError(t, err)
383	assert.Empty(t, comments, "comments")
384	read, err := mrs.GetRead(ctx, n, 1)
385	require.NoError(t, err)
386	assert.True(t, read.IsZero(), "read position")
387}
388
389// TestDeleteRacingWithWrites: writes racing with Delete either land before it
390// and are deleted, or are refused (not found after it, or a conflict when the
391// retries run out under this contention); nothing is left for a repository
392// created again under the name. Delete itself may report a conflict under the
393// same contention and is then called again.
394func TestDeleteRacingWithWrites(t *testing.T) {
395	ctx := context.Background()
396	for round := range 20 {
397		s, mrs, n := repoWithMRs(t)
398		mr, err := mrs.Create(ctx, newMR(t, n, "t"))
399		require.NoError(t, err)
400
401		var wg sync.WaitGroup
402		for w := range 4 {
403			wg.Add(1)
404			go func() {
405				defer wg.Done()
406				for i := range 30 {
407					if w%2 == 0 {
408						err := mrs.SetRead(ctx, n, mr.Number, oid('a'))
409						if err != nil {
410							assert.True(t, errors.Is(err, domain.ErrConflict) || errors.Is(err, domain.ErrNotFound), "round %d: %v", round, err)
411							return
412						}
413						continue
414					}
415					cur, err := mrs.Get(ctx, n, mr.Number)
416					if err != nil {
417						return
418					}
419					cur.CommentSeq = int64(w*1000 + i + 1)
420					err = mrs.SaveComment(ctx, cur, newComment(t, cur.CommentSeq, "note"))
421					if err != nil && !assert.True(t, errors.Is(err, domain.ErrConflict) || errors.Is(err, domain.ErrNotFound), "round %d: %v", round, err) {
422						return
423					}
424				}
425			}()
426		}
427		// Delete gives up with a conflict when the writers keep rewriting the
428		// repository key for deleteTries listings in a row; it is documented as
429		// safe to call again, as a caller does.
430		var delErr error
431		for range 20 {
432			if delErr = s.Delete(ctx, n); !errors.Is(delErr, domain.ErrConflict) {
433				break
434			}
435		}
436		wg.Wait()
437		require.NoError(t, delErr)
438
439		require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPublic}))
440		assertNoMergeRequestKeys(t, mrs, n)
441		again, err := mrs.Create(ctx, newMR(t, n, "fresh"))
442		require.NoError(t, err)
443		assert.Equal(t, int64(1), again.Number)
444	}
445}
446
447func TestDeleteManyMergeRequests(t *testing.T) {
448	ctx := context.Background()
449	s, mrs, n := repoWithMRs(t)
450	for range 1100 {
451		_, err := mrs.Create(ctx, newMR(t, n, "t"))
452		require.NoError(t, err)
453	}
454	require.NoError(t, s.Delete(ctx, n))
455	list, err := mrs.List(ctx, n)
456	require.NoError(t, err)
457	assert.Empty(t, list)
458	_, err = s.Get(ctx, n)
459	assert.ErrorIs(t, err, domain.ErrNotFound)
460}
461
462func TestRepoSettings(t *testing.T) {
463	ctx := context.Background()
464	s := openStore(t, t.TempDir())
465	n := name(t, "proj")
466
467	// Created without settings: they are stored explicitly, with the defaults.
468	require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
469	got, err := s.Get(ctx, n)
470	require.NoError(t, err)
471	assert.Equal(t, domain.DefaultRepoSettings(), got.Settings)
472
473	got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, Issues: domain.IssuesOwner}
474	_, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = got.Settings; return true, nil })
475	require.NoError(t, err)
476	again, err := s.Get(ctx, n)
477	require.NoError(t, err)
478	assert.Equal(t, got.Settings, again.Settings)
479
480	got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{"rebase"}}
481	_, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = got.Settings; return true, nil })
482	assert.ErrorIs(t, err, domain.ErrInvalid)
483}
484
485func TestRepoSettingsKeepAllowAutoMerge(t *testing.T) {
486	ctx := context.Background()
487	s := openStore(t, t.TempDir())
488	n := name(t, "proj")
489	require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
490	got, err := s.Get(ctx, n)
491	require.NoError(t, err)
492	assert.False(t, got.Settings.AllowAutoMerge)
493
494	settings := domain.DefaultRepoSettings()
495	settings.AllowAutoMerge = true
496	_, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = settings; return true, nil })
497	require.NoError(t, err)
498	got, err = s.Get(ctx, n)
499	require.NoError(t, err)
500	assert.Equal(t, settings, got.Settings)
501}
502
503// TestRepoWithoutStoredSettingsReadsTheDefaults: a repository stored before
504// merge requests existed has no settings field.
505func TestRepoWithoutStoredSettingsReadsTheDefaults(t *testing.T) {
506	ctx := context.Background()
507	dir := t.TempDir()
508	s, err := kvstore.Open(ctx, dir)
509	require.NoError(t, err)
510	require.NoError(t, s.Close(ctx))
511
512	// Write a legacy value straight into the database.
513	db, err := kv.Open(ctx, dir, kv.Options{})
514	require.NoError(t, err)
515	legacy, err := proto.Marshal(&storagepb.Repo{Name: "legacy", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC})
516	require.NoError(t, err)
517	var b kv.Batch
518	b.Put([]byte("repo/legacy"), legacy)
519	_, err = db.Apply(ctx, &b)
520	require.NoError(t, err)
521	require.NoError(t, db.Close(ctx))
522
523	s = openStore(t, dir)
524	got, err := s.Get(ctx, name(t, "legacy"))
525	require.NoError(t, err)
526	assert.Equal(t, domain.DefaultRepoSettings(), got.Settings)
527	list, err := s.List(ctx)
528	require.NoError(t, err)
529	require.Len(t, list, 1)
530	assert.Equal(t, domain.DefaultRepoSettings(), list[0].Settings)
531}
532
533func TestSigningSeed(t *testing.T) {
534	ctx := context.Background()
535	dir := t.TempDir()
536	s := openStore(t, dir)
537	calls := 0
538	gen := func() ([]byte, error) {
539		calls++
540		return []byte(strings.Repeat(string(rune('a'+calls)), kvstore.SigningSeedLen)), nil
541	}
542
543	first, err := s.SigningSeed(ctx, gen, created)
544	require.NoError(t, err)
545	require.Len(t, first, kvstore.SigningSeedLen)
546	second, err := s.SigningSeed(ctx, gen, created)
547	require.NoError(t, err)
548	assert.Equal(t, first, second, "generated once")
549	assert.Equal(t, 1, calls)
550
551	// It survives a restart.
552	require.NoError(t, s.Close(ctx))
553	s2 := openStore(t, dir)
554	third, err := s2.SigningSeed(ctx, gen, created)
555	require.NoError(t, err)
556	assert.Equal(t, first, third)
557	assert.Equal(t, 1, calls)
558}
559
560func TestSigningSeedConcurrentFirstStart(t *testing.T) {
561	ctx := context.Background()
562	s := openStore(t, t.TempDir())
563	var (
564		wg    sync.WaitGroup
565		mu    sync.Mutex
566		seeds [][]byte
567		n     byte
568	)
569	for range 6 {
570		wg.Go(func() {
571			seed, err := s.SigningSeed(ctx, func() ([]byte, error) {
572				mu.Lock()
573				defer mu.Unlock()
574				n++
575				return []byte(strings.Repeat(string(rune('A'+n)), kvstore.SigningSeedLen)), nil
576			}, created)
577			if assert.NoError(t, err) {
578				mu.Lock()
579				seeds = append(seeds, seed)
580				mu.Unlock()
581			}
582		})
583	}
584	wg.Wait()
585	for _, seed := range seeds {
586		assert.Equal(t, seeds[0], seed, "every caller gets the winner's seed")
587	}
588}
589
590func TestSigningSeedRejectsBadGenerators(t *testing.T) {
591	ctx := context.Background()
592	s := openStore(t, t.TempDir())
593	_, err := s.SigningSeed(ctx, func() ([]byte, error) { return []byte("short"), nil }, created)
594	require.Error(t, err)
595	assert.NotContains(t, err.Error(), "short")
596	_, err = s.SigningSeed(ctx, func() ([]byte, error) { return nil, assert.AnError }, created)
597	require.ErrorIs(t, err, assert.AnError)
598}
599
600func TestNoteFieldsRoundTrip(t *testing.T) {
601	ctx := context.Background()
602	_, store, name := repoWithMRs(t)
603	mr, err := store.Create(ctx, newMR(t, name, "notes"))
604	require.NoError(t, err)
605	for id := int64(1); id <= 2; id++ {
606		mr, err = store.Get(ctx, name, mr.Number)
607		require.NoError(t, err)
608		c := newComment(t, id, "note")
609		if id == 2 {
610			c.ReplyTo = 1
611			c.StartLine = 1
612			c.Verdict = domain.VerdictApprove
613			c.EditedAt = created
614			c.Deleted = true
615		}
616		mr.CommentSeq = id
617		require.NoError(t, store.SaveComment(ctx, mr, c))
618		got, err := store.GetComment(ctx, name, mr.Number, id)
619		require.NoError(t, err)
620		c.Version = got.Version
621		assert.Equal(t, c, got)
622	}
623}
624
625// TestStoredOldDefaultModesGainMerge: settings written before merge commits
626// (no merge_modes_v2) with exactly {fast-forward, squash} read with merge too;
627// the same set written by this build is an explicit choice and stays.
628func TestStoredOldDefaultModesGainMerge(t *testing.T) {
629	ctx := context.Background()
630	dir := t.TempDir()
631	s, err := kvstore.Open(ctx, dir)
632	require.NoError(t, err)
633	require.NoError(t, s.Close(ctx))
634
635	put := func(db *kv.DB, repo string, modes ...storagepb.MergeMode) {
636		val, err := proto.Marshal(&storagepb.Repo{Name: repo, Visibility: storagepb.Visibility_VISIBILITY_PUBLIC,
637			Settings: &storagepb.RepoSettings{AllowedMergeModes: modes}})
638		require.NoError(t, err)
639		var b kv.Batch
640		b.Put([]byte("repo/"+repo), val)
641		_, err = db.Apply(ctx, &b)
642		require.NoError(t, err)
643	}
644	db, err := kv.Open(ctx, dir, kv.Options{})
645	require.NoError(t, err)
646	put(db, "old", storagepb.MergeMode_MERGE_MODE_FAST_FORWARD, storagepb.MergeMode_MERGE_MODE_SQUASH)
647	put(db, "single", storagepb.MergeMode_MERGE_MODE_SQUASH)
648	require.NoError(t, db.Close(ctx))
649
650	s = openStore(t, dir)
651	old, err := s.Get(ctx, name(t, "old"))
652	require.NoError(t, err)
653	assert.Equal(t, domain.MergeModes(), old.Settings.AllowedMergeModes)
654	single, err := s.Get(ctx, name(t, "single"))
655	require.NoError(t, err)
656	assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, single.Settings.AllowedMergeModes)
657
658	// An explicit {ff, squash} written now is kept.
659	_, err = s.Mutate(ctx, old.Name, func(r *domain.Repo) (bool, error) {
660		r.Settings.AllowedMergeModes = []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}
661		return true, nil
662	})
663	require.NoError(t, err)
664	again, err := s.Get(ctx, name(t, "old"))
665	require.NoError(t, err)
666	assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, again.Settings.AllowedMergeModes)
667}
668
669func mergeability(mr domain.MergeRequest) domain.Mergeability {
670	return domain.Mergeability{
671		Repo: mr.Repo, Number: mr.Number, State: domain.MergeabilityConflict,
672		TargetID: oid('a'), HeadID: oid('b'), Behind: 3, BehindCapped: true,
673		ConflictPaths: []string{"a.txt", "b/c.txt"}, ConflictPathsTruncated: true, CheckedAt: created,
674	}
675}
676
677func TestPutMergeabilityCAS(t *testing.T) {
678	ctx := context.Background()
679	_, mrs, n := repoWithMRs(t)
680	mr, err := mrs.Create(ctx, newMR(t, n, "t"))
681	require.NoError(t, err)
682
683	_, err = mrs.GetMergeability(ctx, n, mr.Number)
684	assert.ErrorIs(t, err, domain.ErrNotFound)
685
686	m := mergeability(mr)
687	require.NoError(t, mrs.PutMergeability(ctx, m))
688	assert.ErrorIs(t, mrs.PutMergeability(ctx, m), domain.ErrConflict, "version 0 needs it absent")
689
690	got, err := mrs.GetMergeability(ctx, n, mr.Number)
691	require.NoError(t, err)
692	assert.NotZero(t, got.Version)
693	m.Version = got.Version
694	assert.Equal(t, m, got)
695
696	next := got
697	next.State = domain.MergeabilityUpToDate
698	require.NoError(t, mrs.PutMergeability(ctx, next))
699	assert.ErrorIs(t, mrs.PutMergeability(ctx, next), domain.ErrConflict, "stale version")
700
701	require.NoError(t, mrs.DeleteMergeability(ctx, n, mr.Number))
702	require.NoError(t, mrs.DeleteMergeability(ctx, n, mr.Number), "absent is fine")
703	_, err = mrs.GetMergeability(ctx, n, mr.Number)
704	assert.ErrorIs(t, err, domain.ErrNotFound)
705
706	missing := mergeability(mr)
707	missing.Number = 99
708	assert.ErrorIs(t, mrs.PutMergeability(ctx, missing), domain.ErrNotFound)
709
710	checking := mergeability(mr)
711	checking.State = domain.MergeabilityChecking
712	assert.ErrorIs(t, mrs.PutMergeability(ctx, checking), domain.ErrInvalid)
713}
714
715func TestMergeabilityRefusedAndDroppedWhenNotOpen(t *testing.T) {
716	for _, state := range []domain.MRState{domain.MRStateMerged, domain.MRStateClosed} {
717		ctx := context.Background()
718		_, mrs, n := repoWithMRs(t)
719		mr, err := mrs.Create(ctx, newMR(t, n, "t"))
720		require.NoError(t, err)
721		require.NoError(t, mrs.PutMergeability(ctx, mergeability(mr)))
722
723		cur, err := mrs.Get(ctx, n, mr.Number)
724		require.NoError(t, err)
725		cur.State = state
726		require.NoError(t, mrs.Update(ctx, cur))
727		_, err = mrs.GetMergeability(ctx, n, mr.Number)
728		assert.ErrorIs(t, err, domain.ErrNotFound, state)
729
730		assert.ErrorIs(t, mrs.PutMergeability(ctx, mergeability(mr)), domain.ErrNotFound, "a late worker never resurrects it: %s", state)
731	}
732}
733
734func TestDeleteWipesMergeability(t *testing.T) {
735	ctx := context.Background()
736	s, mrs, n := repoWithMRs(t)
737	mr, err := mrs.Create(ctx, newMR(t, n, "t"))
738	require.NoError(t, err)
739	require.NoError(t, mrs.PutMergeability(ctx, mergeability(mr)))
740	require.NoError(t, s.Delete(ctx, n))
741	_, err = mrs.GetMergeability(ctx, n, mr.Number)
742	assert.ErrorIs(t, err, domain.ErrNotFound)
743}

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