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) EnableAutoMerge(ctx context.Context, req *connect.Request[pb.EnableAutoMergeRequest]) (*connect.Response[pb.EnableAutoMergeResponse], error) {
23 name, err := domain.ParseRepoName(req.Msg.GetRepo())
24 if err != nil {
25 return nil, connectError(ctx, err)
26 }
27 mode, err := mergeModeFromPB(req.Msg.GetMode())
28 if err != nil {
29 return nil, connectError(ctx, err)
30 }
31 head, err := headID(req.Msg.GetHeadId())
32 if err != nil {
33 return nil, connectError(ctx, err)
34 }
35 mr, err := s.mrs.EnableAutoMerge(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.EnableAutoMergeParams{Mode: mode, HeadID: head})
36 if err != nil {
37 return nil, connectError(ctx, err)
38 }
39 return connect.NewResponse(&pb.EnableAutoMergeResponse{MergeRequest: mergeRequestToPB(mr)}), nil
40}
41
42func (s *mergeRequestService) CancelAutoMerge(ctx context.Context, req *connect.Request[pb.CancelAutoMergeRequest]) (*connect.Response[pb.CancelAutoMergeResponse], error) {
43 name, err := domain.ParseRepoName(req.Msg.GetRepo())
44 if err != nil {
45 return nil, connectError(ctx, err)
46 }
47 mr, err := s.mrs.CancelAutoMerge(ctx, caller(ctx), name, req.Msg.GetNumber())
48 if err != nil {
49 return nil, connectError(ctx, err)
50 }
51 return connect.NewResponse(&pb.CancelAutoMergeResponse{MergeRequest: mergeRequestToPB(mr)}), nil
52}
53
54func (s *mergeRequestService) EditComment(ctx context.Context, req *connect.Request[pb.EditCommentRequest]) (*connect.Response[pb.EditCommentResponse], error) {
55 name, err := domain.ParseRepoName(req.Msg.GetRepo())
56 if err != nil {
57 return nil, connectError(ctx, err)
58 }
59 c, err := s.mrs.EditComment(ctx, caller(ctx), name, req.Msg.GetNumber(), commentID(req.Msg.GetCommentId()), req.Msg.GetText())
60 if err != nil {
61 return nil, connectError(ctx, err)
62 }
63 return connect.NewResponse(&pb.EditCommentResponse{Comment: commentToPB(c)}), nil
64}
65
66func (s *mergeRequestService) DeleteComment(ctx context.Context, req *connect.Request[pb.DeleteCommentRequest]) (*connect.Response[pb.DeleteCommentResponse], error) {
67 name, err := domain.ParseRepoName(req.Msg.GetRepo())
68 if err != nil {
69 return nil, connectError(ctx, err)
70 }
71 c, err := s.mrs.DeleteComment(ctx, caller(ctx), name, req.Msg.GetNumber(), commentID(req.Msg.GetCommentId()))
72 if err != nil {
73 return nil, connectError(ctx, err)
74 }
75 return connect.NewResponse(&pb.DeleteCommentResponse{Comment: commentToPB(c)}), nil
76}
77
78func verdictToPB(v domain.Verdict) pb.Verdict {
79 switch v {
80 case domain.VerdictApprove:
81 return pb.Verdict_VERDICT_APPROVE
82 case domain.VerdictChangesRequested:
83 return pb.Verdict_VERDICT_CHANGES_REQUESTED
84 }
85 return pb.Verdict_VERDICT_UNSPECIFIED
86}
87
88func verdictFromPB(v pb.Verdict) (domain.Verdict, error) {
89 switch v {
90 case pb.Verdict_VERDICT_UNSPECIFIED:
91 return domain.VerdictNone, nil
92 case pb.Verdict_VERDICT_APPROVE:
93 return domain.VerdictApprove, nil
94 case pb.Verdict_VERDICT_CHANGES_REQUESTED:
95 return domain.VerdictChangesRequested, nil
96 }
97 return domain.VerdictNone, fmt.Errorf("%w: verdict %v", domain.ErrInvalid, v)
98}
99
100func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState {
101 switch s {
102 case domain.MRStateOpen:
103 return pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN
104 case domain.MRStateMerged:
105 return pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED
106 case domain.MRStateClosed:
107 return pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED
108 }
109 return pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED
110}
111
112// mergeRequestStateFromPB maps the state filter of a listing; unspecified is
113// the empty state, which means every state.
114func mergeRequestStateFromPB(s pb.MergeRequestState) (domain.MRState, error) {
115 switch s {
116 case pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED:
117 return "", nil
118 case pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN:
119 return domain.MRStateOpen, nil
120 case pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED:
121 return domain.MRStateMerged, nil
122 case pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED:
123 return domain.MRStateClosed, nil
124 }
125 return "", fmt.Errorf("%w: merge request state %v", domain.ErrInvalid, s)
126}
127
128func diffSideToPB(s domain.DiffSide) pb.DiffSide {
129 switch s {
130 case domain.SideOld:
131 return pb.DiffSide_DIFF_SIDE_OLD
132 case domain.SideNew:
133 return pb.DiffSide_DIFF_SIDE_NEW
134 }
135 return pb.DiffSide_DIFF_SIDE_UNSPECIFIED
136}
137
138func diffSideFromPB(s pb.DiffSide) (domain.DiffSide, error) {
139 switch s {
140 case pb.DiffSide_DIFF_SIDE_UNSPECIFIED:
141 return domain.SideNone, nil
142 case pb.DiffSide_DIFF_SIDE_OLD:
143 return domain.SideOld, nil
144 case pb.DiffSide_DIFF_SIDE_NEW:
145 return domain.SideNew, nil
146 }
147 return "", fmt.Errorf("%w: diff side %v", domain.ErrInvalid, s)
148}
149
150// mergeRequestToPB maps a request. An open one carries the CHECKING
151// mergeability: the server has no result in hand here; Get and List override
152// it with the computed one.
153func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest {
154 out := &pb.MergeRequest{
155 Repo: mr.Repo.String(),
156 Number: mr.Number,
157 Title: mr.Title.String(),
158 Description: mr.Description.String(),
159 SourceBranch: mr.Source,
160 TargetBranch: mr.Target,
161 State: mergeRequestStateToPB(mr.State),
162 Author: mr.Author,
163 CreatedAt: timestamp(mr.CreatedAt),
164 UpdatedAt: timestamp(mr.UpdatedAt),
165 HeadId: mr.HeadID.String(),
166 MergedCommitId: mr.MergedCommitID.String(),
167 MergeMode: mergeModeToPB(mr.MergeMode),
168 AutoMerged: mr.AutoMerged,
169 }
170 if am := mr.AutoMerge; am != nil {
171 out.AutoMerge = &pb.AutoMerge{
172 Mode: mergeModeToPB(am.Mode), HeadId: am.HeadID.String(),
173 EnabledBy: am.EnablerLabel, EnabledAt: timestamp(am.EnabledAt),
174 }
175 }
176 if mr.IsOpen() {
177 out.Mergeability = &pb.Mergeability{State: pb.MergeabilityState_MERGEABILITY_STATE_CHECKING}
178 }
179 return out
180}
181
182// mergeRequestWithMergeabilityToPB is mergeRequestToPB with the mergeability of
183// an open request; m is ignored for a request that is not open.
184func mergeRequestWithMergeabilityToPB(mr domain.MergeRequest, m domain.Mergeability) *pb.MergeRequest {
185 out := mergeRequestToPB(mr)
186 if mr.IsOpen() && m.State != "" {
187 out.Mergeability = mergeabilityToPB(m)
188 }
189 return out
190}
191
192func mergeabilityStateToPB(s domain.MergeabilityState) pb.MergeabilityState {
193 switch s {
194 case domain.MergeabilityChecking:
195 return pb.MergeabilityState_MERGEABILITY_STATE_CHECKING
196 case domain.MergeabilityUpToDate:
197 return pb.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE
198 case domain.MergeabilityBehind:
199 return pb.MergeabilityState_MERGEABILITY_STATE_BEHIND
200 case domain.MergeabilityConflict:
201 return pb.MergeabilityState_MERGEABILITY_STATE_CONFLICT
202 }
203 return pb.MergeabilityState_MERGEABILITY_STATE_UNSPECIFIED
204}
205
206func mergeabilityToPB(m domain.Mergeability) *pb.Mergeability {
207 return &pb.Mergeability{
208 State: mergeabilityStateToPB(m.State),
209 TargetId: m.TargetID.String(),
210 HeadId: m.HeadID.String(),
211 Behind: int32(m.Behind), //nolint:gosec // capped at domain.MaxBehindCount
212 BehindCapped: m.BehindCapped,
213 ConflictPaths: m.ConflictPaths,
214 ConflictPathsTruncated: m.ConflictPathsTruncated,
215 CheckedAt: timestamp(m.CheckedAt),
216 }
217}
218
219func commentToPB(c domain.Comment) *pb.Comment {
220 return &pb.Comment{
221 Id: strconv.FormatInt(c.ID, 10),
222 Author: c.Author,
223 Path: c.Path,
224 Line: int32(c.Line), //nolint:gosec // a line of a diff, validated against it
225 Side: diffSideToPB(c.Side),
226 CommitId: c.CommitID.String(),
227 Text: c.Text.String(),
228 Resolved: c.Resolved,
229 CreatedAt: timestamp(c.CreatedAt),
230 ReplyTo: optionalCommentID(c.ReplyTo),
231 StartLine: int32(c.StartLine), //nolint:gosec // validated diff line
232 Verdict: verdictToPB(c.Verdict), EditedAt: timestamp(c.EditedAt), Deleted: c.Deleted,
233 }
234}
235
236func optionalCommentID(id int64) string {
237 if id == 0 {
238 return ""
239 }
240 return strconv.FormatInt(id, 10)
241}
242
243// commentID parses the opaque id of a comment. One that this server never
244// issued is 0, which names no comment.
245func commentID(s string) int64 {
246 n, err := strconv.ParseInt(s, 10, 64)
247 if err != nil || n < 1 || strconv.FormatInt(n, 10) != s {
248 return 0
249 }
250 return n
251}
252
253// headID parses a commit id of a request; the empty string is the zero id.
254func headID(s string) (domain.ObjectID, error) {
255 if s == "" {
256 return domain.ObjectID{}, nil
257 }
258 var v domain.Violations
259 id, err := domain.ParseObjectID(s)
260 if err != nil {
261 v.Add("head_id", "is not a full commit id")
262 }
263 return id, v.Err()
264}
265
266func (s *mergeRequestService) CreateMergeRequest(ctx context.Context, req *connect.Request[pb.CreateMergeRequestRequest]) (*connect.Response[pb.CreateMergeRequestResponse], error) {
267 name, err := domain.ParseRepoName(req.Msg.GetRepo())
268 if err != nil {
269 return nil, connectError(ctx, err)
270 }
271 mr, err := s.mrs.Create(ctx, caller(ctx), name, ports.CreateMergeRequestParams{
272 Title: req.Msg.GetTitle(), Description: req.Msg.GetDescription(),
273 Source: req.Msg.GetSourceBranch(), Target: req.Msg.GetTargetBranch(),
274 })
275 if err != nil {
276 return nil, connectError(ctx, err)
277 }
278 return connect.NewResponse(&pb.CreateMergeRequestResponse{MergeRequest: mergeRequestToPB(mr)}), nil
279}
280
281func (s *mergeRequestService) ListMergeRequests(ctx context.Context, req *connect.Request[pb.ListMergeRequestsRequest]) (*connect.Response[pb.ListMergeRequestsResponse], error) {
282 name, err := domain.ParseRepoName(req.Msg.GetRepo())
283 if err != nil {
284 return nil, connectError(ctx, err)
285 }
286 state, err := mergeRequestStateFromPB(req.Msg.GetState())
287 if err != nil {
288 return nil, connectError(ctx, err)
289 }
290 page, err := s.mrs.List(ctx, caller(ctx), name, ports.ListMergeRequestsParams{
291 State: state, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
292 })
293 if err != nil {
294 return nil, connectError(ctx, err)
295 }
296 out := &pb.ListMergeRequestsResponse{NextPageToken: page.NextPageToken, MergeRequests: make([]*pb.MergeRequest, 0, len(page.MergeRequests))}
297 for _, mr := range page.MergeRequests {
298 out.MergeRequests = append(out.MergeRequests, mergeRequestWithMergeabilityToPB(mr, page.Mergeability[mr.Number]))
299 }
300 return connect.NewResponse(out), nil
301}
302
303func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect.Request[pb.GetMergeRequestRequest]) (*connect.Response[pb.GetMergeRequestResponse], error) {
304 name, err := domain.ParseRepoName(req.Msg.GetRepo())
305 if err != nil {
306 return nil, connectError(ctx, err)
307 }
308 d, err := s.mrs.Get(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.GetMergeRequestOptions{SinceLastRead: req.Msg.GetSinceLastRead()})
309 if err != nil {
310 return nil, connectError(ctx, err)
311 }
312 out := &pb.GetMergeRequestResponse{
313 MergeRequest: mergeRequestWithMergeabilityToPB(d.MergeRequest, d.Mergeability),
314 CommitsTruncated: d.CommitsTruncated,
315 DiffBaseId: d.DiffBaseID.String(),
316 Stats: diffStatsToPB(d.Stats),
317 Diff: diffToPB(d.Diff),
318 LastReadId: d.LastReadID.String(),
319 LastReadLost: d.LastReadLost,
320 }
321 for _, c := range d.Commits {
322 out.Commits = append(out.Commits, commitToPB(c))
323 }
324 for _, c := range d.Comments {
325 out.Comments = append(out.Comments, commentToPB(c))
326 }
327 for _, v := range d.Verdicts {
328 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})
329 }
330 return connect.NewResponse(out), nil
331}
332
333func (s *mergeRequestService) AddComment(ctx context.Context, req *connect.Request[pb.AddCommentRequest]) (*connect.Response[pb.AddCommentResponse], error) {
334 name, err := domain.ParseRepoName(req.Msg.GetRepo())
335 if err != nil {
336 return nil, connectError(ctx, err)
337 }
338 side, err := diffSideFromPB(req.Msg.GetSide())
339 if err != nil {
340 return nil, connectError(ctx, err)
341 }
342 verdict, err := verdictFromPB(req.Msg.GetVerdict())
343 if err != nil {
344 return nil, connectError(ctx, err)
345 }
346 replyTo := commentID(req.Msg.GetReplyTo())
347 if req.Msg.GetReplyTo() != "" && replyTo == 0 {
348 var v domain.Violations
349 v.Add("reply_to", "is not a note id")
350 return nil, connectError(ctx, v.Err())
351 }
352 c, err := s.mrs.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.AddCommentParams{
353 ReplyTo: replyTo, StartLine: int(req.Msg.GetStartLine()), Verdict: verdict,
354 Path: req.Msg.GetPath(), Line: int(req.Msg.GetLine()), Side: side, Text: req.Msg.GetText(),
355 })
356 if err != nil {
357 return nil, connectError(ctx, err)
358 }
359 return connect.NewResponse(&pb.AddCommentResponse{Comment: commentToPB(c)}), nil
360}
361
362func (s *mergeRequestService) ResolveComment(ctx context.Context, req *connect.Request[pb.ResolveCommentRequest]) (*connect.Response[pb.ResolveCommentResponse], error) {
363 name, err := domain.ParseRepoName(req.Msg.GetRepo())
364 if err != nil {
365 return nil, connectError(ctx, err)
366 }
367 // An id this server never issued is 0: the use case answers not found, after
368 // the repository and the scope were checked, as for any unknown comment.
369 c, err := s.mrs.ResolveComment(ctx, caller(ctx), name, req.Msg.GetNumber(), commentID(req.Msg.GetCommentId()), req.Msg.GetResolved())
370 if err != nil {
371 return nil, connectError(ctx, err)
372 }
373 return connect.NewResponse(&pb.ResolveCommentResponse{Comment: commentToPB(c)}), nil
374}
375
376func (s *mergeRequestService) CloseMergeRequest(ctx context.Context, req *connect.Request[pb.CloseMergeRequestRequest]) (*connect.Response[pb.CloseMergeRequestResponse], error) {
377 name, err := domain.ParseRepoName(req.Msg.GetRepo())
378 if err != nil {
379 return nil, connectError(ctx, err)
380 }
381 mr, err := s.mrs.Close(ctx, caller(ctx), name, req.Msg.GetNumber())
382 if err != nil {
383 return nil, connectError(ctx, err)
384 }
385 return connect.NewResponse(&pb.CloseMergeRequestResponse{MergeRequest: mergeRequestToPB(mr)}), nil
386}
387
388func (s *mergeRequestService) MergeMergeRequest(ctx context.Context, req *connect.Request[pb.MergeMergeRequestRequest]) (*connect.Response[pb.MergeMergeRequestResponse], error) {
389 name, err := domain.ParseRepoName(req.Msg.GetRepo())
390 if err != nil {
391 return nil, connectError(ctx, err)
392 }
393 mode, err := mergeModeFromPB(req.Msg.GetMode())
394 if err != nil {
395 return nil, connectError(ctx, err)
396 }
397 head, err := headID(req.Msg.GetHeadId())
398 if err != nil {
399 return nil, connectError(ctx, err)
400 }
401 res, err := s.mrs.Merge(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.MergeParams{
402 Mode: mode, HeadID: head, DeleteSource: req.Msg.GetDeleteSourceBranch(), IgnoreChecks: req.Msg.GetIgnoreChecks(),
403 })
404 if err != nil {
405 return nil, connectError(ctx, err)
406 }
407 return connect.NewResponse(&pb.MergeMergeRequestResponse{
408 MergeRequest: mergeRequestToPB(res.MergeRequest), SourceBranchDeleted: res.SourceDeleted,
409 }), nil
410}
411
412func (s *mergeRequestService) MarkRead(ctx context.Context, req *connect.Request[pb.MarkReadRequest]) (*connect.Response[pb.MarkReadResponse], error) {
413 name, err := domain.ParseRepoName(req.Msg.GetRepo())
414 if err != nil {
415 return nil, connectError(ctx, err)
416 }
417 head, err := headID(req.Msg.GetHeadId())
418 if err != nil {
419 return nil, connectError(ctx, err)
420 }
421 if _, err := s.mrs.MarkRead(ctx, caller(ctx), name, req.Msg.GetNumber(), head); err != nil {
422 return nil, connectError(ctx, err)
423 }
424 return connect.NewResponse(&pb.MarkReadResponse{}), nil
425}
426
427func (s *mergeRequestService) MarkReady(ctx context.Context, req *connect.Request[pb.MarkReadyRequest]) (*connect.Response[pb.MarkReadyResponse], error) {
428 name, err := domain.ParseRepoName(req.Msg.GetRepo())
429 if err != nil {
430 return nil, connectError(ctx, err)
431 }
432 mr, err := s.mrs.MarkReady(ctx, caller(ctx), name, req.Msg.GetNumber())
433 if err != nil {
434 return nil, connectError(ctx, err)
435 }
436 return connect.NewResponse(&pb.MarkReadyResponse{MergeRequest: mergeRequestToPB(mr)}), nil
437}