v0.30.0 c899179

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

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