v0.24.0 166837a

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

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 4

Tags 45

↑↓ moveenter openesc close