detached aa64b62

fix(mr): recheck the reply target when a note save is retried

commit
aa64b62ba28ff6b206beae157e940b28f30d8871
author
brice · · 2026-10-19 05:34 UTC
parent
b31a354
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +41 -12
M internal/usecase/mergerequests.go +28 -12
@@ -594,20 +594,11 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
594594 return domain.Comment{}, err
595595 }
596596 if params.ReplyTo != 0 {
597- root, err := m.store.GetComment(ctx, name, number, params.ReplyTo)
598- switch {
599- case errors.Is(err, domain.ErrNotFound):
600- v.Add("reply_to", fmt.Sprintf("no note ^%d", params.ReplyTo))
601- case err != nil:
602- return domain.Comment{}, err
603- case root.IsReply() || root.Deleted:
604- v.Add("reply_to", "must name a non-deleted root note")
605- default:
606- params.Path, params.Line, params.StartLine, params.Side = root.Path, root.Line, root.StartLine, root.Side
607- }
608- if err := v.Err(); err != nil {
597+ root, err := m.replyRoot(ctx, name, number, params.ReplyTo)
598+ if err != nil {
609599 return domain.Comment{}, err
610600 }
601+ params.Path, params.Line, params.StartLine, params.Side = root.Path, root.Line, root.StartLine, root.Side
611602 }
612603 if params.Verdict != domain.VerdictNone {
613604 if err := scopeAccess(p, repo, domain.ScopeMRReview); err != nil {
@@ -650,6 +641,12 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
650641 if !fresh.IsOpen() {
651642 return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, fresh.State)
652643 }
644+ if params.ReplyTo != 0 {
645+ // The root may have been deleted by the write that got in between.
646+ if _, rerr := m.replyRoot(ctx, name, number, params.ReplyTo); rerr != nil {
647+ return domain.Comment{}, rerr
648+ }
649+ }
653650 fresh.HeadID = mr.HeadID
654651 mr = fresh
655652 }
@@ -1241,3 +1238,22 @@ func (m *MergeRequests) changeComment(ctx context.Context, p domain.Principal, n
12411238 }
12421239 return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)
12431240 }
1241+
1242+// replyRoot returns the note a reply targets, or a violation when it is
1243+// missing, itself a reply, or deleted.
1244+func (m *MergeRequests) replyRoot(ctx context.Context, name domain.RepoName, number, id int64) (domain.Comment, error) {
1245+ root, err := m.store.GetComment(ctx, name, number, id)
1246+ var v domain.Violations
1247+ switch {
1248+ case errors.Is(err, domain.ErrNotFound):
1249+ v.Add("reply_to", fmt.Sprintf("no note ^%d", id))
1250+ case err != nil:
1251+ return domain.Comment{}, err
1252+ case root.IsReply() || root.Deleted:
1253+ v.Add("reply_to", "must name a non-deleted root note")
1254+ }
1255+ if err := v.Err(); err != nil {
1256+ return domain.Comment{}, err
1257+ }
1258+ return root, nil
1259+}
@@ -594,20 +594,11 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
594 return domain.Comment{}, err594 return domain.Comment{}, err
595 }595 }
596 if params.ReplyTo != 0 {596 if params.ReplyTo != 0 {
597- root, err := m.store.GetComment(ctx, name, number, params.ReplyTo)597+ root, err := m.replyRoot(ctx, name, number, params.ReplyTo)
598- switch {598+ if err != nil {
599- case errors.Is(err, domain.ErrNotFound):
600- v.Add("reply_to", fmt.Sprintf("no note ^%d", params.ReplyTo))
601- case err != nil:
602- return domain.Comment{}, err
603- case root.IsReply() || root.Deleted:
604- v.Add("reply_to", "must name a non-deleted root note")
605- default:
606- params.Path, params.Line, params.StartLine, params.Side = root.Path, root.Line, root.StartLine, root.Side
607- }
608- if err := v.Err(); err != nil {
609 return domain.Comment{}, err599 return domain.Comment{}, err
610 }600 }
601+ params.Path, params.Line, params.StartLine, params.Side = root.Path, root.Line, root.StartLine, root.Side
611 }602 }
612 if params.Verdict != domain.VerdictNone {603 if params.Verdict != domain.VerdictNone {
613 if err := scopeAccess(p, repo, domain.ScopeMRReview); err != nil {604 if err := scopeAccess(p, repo, domain.ScopeMRReview); err != nil {
@@ -650,6 +641,12 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
650 if !fresh.IsOpen() {641 if !fresh.IsOpen() {
651 return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, fresh.State)642 return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, fresh.State)
652 }643 }
644+ if params.ReplyTo != 0 {
645+ // The root may have been deleted by the write that got in between.
646+ if _, rerr := m.replyRoot(ctx, name, number, params.ReplyTo); rerr != nil {
647+ return domain.Comment{}, rerr
648+ }
649+ }
653 fresh.HeadID = mr.HeadID650 fresh.HeadID = mr.HeadID
654 mr = fresh651 mr = fresh
655 }652 }
@@ -1241,3 +1238,22 @@ func (m *MergeRequests) changeComment(ctx context.Context, p domain.Principal, n
1241 }1238 }
1242 return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)1239 return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)
1243 }1240 }
1241+
1242+// replyRoot returns the note a reply targets, or a violation when it is
1243+// missing, itself a reply, or deleted.
1244+func (m *MergeRequests) replyRoot(ctx context.Context, name domain.RepoName, number, id int64) (domain.Comment, error) {
1245+ root, err := m.store.GetComment(ctx, name, number, id)
1246+ var v domain.Violations
1247+ switch {
1248+ case errors.Is(err, domain.ErrNotFound):
1249+ v.Add("reply_to", fmt.Sprintf("no note ^%d", id))
1250+ case err != nil:
1251+ return domain.Comment{}, err
1252+ case root.IsReply() || root.Deleted:
1253+ v.Add("reply_to", "must name a non-deleted root note")
1254+ }
1255+ if err := v.Err(); err != nil {
1256+ return domain.Comment{}, err
1257+ }
1258+ return root, nil
1259+}
M internal/usecase/mergerequests_test.go +13 -0
@@ -152,6 +152,19 @@ func TestNoteMutationRetries(t *testing.T) {
152152 }
153153 }
154154
155+// A reply whose root is deleted while the save is retried is rejected.
156+func TestAddNoteRechecksReplyRootOnConflict(t *testing.T) {
157+ u := newMRUnit(t)
158+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
159+ u.git.EXPECT().BranchHead(mock.Anything, mock.Anything, mock.Anything).Return(domain.ObjectID{}, domain.ErrNotFound)
160+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{State: domain.MRStateOpen}, nil).Twice()
161+ u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(domain.Comment{ID: 1}, nil).Once()
162+ u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(domain.Comment{ID: 1, Deleted: true}, nil).Once()
163+ u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
164+ _, err := u.mrs.AddComment(context.Background(), domain.Root(), u.repo.Name, 1, ports.AddCommentParams{ReplyTo: 1, Text: "x"})
165+ require.ErrorIs(t, err, domain.ErrInvalid)
166+}
167+
155168 func TestAddNoteValidation(t *testing.T) {
156169 for _, tc := range []struct {
157170 name string
@@ -152,6 +152,19 @@ func TestNoteMutationRetries(t *testing.T) {
152 }152 }
153 }153 }
154 154
155+// A reply whose root is deleted while the save is retried is rejected.
156+func TestAddNoteRechecksReplyRootOnConflict(t *testing.T) {
157+ u := newMRUnit(t)
158+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
159+ u.git.EXPECT().BranchHead(mock.Anything, mock.Anything, mock.Anything).Return(domain.ObjectID{}, domain.ErrNotFound)
160+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{State: domain.MRStateOpen}, nil).Twice()
161+ u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(domain.Comment{ID: 1}, nil).Once()
162+ u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(domain.Comment{ID: 1, Deleted: true}, nil).Once()
163+ u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
164+ _, err := u.mrs.AddComment(context.Background(), domain.Root(), u.repo.Name, 1, ports.AddCommentParams{ReplyTo: 1, Text: "x"})
165+ require.ErrorIs(t, err, domain.ErrInvalid)
166+}
167+
155 func TestAddNoteValidation(t *testing.T) {168 func TestAddNoteValidation(t *testing.T) {
156 for _, tc := range []struct {169 for _, tc := range []struct {
157 name string170 name string

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