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}