detached 0e6522c

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{ 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}

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