1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "io"
10
11 "connectrpc.com/connect"
12
13 pb "bnema.dev/ermite-api/gen/ermite"
14 "bnema.dev/ermite/internal/app"
15 "bnema.dev/ermite/internal/domain"
16)
17
18// BlobChunkSize is the largest chunk of a GetBlob stream.
19const BlobChunkSize = 64 << 10
20
21// gitService implements ermiteconnect.GitServiceHandler.
22type gitService struct{ browse *app.Browse }
23
24// target is the repository and revision most requests start with.
25func target(repo, revision string) (domain.RepoName, domain.Revision, error) {
26 name, err := domain.ParseRepoName(repo)
27 if err != nil {
28 return domain.RepoName{}, domain.Revision{}, err
29 }
30 rev, err := domain.ParseRevision(revision)
31 if err != nil {
32 return domain.RepoName{}, domain.Revision{}, err
33 }
34 return name, rev, nil
35}
36
37func (s *gitService) ListRefs(ctx context.Context, req *connect.Request[pb.ListRefsRequest]) (*connect.Response[pb.ListRefsResponse], error) {
38 name, err := domain.ParseRepoName(req.Msg.GetRepo())
39 if err != nil {
40 return nil, connectError(ctx, err)
41 }
42 params := app.ListRefsParams{PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken()}
43 for _, k := range req.Msg.GetKinds() {
44 kind, err := refKindFromPB(k)
45 if err != nil {
46 return nil, connectError(ctx, err)
47 }
48 params.Kinds = append(params.Kinds, kind)
49 }
50 page, err := s.browse.ListRefs(ctx, caller(ctx), name, params)
51 if err != nil {
52 return nil, connectError(ctx, err)
53 }
54 out := &pb.ListRefsResponse{NextPageToken: page.NextPageToken, Refs: make([]*pb.Ref, 0, len(page.Refs))}
55 for _, r := range page.Refs {
56 out.Refs = append(out.Refs, refToPB(r))
57 }
58 return connect.NewResponse(out), nil
59}
60
61func (s *gitService) GetTree(ctx context.Context, req *connect.Request[pb.GetTreeRequest]) (*connect.Response[pb.GetTreeResponse], error) {
62 name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
63 if err != nil {
64 return nil, connectError(ctx, err)
65 }
66 path, err := domain.ParseTreePath(req.Msg.GetPath())
67 if err != nil {
68 return nil, connectError(ctx, err)
69 }
70 tree, err := s.browse.GetTree(ctx, caller(ctx), name, rev, path, req.Msg.GetIncludeLastCommit())
71 if err != nil {
72 return nil, connectError(ctx, err)
73 }
74 out := &pb.GetTreeResponse{
75 CommitId: tree.CommitID.String(),
76 TreeId: tree.ID.String(),
77 Entries: make([]*pb.TreeEntry, 0, len(tree.Entries)),
78
79 LastCommitsTruncated: tree.LastCommitsOmitted,
80 Truncated: tree.Truncated,
81 }
82 for _, e := range tree.Entries {
83 out.Entries = append(out.Entries, treeEntryToPB(e))
84 }
85 return connect.NewResponse(out), nil
86}
87
88// GetBlob streams the blob in chunks of BlobChunkSize. The first message
89// carries the metadata and the first chunk.
90func (s *gitService) GetBlob(ctx context.Context, req *connect.Request[pb.GetBlobRequest], stream *connect.ServerStream[pb.GetBlobResponse]) error {
91 name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
92 if err != nil {
93 return connectError(ctx, err)
94 }
95 if req.Msg.GetPath() == "" {
96 return connectError(ctx, invalidPath("path is required"))
97 }
98 path, err := domain.ParseTreePath(req.Msg.GetPath())
99 if err != nil {
100 return connectError(ctx, err)
101 }
102 info, body, err := s.browse.OpenBlob(ctx, caller(ctx), name, rev, path)
103 if err != nil {
104 return connectError(ctx, err)
105 }
106 defer func() { _ = body.Close() }()
107
108 // Send marshals the message before it returns, so the buffer is reused.
109 buf := make([]byte, BlobChunkSize)
110 first := true
111 for {
112 n, rerr := io.ReadFull(body, buf)
113 eof := errors.Is(rerr, io.EOF) || errors.Is(rerr, io.ErrUnexpectedEOF)
114 if rerr != nil && !eof {
115 return connectError(ctx, rerr)
116 }
117 // An empty file still gets the message with the metadata.
118 if n > 0 || first {
119 msg := &pb.GetBlobResponse{Chunk: buf[:n]}
120 if first {
121 msg.Info = blobInfoToPB(info)
122 first = false
123 }
124 if err := stream.Send(msg); err != nil {
125 return err
126 }
127 }
128 if eof {
129 return nil
130 }
131 }
132}
133
134func invalidPath(msg string) error {
135 return fmt.Errorf("%w: %s", domain.ErrInvalidPath, msg)
136}
137
138func (s *gitService) ListCommits(ctx context.Context, req *connect.Request[pb.ListCommitsRequest]) (*connect.Response[pb.ListCommitsResponse], error) {
139 name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
140 if err != nil {
141 return nil, connectError(ctx, err)
142 }
143 path, err := domain.ParseTreePath(req.Msg.GetPath())
144 if err != nil {
145 return nil, connectError(ctx, err)
146 }
147 page, err := s.browse.ListCommits(ctx, caller(ctx), name, app.ListCommitsParams{
148 Revision: rev, Path: path, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
149 })
150 if err != nil {
151 return nil, connectError(ctx, err)
152 }
153 out := &pb.ListCommitsResponse{NextPageToken: page.NextPageToken, Commits: make([]*pb.Commit, 0, len(page.Commits))}
154 for _, c := range page.Commits {
155 out.Commits = append(out.Commits, commitToPB(c))
156 }
157 return connect.NewResponse(out), nil
158}
159
160func (s *gitService) GetCommit(ctx context.Context, req *connect.Request[pb.GetCommitRequest]) (*connect.Response[pb.GetCommitResponse], error) {
161 name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
162 if err != nil {
163 return nil, connectError(ctx, err)
164 }
165 detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev)
166 if err != nil {
167 return nil, connectError(ctx, err)
168 }
169 return connect.NewResponse(commitDetailToPB(detail)), nil
170}
171
172//hexcheck:ignore suspicious-business-logic-in-adapter port method name only
173func (s *gitService) ResolveRevision(ctx context.Context, req *connect.Request[pb.ResolveRevisionRequest]) (*connect.Response[pb.ResolveRevisionResponse], error) {
174 name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
175 if err != nil {
176 return nil, connectError(ctx, err)
177 }
178 res, err := s.browse.ResolveRevision(ctx, caller(ctx), name, rev)
179 if err != nil {
180 return nil, connectError(ctx, err)
181 }
182 return connect.NewResponse(&pb.ResolveRevisionResponse{CommitId: res.CommitID.String(), Ref: res.Ref}), nil
183}