v0.27.0 0039612

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		Topics:        v.Topics,
69		Visibility:    visibilityToPB(v.Visibility),
70		DefaultBranch: v.DefaultBranch,
71		CreatedAt:     timestamp(v.CreatedAt),
72		PushedAt:      timestamp(v.PushedAt),
73		Settings:      settingsToPB(settings),
74	}
75}
76
77func settingsToPB(s domain.RepoSettings) *pb.RepoSettings {
78	out := &pb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock}
79	for _, m := range s.AllowedMergeModes {
80		out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m))
81	}
82	return out
83}
84
85func mergeModeToPB(m domain.MergeMode) pb.MergeMode {
86	switch m {
87	case domain.MergeModeFastForward:
88		return pb.MergeMode_MERGE_MODE_FAST_FORWARD
89	case domain.MergeModeSquash:
90		return pb.MergeMode_MERGE_MODE_SQUASH
91	}
92	return pb.MergeMode_MERGE_MODE_UNSPECIFIED
93}
94
95// mergeModeFromPB maps a mode of a merge request: unspecified is the empty
96// domain.MergeMode, which the use case reads as fast-forward.
97func mergeModeFromPB(m pb.MergeMode) (domain.MergeMode, error) {
98	switch m {
99	case pb.MergeMode_MERGE_MODE_UNSPECIFIED:
100		return "", nil
101	case pb.MergeMode_MERGE_MODE_FAST_FORWARD:
102		return domain.MergeModeFastForward, nil
103	case pb.MergeMode_MERGE_MODE_SQUASH:
104		return domain.MergeModeSquash, nil
105	}
106	return "", fmt.Errorf("%w: merge mode %v", domain.ErrInvalid, m)
107}
108
109// mergeModesFromPB maps the allowed modes of the settings, where unspecified
110// is rejected.
111func mergeModesFromPB(in []pb.MergeMode) ([]domain.MergeMode, error) {
112	out := make([]domain.MergeMode, 0, len(in))
113	for _, m := range in {
114		mode, err := mergeModeFromPB(m)
115		if err != nil {
116			return nil, err
117		}
118		if mode == "" {
119			return nil, fmt.Errorf("%w: MERGE_MODE_UNSPECIFIED is not a merge mode", domain.ErrInvalid)
120		}
121		out = append(out, mode)
122	}
123	return out, nil
124}
125
126var scopeToPB = map[domain.Scope]pb.Scope{
127	domain.ScopeRepoRead:       pb.Scope_SCOPE_REPO_READ,
128	domain.ScopeRepoWrite:      pb.Scope_SCOPE_REPO_WRITE,
129	domain.ScopeRepoAdmin:      pb.Scope_SCOPE_REPO_ADMIN,
130	domain.ScopeMRWrite:        pb.Scope_SCOPE_MR_WRITE,
131	domain.ScopeMRMerge:        pb.Scope_SCOPE_MR_MERGE,
132	domain.ScopeCIWrite:        pb.Scope_SCOPE_CI_WRITE,
133	domain.ScopeSecretsWrite:   pb.Scope_SCOPE_SECRETS_WRITE,
134	domain.ScopeRunnerRegister: pb.Scope_SCOPE_RUNNER_REGISTER,
135	domain.ScopeTokenAdmin:     pb.Scope_SCOPE_TOKEN_ADMIN,
136}
137
138func scopeFromPB(s pb.Scope) (domain.Scope, error) {
139	for d, p := range scopeToPB {
140		if p == s {
141			return d, nil
142		}
143	}
144	return "", fmt.Errorf("%w: %v", domain.ErrInvalidScope, s)
145}
146
147func tokenToPB(t domain.Token) *pb.Token {
148	out := &pb.Token{
149		Id:         t.ID,
150		Label:      t.Label,
151		ExpiresAt:  timestamp(t.ExpiresAt),
152		CreatedAt:  timestamp(t.CreatedAt),
153		LastUsedAt: timestamp(t.LastUsedAt),
154	}
155	for _, s := range t.Scopes {
156		out.Scopes = append(out.Scopes, scopeToPB[s])
157	}
158	for _, r := range t.Repos {
159		out.Repos = append(out.Repos, r.String())
160	}
161	out.BranchPrefixes = slices.Clone(t.BranchPrefixes)
162	return out
163}
164
165func refKindToPB(k domain.RefKind) pb.RefKind {
166	switch k {
167	case domain.RefKindBranch:
168		return pb.RefKind_REF_KIND_BRANCH
169	case domain.RefKindTag:
170		return pb.RefKind_REF_KIND_TAG
171	case domain.RefKindHead:
172		return pb.RefKind_REF_KIND_HEAD
173	}
174	return pb.RefKind_REF_KIND_UNSPECIFIED
175}
176
177func refKindFromPB(k pb.RefKind) (domain.RefKind, error) {
178	switch k {
179	case pb.RefKind_REF_KIND_BRANCH:
180		return domain.RefKindBranch, nil
181	case pb.RefKind_REF_KIND_TAG:
182		return domain.RefKindTag, nil
183	case pb.RefKind_REF_KIND_HEAD:
184		return domain.RefKindHead, nil
185	}
186	return "", fmt.Errorf("%w: %v", domain.ErrInvalidRefKind, k)
187}
188
189func refToPB(r domain.Ref) *pb.Ref {
190	return &pb.Ref{
191		Name:      r.Name,
192		ShortName: r.ShortName,
193		Kind:      refKindToPB(r.Kind),
194		Id:        r.ID.String(),
195		CommitId:  r.CommitID.String(),
196	}
197}
198
199func entryKindToPB(k domain.EntryKind) pb.EntryKind {
200	switch k {
201	case domain.EntryFile:
202		return pb.EntryKind_ENTRY_KIND_FILE
203	case domain.EntryExecutable:
204		return pb.EntryKind_ENTRY_KIND_EXECUTABLE
205	case domain.EntryDirectory:
206		return pb.EntryKind_ENTRY_KIND_DIRECTORY
207	case domain.EntrySymlink:
208		return pb.EntryKind_ENTRY_KIND_SYMLINK
209	case domain.EntrySubmodule:
210		return pb.EntryKind_ENTRY_KIND_SUBMODULE
211	}
212	return pb.EntryKind_ENTRY_KIND_UNSPECIFIED
213}
214
215func signatureToPB(s domain.Signature) *pb.Signature {
216	return &pb.Signature{Name: s.Name, Email: s.Email, Time: timestamp(s.Time)}
217}
218
219func commitToPB(c domain.Commit) *pb.Commit {
220	out := &pb.Commit{
221		Id:        c.ID.String(),
222		Author:    signatureToPB(c.Author),
223		Committer: signatureToPB(c.Committer),
224		Summary:   c.Summary,
225		Message:   c.Message,
226	}
227	for _, p := range c.Parents {
228		out.ParentIds = append(out.ParentIds, p.String())
229	}
230	if c.Verification.Status != domain.StatusUnchecked {
231		out.Verification = verificationToPB(c.Verification)
232	}
233	return out
234}
235
236func verificationToPB(v domain.CommitVerification) *pb.CommitVerification {
237	return &pb.CommitVerification{
238		Format:      signatureFormatToPB(v.Format),
239		Status:      signatureStatusToPB(v.Status),
240		Fingerprint: v.Fingerprint,
241		KeyId:       v.KeyID,
242		KeyTitle:    v.KeyTitle,
243	}
244}
245
246func signatureFormatToPB(f domain.SignatureFormat) pb.SignatureFormat {
247	switch f {
248	case domain.FormatSSH:
249		return pb.SignatureFormat_SIGNATURE_FORMAT_SSH
250	case domain.FormatGPG:
251		return pb.SignatureFormat_SIGNATURE_FORMAT_GPG
252	case domain.FormatX509:
253		return pb.SignatureFormat_SIGNATURE_FORMAT_X509
254	case domain.FormatNone:
255		return pb.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED
256	}
257	return pb.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED
258}
259
260// signatureFormatFromPB maps the format of a key to add: only SSH and GPG keys
261// can be trusted.
262func signatureFormatFromPB(f pb.SignatureFormat) (domain.SignatureFormat, error) {
263	switch f {
264	case pb.SignatureFormat_SIGNATURE_FORMAT_SSH:
265		return domain.FormatSSH, nil
266	case pb.SignatureFormat_SIGNATURE_FORMAT_GPG:
267		return domain.FormatGPG, nil
268	case pb.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED, pb.SignatureFormat_SIGNATURE_FORMAT_X509:
269	}
270	return domain.FormatNone, fmt.Errorf("%w: key format must be ssh or gpg", domain.ErrInvalid)
271}
272
273func signatureStatusToPB(s domain.SignatureStatus) pb.SignatureStatus {
274	switch s {
275	case domain.StatusUnsigned:
276		return pb.SignatureStatus_SIGNATURE_STATUS_UNSIGNED
277	case domain.StatusVerified:
278		return pb.SignatureStatus_SIGNATURE_STATUS_VERIFIED
279	case domain.StatusUnknownKey:
280		return pb.SignatureStatus_SIGNATURE_STATUS_UNKNOWN_KEY
281	case domain.StatusBadSignature:
282		return pb.SignatureStatus_SIGNATURE_STATUS_BAD_SIGNATURE
283	case domain.StatusEmailMismatch:
284		return pb.SignatureStatus_SIGNATURE_STATUS_EMAIL_MISMATCH
285	case domain.StatusExpired:
286		return pb.SignatureStatus_SIGNATURE_STATUS_EXPIRED
287	case domain.StatusRevoked:
288		return pb.SignatureStatus_SIGNATURE_STATUS_REVOKED
289	case domain.StatusUnsupported:
290		return pb.SignatureStatus_SIGNATURE_STATUS_UNSUPPORTED
291	case domain.StatusUnchecked:
292		return pb.SignatureStatus_SIGNATURE_STATUS_UNSPECIFIED
293	}
294	return pb.SignatureStatus_SIGNATURE_STATUS_UNSPECIFIED
295}
296
297func trustedKeyToPB(k domain.TrustedKey) *pb.TrustedKey {
298	return &pb.TrustedKey{
299		Id:        k.ID,
300		Format:    signatureFormatToPB(k.Format),
301		Title:     k.Title,
302		PublicKey: k.PublicKey,
303		Emails:    slices.Clone(k.Emails),
304		CreatedAt: timestamp(k.CreatedAt),
305		Builtin:   k.Builtin,
306	}
307}
308
309func treeEntryToPB(e domain.TreeEntry) *pb.TreeEntry {
310	out := &pb.TreeEntry{
311		Name: e.Name,
312		Path: e.Path,
313		Kind: entryKindToPB(e.Kind),
314		Mode: e.Mode,
315		Id:   e.ID.String(),
316		Size: e.Size,
317	}
318	if e.LastCommit != nil {
319		out.LastCommit = commitToPB(*e.LastCommit)
320	}
321	return out
322}
323
324func blobInfoToPB(b domain.BlobInfo) *pb.BlobInfo {
325	return &pb.BlobInfo{
326		Id:        b.ID.String(),
327		Path:      b.Path,
328		Size:      b.Size,
329		Binary:    b.Binary,
330		MediaType: b.MediaType,
331		Mode:      b.Mode,
332	}
333}
334
335func fileStatusToPB(s domain.FileStatus) pb.FileStatus {
336	switch s {
337	case domain.FileAdded:
338		return pb.FileStatus_FILE_STATUS_ADDED
339	case domain.FileDeleted:
340		return pb.FileStatus_FILE_STATUS_DELETED
341	case domain.FileModified:
342		return pb.FileStatus_FILE_STATUS_MODIFIED
343	case domain.FileRenamed:
344		return pb.FileStatus_FILE_STATUS_RENAMED
345	case domain.FileCopied:
346		return pb.FileStatus_FILE_STATUS_COPIED
347	case domain.FileTypeChanged:
348		return pb.FileStatus_FILE_STATUS_TYPE_CHANGED
349	}
350	return pb.FileStatus_FILE_STATUS_UNSPECIFIED
351}
352
353func lineKindToPB(k domain.LineKind) pb.LineKind {
354	switch k {
355	case domain.LineContext:
356		return pb.LineKind_LINE_KIND_CONTEXT
357	case domain.LineAddition:
358		return pb.LineKind_LINE_KIND_ADDITION
359	case domain.LineDeletion:
360		return pb.LineKind_LINE_KIND_DELETION
361	}
362	return pb.LineKind_LINE_KIND_UNSPECIFIED
363}
364
365func hunkToPB(h domain.Hunk) *pb.Hunk {
366	out := &pb.Hunk{
367		OldStart: int32(h.OldStart), OldLines: int32(h.OldLines),
368		NewStart: int32(h.NewStart), NewLines: int32(h.NewLines),
369		Header: h.Header,
370		Lines:  make([]*pb.Line, 0, len(h.Lines)),
371	}
372	for _, l := range h.Lines {
373		out.Lines = append(out.Lines, &pb.Line{
374			Kind:           lineKindToPB(l.Kind),
375			OldNumber:      int32(l.OldNumber),
376			NewNumber:      int32(l.NewNumber),
377			Text:           l.Text,
378			NoNewlineAtEof: l.NoNewlineAtEOF,
379		})
380	}
381	return out
382}
383
384func fileDiffToPB(f domain.FileDiff) *pb.FileDiff {
385	out := &pb.FileDiff{
386		OldPath: f.OldPath, NewPath: f.NewPath,
387		Status:  fileStatusToPB(f.Status),
388		OldMode: f.OldMode, NewMode: f.NewMode,
389		OldId: f.OldID.String(), NewId: f.NewID.String(),
390		Binary:     f.Binary,
391		Additions:  int32(f.Additions),
392		Deletions:  int32(f.Deletions),
393		Similarity: int32(f.Similarity),
394		Truncated:  f.Truncated,
395	}
396	for _, h := range f.Hunks {
397		out.Hunks = append(out.Hunks, hunkToPB(h))
398	}
399	return out
400}
401
402func diffStatsToPB(s domain.DiffStats) *pb.DiffStats {
403	return &pb.DiffStats{
404		FilesChanged: int32(s.FilesChanged),
405		Additions:    int32(s.Additions),
406		Deletions:    int32(s.Deletions),
407	}
408}
409
410func diffToPB(d domain.Diff) *pb.Diff {
411	out := &pb.Diff{Truncated: d.Truncated}
412	for _, f := range d.Files {
413		out.Files = append(out.Files, fileDiffToPB(f))
414	}
415	return out
416}
417
418func commitDetailToPB(d domain.CommitDetail) *pb.GetCommitResponse {
419	return &pb.GetCommitResponse{
420		Commit: commitToPB(d.Commit),
421		Stats:  diffStatsToPB(d.Stats),
422		Diff:   diffToPB(d.Diff),
423	}
424}
425
426// oidToPB turns the zero object id of a ref update (no object) into "".
427func oidToPB(oid string) string {
428	if oid == domain.ZeroOID {
429		return ""
430	}
431	return oid
432}
433
434func refUpdatesToPB(updates []domain.RefUpdate) []*pb.RefUpdate {
435	out := make([]*pb.RefUpdate, 0, len(updates))
436	for _, u := range updates {
437		out = append(out, &pb.RefUpdate{Ref: u.Ref, OldId: oidToPB(u.Old), NewId: oidToPB(u.New)})
438	}
439	return out
440}
441
442// watchEventToPB maps one item of a watch; nil when the event has no
443// representation in the contract.
444func watchEventToPB(we ports.WatchEvent) *pb.Event {
445	if we.Gap {
446		return &pb.Event{Kind: &pb.Event_Gap{Gap: &pb.Gap{}}}
447	}
448	out := &pb.Event{Id: we.ID}
449	switch ev := we.Event.(type) {
450	case domain.RepoCreated:
451		out.Time = timestamp(ev.At)
452		out.Kind = &pb.Event_RepoCreated{RepoCreated: &pb.RepoCreated{Repo: repoOrName(we, ev.Repo, ev.Visibility)}}
453	case domain.RepoUpdated:
454		out.Time = timestamp(ev.At)
455		out.Kind = &pb.Event_RepoUpdated{RepoUpdated: &pb.RepoUpdated{Repo: repoOrName(we, ev.Repo, "")}}
456	case domain.RepoDeleted:
457		out.Time = timestamp(ev.At)
458		out.Kind = &pb.Event_RepoDeleted{RepoDeleted: &pb.RepoDeleted{Repo: ev.Repo.String()}}
459	case domain.PushEvent:
460		out.Time = timestamp(ev.At)
461		out.Kind = &pb.Event_Pushed{Pushed: &pb.Pushed{Repo: ev.Repo.String(), RefUpdates: refUpdatesToPB(ev.Updates)}}
462	case domain.MergeRequestOpened:
463		out.Time = timestamp(ev.At)
464		out.Kind = &pb.Event_MergeRequestOpened{MergeRequestOpened: &pb.MergeRequestOpened{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}
465	case domain.MergeRequestMerged:
466		out.Time = timestamp(ev.At)
467		out.Kind = &pb.Event_MergeRequestMerged{MergeRequestMerged: &pb.MergeRequestMerged{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}
468	case domain.MergeRequestClosed:
469		out.Time = timestamp(ev.At)
470		out.Kind = &pb.Event_MergeRequestClosed{MergeRequestClosed: &pb.MergeRequestClosed{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}
471	case domain.CommentAdded:
472		out.Time = timestamp(ev.At)
473		out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
474	case domain.RunUpdated:
475		out.Time = timestamp(ev.At)
476		out.Kind = runEventToPB(ev)
477	case domain.JobUpdated:
478		out.Time = timestamp(ev.At)
479		out.Kind = jobEventToPB(ev)
480	default:
481		return nil
482	}
483	return out
484}
485
486func repoOrName(we ports.WatchEvent, name domain.RepoName, vis domain.Visibility) *pb.Repo {
487	if we.Repo != nil {
488		return repoToPB(*we.Repo)
489	}
490	return &pb.Repo{Name: name.String(), Visibility: visibilityToPB(vis)}
491}
492
493// passkeysToPB converts passkeys; the credential id is base64url without
494// padding, the form RemovePasskey takes back. Key material never leaves.
495func passkeysToPB(list []domain.Passkey) []*pb.Passkey {
496	out := make([]*pb.Passkey, 0, len(list))
497	for _, p := range list {
498		out = append(out, &pb.Passkey{
499			Id:         base64.RawURLEncoding.EncodeToString(p.ID),
500			Name:       p.Name,
501			CreatedAt:  timestamp(p.CreatedAt),
502			LastUsedAt: timestamp(p.LastUsedAt),
503			Synced:     p.BackupState,
504		})
505	}
506	return out
507}

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 44

↑↓ moveenter openesc close