1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6 "context"
7 "fmt"
8 "strconv"
9
10 "connectrpc.com/connect"
11
12 pb "bnema.dev/ermite-api/gen/ermite"
13 "bnema.dev/ermite/internal/app"
14 "bnema.dev/ermite/internal/domain"
15)
16
17// mergeRequestService implements ermiteconnect.MergeRequestServiceHandler.
18type mergeRequestService struct{ mrs *app.MergeRequests }
19
20func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState {
21 switch s {
22 case domain.MRStateOpen:
23 return pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN
24 case domain.MRStateMerged:
25 return pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED
26 case domain.MRStateClosed:
27 return pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED
28 }
29 return pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED
30}
31
32// mergeRequestStateFromPB maps the state filter of a listing; unspecified is
33// the empty state, which means every state.
34func mergeRequestStateFromPB(s pb.MergeRequestState) (domain.MRState, error) {
35 switch s {
36 case pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED:
37 return "", nil
38 case pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN:
39 return domain.MRStateOpen, nil
40 case pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED:
41 return domain.MRStateMerged, nil
42 case pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED:
43 return domain.MRStateClosed, nil
44 }
45 return "", fmt.Errorf("%w: merge request state %v", domain.ErrInvalid, s)
46}
47
48func diffSideToPB(s domain.DiffSide) pb.DiffSide {
49 switch s {
50 case domain.SideOld:
51 return pb.DiffSide_DIFF_SIDE_OLD
52 case domain.SideNew:
53 return pb.DiffSide_DIFF_SIDE_NEW
54 }
55 return pb.DiffSide_DIFF_SIDE_UNSPECIFIED
56}
57
58func diffSideFromPB(s pb.DiffSide) (domain.DiffSide, error) {
59 switch s {
60 case pb.DiffSide_DIFF_SIDE_UNSPECIFIED:
61 return domain.SideNone, nil
62 case pb.DiffSide_DIFF_SIDE_OLD:
63 return domain.SideOld, nil
64 case pb.DiffSide_DIFF_SIDE_NEW:
65 return domain.SideNew, nil
66 }
67 return "", fmt.Errorf("%w: diff side %v", domain.ErrInvalid, s)
68}
69
70func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest {
71 return &pb.MergeRequest{
72 Repo: mr.Repo.String(),
73 Number: mr.Number,
74 Title: mr.Title.String(),
75 Description: mr.Description.String(),
76 SourceBranch: mr.Source,
77 TargetBranch: mr.Target,
78 State: mergeRequestStateToPB(mr.State),
79 Author: mr.Author,
80 CreatedAt: timestamp(mr.CreatedAt),
81 UpdatedAt: timestamp(mr.UpdatedAt),
82 HeadId: mr.HeadID.String(),
83 MergedCommitId: mr.MergedCommitID.String(),
84 MergeMode: mergeModeToPB(mr.MergeMode),
85 }
86}
87
88func commentToPB(c domain.Comment) *pb.Comment {
89 return &pb.Comment{
90 Id: strconv.FormatInt(c.ID, 10),
91 Author: c.Author,
92 Path: c.Path,
93 Line: int32(c.Line), //nolint:gosec // a line of a diff, validated against it
94 Side: diffSideToPB(c.Side),
95 CommitId: c.CommitID.String(),
96 Text: c.Text.String(),
97 Resolved: c.Resolved,
98 CreatedAt: timestamp(c.CreatedAt),
99 }
100}
101
102// commentID parses the opaque id of a comment. One that this server never
103// issued is 0, which names no comment.
104func commentID(s string) int64 {
105 n, err := strconv.ParseInt(s, 10, 64)
106 if err != nil || n < 1 || strconv.FormatInt(n, 10) != s {
107 return 0
108 }
109 return n
110}
111
112// headID parses a commit id of a request; the empty string is the zero id.
113//
114//hexcheck:ignore suspicious-business-logic-in-adapter parses a request field into the domain id and names the field in the error; no rule is decided here
115func headID(s string) (domain.ObjectID, error) {
116 if s == "" {
117 return domain.ObjectID{}, nil
118 }
119 var v domain.Violations
120 id, err := domain.ParseObjectID(s)
121 if err != nil {
122 v.Add("head_id", "is not a full commit id")
123 }
124 return id, v.Err()
125}
126
127func (s *mergeRequestService) CreateMergeRequest(ctx context.Context, req *connect.Request[pb.CreateMergeRequestRequest]) (*connect.Response[pb.CreateMergeRequestResponse], error) {
128 name, err := domain.ParseRepoName(req.Msg.GetRepo())
129 if err != nil {
130 return nil, connectError(ctx, err)
131 }
132 mr, err := s.mrs.Create(ctx, caller(ctx), name, app.CreateMergeRequestParams{
133 Title: req.Msg.GetTitle(), Description: req.Msg.GetDescription(),
134 Source: req.Msg.GetSourceBranch(), Target: req.Msg.GetTargetBranch(),
135 })
136 if err != nil {
137 return nil, connectError(ctx, err)
138 }
139 return connect.NewResponse(&pb.CreateMergeRequestResponse{MergeRequest: mergeRequestToPB(mr)}), nil
140}
141
142func (s *mergeRequestService) ListMergeRequests(ctx context.Context, req *connect.Request[pb.ListMergeRequestsRequest]) (*connect.Response[pb.ListMergeRequestsResponse], error) {
143 name, err := domain.ParseRepoName(req.Msg.GetRepo())
144 if err != nil {
145 return nil, connectError(ctx, err)
146 }
147 state, err := mergeRequestStateFromPB(req.Msg.GetState())
148 if err != nil {
149 return nil, connectError(ctx, err)
150 }
151 page, err := s.mrs.List(ctx, caller(ctx), name, app.ListMergeRequestsParams{
152 State: state, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
153 })
154 if err != nil {
155 return nil, connectError(ctx, err)
156 }
157 out := &pb.ListMergeRequestsResponse{NextPageToken: page.NextPageToken, MergeRequests: make([]*pb.MergeRequest, 0, len(page.MergeRequests))}
158 for _, mr := range page.MergeRequests {
159 out.MergeRequests = append(out.MergeRequests, mergeRequestToPB(mr))
160 }
161 return connect.NewResponse(out), nil
162}
163
164func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect.Request[pb.GetMergeRequestRequest]) (*connect.Response[pb.GetMergeRequestResponse], error) {
165 name, err := domain.ParseRepoName(req.Msg.GetRepo())
166 if err != nil {
167 return nil, connectError(ctx, err)
168 }
169 d, err := s.mrs.Get(ctx, caller(ctx), name, req.Msg.GetNumber(), req.Msg.GetSinceLastRead())
170 if err != nil {
171 return nil, connectError(ctx, err)
172 }
173 out := &pb.GetMergeRequestResponse{
174 MergeRequest: mergeRequestToPB(d.MergeRequest),
175 CommitsTruncated: d.CommitsTruncated,
176 DiffBaseId: d.DiffBaseID.String(),
177 Stats: diffStatsToPB(d.Stats),
178 Diff: diffToPB(d.Diff),
179 LastReadId: d.LastReadID.String(),
180 LastReadLost: d.LastReadLost,
181 }
182 for _, c := range d.Commits {
183 out.Commits = append(out.Commits, commitToPB(c))
184 }
185 for _, c := range d.Comments {
186 out.Comments = append(out.Comments, commentToPB(c))
187 }
188 return connect.NewResponse(out), nil
189}
190
191func (s *mergeRequestService) AddComment(ctx context.Context, req *connect.Request[pb.AddCommentRequest]) (*connect.Response[pb.AddCommentResponse], error) {
192 name, err := domain.ParseRepoName(req.Msg.GetRepo())
193 if err != nil {
194 return nil, connectError(ctx, err)
195 }
196 side, err := diffSideFromPB(req.Msg.GetSide())
197 if err != nil {
198 return nil, connectError(ctx, err)
199 }
200 c, err := s.mrs.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), app.AddCommentParams{
201 Path: req.Msg.GetPath(), Line: int(req.Msg.GetLine()), Side: side, Text: req.Msg.GetText(),
202 })
203 if err != nil {
204 return nil, connectError(ctx, err)
205 }
206 return connect.NewResponse(&pb.AddCommentResponse{Comment: commentToPB(c)}), nil
207}
208
209func (s *mergeRequestService) ResolveComment(ctx context.Context, req *connect.Request[pb.ResolveCommentRequest]) (*connect.Response[pb.ResolveCommentResponse], error) {
210 name, err := domain.ParseRepoName(req.Msg.GetRepo())
211 if err != nil {
212 return nil, connectError(ctx, err)
213 }
214 // An id this server never issued is 0: the use case answers not found, after
215 // the repository and the scope were checked, as for any unknown comment.
216 c, err := s.mrs.ResolveComment(ctx, caller(ctx), name, req.Msg.GetNumber(), commentID(req.Msg.GetCommentId()), req.Msg.GetResolved())
217 if err != nil {
218 return nil, connectError(ctx, err)
219 }
220 return connect.NewResponse(&pb.ResolveCommentResponse{Comment: commentToPB(c)}), nil
221}
222
223func (s *mergeRequestService) CloseMergeRequest(ctx context.Context, req *connect.Request[pb.CloseMergeRequestRequest]) (*connect.Response[pb.CloseMergeRequestResponse], error) {
224 name, err := domain.ParseRepoName(req.Msg.GetRepo())
225 if err != nil {
226 return nil, connectError(ctx, err)
227 }
228 mr, err := s.mrs.Close(ctx, caller(ctx), name, req.Msg.GetNumber())
229 if err != nil {
230 return nil, connectError(ctx, err)
231 }
232 return connect.NewResponse(&pb.CloseMergeRequestResponse{MergeRequest: mergeRequestToPB(mr)}), nil
233}
234
235func (s *mergeRequestService) MergeMergeRequest(ctx context.Context, req *connect.Request[pb.MergeMergeRequestRequest]) (*connect.Response[pb.MergeMergeRequestResponse], error) {
236 name, err := domain.ParseRepoName(req.Msg.GetRepo())
237 if err != nil {
238 return nil, connectError(ctx, err)
239 }
240 mode, err := mergeModeFromPB(req.Msg.GetMode())
241 if err != nil {
242 return nil, connectError(ctx, err)
243 }
244 head, err := headID(req.Msg.GetHeadId())
245 if err != nil {
246 return nil, connectError(ctx, err)
247 }
248 res, err := s.mrs.Merge(ctx, caller(ctx), name, req.Msg.GetNumber(), app.MergeParams{
249 Mode: mode, HeadID: head, DeleteSource: req.Msg.GetDeleteSourceBranch(),
250 })
251 if err != nil {
252 return nil, connectError(ctx, err)
253 }
254 return connect.NewResponse(&pb.MergeMergeRequestResponse{
255 MergeRequest: mergeRequestToPB(res.MergeRequest), SourceBranchDeleted: res.SourceDeleted,
256 }), nil
257}
258
259func (s *mergeRequestService) MarkRead(ctx context.Context, req *connect.Request[pb.MarkReadRequest]) (*connect.Response[pb.MarkReadResponse], error) {
260 name, err := domain.ParseRepoName(req.Msg.GetRepo())
261 if err != nil {
262 return nil, connectError(ctx, err)
263 }
264 head, err := headID(req.Msg.GetHeadId())
265 if err != nil {
266 return nil, connectError(ctx, err)
267 }
268 if _, err := s.mrs.MarkRead(ctx, caller(ctx), name, req.Msg.GetNumber(), head); err != nil {
269 return nil, connectError(ctx, err)
270 }
271 return connect.NewResponse(&pb.MarkReadResponse{}), nil
272}