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