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
118// mergeRequestToPB maps a request. An open one carries the CHECKING
119// mergeability: the server has no result in hand here; Get and List override
120// it with the computed one.
121func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest {
122 out := &pb.MergeRequest{
123 Repo: mr.Repo.String(),
124 Number: mr.Number,
125 Title: mr.Title.String(),
126 Description: mr.Description.String(),
127 SourceBranch: mr.Source,
128 TargetBranch: mr.Target,
129 State: mergeRequestStateToPB(mr.State),
130 Author: mr.Author,
131 CreatedAt: timestamp(mr.CreatedAt),
132 UpdatedAt: timestamp(mr.UpdatedAt),
133 HeadId: mr.HeadID.String(),
134 MergedCommitId: mr.MergedCommitID.String(),
135 MergeMode: mergeModeToPB(mr.MergeMode),
136 }
137 if mr.IsOpen() {
138 out.Mergeability = &pb.Mergeability{State: pb.MergeabilityState_MERGEABILITY_STATE_CHECKING}
139 }
140 return out
141}
142
143// mergeRequestWithMergeabilityToPB is mergeRequestToPB with the mergeability of
144// an open request; m is ignored for a request that is not open.
145func mergeRequestWithMergeabilityToPB(mr domain.MergeRequest, m domain.Mergeability) *pb.MergeRequest {
146 out := mergeRequestToPB(mr)
147 if mr.IsOpen() && m.State != "" {
148 out.Mergeability = mergeabilityToPB(m)
149 }
150 return out
151}
152
153func mergeabilityStateToPB(s domain.MergeabilityState) pb.MergeabilityState {
154 switch s {
155 case domain.MergeabilityChecking:
156 return pb.MergeabilityState_MERGEABILITY_STATE_CHECKING
157 case domain.MergeabilityUpToDate:
158 return pb.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE
159 case domain.MergeabilityBehind:
160 return pb.MergeabilityState_MERGEABILITY_STATE_BEHIND
161 case domain.MergeabilityConflict:
162 return pb.MergeabilityState_MERGEABILITY_STATE_CONFLICT
163 }
164 return pb.MergeabilityState_MERGEABILITY_STATE_UNSPECIFIED
165}
166
167func mergeabilityToPB(m domain.Mergeability) *pb.Mergeability {
168 return &pb.Mergeability{
169 State: mergeabilityStateToPB(m.State),
170 TargetId: m.TargetID.String(),
171 HeadId: m.HeadID.String(),
172 Behind: int32(m.Behind), //nolint:gosec // capped at domain.MaxBehindCount
173 BehindCapped: m.BehindCapped,
174 ConflictPaths: m.ConflictPaths,
175 ConflictPathsTruncated: m.ConflictPathsTruncated,
176 CheckedAt: timestamp(m.CheckedAt),
177 }
178}
179
180func commentToPB(c domain.Comment) *pb.Comment {
181 return &pb.Comment{
182 Id: strconv.FormatInt(c.ID, 10),
183 Author: c.Author,
184 Path: c.Path,
185 Line: int32(c.Line), //nolint:gosec // a line of a diff, validated against it
186 Side: diffSideToPB(c.Side),
187 CommitId: c.CommitID.String(),
188 Text: c.Text.String(),
189 Resolved: c.Resolved,
190 CreatedAt: timestamp(c.CreatedAt),
191 ReplyTo: optionalCommentID(c.ReplyTo),
192 StartLine: int32(c.StartLine), //nolint:gosec // validated diff line
193 Verdict: verdictToPB(c.Verdict), EditedAt: timestamp(c.EditedAt), Deleted: c.Deleted,
194 }
195}
196
197func optionalCommentID(id int64) string {
198 if id == 0 {
199 return ""
200 }
201 return strconv.FormatInt(id, 10)
202}
203
204// commentID parses the opaque id of a comment. One that this server never
205// issued is 0, which names no comment.
206func commentID(s string) int64 {
207 n, err := strconv.ParseInt(s, 10, 64)
208 if err != nil || n < 1 || strconv.FormatInt(n, 10) != s {
209 return 0
210 }
211 return n
212}
213
214// headID parses a commit id of a request; the empty string is the zero id.
215func headID(s string) (domain.ObjectID, error) {
216 if s == "" {
217 return domain.ObjectID{}, nil
218 }
219 var v domain.Violations
220 id, err := domain.ParseObjectID(s)
221 if err != nil {
222 v.Add("head_id", "is not a full commit id")
223 }
224 return id, v.Err()
225}
226
227func (s *mergeRequestService) CreateMergeRequest(ctx context.Context, req *connect.Request[pb.CreateMergeRequestRequest]) (*connect.Response[pb.CreateMergeRequestResponse], error) {
228 name, err := domain.ParseRepoName(req.Msg.GetRepo())
229 if err != nil {
230 return nil, connectError(ctx, err)
231 }
232 mr, err := s.mrs.Create(ctx, caller(ctx), name, ports.CreateMergeRequestParams{
233 Title: req.Msg.GetTitle(), Description: req.Msg.GetDescription(),
234 Source: req.Msg.GetSourceBranch(), Target: req.Msg.GetTargetBranch(),
235 })
236 if err != nil {
237 return nil, connectError(ctx, err)
238 }
239 return connect.NewResponse(&pb.CreateMergeRequestResponse{MergeRequest: mergeRequestToPB(mr)}), nil
240}
241
242func (s *mergeRequestService) ListMergeRequests(ctx context.Context, req *connect.Request[pb.ListMergeRequestsRequest]) (*connect.Response[pb.ListMergeRequestsResponse], error) {
243 name, err := domain.ParseRepoName(req.Msg.GetRepo())
244 if err != nil {
245 return nil, connectError(ctx, err)
246 }
247 state, err := mergeRequestStateFromPB(req.Msg.GetState())
248 if err != nil {
249 return nil, connectError(ctx, err)
250 }
251 page, err := s.mrs.List(ctx, caller(ctx), name, ports.ListMergeRequestsParams{
252 State: state, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
253 })
254 if err != nil {
255 return nil, connectError(ctx, err)
256 }
257 out := &pb.ListMergeRequestsResponse{NextPageToken: page.NextPageToken, MergeRequests: make([]*pb.MergeRequest, 0, len(page.MergeRequests))}
258 for _, mr := range page.MergeRequests {
259 out.MergeRequests = append(out.MergeRequests, mergeRequestWithMergeabilityToPB(mr, page.Mergeability[mr.Number]))
260 }
261 return connect.NewResponse(out), nil
262}
263
264func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect.Request[pb.GetMergeRequestRequest]) (*connect.Response[pb.GetMergeRequestResponse], error) {
265 name, err := domain.ParseRepoName(req.Msg.GetRepo())
266 if err != nil {
267 return nil, connectError(ctx, err)
268 }
269 d, err := s.mrs.Get(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.GetMergeRequestOptions{SinceLastRead: req.Msg.GetSinceLastRead()})
270 if err != nil {
271 return nil, connectError(ctx, err)
272 }
273 out := &pb.GetMergeRequestResponse{
274 MergeRequest: mergeRequestWithMergeabilityToPB(d.MergeRequest, d.Mergeability),
275 CommitsTruncated: d.CommitsTruncated,
276 DiffBaseId: d.DiffBaseID.String(),
277 Stats: diffStatsToPB(d.Stats),
278 Diff: diffToPB(d.Diff),
279 LastReadId: d.LastReadID.String(),
280 LastReadLost: d.LastReadLost,
281 }
282 for _, c := range d.Commits {
283 out.Commits = append(out.Commits, commitToPB(c))
284 }
285 for _, c := range d.Comments {
286 out.Comments = append(out.Comments, commentToPB(c))
287 }
288 for _, v := range d.Verdicts {
289 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})
290 }
291 return connect.NewResponse(out), nil
292}
293
294func (s *mergeRequestService) AddComment(ctx context.Context, req *connect.Request[pb.AddCommentRequest]) (*connect.Response[pb.AddCommentResponse], error) {
295 name, err := domain.ParseRepoName(req.Msg.GetRepo())
296 if err != nil {
297 return nil, connectError(ctx, err)
298 }
299 side, err := diffSideFromPB(req.Msg.GetSide())
300 if err != nil {
301 return nil, connectError(ctx, err)
302 }
303 verdict, err := verdictFromPB(req.Msg.GetVerdict())
304 if err != nil {
305 return nil, connectError(ctx, err)
306 }
307 replyTo := commentID(req.Msg.GetReplyTo())
308 if req.Msg.GetReplyTo() != "" && replyTo == 0 {
309 var v domain.Violations
310 v.Add("reply_to", "is not a note id")
311 return nil, connectError(ctx, v.Err())
312 }
313 c, err := s.mrs.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.AddCommentParams{
314 ReplyTo: replyTo, StartLine: int(req.Msg.GetStartLine()), Verdict: verdict,
315 Path: req.Msg.GetPath(), Line: int(req.Msg.GetLine()), Side: side, Text: req.Msg.GetText(),
316 })
317 if err != nil {
318 return nil, connectError(ctx, err)
319 }
320 return connect.NewResponse(&pb.AddCommentResponse{Comment: commentToPB(c)}), nil
321}
322
323func (s *mergeRequestService) ResolveComment(ctx context.Context, req *connect.Request[pb.ResolveCommentRequest]) (*connect.Response[pb.ResolveCommentResponse], error) {
324 name, err := domain.ParseRepoName(req.Msg.GetRepo())
325 if err != nil {
326 return nil, connectError(ctx, err)
327 }
328 // An id this server never issued is 0: the use case answers not found, after
329 // the repository and the scope were checked, as for any unknown comment.
330 c, err := s.mrs.ResolveComment(ctx, caller(ctx), name, req.Msg.GetNumber(), commentID(req.Msg.GetCommentId()), req.Msg.GetResolved())
331 if err != nil {
332 return nil, connectError(ctx, err)
333 }
334 return connect.NewResponse(&pb.ResolveCommentResponse{Comment: commentToPB(c)}), nil
335}
336
337func (s *mergeRequestService) CloseMergeRequest(ctx context.Context, req *connect.Request[pb.CloseMergeRequestRequest]) (*connect.Response[pb.CloseMergeRequestResponse], error) {
338 name, err := domain.ParseRepoName(req.Msg.GetRepo())
339 if err != nil {
340 return nil, connectError(ctx, err)
341 }
342 mr, err := s.mrs.Close(ctx, caller(ctx), name, req.Msg.GetNumber())
343 if err != nil {
344 return nil, connectError(ctx, err)
345 }
346 return connect.NewResponse(&pb.CloseMergeRequestResponse{MergeRequest: mergeRequestToPB(mr)}), nil
347}
348
349func (s *mergeRequestService) MergeMergeRequest(ctx context.Context, req *connect.Request[pb.MergeMergeRequestRequest]) (*connect.Response[pb.MergeMergeRequestResponse], error) {
350 name, err := domain.ParseRepoName(req.Msg.GetRepo())
351 if err != nil {
352 return nil, connectError(ctx, err)
353 }
354 mode, err := mergeModeFromPB(req.Msg.GetMode())
355 if err != nil {
356 return nil, connectError(ctx, err)
357 }
358 head, err := headID(req.Msg.GetHeadId())
359 if err != nil {
360 return nil, connectError(ctx, err)
361 }
362 res, err := s.mrs.Merge(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.MergeParams{
363 Mode: mode, HeadID: head, DeleteSource: req.Msg.GetDeleteSourceBranch(),
364 })
365 if err != nil {
366 return nil, connectError(ctx, err)
367 }
368 return connect.NewResponse(&pb.MergeMergeRequestResponse{
369 MergeRequest: mergeRequestToPB(res.MergeRequest), SourceBranchDeleted: res.SourceDeleted,
370 }), nil
371}
372
373func (s *mergeRequestService) MarkRead(ctx context.Context, req *connect.Request[pb.MarkReadRequest]) (*connect.Response[pb.MarkReadResponse], error) {
374 name, err := domain.ParseRepoName(req.Msg.GetRepo())
375 if err != nil {
376 return nil, connectError(ctx, err)
377 }
378 head, err := headID(req.Msg.GetHeadId())
379 if err != nil {
380 return nil, connectError(ctx, err)
381 }
382 if _, err := s.mrs.MarkRead(ctx, caller(ctx), name, req.Msg.GetNumber(), head); err != nil {
383 return nil, connectError(ctx, err)
384 }
385 return connect.NewResponse(&pb.MarkReadResponse{}), nil
386}