convert_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6	"context"
7	"fmt"
8	"testing"
9	"time"
10
11	"connectrpc.com/connect"
12	"github.com/stretchr/testify/assert"
13	"github.com/stretchr/testify/require"
14	"google.golang.org/genproto/googleapis/rpc/errdetails"
15	"google.golang.org/protobuf/types/known/fieldmaskpb"
16
17	pb "bnema.dev/ermite-api/gen/ermite"
18	"bnema.dev/ermite/internal/domain"
19	"bnema.dev/ermite/internal/ports"
20)
21
22// Every value of the domain maps to a real value of the contract, never to
23// UNSPECIFIED, and the mappers that have an inverse round-trip. A value added
24// to the domain and forgotten here would reach clients as "not set".
25func TestEnumMappersCoverEveryDomainValue(t *testing.T) {
26	t.Parallel()
27	t.Run("visibility", func(t *testing.T) {
28		t.Parallel()
29		for _, v := range []domain.Visibility{domain.VisibilityPublic, domain.VisibilityPrivate} {
30			got := visibilityToPB(v)
31			assert.NotEqual(t, pb.Visibility_VISIBILITY_UNSPECIFIED, got, v)
32			back, err := visibilityFromPB(got)
33			require.NoError(t, err, v)
34			assert.Equal(t, v, back)
35		}
36		assert.Equal(t, pb.Visibility_VISIBILITY_UNSPECIFIED, visibilityToPB(""))
37		_, err := visibilityFromPB(pb.Visibility(99))
38		assert.ErrorIs(t, err, domain.ErrInvalidVisibility)
39	})
40
41	t.Run("scope", func(t *testing.T) {
42		t.Parallel()
43		for _, s := range domain.AllScopes() {
44			got, ok := scopeToPB[s]
45			require.True(t, ok, "scope %s has no counterpart in the contract", s)
46			assert.NotEqual(t, pb.Scope_SCOPE_UNSPECIFIED, got, s)
47			back, err := scopeFromPB(got)
48			require.NoError(t, err, s)
49			assert.Equal(t, s, back)
50		}
51		_, err := scopeFromPB(pb.Scope_SCOPE_UNSPECIFIED)
52		assert.ErrorIs(t, err, domain.ErrInvalidScope)
53		// Every scope of the contract is a scope of the domain.
54		for v := range pb.Scope_name {
55			if pb.Scope(v) == pb.Scope_SCOPE_UNSPECIFIED {
56				continue
57			}
58			_, err := scopeFromPB(pb.Scope(v))
59			assert.NoError(t, err, pb.Scope(v))
60		}
61	})
62
63	t.Run("ref kind", func(t *testing.T) {
64		t.Parallel()
65		for _, k := range []domain.RefKind{domain.RefKindHead, domain.RefKindBranch, domain.RefKindTag} {
66			got := refKindToPB(k)
67			assert.NotEqual(t, pb.RefKind_REF_KIND_UNSPECIFIED, got, k)
68			back, err := refKindFromPB(got)
69			require.NoError(t, err, k)
70			assert.Equal(t, k, back)
71		}
72		_, err := refKindFromPB(pb.RefKind_REF_KIND_UNSPECIFIED)
73		assert.ErrorIs(t, err, domain.ErrInvalidRefKind)
74	})
75
76	t.Run("entry kind", func(t *testing.T) {
77		t.Parallel()
78		seen := map[pb.EntryKind]domain.EntryKind{}
79		for _, k := range []domain.EntryKind{
80			domain.EntryFile, domain.EntryExecutable, domain.EntryDirectory, domain.EntrySymlink, domain.EntrySubmodule,
81		} {
82			got := entryKindToPB(k)
83			assert.NotEqual(t, pb.EntryKind_ENTRY_KIND_UNSPECIFIED, got, k)
84			assert.NotContains(t, seen, got, "%s and %s share a value", k, seen[got])
85			seen[got] = k
86		}
87	})
88
89	t.Run("file status", func(t *testing.T) {
90		t.Parallel()
91		seen := map[pb.FileStatus]domain.FileStatus{}
92		for _, s := range []domain.FileStatus{
93			domain.FileAdded, domain.FileDeleted, domain.FileModified, domain.FileRenamed, domain.FileCopied, domain.FileTypeChanged,
94		} {
95			got := fileStatusToPB(s)
96			assert.NotEqual(t, pb.FileStatus_FILE_STATUS_UNSPECIFIED, got, s)
97			assert.NotContains(t, seen, got, "%s and %s share a value", s, seen[got])
98			seen[got] = s
99		}
100	})
101
102	t.Run("line kind", func(t *testing.T) {
103		t.Parallel()
104		seen := map[pb.LineKind]domain.LineKind{}
105		for _, k := range []domain.LineKind{domain.LineContext, domain.LineAddition, domain.LineDeletion} {
106			got := lineKindToPB(k)
107			assert.NotEqual(t, pb.LineKind_LINE_KIND_UNSPECIFIED, got, k)
108			assert.NotContains(t, seen, got, "%s and %s share a value", k, seen[got])
109			seen[got] = k
110		}
111	})
112
113	// Every value of the contract except UNSPECIFIED is produced by some value
114	// of the domain: nothing in the contract is unreachable.
115	t.Run("the contract has no value the domain cannot produce", func(t *testing.T) {
116		t.Parallel()
117		produced := func(names map[int32]string, from func(v int32) bool) {
118			for v, n := range names {
119				if v == 0 {
120					continue
121				}
122				assert.True(t, from(v), "%s is never produced", n)
123			}
124		}
125		produced(pb.EntryKind_name, func(v int32) bool {
126			for _, k := range []domain.EntryKind{domain.EntryFile, domain.EntryExecutable, domain.EntryDirectory, domain.EntrySymlink, domain.EntrySubmodule} {
127				if entryKindToPB(k) == pb.EntryKind(v) {
128					return true
129				}
130			}
131			return false
132		})
133		produced(pb.FileStatus_name, func(v int32) bool {
134			for _, k := range []domain.FileStatus{domain.FileAdded, domain.FileDeleted, domain.FileModified, domain.FileRenamed, domain.FileCopied, domain.FileTypeChanged} {
135				if fileStatusToPB(k) == pb.FileStatus(v) {
136					return true
137				}
138			}
139			return false
140		})
141		produced(pb.LineKind_name, func(v int32) bool {
142			for _, k := range []domain.LineKind{domain.LineContext, domain.LineAddition, domain.LineDeletion} {
143				if lineKindToPB(k) == pb.LineKind(v) {
144					return true
145				}
146			}
147			return false
148		})
149	})
150}
151
152func TestMergeModeMappers(t *testing.T) {
153	t.Parallel()
154	for _, m := range domain.MergeModes() {
155		got := mergeModeToPB(m)
156		assert.NotEqual(t, pb.MergeMode_MERGE_MODE_UNSPECIFIED, got, m)
157		back, err := mergeModeFromPB(got)
158		require.NoError(t, err, m)
159		assert.Equal(t, m, back)
160	}
161	// Unspecified in a merge request is the default mode, left to the use case;
162	// in the settings it is refused.
163	m, err := mergeModeFromPB(pb.MergeMode_MERGE_MODE_UNSPECIFIED)
164	require.NoError(t, err)
165	assert.Empty(t, m)
166	_, err = mergeModesFromPB([]pb.MergeMode{pb.MergeMode_MERGE_MODE_UNSPECIFIED})
167	assert.ErrorIs(t, err, domain.ErrInvalid)
168	_, err = mergeModeFromPB(pb.MergeMode(99))
169	assert.ErrorIs(t, err, domain.ErrInvalid)
170	// Every mode of the contract is known.
171	for v := range pb.MergeMode_name {
172		if pb.MergeMode(v) != pb.MergeMode_MERGE_MODE_UNSPECIFIED {
173			_, err := mergeModeFromPB(pb.MergeMode(v))
174			assert.NoError(t, err, pb.MergeMode(v))
175		}
176	}
177}
178
179func TestMergeRequestEnumMappers(t *testing.T) {
180	t.Parallel()
181	for _, s := range []domain.MRState{domain.MRStateOpen, domain.MRStateMerged, domain.MRStateClosed} {
182		got := mergeRequestStateToPB(s)
183		assert.NotEqual(t, pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED, got, s)
184		back, err := mergeRequestStateFromPB(got)
185		require.NoError(t, err, s)
186		assert.Equal(t, s, back)
187	}
188	all, err := mergeRequestStateFromPB(pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED)
189	require.NoError(t, err)
190	assert.Empty(t, all, "unspecified means every state")
191	_, err = mergeRequestStateFromPB(99)
192	assert.ErrorIs(t, err, domain.ErrInvalid)
193
194	for _, s := range []domain.DiffSide{domain.SideOld, domain.SideNew, domain.SideNone} {
195		back, err := diffSideFromPB(diffSideToPB(s))
196		require.NoError(t, err, s)
197		assert.Equal(t, s, back)
198	}
199	assert.Equal(t, pb.DiffSide_DIFF_SIDE_UNSPECIFIED, diffSideToPB(domain.SideNone), "a general comment")
200	_, err = diffSideFromPB(99)
201	assert.ErrorIs(t, err, domain.ErrInvalid)
202}
203
204func TestCommentID(t *testing.T) {
205	t.Parallel()
206	assert.EqualValues(t, 7, commentID("7"))
207	for _, bad := range []string{"", "0", "-1", "07", "+7", "x", "7 ", "9223372036854775808"} {
208		assert.Zero(t, commentID(bad), "%q names no comment", bad)
209	}
210}
211
212func TestRepoToPBAlwaysHasSettings(t *testing.T) {
213	t.Parallel()
214	// A repository value without settings (never stored with them) reads as the defaults.
215	r := repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x")}})
216	assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, r.GetSettings().GetAllowedMergeModes())
217	assert.False(t, r.GetSettings().GetAllowAutoMerge())
218
219	custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}}
220	r = repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x"), Settings: custom}})
221	assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes())
222	assert.False(t, r.GetSettings().GetAllowAutoMerge())
223}
224
225func mustName(t *testing.T, s string) domain.RepoName {
226	t.Helper()
227	n, err := domain.ParseRepoName(s)
228	require.NoError(t, err)
229	return n
230}
231
232func TestUpdateParamsSettingsPaths(t *testing.T) {
233	t.Parallel()
234	msg := func(paths ...string) *pb.UpdateRepoRequest {
235		return &pb.UpdateRepoRequest{
236			UpdateMask: &fieldmaskpb.FieldMask{Paths: paths},
237			Settings: &pb.RepoSettings{
238				AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH},
239				AllowAutoMerge:    true,
240			},
241		}
242	}
243	p, err := updateParams(msg("settings"))
244	require.NoError(t, err)
245	require.NotNil(t, p.AllowedMergeModes)
246	assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, *p.AllowedMergeModes)
247	require.NotNil(t, p.AllowAutoMerge)
248	assert.True(t, *p.AllowAutoMerge)
249
250	p, err = updateParams(msg("settings.allowed_merge_modes"))
251	require.NoError(t, err)
252	assert.NotNil(t, p.AllowedMergeModes)
253	assert.Nil(t, p.AllowAutoMerge, "not named: left alone")
254
255	p, err = updateParams(msg("settings.allow_auto_merge"))
256	require.NoError(t, err)
257	assert.Nil(t, p.AllowedMergeModes)
258	assert.NotNil(t, p.AllowAutoMerge)
259
260	// Modes named with nothing to take them from are an empty list, which the use case refuses.
261	p, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.allowed_merge_modes"}}})
262	require.NoError(t, err)
263	require.NotNil(t, p.AllowedMergeModes)
264	assert.Empty(t, *p.AllowedMergeModes)
265
266	_, err = updateParams(msg("settings.other"))
267	assert.ErrorIs(t, err, domain.ErrInvalid)
268}
269
270func TestConnectErrorCarriesTheFieldsOfARejection(t *testing.T) {
271	t.Parallel()
272	var v domain.Violations
273	v.Add("title", "must be one line")
274	v.Add("source_branch", "does not exist")
275	err := connectError(context.Background(), fmt.Errorf("create: %w", v.Err()))
276	var ce *connect.Error
277	require.ErrorAs(t, err, &ce)
278	assert.Equal(t, connect.CodeInvalidArgument, ce.Code())
279	require.Len(t, ce.Details(), 1)
280	val, derr := ce.Details()[0].Value()
281	require.NoError(t, derr)
282	br, ok := val.(*errdetails.BadRequest)
283	require.True(t, ok)
284	require.Len(t, br.GetFieldViolations(), 2)
285	assert.Equal(t, "title", br.GetFieldViolations()[0].GetField())
286	assert.Equal(t, "must be one line", br.GetFieldViolations()[0].GetDescription())
287
288	// Other errors carry no detail, and a text error alone is invalid_argument.
289	err = connectError(context.Background(), domain.ErrNotFound)
290	require.ErrorAs(t, err, &ce)
291	assert.Empty(t, ce.Details())
292	_, perr := domain.ParsePlainText("\x07")
293	err = connectError(context.Background(), perr)
294	require.ErrorAs(t, err, &ce)
295	assert.Equal(t, connect.CodeInvalidArgument, ce.Code())
296}
297
298func TestWatchEventToPBMergeRequestEvents(t *testing.T) {
299	t.Parallel()
300	repo := mustName(t, "proj")
301	title, err := domain.ParseMRTitle("Add x")
302	require.NoError(t, err)
303	mr := domain.MergeRequest{Repo: repo, Number: 4, Title: title, Source: "feat", Target: "main", State: domain.MRStateMerged, MergeMode: domain.MergeModeSquash}
304	at := time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)
305
306	for name, tc := range map[string]struct {
307		ev   domain.Event
308		pick func(*pb.Event) *pb.MergeRequest
309	}{
310		"opened": {domain.MergeRequestOpened{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestOpened().GetMergeRequest() }},
311		"merged": {domain.MergeRequestMerged{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestMerged().GetMergeRequest() }},
312		"closed": {domain.MergeRequestClosed{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestClosed().GetMergeRequest() }},
313	} {
314		t.Run(name, func(t *testing.T) {
315			t.Parallel()
316			got := watchEventToPB(ports.WatchEvent{ID: "e1", Event: tc.ev})
317			require.NotNil(t, got)
318			assert.Equal(t, "e1", got.GetId())
319			assert.Equal(t, at, got.GetTime().AsTime())
320			m := tc.pick(got)
321			require.NotNil(t, m)
322			assert.Equal(t, "proj", m.GetRepo())
323			assert.EqualValues(t, 4, m.GetNumber())
324			assert.Equal(t, "Add x", m.GetTitle())
325			assert.Equal(t, pb.MergeMode_MERGE_MODE_SQUASH, m.GetMergeMode())
326		})
327	}
328
329	c := domain.Comment{ID: 9, Author: "root", Path: "a.txt", Line: 3, Side: domain.SideOld, CreatedAt: at}
330	got := watchEventToPB(ports.WatchEvent{ID: "e2", Event: domain.CommentAdded{Repo: repo, Number: 4, Comment: c, At: at}})
331	added := got.GetCommentAdded()
332	require.NotNil(t, added)
333	assert.Equal(t, "proj", added.GetRepo())
334	assert.EqualValues(t, 4, added.GetNumber())
335	assert.Equal(t, "9", added.GetComment().GetId())
336	assert.Equal(t, pb.DiffSide_DIFF_SIDE_OLD, added.GetComment().GetSide())
337	assert.EqualValues(t, 3, added.GetComment().GetLine())
338}
339
340func TestIssueMappers(t *testing.T) {
341	t.Parallel()
342	for _, m := range domain.IssuesModes() {
343		got, err := issuesModeFromPB(issuesModeToPB(m))
344		require.NoError(t, err)
345		assert.Equal(t, m, got)
346	}
347	assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OFF, issuesModeToPB(""), "the empty mode is off")
348	_, err := issuesModeFromPB(pb.IssuesMode_ISSUES_MODE_UNSPECIFIED)
349	assert.ErrorIs(t, err, domain.ErrInvalid)
350
351	s, err := issueStateFromPB(pb.IssueState_ISSUE_STATE_UNSPECIFIED)
352	require.NoError(t, err)
353	assert.Empty(t, s)
354	_, err = issueStateFromPB(pb.IssueState_ISSUE_STATE_PENDING)
355	assert.ErrorIs(t, err, domain.ErrInvalid)
356
357	n, err := domain.ParseRepoName("proj")
358	require.NoError(t, err)
359	ev := watchEventToPB(ports.WatchEvent{ID: "1", Event: domain.IssueClosed{Issue: domain.Issue{Repo: n, Number: 4, State: domain.IssueStateClosed}}})
360	require.NotNil(t, ev.GetIssueClosed())
361	assert.EqualValues(t, 4, ev.GetIssueClosed().GetIssue().GetNumber())
362	assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, ev.GetIssueClosed().GetIssue().GetState())
363	ev = watchEventToPB(ports.WatchEvent{ID: "2", Event: domain.IssueCommented{Repo: n, Number: 4, Comment: domain.IssueComment{ID: 3}}})
364	assert.Equal(t, "3", ev.GetIssueCommented().GetComment().GetId())
365	assert.NotNil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssueOpened{}}).GetIssueOpened())
366	assert.NotNil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssueReopened{}}).GetIssueReopened())
367}
368
369func TestWatchEventToPBSkipsPendingRemoved(t *testing.T) {
370	t.Parallel()
371	assert.Nil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssuePendingRemoved{}}))
372}
373
374func TestIssueStateToPBNeverPending(t *testing.T) {
375	t.Parallel()
376	assert.Equal(t, pb.IssueState_ISSUE_STATE_UNSPECIFIED, issueStateToPB(domain.IssueState("pending")))
377	assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, issueStateToPB(domain.IssueStateOpen))
378}
379
380func TestMergeRequestToPBMergeabilityOnlyWhileOpen(t *testing.T) {
381	t.Parallel()
382	repo := mustName(t, "proj")
383	for state, want := range map[domain.MRState]bool{domain.MRStateOpen: true, domain.MRStateMerged: false, domain.MRStateClosed: false} {
384		mr := domain.MergeRequest{Repo: repo, Number: 4, State: state}
385		got := mergeRequestToPB(mr).GetMergeability()
386		if !want {
387			assert.Nil(t, got, state)
388			continue
389		}
390		require.NotNil(t, got)
391		assert.Equal(t, pb.MergeabilityState_MERGEABILITY_STATE_CHECKING, got.GetState())
392		ev := watchEventToPB(ports.WatchEvent{ID: "e", Event: domain.MergeRequestOpened{MergeRequest: mr}})
393		assert.Equal(t, pb.MergeabilityState_MERGEABILITY_STATE_CHECKING, ev.GetMergeRequestOpened().GetMergeRequest().GetMergeability().GetState())
394	}
395	for _, ev := range []domain.Event{domain.MergeRequestMerged{MergeRequest: domain.MergeRequest{Repo: repo, State: domain.MRStateMerged}}, domain.MergeRequestClosed{MergeRequest: domain.MergeRequest{Repo: repo, State: domain.MRStateClosed}}} {
396		got := watchEventToPB(ports.WatchEvent{ID: "e", Event: ev})
397		assert.Nil(t, got.GetMergeRequestMerged().GetMergeRequest().GetMergeability())
398		assert.Nil(t, got.GetMergeRequestClosed().GetMergeRequest().GetMergeability())
399	}
400}
401
402func TestFileDiffOmissionMapping(t *testing.T) {
403	for o, want := range map[domain.FileOmission]pb.FileOmission{
404		domain.OmitNone:        pb.FileOmission_FILE_OMISSION_UNSPECIFIED,
405		domain.OmitGenerated:   pb.FileOmission_FILE_OMISSION_GENERATED,
406		domain.OmitLarge:       pb.FileOmission_FILE_OMISSION_LARGE,
407		domain.OmitBudget:      pb.FileOmission_FILE_OMISSION_BUDGET,
408		domain.OmitUnavailable: pb.FileOmission_FILE_OMISSION_UNAVAILABLE,
409	} {
410		f := fileDiffToPB(domain.FileDiff{NewPath: "a", Omitted: o})
411		assert.Equal(t, want, f.GetOmitted())
412		assert.Equal(t, o != domain.OmitNone, f.GetTruncated())
413		d := diffToPB(domain.Diff{Files: []domain.FileDiff{{Omitted: o}}})
414		assert.Equal(t, o != domain.OmitNone, d.GetTruncated())
415	}
416	assert.True(t, diffToPB(domain.Diff{Truncated: true}).GetTruncated())
417	assert.False(t, diffToPB(domain.Diff{Files: []domain.FileDiff{{}}}).GetTruncated())
418}
419
420func TestResolvedIssueContract(t *testing.T) {
421	title, err := domain.ParseMRTitle("Fix")
422	require.NoError(t, err)
423	for _, state := range []domain.IssueState{domain.IssueStateOpen, domain.IssueStateClosed, domain.IssueStateResolved} {
424		back, err := issueStateFromPB(issueStateToPB(state))
425		require.NoError(t, err)
426		assert.Equal(t, state, back)
427		assert.NotEqual(t, pb.MergeRequestIssueState_MERGE_REQUEST_ISSUE_STATE_UNSPECIFIED, mergeRequestIssueToPB(ports.MergeRequestIssue{State: state}).GetState())
428	}
429	for kind, want := range map[domain.IssueEventKind]pb.IssueEventKind{
430		domain.IssueEventReferenced: pb.IssueEventKind_ISSUE_EVENT_KIND_REFERENCED,
431		domain.IssueEventClosed:     pb.IssueEventKind_ISSUE_EVENT_KIND_CLOSED,
432		domain.IssueEventReopened:   pb.IssueEventKind_ISSUE_EVENT_KIND_REOPENED,
433		domain.IssueEventResolved:   pb.IssueEventKind_ISSUE_EVENT_KIND_RESOLVED,
434	} {
435		at := time.Now().UTC()
436		got := issueEventToPB(ports.IssueEventDetail{Event: domain.IssueEvent{ID: 3, Kind: kind, Issue: 4, MergeRequest: 5, Closes: true, Actor: "merger", CreatedAt: at}, MergeRequestTitle: title})
437		assert.Equal(t, want, got.GetKind())
438		assert.Equal(t, "3", got.GetId())
439		assert.EqualValues(t, 4, got.GetIssue())
440		assert.EqualValues(t, 5, got.GetMergeRequest())
441		assert.True(t, got.GetCloses())
442		assert.Equal(t, "merger", got.GetActor())
443		assert.Equal(t, "Fix", got.GetMergeRequestTitle())
444		assert.Equal(t, at, got.GetCreatedAt().AsTime())
445	}
446	ev := watchEventToPB(ports.WatchEvent{Event: domain.IssueResolved{Issue: domain.Issue{Number: 4, State: domain.IssueStateResolved}}})
447	require.NotNil(t, ev.GetIssueResolved())
448	assert.Equal(t, pb.IssueState_ISSUE_STATE_RESOLVED, ev.GetIssueResolved().GetIssue().GetState())
449}

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 3

Tags 45

↑↓ moveenter openesc close