detached fda3c2b

issues_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package kvstore_test
4
5import (
6	"context"
7	"slices"
8	"sort"
9	"strconv"
10	"sync"
11	"testing"
12
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/require"
15	"google.golang.org/protobuf/proto"
16
17	storagepb "bnema.dev/ermite/gen/ermite/storage"
18	"bnema.dev/ermite/internal/adapters/kvstore"
19	"bnema.dev/ermite/internal/domain"
20	"bnema.dev/ermite/internal/ports"
21)
22
23func newIssue(t *testing.T, repo domain.RepoName, title string) domain.Issue {
24	t.Helper()
25	ti, err := domain.ParseIssueTitle(title)
26	require.NoError(t, err)
27	body, err := domain.ParseIssueBody("what\nhappened")
28	require.NoError(t, err)
29	return domain.Issue{
30		Repo: repo, Title: ti, Body: body, State: domain.IssueStateOpen,
31		Author: "agent", CreatedAt: created, UpdatedAt: created,
32	}
33}
34
35func newIssueComment(t *testing.T, id int64, text string) domain.IssueComment {
36	t.Helper()
37	body, err := domain.ParseIssueBody(text)
38	require.NoError(t, err)
39	return domain.IssueComment{ID: id, Author: "agent", Text: body, CreatedAt: created}
40}
41
42// countKeys counts the keys of the store under prefix.
43func countKeys(t *testing.T, s *kvstore.Store, prefix string) int {
44	t.Helper()
45	return s.CountKeysForTest(context.Background(), prefix)
46}
47
48func TestIssueRoundTripAndPaging(t *testing.T) {
49	ctx := context.Background()
50	s, _, n := repoWithMRs(t)
51	is := s.Issues()
52
53	first, err := is.Create(ctx, newIssue(t, n, "one"))
54	require.NoError(t, err)
55	assert.Equal(t, int64(1), first.Number)
56	assert.NotZero(t, first.Version)
57	got, err := is.Get(ctx, n, 1)
58	require.NoError(t, err)
59	assert.Equal(t, first, got)
60	_, err = is.Get(ctx, n, 2)
61	assert.ErrorIs(t, err, domain.ErrNotFound)
62
63	for _, title := range []string{"two", "three", "four"} {
64		_, err := is.Create(ctx, newIssue(t, n, title))
65		require.NoError(t, err)
66	}
67	closed, err := is.Get(ctx, n, 2)
68	require.NoError(t, err)
69	closed.State = domain.IssueStateClosed
70	require.NoError(t, is.Update(ctx, closed))
71
72	page, err := is.List(ctx, n, ports.ListIssuesParams{PageSize: 3})
73	require.NoError(t, err)
74	require.Len(t, page.Issues, 3)
75	assert.Equal(t, int64(4), page.Issues[0].Number)
76	require.NotEmpty(t, page.NextPageToken)
77	page, err = is.List(ctx, n, ports.ListIssuesParams{PageSize: 3, PageToken: page.NextPageToken})
78	require.NoError(t, err)
79	require.Len(t, page.Issues, 1)
80	assert.Equal(t, int64(1), page.Issues[0].Number)
81	assert.Empty(t, page.NextPageToken)
82
83	page, err = is.List(ctx, n, ports.ListIssuesParams{State: domain.IssueStateClosed})
84	require.NoError(t, err)
85	require.Len(t, page.Issues, 1)
86	assert.Equal(t, int64(2), page.Issues[0].Number)
87
88	_, err = is.List(ctx, n, ports.ListIssuesParams{PageToken: "x"})
89	assert.ErrorIs(t, err, domain.ErrInvalid)
90
91	_, err = is.Create(ctx, newIssue(t, name(t, "ghost"), "x"))
92	assert.ErrorIs(t, err, domain.ErrNotFound)
93}
94
95func TestIssueUpdateNeedsTheVersionItWasReadAt(t *testing.T) {
96	ctx := context.Background()
97	s, _, n := repoWithMRs(t)
98	is := s.Issues()
99	first, err := is.Create(ctx, newIssue(t, n, "t"))
100	require.NoError(t, err)
101	a, b := first, first
102	a.State = domain.IssueStateClosed
103	require.NoError(t, is.Update(ctx, a))
104	b.State = domain.IssueStateClosed
105	assert.ErrorIs(t, is.Update(ctx, b), domain.ErrConflict)
106}
107
108func TestIssueCommentsAndSaveCommentPreconditions(t *testing.T) {
109	ctx := context.Background()
110	s, _, n := repoWithMRs(t)
111	is := s.Issues()
112	issue, err := is.Create(ctx, newIssue(t, n, "t"))
113	require.NoError(t, err)
114
115	issue.CommentSeq = 1
116	require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 1, "first")))
117	// The issue moved on: the same version is refused.
118	issue.CommentSeq = 2
119	assert.ErrorIs(t, is.SaveComment(ctx, issue, newIssueComment(t, 2, "second")), domain.ErrConflict)
120	issue, err = is.Get(ctx, n, issue.Number)
121	require.NoError(t, err)
122	issue.CommentSeq = 2
123	require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 2, "second")))
124
125	comments, err := is.ListComments(ctx, n, issue.Number)
126	require.NoError(t, err)
127	require.Len(t, comments, 2)
128	assert.Equal(t, "first", comments[0].Text.String())
129	assert.Equal(t, int64(2), comments[1].ID)
130
131	require.NoError(t, s.Delete(ctx, n))
132	assert.ErrorIs(t, is.SaveComment(ctx, issue, newIssueComment(t, 3, "late")), domain.ErrNotFound)
133}
134
135func TestConcurrentMergeRequestsAndIssuesNeverShareANumber(t *testing.T) {
136	ctx := context.Background()
137	s, mrs, n := repoWithMRs(t)
138	is := s.Issues()
139	const workers = 16
140	var (
141		wg      sync.WaitGroup
142		mu      sync.Mutex
143		numbers []int64
144	)
145	for w := range workers {
146		wg.Go(func() {
147			var number int64
148			if w%2 == 0 {
149				got, err := mrs.Create(ctx, newMR(t, n, "t"))
150				if !assert.NoError(t, err) {
151					return
152				}
153				number = got.Number
154			} else {
155				got, err := is.Create(ctx, newIssue(t, n, "t"))
156				if !assert.NoError(t, err) {
157					return
158				}
159				number = got.Number
160			}
161			mu.Lock()
162			numbers = append(numbers, number)
163			mu.Unlock()
164		})
165	}
166	wg.Wait()
167	want := make([]int64, workers)
168	for i := range want {
169		want[i] = int64(i + 1)
170	}
171	assert.ElementsMatch(t, want, numbers, "every number is used once, by an issue or a merge request")
172}
173
174func TestDeleteDropsTheIssuesOfTheRepository(t *testing.T) {
175	ctx := context.Background()
176	s, _, n := repoWithMRs(t)
177	sib := name(t, "proj-2")
178	require.NoError(t, s.Create(ctx, domain.Repo{Name: sib, CreatedAt: created, Visibility: domain.VisibilityPublic}))
179	is := s.Issues()
180
181	for _, repo := range []domain.RepoName{n, sib} {
182		issue, err := is.Create(ctx, newIssue(t, repo, "t"))
183		require.NoError(t, err)
184		issue.CommentSeq = 1
185		require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 1, "note")))
186	}
187	require.NoError(t, s.Delete(ctx, n))
188
189	page, err := is.List(ctx, n, ports.ListIssuesParams{})
190	require.NoError(t, err)
191	assert.Empty(t, page.Issues)
192	comments, err := is.ListComments(ctx, n, 1)
193	require.NoError(t, err)
194	assert.Empty(t, comments)
195	assert.Empty(t, openOf(t, is, n))
196	assert.Zero(t, countKeys(t, s, "is/proj/"))
197	assert.Zero(t, countKeys(t, s, "isc/proj/"))
198	assert.Zero(t, countKeys(t, s, "isopen/proj/"))
199
200	open := openOf(t, is, sib)
201	assert.Len(t, open, 1, "a repository whose name starts like the deleted one keeps its issues")
202	comments, err = is.ListComments(ctx, sib, 1)
203	require.NoError(t, err)
204	assert.Len(t, comments, 1)
205
206	require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
207	again, err := is.Create(ctx, newIssue(t, n, "fresh"))
208	require.NoError(t, err)
209	assert.Equal(t, int64(1), again.Number)
210}
211
212func TestOpenIssueIndexFollowsUpdates(t *testing.T) {
213	ctx := context.Background()
214	s, _, n := repoWithMRs(t)
215	is := s.Issues()
216	a, err := is.Create(ctx, newIssue(t, n, "a"))
217	require.NoError(t, err)
218	b, err := is.Create(ctx, newIssue(t, n, "b"))
219	require.NoError(t, err)
220
221	numbers := func() []int64 {
222		open := openOf(t, is, n)
223		var out []int64
224		for _, i := range open {
225			out = append(out, i.Number)
226		}
227		return out
228	}
229	assert.Equal(t, []int64{2, 1}, numbers())
230	assert.Equal(t, 2, countKeys(t, s, "isopen/proj/"))
231
232	a.State = domain.IssueStateClosed
233	require.NoError(t, is.Update(ctx, a))
234	assert.Equal(t, []int64{2}, numbers())
235	assert.Equal(t, 1, countKeys(t, s, "isopen/proj/"))
236
237	a, err = is.Get(ctx, n, a.Number)
238	require.NoError(t, err)
239	a.State = domain.IssueStateOpen
240	require.NoError(t, is.Update(ctx, a))
241	assert.Equal(t, []int64{2, 1}, numbers())
242
243	// A comment keeps the index as the issue says.
244	b.State = domain.IssueStateClosed
245	b.CommentSeq = 1
246	require.NoError(t, is.SaveComment(ctx, b, newIssueComment(t, 1, "closing")))
247	assert.Equal(t, []int64{1}, numbers())
248	assert.Equal(t, 1, countKeys(t, s, "isopen/proj/"))
249}
250
251func TestRepoSettingsKeepTheIssuesMode(t *testing.T) {
252	ctx := context.Background()
253	s := openStore(t, t.TempDir())
254	n := name(t, "proj")
255	require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
256	got, err := s.Get(ctx, n)
257	require.NoError(t, err)
258	assert.Equal(t, domain.IssuesOff, got.Settings.Issues)
259	for _, m := range []domain.IssuesMode{domain.IssuesOwner, domain.IssuesPublic, domain.IssuesOff} {
260		got.Settings.Issues = m
261		_, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = got.Settings; return true, nil })
262		require.NoError(t, err)
263		again, err := s.Get(ctx, n)
264		require.NoError(t, err)
265		assert.Equal(t, m, again.Settings.Issues)
266	}
267	got.Settings.Issues = "everyone"
268	_, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = got.Settings; return true, nil })
269	assert.ErrorIs(t, err, domain.ErrInvalid)
270}
271
272// TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every
273// original scope keep their owner rights, gaining mr:review and issue:write
274// (each only if missing), then mr:force-merge in schema 5 and release:write in
275// schema 6; no other token changes.
276func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
277	ctx := context.Background()
278	dir := t.TempDir()
279	putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 3}))
280
281	all9 := []string{
282		"repo:read", "repo:write", "repo:admin", "mr:write", "mr:merge",
283		"ci:write", "secrets:write", "runner:register", "token:admin",
284	}
285	tokens := map[string]*storagepb.Token{
286		"full":      {Id: "full", Scopes: all9},
287		"full10":    {Id: "full10", Scopes: append(slices.Clone(all9), "mr:review")},
288		"reposmr":   {Id: "reposmr", Scopes: append(slices.Clone(all9), "mr:review"), Repos: []string{"proj"}},
289		"missing":   {Id: "missing", Scopes: all9[:8]},
290		"repos":     {Id: "repos", Scopes: all9, Repos: []string{"proj"}},
291		"prefix":    {Id: "prefix", Scopes: all9, BranchPrefixes: []string{"agent/"}},
292		"small":     {Id: "small", Scopes: []string{"repo:read"}},
293		"otherfull": {Id: "otherfull", Scopes: append([]string{"token:admin"}, all9[:8]...)},
294	}
295	for id, tok := range tokens {
296		tok.Hash = make([]byte, 32)
297		tok.Hash[0] = byte(len(id))
298		putRaw(t, dir, "token/"+id, mustProto(t, tok))
299	}
300
301	s, err := kvstore.Open(ctx, dir)
302	require.NoError(t, err)
303	t.Cleanup(func() { _ = s.Close(ctx) })
304	get := func(id string) domain.Token {
305		tok, err := s.Tokens().Get(ctx, id)
306		require.NoError(t, err)
307		return tok
308	}
309
310	full := get("full")
311	assert.Contains(t, full.Scopes, domain.ScopeIssueWrite)
312	assert.Contains(t, full.Scopes, domain.ScopeMRReview)
313	assert.Len(t, full.Scopes, 13)
314	assert.Contains(t, full.Scopes, domain.ScopeReleaseWrite)
315	full10 := get("full10")
316	assert.Len(t, full10.Scopes, 13)
317	assert.Contains(t, full10.Scopes, domain.ScopeReleaseWrite)
318	for _, id := range []string{"repos", "prefix", "missing", "reposmr", "small"} {
319		assert.NotContains(t, get(id).Scopes, domain.ScopeReleaseWrite)
320	}
321	assert.Equal(t, 1, countScope(full10.Scopes, domain.ScopeMRReview))
322	assert.Contains(t, full10.Scopes, domain.ScopeIssueWrite)
323	assert.True(t, domain.TokenPrincipal(full10).HasOwnerRights())
324	assert.Len(t, get("reposmr").Scopes, 10)
325	assert.NotContains(t, get("reposmr").Scopes, domain.ScopeIssueWrite)
326	assert.True(t, domain.TokenPrincipal(full).HasOwnerRights())
327	assert.True(t, domain.TokenPrincipal(get("otherfull")).HasOwnerRights())
328
329	for _, id := range []string{"missing", "reposmr", "repos", "prefix", "small"} {
330		assert.NotContains(t, get(id).Scopes, domain.ScopeMRForceMerge)
331	}
332	assert.NotContains(t, get("missing").Scopes, domain.ScopeIssueWrite)
333	assert.NotContains(t, get("missing").Scopes, domain.ScopeMRReview)
334	assert.NotContains(t, get("repos").Scopes, domain.ScopeMRReview)
335	assert.NotContains(t, get("prefix").Scopes, domain.ScopeMRReview)
336	assert.False(t, domain.TokenPrincipal(get("missing")).HasOwnerRights())
337	assert.NotContains(t, get("repos").Scopes, domain.ScopeIssueWrite)
338	assert.NotContains(t, get("prefix").Scopes, domain.ScopeIssueWrite)
339	assert.Equal(t, []domain.Scope{domain.ScopeRepoRead}, get("small").Scopes)
340	assert.Len(t, get("missing").Scopes, 8)
341	assert.Len(t, get("repos").Scopes, 9)
342	assert.Len(t, get("prefix").Scopes, 9)
343	assert.Equal(t, []string{"agent/"}, get("prefix").BranchPrefixes)
344	assert.Equal(t, "full", full.ID)
345
346	require.NoError(t, s.Close(ctx))
347	assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
348
349	// A second open changes nothing.
350	s2, err := kvstore.Open(ctx, dir)
351	require.NoError(t, err)
352	tok, err := s2.Tokens().Get(ctx, "full")
353	require.NoError(t, err)
354	assert.Equal(t, full.Scopes, tok.Scopes)
355	tok10, err := s2.Tokens().Get(ctx, "full10")
356	require.NoError(t, err)
357	assert.Equal(t, full10.Scopes, tok10.Scopes)
358	require.NoError(t, s2.Close(ctx))
359}
360
361func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) {
362	ctx := context.Background()
363	dir := t.TempDir()
364	putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1}))
365	_, err := kvstore.Open(ctx, dir)
366	require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
367	assert.Equal(t, uint32(6), uint32(kvstore.SchemaVersion))
368}
369
370// A reopen racing the delete of the repository either lands before the delete
371// removes it or fails; it never leaves an isopen/ key of a repository that is
372// gone.
373func TestDeleteVersusReopenLeavesNoIssueKeys(t *testing.T) {
374	ctx := context.Background()
375	for range 30 {
376		s, _, n := repoWithMRs(t)
377		is := s.Issues()
378		issue, err := is.Create(ctx, newIssue(t, n, "t"))
379		require.NoError(t, err)
380		issue.State = domain.IssueStateClosed
381		require.NoError(t, is.Update(ctx, issue))
382		closed, err := is.Get(ctx, n, issue.Number)
383		require.NoError(t, err)
384		closed.State = domain.IssueStateOpen
385
386		var wg sync.WaitGroup
387		start := make(chan struct{})
388		wg.Add(2)
389		go func() { defer wg.Done(); <-start; _ = is.Update(ctx, closed) }()
390		go func() { defer wg.Done(); <-start; _ = s.Delete(ctx, n) }()
391		close(start)
392		wg.Wait()
393
394		for _, prefix := range []string{"is/proj/", "isc/proj/", "isopen/proj/"} {
395			assert.Zero(t, countKeys(t, s, prefix), prefix)
396		}
397		assert.ErrorIs(t, is.Update(ctx, closed), domain.ErrNotFound)
398	}
399}
400
401func TestListOpenAllSpansRepositoriesThroughTheIndex(t *testing.T) {
402	ctx := context.Background()
403	s, mrs, n := repoWithMRs(t)
404	other := name(t, "other")
405	require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic}))
406	is := s.Issues()
407
408	i1, err := is.Create(ctx, newIssue(t, n, "one"))
409	require.NoError(t, err)
410	i2, err := is.Create(ctx, newIssue(t, other, "two"))
411	require.NoError(t, err)
412	i3, err := is.Create(ctx, newIssue(t, other, "three"))
413	require.NoError(t, err)
414	i3.State = domain.IssueStateClosed
415	require.NoError(t, is.Update(ctx, i3))
416	_, err = mrs.Create(ctx, newMR(t, n, "mr1"))
417	require.NoError(t, err)
418	m2, err := mrs.Create(ctx, newMR(t, other, "mr2"))
419	require.NoError(t, err)
420	m3, err := mrs.Create(ctx, newMR(t, other, "mr3"))
421	require.NoError(t, err)
422	m3.State = domain.MRStateClosed
423	require.NoError(t, mrs.Update(ctx, m3))
424
425	issues, err := is.ListOpenAll(ctx)
426	require.NoError(t, err)
427	var got []string
428	for _, i := range issues {
429		got = append(got, i.Repo.String()+"#"+strconv.FormatInt(i.Number, 10))
430	}
431	assert.ElementsMatch(t, []string{
432		i1.Repo.String() + "#" + strconv.FormatInt(i1.Number, 10),
433		i2.Repo.String() + "#" + strconv.FormatInt(i2.Number, 10),
434	}, got)
435
436	open, err := mrs.ListOpenAll(ctx)
437	require.NoError(t, err)
438	got = nil
439	for _, m := range open {
440		got = append(got, m.Repo.String()+"#"+strconv.FormatInt(m.Number, 10))
441	}
442	assert.ElementsMatch(t, []string{"proj#2", m2.Repo.String() + "#" + strconv.FormatInt(m2.Number, 10)}, got)
443	assert.Len(t, open, 2)
444}
445
446func TestListOpenAllIsEmptyWithoutOpenItems(t *testing.T) {
447	ctx := context.Background()
448	s, mrs, _ := repoWithMRs(t)
449	issues, err := s.Issues().ListOpenAll(ctx)
450	require.NoError(t, err)
451	assert.Empty(t, issues)
452	open, err := mrs.ListOpenAll(ctx)
453	require.NoError(t, err)
454	assert.Empty(t, open)
455}
456
457func TestListOpenAllSkipsAStaleIndexEntry(t *testing.T) {
458	ctx := context.Background()
459	s, mrs, n := repoWithMRs(t)
460	is := s.Issues()
461	keepI, err := is.Create(ctx, newIssue(t, n, "keep"))
462	require.NoError(t, err)
463	goneI, err := is.Create(ctx, newIssue(t, n, "gone"))
464	require.NoError(t, err)
465	keepM, err := mrs.Create(ctx, newMR(t, n, "keep"))
466	require.NoError(t, err)
467	goneM, err := mrs.Create(ctx, newMR(t, n, "gone"))
468	require.NoError(t, err)
469
470	require.NoError(t, is.DropIssueRecordForTest(ctx, n, goneI.Number))
471	require.NoError(t, mrs.DropMRRecordForTest(ctx, n, goneM.Number))
472	require.Positive(t, countKeys(t, s, "isopen/proj/"), "the index entries are still there")
473	require.Positive(t, countKeys(t, s, "mropen/proj/"))
474
475	issues, err := is.ListOpenAll(ctx)
476	require.NoError(t, err)
477	require.Len(t, issues, 1)
478	assert.Equal(t, keepI.Number, issues[0].Number)
479	open, err := mrs.ListOpenAll(ctx)
480	require.NoError(t, err)
481	require.Len(t, open, 1)
482	assert.Equal(t, keepM.Number, open[0].Number)
483}
484
485func TestIssueListPagesThroughManyIssues(t *testing.T) {
486	ctx := context.Background()
487	s, _, n := repoWithMRs(t)
488	is := s.Issues()
489	const total = 53
490	for i := 1; i <= total; i++ {
491		_, err := is.Create(ctx, newIssue(t, n, "issue "+strconv.Itoa(i)))
492		require.NoError(t, err)
493	}
494	for i := 2; i <= total; i += 3 { // 2, 5, 8, ...
495		is, err := is.Get(ctx, n, int64(i))
496		require.NoError(t, err)
497		is.State = domain.IssueStateClosed
498		require.NoError(t, s.Issues().Update(ctx, is))
499	}
500
501	walk := func(state domain.IssueState, size int) []int64 {
502		var got []int64
503		token := ""
504		for {
505			page, err := s.Issues().List(ctx, n, ports.ListIssuesParams{State: state, PageSize: size, PageToken: token})
506			require.NoError(t, err)
507			require.LessOrEqual(t, len(page.Issues), size)
508			for _, is := range page.Issues {
509				got = append(got, is.Number)
510			}
511			if page.NextPageToken == "" {
512				return got
513			}
514			token = page.NextPageToken
515		}
516	}
517	for _, size := range []int{1, 7, 10, 53, 100} {
518		var all, closed []int64
519		for i := int64(total); i >= 1; i-- {
520			all = append(all, i)
521			if i%3 == 2 {
522				closed = append(closed, i)
523			}
524		}
525		assert.Equal(t, all, walk("", size), "size %d", size)
526		assert.Equal(t, closed, walk(domain.IssueStateClosed, size), "closed, size %d", size)
527	}
528	page, err := s.Issues().List(ctx, n, ports.ListIssuesParams{PageSize: 10, PageToken: "1"})
529	require.NoError(t, err)
530	assert.Empty(t, page.Issues)
531	assert.Empty(t, page.NextPageToken)
532}
533
534// openOf lists the open issues of one repository through the isopen/ index.
535func openOf(t *testing.T, is *kvstore.Issues, repo domain.RepoName) []domain.Issue {
536	t.Helper()
537	all, err := is.ListOpenAll(context.Background())
538	require.NoError(t, err)
539	var out []domain.Issue
540	for _, i := range all {
541		if i.Repo == repo {
542			out = append(out, i)
543		}
544	}
545	sort.Slice(out, func(a, b int) bool { return out[a].Number > out[b].Number })
546	return out
547}
548
549func TestStoredPendingIssueStateIsCorrupt(t *testing.T) {
550	ctx := context.Background()
551	s, _, n := repoWithMRs(t)
552	is := s.Issues()
553	raw, err := proto.Marshal(&storagepb.Issue{Number: 1, Title: "x", State: storagepb.IssueState_ISSUE_STATE_PENDING})
554	require.NoError(t, err)
555	require.NoError(t, is.PutRawIssueForTest(ctx, n, 1, raw))
556	_, err = is.Get(ctx, n, 1)
557	assert.ErrorContains(t, err, "pending")
558	_, err = is.List(ctx, n, ports.ListIssuesParams{})
559	assert.ErrorContains(t, err, "pending")
560	_, err = is.List(ctx, n, ports.ListIssuesParams{State: domain.IssueStateOpen})
561	assert.ErrorContains(t, err, "pending")
562}
563
564func countScope(scopes []domain.Scope, want domain.Scope) int {
565	n := 0
566	for _, s := range scopes {
567		if s == want {
568			n++
569		}
570	}
571	return n
572}
573
574func TestSchema5UpgradesOwnerEquivalentTokens(t *testing.T) {
575	ctx := context.Background()
576	dir := t.TempDir()
577	putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 4}))
578	var previous []string
579	for _, sc := range domain.AllScopes() {
580		if sc != domain.ScopeMRForceMerge && sc != domain.ScopeReleaseWrite {
581			previous = append(previous, sc.String())
582		}
583	}
584	tokens := map[string]*storagepb.Token{
585		"owner":   {Id: "owner", Scopes: previous},
586		"repos":   {Id: "repos", Scopes: previous, Repos: []string{"proj"}},
587		"branch":  {Id: "branch", Scopes: previous, BranchPrefixes: []string{"agent/"}},
588		"partial": {Id: "partial", Scopes: previous[:10]},
589	}
590	for id, tok := range tokens {
591		tok.Hash = make([]byte, 32)
592		putRaw(t, dir, "token/"+id, mustProto(t, tok))
593	}
594	for range 2 {
595		s, err := kvstore.Open(ctx, dir)
596		require.NoError(t, err)
597		for id, original := range tokens {
598			tok, err := s.Tokens().Get(ctx, id)
599			require.NoError(t, err)
600			if id == "owner" {
601				assert.True(t, domain.TokenPrincipal(tok).HasOwnerRights())
602				assert.Equal(t, 1, countScope(tok.Scopes, domain.ScopeMRForceMerge))
603				assert.Equal(t, 1, countScope(tok.Scopes, domain.ScopeReleaseWrite))
604				assert.Len(t, tok.Scopes, 13)
605			} else {
606				assert.NotContains(t, tok.Scopes, domain.ScopeMRForceMerge)
607				assert.NotContains(t, tok.Scopes, domain.ScopeReleaseWrite)
608				var got []string
609				for _, sc := range tok.Scopes {
610					got = append(got, sc.String())
611				}
612				assert.Equal(t, original.Scopes, got)
613			}
614		}
615		require.NoError(t, s.Close(ctx))
616		assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
617	}
618}

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