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/domain"
14 "bnema.dev/ermite/internal/ports"
15)
16
17// mergeRequestService implements ermiteconnect.MergeRequestServiceHandler.
18type mergeRequestService struct {
19 mrs ports.MergeRequestService
20}
21
22func (s *mergeRequestService) EditComment(ctx context.Context, req *connect.Request[pb.EditCommentRequest]) (*connect.Response[pb.EditCommentResponse], error) {
23 name, err := domain.ParseRepoName(req.Msg.GetRepo())
24 if err != nil {
25 return nil, connectError(ctx, err)
26 }
27 c, err := s.mrs.EditComment(ctx, caller(ctx), name, req.Msg.GetNumber(), commentID(req.Msg.GetCommentId()), req.Msg.GetText())
28 if err != nil {
29 return nil, connectError(ctx, err)
30 }
31 return connect.NewResponse(&pb.EditCommentResponse{Comment: commentToPB(c)}), nil
32}
33
34func (s *mergeRequestService) DeleteComment(ctx context.Context, req *connect.Request[pb.DeleteCommentRequest]) (*connect.Response[pb.DeleteCommentResponse], error) {
35 name, err := domain.ParseRepoName(req.Msg.GetRepo())
36 if err != nil {
37 return nil, connectError(ctx, err)
38 }
39 c, err := s.mrs.DeleteComment(ctx, caller(ctx), name, req.Msg.GetNumber(), commentID(req.Msg.GetCommentId()))
40 if err != nil {
41 return nil, connectError(ctx, err)
42 }
43 return connect.NewResponse(&pb.DeleteCommentResponse{Comment: commentToPB(c)}), nil
44}
45
46func verdictToPB(v domain.Verdict) pb.Verdict {
47 switch v {
48 case domain.VerdictApprove:
49 return pb.Verdict_VERDICT_APPROVE
50 case domain.VerdictChangesRequested:
51 return pb.Verdict_VERDICT_CHANGES_REQUESTED
52 }
53 return pb.Verdict_VERDICT_UNSPECIFIED
54}
55
56func verdictFromPB(v pb.Verdict) (domain.Verdict, error) {
57 switch v {
58 case pb.Verdict_VERDICT_UNSPECIFIED:
59 return domain.VerdictNone, nil
60 case pb.Verdict_VERDICT_APPROVE:
61 return domain.VerdictApprove, nil
62 case pb.Verdict_VERDICT_CHANGES_REQUESTED:
63 return domain.VerdictChangesRequested, nil
64 }
65 return domain.VerdictNone, fmt.Errorf("%w: verdict %v", domain.ErrInvalid, v)
66}
67
68func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState {
69 switch s {
70 case domain.MRStateOpen:
71 return pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN
72 case domain.MRStateMerged:
73 return pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED
74 case domain.MRStateClosed:
75 return pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED
76 }
77 return pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED
78}
79
80// mergeRequestStateFromPB maps the state filter of a listing; unspecified is
81// the empty state, which means every state.
82func mergeRequestStateFromPB(s pb.MergeRequestState) (domain.MRState, error) {
83 switch s {
84 case pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED:
85 return "", nil
86 case pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN:
87 return domain.MRStateOpen, nil
88 case pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED:
89 return domain.MRStateMerged, nil
90 case pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED:
91 return domain.MRStateClosed, nil
92 }
93 return "", fmt.Errorf("%w: merge request state %v", domain.ErrInvalid, s)
94}
95
96func diffSideToPB(s domain.DiffSide) pb.DiffSide {
97 switch s {
98 case domain.SideOld:
99 return pb.DiffSide_DIFF_SIDE_OLD
100 case domain.SideNew:
101 return pb.DiffSide_DIFF_SIDE_NEW
102 }
103 return pb.DiffSide_DIFF_SIDE_UNSPECIFIED
104}
105
106func diffSideFromPB(s pb.DiffSide) (domain.DiffSide, error) {
107 switch s {
108 case pb.DiffSide_DIFF_SIDE_UNSPECIFIED:
109 return domain.SideNone, nil
110 case pb.DiffSide_DIFF_SIDE_OLD:
111 return domain.SideOld, nil
112 case pb.DiffSide_DIFF_SIDE_NEW:
113 return domain.SideNew, nil
114 }
115 return "", fmt.Errorf("%w: diff side %v", domain.ErrInvalid, s)
116}
117
118func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest {
119 return &pb.MergeRequest{
120 Repo: mr.Repo.String(),
121 Number: mr.Number,
122 Title: mr.Title.String(),
123 Description: mr.Description.String(),
124 SourceBranch: mr.Source,
125 TargetBranch: mr.Target,
126 State: mergeRequestStateToPB(mr.State),
127 Author: mr.Author,
128 CreatedAt: timestamp(mr.CreatedAt),
129 UpdatedAt: timestamp(mr.UpdatedAt),
130 HeadId: mr.HeadID.String(),
131 MergedCommitId: mr.MergedCommitID.String(),
132 MergeMode: mergeModeToPB(mr.MergeMode),
133 }
134}
135
136func commentToPB(c domain.Comment) *pb.Comment {
137 return &pb.Comment{
138 Id: strconv.FormatInt(c.ID, 10),
139 Author: c.Author,
140 Path: c.Path,
141 Line: int32(c.Line), //nolint:gosec // a line of a diff, validated against it
142 Side: diffSideToPB(c.Side),
143 CommitId: c.CommitID.String(),
144 Text: c.Text.String(),
145 Resolved: c.Resolved,
146 CreatedAt: timestamp(c.CreatedAt),
147 ReplyTo: optionalCommentID(c.ReplyTo),
148 StartLine: int32(c.StartLine), //nolint:gosec // validated diff line
149 Verdict: verdictToPB(c.Verdict), EditedAt: timestamp(c.EditedAt), Deleted: c.Deleted,
150 }
151}
152
153func optionalCommentID(id int64) string {
154 if id == 0 {
155 return ""
156 }
157 return strconv.FormatInt(id, 10)
158}
159
160// commentID parses the opaque id of a comment. One that this server never
161// issued is 0, which names no comment.
162func commentID(s string) int64 {
163 n, err := strconv.ParseInt(s, 10, 64)
164 if err != nil || n < 1 || strconv.FormatInt(n, 10) != s {
165 return 0
166 }
167 return n
168}
169
170// headID parses a commit id of a request; the empty string is the zero id.
171//
172//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
173func headID(s string) (domain.ObjectID, error) {
174 if s == "" {
175 return domain.ObjectID{}, nil
176 }
177 var v domain.Violations
178 id, err := domain.ParseObjectID(s)
179 if err != nil {
180 v.Add("head_id", "is not a full commit id")
181 }
182 return id, v.Err()
183}
184
185func (s *mergeRequestService) CreateMergeRequest(ctx context.Context, req *connect.Request[pb.CreateMergeRequestRequest]) (*connect.Response[pb.CreateMergeRequestResponse], error) {
186 name, err := domain.ParseRepoName(req.Msg.GetRepo())
187 if err != nil {
188 return nil, connectError(ctx, err)
189 }
190 mr, err := s.mrs.Create(ctx, caller(ctx), name, ports.CreateMergeRequestParams{
191 Title: req.Msg.GetTitle(), Description: req.Msg.GetDescription(),
192 Source: req.Msg.GetSourceBranch(), Target: req.Msg.GetTargetBranch(),
193 })
194 if err != nil {
195 return nil, connectError(ctx, err)
196 }
197 return connect.NewResponse(&pb.CreateMergeRequestResponse{MergeRequest: mergeRequestToPB(mr)}), nil
198}
199
200func (s *mergeRequestService) ListMergeRequests(ctx context.Context, req *connect.Request[pb.ListMergeRequestsRequest]) (*connect.Response[pb.ListMergeRequestsResponse], error) {
201 name, err := domain.ParseRepoName(req.Msg.GetRepo())
202 if err != nil {
203 return nil, connectError(ctx, err)
204 }
205 state, err := mergeRequestStateFromPB(req.Msg.GetState())
206 if err != nil {
207 return nil, connectError(ctx, err)
208 }
209 page, err := s.mrs.List(ctx, caller(ctx), name, ports.ListMergeRequestsParams{
210 State: state, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
211 })
212 if err != nil {
213 return nil, connectError(ctx, err)
214 }
215 out := &pb.ListMergeRequestsResponse{NextPageToken: page.NextPageToken, MergeRequests: make([]*pb.MergeRequest, 0, len(page.MergeRequests))}
216 for _, mr := range page.MergeRequests {
217 out.MergeRequests = append(out.MergeRequests, mergeRequestToPB(mr))
218 }
219 return connect.NewResponse(out), nil
220}
221
222func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect.Request[pb.GetMergeRequestRequest]) (*connect.Response[pb.GetMergeRequestResponse], error) {
223 name, err := domain.ParseRepoName(req.Msg.GetRepo())
224 if err != nil {
225 return nil, connectError(ctx, err)
226 }
227 d, err := s.mrs.Get(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.GetMergeRequestOptions{SinceLastRead: req.Msg.GetSinceLastRead()})
228 if err != nil {
229 return nil, connectError(ctx, err)
230 }
231 out := &pb.GetMergeRequestResponse{
232 MergeRequest: mergeRequestToPB(d.MergeRequest),
233 CommitsTruncated: d.CommitsTruncated,
234 DiffBaseId: d.DiffBaseID.String(),
235 Stats: diffStatsToPB(d.Stats),
236 Diff: diffToPB(d.Diff),
237 LastReadId: d.LastReadID.String(),
238 LastReadLost: d.LastReadLost,
239 }
240 for _, c := range d.Commits {
241 out.Commits = append(out.Commits, commitToPB(c))
242 }
243 for _, c := range d.Comments {
244 out.Comments = append(out.Comments, commentToPB(c))
245 }
246 for _, v := range d.Verdicts {
247 out.Verdicts = append(out.Verdicts, &pb.AuthorVerdict{Author: v.Author, Verdict: verdictToPB(v.Verdict), CommentId: strconv.FormatInt(v.CommentID, 10), CommitId: v.CommitID.String(), CreatedAt: timestamp(v.At), Stale: v.Stale})
248 }
249 return connect.NewResponse(out), nil
250}
251
252func (s *mergeRequestService) AddComment(ctx context.Context, req *connect.Request[pb.AddCommentRequest]) (*connect.Response[pb.AddCommentResponse], error) {
253 name, err := domain.ParseRepoName(req.Msg.GetRepo())
254 if err != nil {
255 return nil, connectError(ctx, err)
256 }
257 side, err := diffSideFromPB(req.Msg.GetSide())
258 if err != nil {
259 return nil, connectError(ctx, err)
260 }
261 verdict, err := verdictFromPB(req.Msg.GetVerdict())
262 if err != nil {
263 return nil, connectError(ctx, err)
264 }
265 replyTo := commentID(req.Msg.GetReplyTo())
266 if req.Msg.GetReplyTo() != "" && replyTo == 0 {
267 var v domain.Violations
268 v.Add("reply_to", "is not a note id")
269 return nil, connectError(ctx, v.Err())
270 }
271 c, err := s.mrs.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.AddCommentParams{
272 ReplyTo: replyTo, StartLine: int(req.Msg.GetStartLine()), Verdict: verdict,
273 Path: req.Msg.GetPath(), Line: int(req.Msg.GetLine()), Side: side, Text: req.Msg.GetText(),
274 })
275 if err != nil {
276 return nil, connectError(ctx, err)
277 }
278 return connect.NewResponse(&pb.AddCommentResponse{Comment: commentToPB(c)}), nil
279}
280
281func (s *mergeRequestService) ResolveComment(ctx context.Context, req *connect.Request[pb.ResolveCommentRequest]) (*connect.Response[pb.ResolveCommentResponse], error) {
282 name, err := domain.ParseRepoName(req.Msg.GetRepo())
283 if err != nil {
284 return nil, connectError(ctx, err)
285 }
286 // An id this server never issued is 0: the use case answers not found, after
287 // the repository and the scope were checked, as for any unknown comment.
288 c, err := s.mrs.ResolveComment(ctx, caller(ctx), name, req.Msg.GetNumber(), commentID(req.Msg.GetCommentId()), req.Msg.GetResolved())
289 if err != nil {
290 return nil, connectError(ctx, err)
291 }
292 return connect.NewResponse(&pb.ResolveCommentResponse{Comment: commentToPB(c)}), nil
293}
294
295func (s *mergeRequestService) CloseMergeRequest(ctx context.Context, req *connect.Request[pb.CloseMergeRequestRequest]) (*connect.Response[pb.CloseMergeRequestResponse], error) {
296 name, err := domain.ParseRepoName(req.Msg.GetRepo())
297 if err != nil {
298 return nil, connectError(ctx, err)
299 }
300 mr, err := s.mrs.Close(ctx, caller(ctx), name, req.Msg.GetNumber())
301 if err != nil {
302 return nil, connectError(ctx, err)
303 }
304 return connect.NewResponse(&pb.CloseMergeRequestResponse{MergeRequest: mergeRequestToPB(mr)}), nil
305}
306
307func (s *mergeRequestService) MergeMergeRequest(ctx context.Context, req *connect.Request[pb.MergeMergeRequestRequest]) (*connect.Response[pb.MergeMergeRequestResponse], error) {
308 name, err := domain.ParseRepoName(req.Msg.GetRepo())
309 if err != nil {
310 return nil, connectError(ctx, err)
311 }
312 mode, err := mergeModeFromPB(req.Msg.GetMode())
313 if err != nil {
314 return nil, connectError(ctx, err)
315 }
316 head, err := headID(req.Msg.GetHeadId())
317 if err != nil {
318 return nil, connectError(ctx, err)
319 }
320 res, err := s.mrs.Merge(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.MergeParams{
321 Mode: mode, HeadID: head, DeleteSource: req.Msg.GetDeleteSourceBranch(),
322 })
323 if err != nil {
324 return nil, connectError(ctx, err)
325 }
326 return connect.NewResponse(&pb.MergeMergeRequestResponse{
327 MergeRequest: mergeRequestToPB(res.MergeRequest), SourceBranchDeleted: res.SourceDeleted,
328 }), nil
329}
330
331func (s *mergeRequestService) MarkRead(ctx context.Context, req *connect.Request[pb.MarkReadRequest]) (*connect.Response[pb.MarkReadResponse], error) {
332 name, err := domain.ParseRepoName(req.Msg.GetRepo())
333 if err != nil {
334 return nil, connectError(ctx, err)
335 }
336 head, err := headID(req.Msg.GetHeadId())
337 if err != nil {
338 return nil, connectError(ctx, err)
339 }
340 if _, err := s.mrs.MarkRead(ctx, caller(ctx), name, req.Msg.GetNumber(), head); err != nil {
341 return nil, connectError(ctx, err)
342 }
343 return connect.NewResponse(&pb.MarkReadResponse{}), nil
344}