detached 83e495f

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); no other token changes.
275func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
276	ctx := context.Background()
277	dir := t.TempDir()
278	putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 3}))
279
280	all9 := []string{
281		"repo:read", "repo:write", "repo:admin", "mr:write", "mr:merge",
282		"ci:write", "secrets:write", "runner:register", "token:admin",
283	}
284	tokens := map[string]*storagepb.Token{
285		"full":      {Id: "full", Scopes: all9},
286		"full10":    {Id: "full10", Scopes: append(slices.Clone(all9), "mr:review")},
287		"reposmr":   {Id: "reposmr", Scopes: append(slices.Clone(all9), "mr:review"), Repos: []string{"proj"}},
288		"missing":   {Id: "missing", Scopes: all9[:8]},
289		"repos":     {Id: "repos", Scopes: all9, Repos: []string{"proj"}},
290		"prefix":    {Id: "prefix", Scopes: all9, BranchPrefixes: []string{"agent/"}},
291		"small":     {Id: "small", Scopes: []string{"repo:read"}},
292		"otherfull": {Id: "otherfull", Scopes: append([]string{"token:admin"}, all9[:8]...)},
293	}
294	for id, tok := range tokens {
295		tok.Hash = make([]byte, 32)
296		tok.Hash[0] = byte(len(id))
297		putRaw(t, dir, "token/"+id, mustProto(t, tok))
298	}
299
300	s, err := kvstore.Open(ctx, dir)
301	require.NoError(t, err)
302	t.Cleanup(func() { _ = s.Close(ctx) })
303	get := func(id string) domain.Token {
304		tok, err := s.Tokens().Get(ctx, id)
305		require.NoError(t, err)
306		return tok
307	}
308
309	full := get("full")
310	assert.Contains(t, full.Scopes, domain.ScopeIssueWrite)
311	assert.Contains(t, full.Scopes, domain.ScopeMRReview)
312	assert.Len(t, full.Scopes, 11)
313	full10 := get("full10")
314	assert.Len(t, full10.Scopes, 11)
315	assert.Equal(t, 1, countScope(full10.Scopes, domain.ScopeMRReview))
316	assert.Contains(t, full10.Scopes, domain.ScopeIssueWrite)
317	assert.True(t, domain.TokenPrincipal(full10).HasOwnerRights())
318	assert.Len(t, get("reposmr").Scopes, 10)
319	assert.NotContains(t, get("reposmr").Scopes, domain.ScopeIssueWrite)
320	assert.True(t, domain.TokenPrincipal(full).HasOwnerRights())
321	assert.True(t, domain.TokenPrincipal(get("otherfull")).HasOwnerRights(), "the order of the scopes does not matter")
322
323	assert.NotContains(t, get("missing").Scopes, domain.ScopeIssueWrite)
324	assert.NotContains(t, get("missing").Scopes, domain.ScopeMRReview)
325	assert.NotContains(t, get("repos").Scopes, domain.ScopeMRReview)
326	assert.NotContains(t, get("prefix").Scopes, domain.ScopeMRReview)
327	assert.False(t, domain.TokenPrincipal(get("missing")).HasOwnerRights())
328	assert.NotContains(t, get("repos").Scopes, domain.ScopeIssueWrite)
329	assert.NotContains(t, get("prefix").Scopes, domain.ScopeIssueWrite)
330	assert.Equal(t, []domain.Scope{domain.ScopeRepoRead}, get("small").Scopes)
331	assert.Len(t, get("missing").Scopes, 8)
332	assert.Len(t, get("repos").Scopes, 9)
333	assert.Len(t, get("prefix").Scopes, 9)
334	assert.Equal(t, []string{"agent/"}, get("prefix").BranchPrefixes)
335	assert.Equal(t, "full", full.ID)
336
337	require.NoError(t, s.Close(ctx))
338	assert.Equal(t, uint32(4), readSchema(t, dir))
339
340	// A second open changes nothing.
341	s2, err := kvstore.Open(ctx, dir)
342	require.NoError(t, err)
343	tok, err := s2.Tokens().Get(ctx, "full")
344	require.NoError(t, err)
345	assert.Equal(t, full.Scopes, tok.Scopes)
346	tok10, err := s2.Tokens().Get(ctx, "full10")
347	require.NoError(t, err)
348	assert.Equal(t, full10.Scopes, tok10.Scopes)
349	require.NoError(t, s2.Close(ctx))
350}
351
352func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) {
353	ctx := context.Background()
354	dir := t.TempDir()
355	putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1}))
356	_, err := kvstore.Open(ctx, dir)
357	require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
358	assert.Equal(t, uint32(4), uint32(kvstore.SchemaVersion))
359}
360
361// A reopen racing the delete of the repository either lands before the delete
362// removes it or fails; it never leaves an isopen/ key of a repository that is
363// gone.
364func TestDeleteVersusReopenLeavesNoIssueKeys(t *testing.T) {
365	ctx := context.Background()
366	for range 30 {
367		s, _, n := repoWithMRs(t)
368		is := s.Issues()
369		issue, err := is.Create(ctx, newIssue(t, n, "t"))
370		require.NoError(t, err)
371		issue.State = domain.IssueStateClosed
372		require.NoError(t, is.Update(ctx, issue))
373		closed, err := is.Get(ctx, n, issue.Number)
374		require.NoError(t, err)
375		closed.State = domain.IssueStateOpen
376
377		var wg sync.WaitGroup
378		start := make(chan struct{})
379		wg.Add(2)
380		go func() { defer wg.Done(); <-start; _ = is.Update(ctx, closed) }()
381		go func() { defer wg.Done(); <-start; _ = s.Delete(ctx, n) }()
382		close(start)
383		wg.Wait()
384
385		for _, prefix := range []string{"is/proj/", "isc/proj/", "isopen/proj/"} {
386			assert.Zero(t, countKeys(t, s, prefix), prefix)
387		}
388		assert.ErrorIs(t, is.Update(ctx, closed), domain.ErrNotFound)
389	}
390}
391
392func TestListOpenAllSpansRepositoriesThroughTheIndex(t *testing.T) {
393	ctx := context.Background()
394	s, mrs, n := repoWithMRs(t)
395	other := name(t, "other")
396	require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic}))
397	is := s.Issues()
398
399	i1, err := is.Create(ctx, newIssue(t, n, "one"))
400	require.NoError(t, err)
401	i2, err := is.Create(ctx, newIssue(t, other, "two"))
402	require.NoError(t, err)
403	i3, err := is.Create(ctx, newIssue(t, other, "three"))
404	require.NoError(t, err)
405	i3.State = domain.IssueStateClosed
406	require.NoError(t, is.Update(ctx, i3))
407	_, err = mrs.Create(ctx, newMR(t, n, "mr1"))
408	require.NoError(t, err)
409	m2, err := mrs.Create(ctx, newMR(t, other, "mr2"))
410	require.NoError(t, err)
411	m3, err := mrs.Create(ctx, newMR(t, other, "mr3"))
412	require.NoError(t, err)
413	m3.State = domain.MRStateClosed
414	require.NoError(t, mrs.Update(ctx, m3))
415
416	issues, err := is.ListOpenAll(ctx)
417	require.NoError(t, err)
418	var got []string
419	for _, i := range issues {
420		got = append(got, i.Repo.String()+"#"+strconv.FormatInt(i.Number, 10))
421	}
422	assert.ElementsMatch(t, []string{
423		i1.Repo.String() + "#" + strconv.FormatInt(i1.Number, 10),
424		i2.Repo.String() + "#" + strconv.FormatInt(i2.Number, 10),
425	}, got)
426
427	open, err := mrs.ListOpenAll(ctx)
428	require.NoError(t, err)
429	got = nil
430	for _, m := range open {
431		got = append(got, m.Repo.String()+"#"+strconv.FormatInt(m.Number, 10))
432	}
433	assert.ElementsMatch(t, []string{"proj#2", m2.Repo.String() + "#" + strconv.FormatInt(m2.Number, 10)}, got)
434	assert.Len(t, open, 2)
435}
436
437func TestListOpenAllIsEmptyWithoutOpenItems(t *testing.T) {
438	ctx := context.Background()
439	s, mrs, _ := repoWithMRs(t)
440	issues, err := s.Issues().ListOpenAll(ctx)
441	require.NoError(t, err)
442	assert.Empty(t, issues)
443	open, err := mrs.ListOpenAll(ctx)
444	require.NoError(t, err)
445	assert.Empty(t, open)
446}
447
448func TestListOpenAllSkipsAStaleIndexEntry(t *testing.T) {
449	ctx := context.Background()
450	s, mrs, n := repoWithMRs(t)
451	is := s.Issues()
452	keepI, err := is.Create(ctx, newIssue(t, n, "keep"))
453	require.NoError(t, err)
454	goneI, err := is.Create(ctx, newIssue(t, n, "gone"))
455	require.NoError(t, err)
456	keepM, err := mrs.Create(ctx, newMR(t, n, "keep"))
457	require.NoError(t, err)
458	goneM, err := mrs.Create(ctx, newMR(t, n, "gone"))
459	require.NoError(t, err)
460
461	require.NoError(t, is.DropIssueRecordForTest(ctx, n, goneI.Number))
462	require.NoError(t, mrs.DropMRRecordForTest(ctx, n, goneM.Number))
463	require.Positive(t, countKeys(t, s, "isopen/proj/"), "the index entries are still there")
464	require.Positive(t, countKeys(t, s, "mropen/proj/"))
465
466	issues, err := is.ListOpenAll(ctx)
467	require.NoError(t, err)
468	require.Len(t, issues, 1)
469	assert.Equal(t, keepI.Number, issues[0].Number)
470	open, err := mrs.ListOpenAll(ctx)
471	require.NoError(t, err)
472	require.Len(t, open, 1)
473	assert.Equal(t, keepM.Number, open[0].Number)
474}
475
476func TestIssueListPagesThroughManyIssues(t *testing.T) {
477	ctx := context.Background()
478	s, _, n := repoWithMRs(t)
479	is := s.Issues()
480	const total = 53
481	for i := 1; i <= total; i++ {
482		_, err := is.Create(ctx, newIssue(t, n, "issue "+strconv.Itoa(i)))
483		require.NoError(t, err)
484	}
485	for i := 2; i <= total; i += 3 { // 2, 5, 8, ...
486		is, err := is.Get(ctx, n, int64(i))
487		require.NoError(t, err)
488		is.State = domain.IssueStateClosed
489		require.NoError(t, s.Issues().Update(ctx, is))
490	}
491
492	walk := func(state domain.IssueState, size int) []int64 {
493		var got []int64
494		token := ""
495		for {
496			page, err := s.Issues().List(ctx, n, ports.ListIssuesParams{State: state, PageSize: size, PageToken: token})
497			require.NoError(t, err)
498			require.LessOrEqual(t, len(page.Issues), size)
499			for _, is := range page.Issues {
500				got = append(got, is.Number)
501			}
502			if page.NextPageToken == "" {
503				return got
504			}
505			token = page.NextPageToken
506		}
507	}
508	for _, size := range []int{1, 7, 10, 53, 100} {
509		var all, closed []int64
510		for i := int64(total); i >= 1; i-- {
511			all = append(all, i)
512			if i%3 == 2 {
513				closed = append(closed, i)
514			}
515		}
516		assert.Equal(t, all, walk("", size), "size %d", size)
517		assert.Equal(t, closed, walk(domain.IssueStateClosed, size), "closed, size %d", size)
518	}
519	page, err := s.Issues().List(ctx, n, ports.ListIssuesParams{PageSize: 10, PageToken: "1"})
520	require.NoError(t, err)
521	assert.Empty(t, page.Issues)
522	assert.Empty(t, page.NextPageToken)
523}
524
525// openOf lists the open issues of one repository through the isopen/ index.
526func openOf(t *testing.T, is *kvstore.Issues, repo domain.RepoName) []domain.Issue {
527	t.Helper()
528	all, err := is.ListOpenAll(context.Background())
529	require.NoError(t, err)
530	var out []domain.Issue
531	for _, i := range all {
532		if i.Repo == repo {
533			out = append(out, i)
534		}
535	}
536	sort.Slice(out, func(a, b int) bool { return out[a].Number > out[b].Number })
537	return out
538}
539
540func TestStoredPendingIssueStateIsCorrupt(t *testing.T) {
541	ctx := context.Background()
542	s, _, n := repoWithMRs(t)
543	is := s.Issues()
544	raw, err := proto.Marshal(&storagepb.Issue{Number: 1, Title: "x", State: storagepb.IssueState_ISSUE_STATE_PENDING})
545	require.NoError(t, err)
546	require.NoError(t, is.PutRawIssueForTest(ctx, n, 1, raw))
547	_, err = is.Get(ctx, n, 1)
548	assert.ErrorContains(t, err, "pending")
549	_, err = is.List(ctx, n, ports.ListIssuesParams{})
550	assert.ErrorContains(t, err, "pending")
551	_, err = is.List(ctx, n, ports.ListIssuesParams{State: domain.IssueStateOpen})
552	assert.ErrorContains(t, err, "pending")
553}
554
555func countScope(scopes []domain.Scope, want domain.Scope) int {
556	n := 0
557	for _, s := range scopes {
558		if s == want {
559			n++
560		}
561	}
562	return n
563}

Keyboard shortcuts

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

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

Branches 4

Tags 45

↑↓ moveenter openesc close