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