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// ListPaths lists the paths of every file of a revision.
89func (s *gitService) ListPaths(ctx context.Context, req *connect.Request[pb.ListPathsRequest]) (*connect.Response[pb.ListPathsResponse], error) {
90 name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
91 if err != nil {
92 return nil, connectError(ctx, err)
93 }
94 list, err := s.browse.ListPaths(ctx, caller(ctx), name, rev)
95 if err != nil {
96 return nil, connectError(ctx, err)
97 }
98 return connect.NewResponse(&pb.ListPathsResponse{
99 CommitId: list.CommitID.String(),
100 TreeId: list.TreeID.String(),
101 Paths: list.Paths,
102 Truncated: list.Truncated,
103 }), nil
104}
105
106// GetBlob streams the blob in chunks of BlobChunkSize. The first message
107// carries the metadata and the first chunk.
108func (s *gitService) GetBlob(ctx context.Context, req *connect.Request[pb.GetBlobRequest], stream *connect.ServerStream[pb.GetBlobResponse]) error {
109 name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
110 if err != nil {
111 return connectError(ctx, err)
112 }
113 if req.Msg.GetPath() == "" {
114 return connectError(ctx, invalidPath("path is required"))
115 }
116 path, err := domain.ParseTreePath(req.Msg.GetPath())
117 if err != nil {
118 return connectError(ctx, err)
119 }
120 info, body, err := s.browse.OpenBlob(ctx, caller(ctx), name, rev, path)
121 if err != nil {
122 return connectError(ctx, err)
123 }
124 defer func() { _ = body.Close() }()
125
126 // Send marshals the message before it returns, so the buffer is reused.
127 buf := make([]byte, BlobChunkSize)
128 first := true
129 for {
130 n, rerr := io.ReadFull(body, buf)
131 eof := errors.Is(rerr, io.EOF) || errors.Is(rerr, io.ErrUnexpectedEOF)
132 if rerr != nil && !eof {
133 return connectError(ctx, rerr)
134 }
135 // An empty file still gets the message with the metadata.
136 if n > 0 || first {
137 msg := &pb.GetBlobResponse{Chunk: buf[:n]}
138 if first {
139 msg.Info = blobInfoToPB(info)
140 first = false
141 }
142 if err := stream.Send(msg); err != nil {
143 return err
144 }
145 }
146 if eof {
147 return nil
148 }
149 }
150}
151
152func invalidPath(msg string) error {
153 return fmt.Errorf("%w: %s", domain.ErrInvalidPath, msg)
154}
155
156func (s *gitService) ListCommits(ctx context.Context, req *connect.Request[pb.ListCommitsRequest]) (*connect.Response[pb.ListCommitsResponse], error) {
157 name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
158 if err != nil {
159 return nil, connectError(ctx, err)
160 }
161 path, err := domain.ParseTreePath(req.Msg.GetPath())
162 if err != nil {
163 return nil, connectError(ctx, err)
164 }
165 page, err := s.browse.ListCommits(ctx, caller(ctx), name, app.ListCommitsParams{
166 Revision: rev, Path: path, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
167 })
168 if err != nil {
169 return nil, connectError(ctx, err)
170 }
171 out := &pb.ListCommitsResponse{NextPageToken: page.NextPageToken, Commits: make([]*pb.Commit, 0, len(page.Commits))}
172 for _, c := range page.Commits {
173 out.Commits = append(out.Commits, commitToPB(c))
174 }
175 return connect.NewResponse(out), nil
176}
177
178func (s *gitService) GetCommit(ctx context.Context, req *connect.Request[pb.GetCommitRequest]) (*connect.Response[pb.GetCommitResponse], error) {
179 name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
180 if err != nil {
181 return nil, connectError(ctx, err)
182 }
183 detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev)
184 if err != nil {
185 return nil, connectError(ctx, err)
186 }
187 return connect.NewResponse(commitDetailToPB(detail)), nil
188}
189
190//hexcheck:ignore suspicious-business-logic-in-adapter port method name only
191func (s *gitService) ResolveRevision(ctx context.Context, req *connect.Request[pb.ResolveRevisionRequest]) (*connect.Response[pb.ResolveRevisionResponse], error) {
192 name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
193 if err != nil {
194 return nil, connectError(ctx, err)
195 }
196 res, err := s.browse.ResolveRevision(ctx, caller(ctx), name, rev)
197 if err != nil {
198 return nil, connectError(ctx, err)
199 }
200 return connect.NewResponse(&pb.ResolveRevisionResponse{CommitId: res.CommitID.String(), Ref: res.Ref}), nil
201}