v0.8.0 eaf4295

convert.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6	"encoding/base64"
7	"fmt"
8	"slices"
9	"time"
10
11	"google.golang.org/protobuf/types/known/timestamppb"
12
13	pb "bnema.dev/ermite-api/gen/ermite"
14	"bnema.dev/ermite/internal/domain"
15	"bnema.dev/ermite/internal/ports"
16)
17
18// This file maps domain values to the messages of the contract and back.
19// Messages are DTOs: the domain never leaks into them, nor they into the
20// domain.
21
22func timestamp(t time.Time) *timestamppb.Timestamp {
23	if t.IsZero() {
24		return nil
25	}
26	return timestamppb.New(t)
27}
28
29func goTime(ts *timestamppb.Timestamp) time.Time {
30	if ts == nil {
31		return time.Time{}
32	}
33	return ts.AsTime()
34}
35
36func visibilityToPB(v domain.Visibility) pb.Visibility {
37	switch v {
38	case domain.VisibilityPublic:
39		return pb.Visibility_VISIBILITY_PUBLIC
40	case domain.VisibilityPrivate:
41		return pb.Visibility_VISIBILITY_PRIVATE
42	}
43	return pb.Visibility_VISIBILITY_UNSPECIFIED
44}
45
46// visibilityFromPB maps a visibility of a request. Unspecified is the empty
47// domain.Visibility, which the use cases treat as "default".
48func visibilityFromPB(v pb.Visibility) (domain.Visibility, error) {
49	switch v {
50	case pb.Visibility_VISIBILITY_UNSPECIFIED:
51		return "", nil
52	case pb.Visibility_VISIBILITY_PUBLIC:
53		return domain.VisibilityPublic, nil
54	case pb.Visibility_VISIBILITY_PRIVATE:
55		return domain.VisibilityPrivate, nil
56	}
57	return "", fmt.Errorf("%w: %v", domain.ErrInvalidVisibility, v)
58}
59
60func repoToPB(v ports.RepoView) *pb.Repo {
61	settings := v.Settings
62	if settings.IsZero() {
63		settings = domain.DefaultRepoSettings()
64	}
65	return &pb.Repo{
66		Name:          v.Name.String(),
67		Description:   v.Description,
68		Visibility:    visibilityToPB(v.Visibility),
69		DefaultBranch: v.DefaultBranch,
70		CreatedAt:     timestamp(v.CreatedAt),
71		PushedAt:      timestamp(v.PushedAt),
72		Settings:      settingsToPB(settings),
73	}
74}
75
76func settingsToPB(s domain.RepoSettings) *pb.RepoSettings {
77	out := &pb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock}
78	for _, m := range s.AllowedMergeModes {
79		out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m))
80	}
81	return out
82}
83
84func mergeModeToPB(m domain.MergeMode) pb.MergeMode {
85	switch m {
86	case domain.MergeModeFastForward:
87		return pb.MergeMode_MERGE_MODE_FAST_FORWARD
88	case domain.MergeModeSquash:
89		return pb.MergeMode_MERGE_MODE_SQUASH
90	}
91	return pb.MergeMode_MERGE_MODE_UNSPECIFIED
92}
93
94// mergeModeFromPB maps a mode of a merge request: unspecified is the empty
95// domain.MergeMode, which the use case reads as fast-forward.
96func mergeModeFromPB(m pb.MergeMode) (domain.MergeMode, error) {
97	switch m {
98	case pb.MergeMode_MERGE_MODE_UNSPECIFIED:
99		return "", nil
100	case pb.MergeMode_MERGE_MODE_FAST_FORWARD:
101		return domain.MergeModeFastForward, nil
102	case pb.MergeMode_MERGE_MODE_SQUASH:
103		return domain.MergeModeSquash, nil
104	}
105	return "", fmt.Errorf("%w: merge mode %v", domain.ErrInvalid, m)
106}
107
108// mergeModesFromPB maps the allowed modes of the settings, where unspecified
109// is rejected.
110func mergeModesFromPB(in []pb.MergeMode) ([]domain.MergeMode, error) {
111	out := make([]domain.MergeMode, 0, len(in))
112	for _, m := range in {
113		mode, err := mergeModeFromPB(m)
114		if err != nil {
115			return nil, err
116		}
117		if mode == "" {
118			return nil, fmt.Errorf("%w: MERGE_MODE_UNSPECIFIED is not a merge mode", domain.ErrInvalid)
119		}
120		out = append(out, mode)
121	}
122	return out, nil
123}
124
125var scopeToPB = map[domain.Scope]pb.Scope{
126	domain.ScopeRepoRead:       pb.Scope_SCOPE_REPO_READ,
127	domain.ScopeRepoWrite:      pb.Scope_SCOPE_REPO_WRITE,
128	domain.ScopeRepoAdmin:      pb.Scope_SCOPE_REPO_ADMIN,
129	domain.ScopeMRWrite:        pb.Scope_SCOPE_MR_WRITE,
130	domain.ScopeMRMerge:        pb.Scope_SCOPE_MR_MERGE,
131	domain.ScopeCIWrite:        pb.Scope_SCOPE_CI_WRITE,
132	domain.ScopeSecretsWrite:   pb.Scope_SCOPE_SECRETS_WRITE,
133	domain.ScopeRunnerRegister: pb.Scope_SCOPE_RUNNER_REGISTER,
134	domain.ScopeTokenAdmin:     pb.Scope_SCOPE_TOKEN_ADMIN,
135}
136
137func scopeFromPB(s pb.Scope) (domain.Scope, error) {
138	for d, p := range scopeToPB {
139		if p == s {
140			return d, nil
141		}
142	}
143	return "", fmt.Errorf("%w: %v", domain.ErrInvalidScope, s)
144}
145
146func tokenToPB(t domain.Token) *pb.Token {
147	out := &pb.Token{
148		Id:         t.ID,
149		Label:      t.Label,
150		ExpiresAt:  timestamp(t.ExpiresAt),
151		CreatedAt:  timestamp(t.CreatedAt),
152		LastUsedAt: timestamp(t.LastUsedAt),
153	}
154	for _, s := range t.Scopes {
155		out.Scopes = append(out.Scopes, scopeToPB[s])
156	}
157	for _, r := range t.Repos {
158		out.Repos = append(out.Repos, r.String())
159	}
160	out.BranchPrefixes = slices.Clone(t.BranchPrefixes)
161	return out
162}
163
164func refKindToPB(k domain.RefKind) pb.RefKind {
165	switch k {
166	case domain.RefKindBranch:
167		return pb.RefKind_REF_KIND_BRANCH
168	case domain.RefKindTag:
169		return pb.RefKind_REF_KIND_TAG
170	case domain.RefKindHead:
171		return pb.RefKind_REF_KIND_HEAD
172	}
173	return pb.RefKind_REF_KIND_UNSPECIFIED
174}
175
176func refKindFromPB(k pb.RefKind) (domain.RefKind, error) {
177	switch k {
178	case pb.RefKind_REF_KIND_BRANCH:
179		return domain.RefKindBranch, nil
180	case pb.RefKind_REF_KIND_TAG:
181		return domain.RefKindTag, nil
182	case pb.RefKind_REF_KIND_HEAD:
183		return domain.RefKindHead, nil
184	}
185	return "", fmt.Errorf("%w: %v", domain.ErrInvalidRefKind, k)
186}
187
188func refToPB(r domain.Ref) *pb.Ref {
189	return &pb.Ref{
190		Name:      r.Name,
191		ShortName: r.ShortName,
192		Kind:      refKindToPB(r.Kind),
193		Id:        r.ID.String(),
194		CommitId:  r.CommitID.String(),
195	}
196}
197
198func entryKindToPB(k domain.EntryKind) pb.EntryKind {
199	switch k {
200	case domain.EntryFile:
201		return pb.EntryKind_ENTRY_KIND_FILE
202	case domain.EntryExecutable:
203		return pb.EntryKind_ENTRY_KIND_EXECUTABLE
204	case domain.EntryDirectory:
205		return pb.EntryKind_ENTRY_KIND_DIRECTORY
206	case domain.EntrySymlink:
207		return pb.EntryKind_ENTRY_KIND_SYMLINK
208	case domain.EntrySubmodule:
209		return pb.EntryKind_ENTRY_KIND_SUBMODULE
210	}
211	return pb.EntryKind_ENTRY_KIND_UNSPECIFIED
212}
213
214func signatureToPB(s domain.Signature) *pb.Signature {
215	return &pb.Signature{Name: s.Name, Email: s.Email, Time: timestamp(s.Time)}
216}
217
218func commitToPB(c domain.Commit) *pb.Commit {
219	out := &pb.Commit{
220		Id:        c.ID.String(),
221		Author:    signatureToPB(c.Author),
222		Committer: signatureToPB(c.Committer),
223		Summary:   c.Summary,
224		Message:   c.Message,
225	}
226	for _, p := range c.Parents {
227		out.ParentIds = append(out.ParentIds, p.String())
228	}
229	return out
230}
231
232func treeEntryToPB(e domain.TreeEntry) *pb.TreeEntry {
233	out := &pb.TreeEntry{
234		Name: e.Name,
235		Path: e.Path,
236		Kind: entryKindToPB(e.Kind),
237		Mode: e.Mode,
238		Id:   e.ID.String(),
239		Size: e.Size,
240	}
241	if e.LastCommit != nil {
242		out.LastCommit = commitToPB(*e.LastCommit)
243	}
244	return out
245}
246
247func blobInfoToPB(b domain.BlobInfo) *pb.BlobInfo {
248	return &pb.BlobInfo{
249		Id:        b.ID.String(),
250		Path:      b.Path,
251		Size:      b.Size,
252		Binary:    b.Binary,
253		MediaType: b.MediaType,
254		Mode:      b.Mode,
255	}
256}
257
258func fileStatusToPB(s domain.FileStatus) pb.FileStatus {
259	switch s {
260	case domain.FileAdded:
261		return pb.FileStatus_FILE_STATUS_ADDED
262	case domain.FileDeleted:
263		return pb.FileStatus_FILE_STATUS_DELETED
264	case domain.FileModified:
265		return pb.FileStatus_FILE_STATUS_MODIFIED
266	case domain.FileRenamed:
267		return pb.FileStatus_FILE_STATUS_RENAMED
268	case domain.FileCopied:
269		return pb.FileStatus_FILE_STATUS_COPIED
270	case domain.FileTypeChanged:
271		return pb.FileStatus_FILE_STATUS_TYPE_CHANGED
272	}
273	return pb.FileStatus_FILE_STATUS_UNSPECIFIED
274}
275
276func lineKindToPB(k domain.LineKind) pb.LineKind {
277	switch k {
278	case domain.LineContext:
279		return pb.LineKind_LINE_KIND_CONTEXT
280	case domain.LineAddition:
281		return pb.LineKind_LINE_KIND_ADDITION
282	case domain.LineDeletion:
283		return pb.LineKind_LINE_KIND_DELETION
284	}
285	return pb.LineKind_LINE_KIND_UNSPECIFIED
286}
287
288func hunkToPB(h domain.Hunk) *pb.Hunk {
289	out := &pb.Hunk{
290		OldStart: int32(h.OldStart), OldLines: int32(h.OldLines),
291		NewStart: int32(h.NewStart), NewLines: int32(h.NewLines),
292		Header: h.Header,
293		Lines:  make([]*pb.Line, 0, len(h.Lines)),
294	}
295	for _, l := range h.Lines {
296		out.Lines = append(out.Lines, &pb.Line{
297			Kind:           lineKindToPB(l.Kind),
298			OldNumber:      int32(l.OldNumber),
299			NewNumber:      int32(l.NewNumber),
300			Text:           l.Text,
301			NoNewlineAtEof: l.NoNewlineAtEOF,
302		})
303	}
304	return out
305}
306
307func fileDiffToPB(f domain.FileDiff) *pb.FileDiff {
308	out := &pb.FileDiff{
309		OldPath: f.OldPath, NewPath: f.NewPath,
310		Status:  fileStatusToPB(f.Status),
311		OldMode: f.OldMode, NewMode: f.NewMode,
312		OldId: f.OldID.String(), NewId: f.NewID.String(),
313		Binary:     f.Binary,
314		Additions:  int32(f.Additions),
315		Deletions:  int32(f.Deletions),
316		Similarity: int32(f.Similarity),
317		Truncated:  f.Truncated,
318	}
319	for _, h := range f.Hunks {
320		out.Hunks = append(out.Hunks, hunkToPB(h))
321	}
322	return out
323}
324
325func diffStatsToPB(s domain.DiffStats) *pb.DiffStats {
326	return &pb.DiffStats{
327		FilesChanged: int32(s.FilesChanged),
328		Additions:    int32(s.Additions),
329		Deletions:    int32(s.Deletions),
330	}
331}
332
333func diffToPB(d domain.Diff) *pb.Diff {
334	out := &pb.Diff{Truncated: d.Truncated}
335	for _, f := range d.Files {
336		out.Files = append(out.Files, fileDiffToPB(f))
337	}
338	return out
339}
340
341func commitDetailToPB(d domain.CommitDetail) *pb.GetCommitResponse {
342	return &pb.GetCommitResponse{
343		Commit: commitToPB(d.Commit),
344		Stats:  diffStatsToPB(d.Stats),
345		Diff:   diffToPB(d.Diff),
346	}
347}
348
349// oidToPB turns the zero object id of a ref update (no object) into "".
350func oidToPB(oid string) string {
351	if oid == domain.ZeroOID {
352		return ""
353	}
354	return oid
355}
356
357func refUpdatesToPB(updates []domain.RefUpdate) []*pb.RefUpdate {
358	out := make([]*pb.RefUpdate, 0, len(updates))
359	for _, u := range updates {
360		out = append(out, &pb.RefUpdate{Ref: u.Ref, OldId: oidToPB(u.Old), NewId: oidToPB(u.New)})
361	}
362	return out
363}
364
365// watchEventToPB maps one item of a watch; nil when the event has no
366// representation in the contract.
367func watchEventToPB(we ports.WatchEvent) *pb.Event {
368	if we.Gap {
369		return &pb.Event{Kind: &pb.Event_Gap{Gap: &pb.Gap{}}}
370	}
371	out := &pb.Event{Id: we.ID}
372	switch ev := we.Event.(type) {
373	case domain.RepoCreated:
374		out.Time = timestamp(ev.At)
375		out.Kind = &pb.Event_RepoCreated{RepoCreated: &pb.RepoCreated{Repo: repoOrName(we, ev.Repo, ev.Visibility)}}
376	case domain.RepoUpdated:
377		out.Time = timestamp(ev.At)
378		out.Kind = &pb.Event_RepoUpdated{RepoUpdated: &pb.RepoUpdated{Repo: repoOrName(we, ev.Repo, "")}}
379	case domain.RepoDeleted:
380		out.Time = timestamp(ev.At)
381		out.Kind = &pb.Event_RepoDeleted{RepoDeleted: &pb.RepoDeleted{Repo: ev.Repo.String()}}
382	case domain.PushEvent:
383		out.Time = timestamp(ev.At)
384		out.Kind = &pb.Event_Pushed{Pushed: &pb.Pushed{Repo: ev.Repo.String(), RefUpdates: refUpdatesToPB(ev.Updates)}}
385	case domain.MergeRequestOpened:
386		out.Time = timestamp(ev.At)
387		out.Kind = &pb.Event_MergeRequestOpened{MergeRequestOpened: &pb.MergeRequestOpened{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}
388	case domain.MergeRequestMerged:
389		out.Time = timestamp(ev.At)
390		out.Kind = &pb.Event_MergeRequestMerged{MergeRequestMerged: &pb.MergeRequestMerged{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}
391	case domain.MergeRequestClosed:
392		out.Time = timestamp(ev.At)
393		out.Kind = &pb.Event_MergeRequestClosed{MergeRequestClosed: &pb.MergeRequestClosed{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}
394	case domain.CommentAdded:
395		out.Time = timestamp(ev.At)
396		out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
397	case domain.RunUpdated:
398		out.Time = timestamp(ev.At)
399		out.Kind = runEventToPB(ev)
400	case domain.JobUpdated:
401		out.Time = timestamp(ev.At)
402		out.Kind = jobEventToPB(ev)
403	default:
404		return nil
405	}
406	return out
407}
408
409func repoOrName(we ports.WatchEvent, name domain.RepoName, vis domain.Visibility) *pb.Repo {
410	if we.Repo != nil {
411		return repoToPB(*we.Repo)
412	}
413	return &pb.Repo{Name: name.String(), Visibility: visibilityToPB(vis)}
414}
415
416// passkeysToPB converts passkeys; the credential id is base64url without
417// padding, the form RemovePasskey takes back. Key material never leaves.
418func passkeysToPB(list []domain.Passkey) []*pb.Passkey {
419	out := make([]*pb.Passkey, 0, len(list))
420	for _, p := range list {
421		out = append(out, &pb.Passkey{
422			Id:         base64.RawURLEncoding.EncodeToString(p.ID),
423			Name:       p.Name,
424			CreatedAt:  timestamp(p.CreatedAt),
425			LastUsedAt: timestamp(p.LastUsedAt),
426			Synced:     p.BackupState,
427		})
428	}
429	return out
430}

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