detached 59d5a0b

mergerequest.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close