detached fda3c2b

mr_api_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6	"strings"
7	"testing"
8	"time"
9
10	pb "bnema.dev/ermite-api/gen/ermite"
11	"bnema.dev/ermite/internal/domain"
12	"bnema.dev/ermite/internal/ports"
13	"bnema.dev/ermite/internal/ports/mocks"
14	"connectrpc.com/connect"
15	"github.com/stretchr/testify/assert"
16	"github.com/stretchr/testify/mock"
17	"github.com/stretchr/testify/require"
18)
19
20func TestNoteRPCFields(t *testing.T) {
21	now := time.Now().UTC()
22	note := domain.Comment{ID: 4, ReplyTo: 2, StartLine: 10, Line: 24, Verdict: domain.VerdictApprove, EditedAt: now, Deleted: true}
23	got := commentToPB(note)
24	assert.Equal(t, "2", got.GetReplyTo())
25	assert.EqualValues(t, 10, got.GetStartLine())
26	assert.Equal(t, pb.Verdict_VERDICT_APPROVE, got.GetVerdict())
27	assert.Equal(t, now, got.GetEditedAt().AsTime())
28	assert.True(t, got.GetDeleted())
29	assert.Empty(t, commentToPB(domain.Comment{}).GetReplyTo())
30	assert.Nil(t, commentToPB(domain.Comment{}).GetEditedAt())
31	for _, v := range []domain.Verdict{domain.VerdictNone, domain.VerdictApprove, domain.VerdictChangesRequested} {
32		back, err := verdictFromPB(verdictToPB(v))
33		require.NoError(t, err)
34		assert.Equal(t, v, back)
35	}
36	m := mocks.NewMergeRequestService(t)
37	m.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), ports.AddCommentParams{Text: "note", StartLine: 10, Line: 24, Verdict: domain.VerdictApprove}).Return(note, nil).Once()
38	res, err := (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, Text: "note", StartLine: 10, Line: 24, Verdict: pb.Verdict_VERDICT_APPROVE}))
39	require.NoError(t, err)
40	assert.Equal(t, got, res.Msg.GetComment())
41	_, err = (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Verdict: pb.Verdict(99)}))
42	requireCode(t, connect.CodeInvalidArgument, err)
43}
44
45func TestNoteMutationRPCCodes(t *testing.T) {
46	for _, edit := range []bool{false, true} {
47		for _, failure := range []error{nil, domain.ErrForbidden, domain.ErrPreconditionFailed, domain.ErrNotFound} {
48			m := mocks.NewMergeRequestService(t)
49			note := domain.Comment{ID: 2, Deleted: !edit}
50			var err error
51			if edit {
52				m.EXPECT().EditComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), int64(2), "new").Return(note, failure).Once()
53				_, err = (&mergeRequestService{m}).EditComment(ownerCtx(), connect.NewRequest(&pb.EditCommentRequest{Repo: "demo", Number: 1, CommentId: "2", Text: "new"}))
54			} else {
55				m.EXPECT().DeleteComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), int64(2)).Return(note, failure).Once()
56				_, err = (&mergeRequestService{m}).DeleteComment(ownerCtx(), connect.NewRequest(&pb.DeleteCommentRequest{Repo: "demo", Number: 1, CommentId: "2"}))
57			}
58			switch failure {
59			case nil:
60				require.NoError(t, err)
61			case domain.ErrForbidden:
62				requireCode(t, connect.CodePermissionDenied, err)
63			case domain.ErrPreconditionFailed:
64				requireCode(t, connect.CodeFailedPrecondition, err)
65			case domain.ErrNotFound:
66				requireCode(t, connect.CodeNotFound, err)
67			}
68		}
69	}
70}
71
72func TestNoteEventsRPC(t *testing.T) {
73	repo := mustRepoName(t, "demo")
74	note := domain.Comment{ID: 3, Verdict: domain.VerdictApprove, Deleted: true}
75	at := time.Now().UTC()
76	events := []domain.Event{
77		domain.CommentEdited{Repo: repo, Number: 1, Comment: note, At: at},
78		domain.CommentDeleted{Repo: repo, Number: 1, Comment: note, At: at},
79		domain.CommentResolved{Repo: repo, Number: 1, Comment: note, At: at},
80	}
81	for i, ev := range events {
82		got := watchEventToPB(ports.WatchEvent{ID: "e", Event: ev})
83		require.NotNil(t, got)
84		assert.Equal(t, at, got.GetTime().AsTime())
85		var c *pb.Comment
86		switch i {
87		case 0:
88			c = got.GetCommentEdited().GetComment()
89		case 1:
90			c = got.GetCommentDeleted().GetComment()
91		case 2:
92			c = got.GetCommentResolved().GetComment()
93		}
94		assert.Equal(t, "3", c.GetId())
95		assert.True(t, c.GetDeleted())
96		assert.Equal(t, pb.Verdict_VERDICT_APPROVE, c.GetVerdict())
97	}
98}
99
100func TestNoteReplyIDValidation(t *testing.T) {
101	for _, id := range []string{"abc", "0", "-1", "01", "9223372036854775808"} {
102		_, err := (&mergeRequestService{mocks.NewMergeRequestService(t)}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, ReplyTo: id, Text: "reply"}))
103		requireCode(t, connect.CodeInvalidArgument, err)
104		assert.Contains(t, err.Error(), "reply_to")
105		assert.Contains(t, err.Error(), "is not a note id")
106	}
107	m := mocks.NewMergeRequestService(t)
108	m.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), ports.AddCommentParams{ReplyTo: 2, Text: "reply"}).Return(domain.Comment{ID: 3, ReplyTo: 2}, nil).Once()
109	res, err := (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, ReplyTo: "2", Text: "reply"}))
110	require.NoError(t, err)
111	assert.Equal(t, "2", res.Msg.GetComment().GetReplyTo())
112}
113
114func TestNoteVerdictsDetailAndScope(t *testing.T) {
115	m := mocks.NewMergeRequestService(t)
116	m.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), ports.GetMergeRequestOptions{}).Return(ports.MergeRequestDetail{Verdicts: []domain.AuthorVerdict{{Author: "owner", Verdict: domain.VerdictApprove, CommentID: 4, Stale: true}}}, nil).Once()
117	res, err := (&mergeRequestService{m}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 1}))
118	require.NoError(t, err)
119	require.Len(t, res.Msg.GetVerdicts(), 1)
120	assert.Equal(t, "4", res.Msg.GetVerdicts()[0].GetCommentId())
121	assert.True(t, res.Msg.GetVerdicts()[0].GetStale())
122	m.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), ports.AddCommentParams{Verdict: domain.VerdictApprove}).Return(domain.Comment{}, domain.ErrForbidden).Once()
123	_, err = (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, Verdict: pb.Verdict_VERDICT_APPROVE}))
124	requireCode(t, connect.CodePermissionDenied, err)
125}
126
127func TestAutoMergeRPCCodesAndFields(t *testing.T) {
128	now := time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC)
129	head, err := domain.ParseObjectID(strings.Repeat("a", 40))
130	require.NoError(t, err)
131	armed := domain.MergeRequest{
132		Repo: mustRepoName(t, "demo"), Number: 1, State: domain.MRStateOpen, Source: "feat", Target: "main", HeadID: head,
133		AutoMerge: &domain.AutoMerge{Mode: domain.MergeModeSquash, HeadID: head, EnablerLabel: "owner", EnabledAt: now},
134	}
135	idle := armed
136	idle.AutoMerge = nil
137
138	pbArmed := mergeRequestToPB(armed)
139	require.NotNil(t, pbArmed.GetAutoMerge())
140	assert.Equal(t, pb.MergeMode_MERGE_MODE_SQUASH, pbArmed.GetAutoMerge().GetMode())
141	assert.Equal(t, head.String(), pbArmed.GetAutoMerge().GetHeadId())
142	assert.Equal(t, "owner", pbArmed.GetAutoMerge().GetEnabledBy())
143	assert.Equal(t, now, pbArmed.GetAutoMerge().GetEnabledAt().AsTime())
144	assert.False(t, pbArmed.GetAutoMerged())
145	assert.Nil(t, mergeRequestToPB(idle).GetAutoMerge())
146	done := idle
147	done.State, done.AutoMerged = domain.MRStateMerged, true
148	assert.True(t, mergeRequestToPB(done).GetAutoMerged())
149
150	// Success: the mode and the request reach the service, the fields come back.
151	m := mocks.NewMergeRequestService(t)
152	m.EXPECT().EnableAutoMerge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), ports.EnableAutoMergeParams{Mode: domain.MergeModeSquash, HeadID: mustObjectID(t, strings.Repeat("a", domain.ObjectIDLen))}).Return(armed, nil).Once()
153	res, err := (&mergeRequestService{m}).EnableAutoMerge(ownerCtx(), connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: "demo", Number: 1, Mode: pb.MergeMode_MERGE_MODE_SQUASH, HeadId: strings.Repeat("a", domain.ObjectIDLen)}))
154	require.NoError(t, err)
155	assert.Equal(t, pbArmed, res.Msg.GetMergeRequest())
156	m.EXPECT().CancelAutoMerge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1)).Return(idle, nil).Once()
157	cres, err := (&mergeRequestService{m}).CancelAutoMerge(ownerCtx(), connect.NewRequest(&pb.CancelAutoMergeRequest{Repo: "demo", Number: 1}))
158	require.NoError(t, err)
159	assert.Nil(t, cres.Msg.GetMergeRequest().GetAutoMerge())
160
161	// Bad input never reaches the service.
162	_, err = (&mergeRequestService{m}).EnableAutoMerge(ownerCtx(), connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: "demo", Number: 1, Mode: pb.MergeMode(99), HeadId: strings.Repeat("a", domain.ObjectIDLen)}))
163	requireCode(t, connect.CodeInvalidArgument, err)
164	_, err = (&mergeRequestService{m}).EnableAutoMerge(ownerCtx(), connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: "demo", Number: 1, HeadId: "abc"}))
165	requireCode(t, connect.CodeInvalidArgument, err)
166	_, err = (&mergeRequestService{m}).CancelAutoMerge(ownerCtx(), connect.NewRequest(&pb.CancelAutoMergeRequest{Repo: "", Number: 1}))
167	requireCode(t, connect.CodeInvalidArgument, err)
168
169	// Domain errors map like the other merge request calls.
170	for _, tc := range []struct {
171		err  error
172		code connect.Code
173	}{
174		{domain.ErrPreconditionFailed, connect.CodeFailedPrecondition},
175		{domain.ErrForbidden, connect.CodePermissionDenied},
176		{domain.ErrNotFound, connect.CodeNotFound},
177	} {
178		m := mocks.NewMergeRequestService(t)
179		m.EXPECT().EnableAutoMerge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), mock.Anything).Return(domain.MergeRequest{}, tc.err).Once()
180		_, err := (&mergeRequestService{m}).EnableAutoMerge(ownerCtx(), connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: "demo", Number: 1, HeadId: strings.Repeat("a", domain.ObjectIDLen)}))
181		requireCode(t, tc.code, err)
182		m.EXPECT().CancelAutoMerge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1)).Return(domain.MergeRequest{}, tc.err).Once()
183		_, err = (&mergeRequestService{m}).CancelAutoMerge(ownerCtx(), connect.NewRequest(&pb.CancelAutoMergeRequest{Repo: "demo", Number: 1}))
184		requireCode(t, tc.code, err)
185	}
186}
187
188func TestAutoMergeWatchEventsAndSettingsToPB(t *testing.T) {
189	armed := domain.MergeRequest{Repo: mustRepoName(t, "demo"), Number: 1, State: domain.MRStateOpen,
190		AutoMerge: &domain.AutoMerge{Mode: domain.MergeModeMerge, EnablerLabel: "owner"}}
191	got := watchEventToPB(ports.WatchEvent{ID: "1", Event: domain.MergeRequestAutoMergeEnabled{MergeRequest: armed}})
192	require.NotNil(t, got.GetMergeRequestAutoMergeEnabled())
193	assert.Equal(t, pb.MergeMode_MERGE_MODE_MERGE_COMMIT, got.GetMergeRequestAutoMergeEnabled().GetMergeRequest().GetAutoMerge().GetMode())
194	got = watchEventToPB(ports.WatchEvent{ID: "2", Event: domain.MergeRequestAutoMergeCancelled{MergeRequest: armed, Reason: "the branch moved"}})
195	require.NotNil(t, got.GetMergeRequestAutoMergeCancelled())
196	assert.Equal(t, "the branch moved", got.GetMergeRequestAutoMergeCancelled().GetReason())
197	got = watchEventToPB(ports.WatchEvent{ID: "3", Event: domain.MergeRequestMerged{MergeRequest: armed, Auto: true}})
198	assert.True(t, got.GetMergeRequestMerged().GetAuto())
199	got = watchEventToPB(ports.WatchEvent{ID: "4", Event: domain.MergeRequestMerged{MergeRequest: armed}})
200	assert.False(t, got.GetMergeRequestMerged().GetAuto())
201
202	assert.True(t, settingsToPB(domain.RepoSettings{AllowAutoMerge: true}).GetAllowAutoMerge())
203	assert.False(t, settingsToPB(domain.RepoSettings{}).GetAllowAutoMerge())
204}

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