feat(cli): replies, ranges, verdicts, edit and delete for merge request notes
- commit
- 8def6de7519f526a5b59085f451ec68bc181c9f3
- author
- brice · · 2026-10-19 05:43 UTC
- parent
- b31a354
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +619 -40
M internal/adapters/rpc/convert.go +9 -0
| @@ -472,6 +472,15 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event { | |||
| 472 | 472 | case domain.CommentAdded: | |
| 473 | 473 | out.Time = timestamp(ev.At) | |
| 474 | 474 | out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}} | |
| 475 | + | case domain.CommentEdited: | |
| 476 | + | out.Time = timestamp(ev.At) | |
| 477 | + | out.Kind = &pb.Event_CommentEdited{CommentEdited: &pb.CommentEdited{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}} | |
| 478 | + | case domain.CommentDeleted: | |
| 479 | + | out.Time = timestamp(ev.At) | |
| 480 | + | out.Kind = &pb.Event_CommentDeleted{CommentDeleted: &pb.CommentDeleted{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}} | |
| 481 | + | case domain.CommentResolved: | |
| 482 | + | out.Time = timestamp(ev.At) | |
| 483 | + | out.Kind = &pb.Event_CommentResolved{CommentResolved: &pb.CommentResolved{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}} | |
| 475 | 484 | case domain.RunUpdated: | |
| 476 | 485 | out.Time = timestamp(ev.At) | |
| 477 | 486 | out.Kind = runEventToPB(ev) | |
| @@ -472,6 +472,15 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event { | |||||
| 472 | case domain.CommentAdded: | 472 | case domain.CommentAdded: | ||
| 473 | out.Time = timestamp(ev.At) | 473 | out.Time = timestamp(ev.At) | ||
| 474 | out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}} | 474 | out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}} | ||
| 475 | + | case domain.CommentEdited: | |||
| 476 | + | out.Time = timestamp(ev.At) | |||
| 477 | + | out.Kind = &pb.Event_CommentEdited{CommentEdited: &pb.CommentEdited{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}} | |||
| 478 | + | case domain.CommentDeleted: | |||
| 479 | + | out.Time = timestamp(ev.At) | |||
| 480 | + | out.Kind = &pb.Event_CommentDeleted{CommentDeleted: &pb.CommentDeleted{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}} | |||
| 481 | + | case domain.CommentResolved: | |||
| 482 | + | out.Time = timestamp(ev.At) | |||
| 483 | + | out.Kind = &pb.Event_CommentResolved{CommentResolved: &pb.CommentResolved{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}} | |||
| 475 | case domain.RunUpdated: | 484 | case domain.RunUpdated: | ||
| 476 | out.Time = timestamp(ev.At) | 485 | out.Time = timestamp(ev.At) | ||
| 477 | out.Kind = runEventToPB(ev) | 486 | out.Kind = runEventToPB(ev) | ||
M internal/adapters/rpc/mergerequest.go +66 -6
| @@ -17,14 +17,50 @@ import ( | |||
| 17 | 17 | // mergeRequestService implements ermiteconnect.MergeRequestServiceHandler. | |
| 18 | 18 | type mergeRequestService struct{ mrs ports.MergeRequestService } | |
| 19 | 19 | ||
| 20 | - | // EditComment is implemented with the note RPC adapter in phase P4a. | |
| 21 | - | func (s *mergeRequestService) EditComment(context.Context, *connect.Request[pb.EditCommentRequest]) (*connect.Response[pb.EditCommentResponse], error) { | |
| 22 | - | return nil, connect.NewError(connect.CodeUnimplemented, fmt.Errorf("EditComment is not implemented")) | |
| 20 | + | func (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 | + | ||
| 32 | + | func (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 | |
| 23 | 42 | } | |
| 24 | 43 | ||
| 25 | - | // DeleteComment is implemented with the note RPC adapter in phase P4a. | |
| 26 | - | func (s *mergeRequestService) DeleteComment(context.Context, *connect.Request[pb.DeleteCommentRequest]) (*connect.Response[pb.DeleteCommentResponse], error) { | |
| 27 | - | return nil, connect.NewError(connect.CodeUnimplemented, fmt.Errorf("DeleteComment is not implemented")) | |
| 44 | + | func 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 | + | ||
| 54 | + | func 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) | |
| 28 | 64 | } | |
| 29 | 65 | ||
| 30 | 66 | func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState { | |
| @@ -106,9 +142,19 @@ func commentToPB(c domain.Comment) *pb.Comment { | |||
| 106 | 142 | Text: c.Text.String(), | |
| 107 | 143 | Resolved: c.Resolved, | |
| 108 | 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, | |
| 109 | 148 | } | |
| 110 | 149 | } | |
| 111 | 150 | ||
| 151 | + | func optionalCommentID(id int64) string { | |
| 152 | + | if id == 0 { | |
| 153 | + | return "" | |
| 154 | + | } | |
| 155 | + | return strconv.FormatInt(id, 10) | |
| 156 | + | } | |
| 157 | + | ||
| 112 | 158 | // commentID parses the opaque id of a comment. One that this server never | |
| 113 | 159 | // issued is 0, which names no comment. | |
| 114 | 160 | func commentID(s string) int64 { | |
| @@ -195,6 +241,9 @@ func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect. | |||
| 195 | 241 | for _, c := range d.Comments { | |
| 196 | 242 | out.Comments = append(out.Comments, commentToPB(c)) | |
| 197 | 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 | + | } | |
| 198 | 247 | return connect.NewResponse(out), nil | |
| 199 | 248 | } | |
| 200 | 249 | ||
| @@ -207,7 +256,18 @@ func (s *mergeRequestService) AddComment(ctx context.Context, req *connect.Reque | |||
| 207 | 256 | if err != nil { | |
| 208 | 257 | return nil, connectError(ctx, err) | |
| 209 | 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 | + | } | |
| 210 | 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, | |
| 211 | 271 | Path: req.Msg.GetPath(), Line: int(req.Msg.GetLine()), Side: side, Text: req.Msg.GetText(), | |
| 212 | 272 | }) | |
| 213 | 273 | if err != nil { | |
| @@ -17,14 +17,50 @@ import ( | |||||
| 17 | // mergeRequestService implements ermiteconnect.MergeRequestServiceHandler. | 17 | // mergeRequestService implements ermiteconnect.MergeRequestServiceHandler. | ||
| 18 | type mergeRequestService struct{ mrs ports.MergeRequestService } | 18 | type mergeRequestService struct{ mrs ports.MergeRequestService } | ||
| 19 | 19 | ||||
| 20 | - | // EditComment is implemented with the note RPC adapter in phase P4a. | 20 | + | func (s *mergeRequestService) EditComment(ctx context.Context, req *connect.Request[pb.EditCommentRequest]) (*connect.Response[pb.EditCommentResponse], error) { |
| 21 | - | func (s *mergeRequestService) EditComment(context.Context, *connect.Request[pb.EditCommentRequest]) (*connect.Response[pb.EditCommentResponse], error) { | 21 | + | name, err := domain.ParseRepoName(req.Msg.GetRepo()) |
| 22 | - | return nil, connect.NewError(connect.CodeUnimplemented, fmt.Errorf("EditComment is not implemented")) | 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 | + | ||||
| 32 | + | func (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 | |||
| 23 | } | 42 | } | ||
| 24 | 43 | ||||
| 25 | - | // DeleteComment is implemented with the note RPC adapter in phase P4a. | 44 | + | func verdictToPB(v domain.Verdict) pb.Verdict { |
| 26 | - | func (s *mergeRequestService) DeleteComment(context.Context, *connect.Request[pb.DeleteCommentRequest]) (*connect.Response[pb.DeleteCommentResponse], error) { | 45 | + | switch v { |
| 27 | - | return nil, connect.NewError(connect.CodeUnimplemented, fmt.Errorf("DeleteComment is not implemented")) | 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 | + | ||||
| 54 | + | func 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) | |||
| 28 | } | 64 | } | ||
| 29 | 65 | ||||
| 30 | func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState { | 66 | func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState { | ||
| @@ -106,9 +142,19 @@ func commentToPB(c domain.Comment) *pb.Comment { | |||||
| 106 | Text: c.Text.String(), | 142 | Text: c.Text.String(), | ||
| 107 | Resolved: c.Resolved, | 143 | Resolved: c.Resolved, | ||
| 108 | CreatedAt: timestamp(c.CreatedAt), | 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, | |||
| 109 | } | 148 | } | ||
| 110 | } | 149 | } | ||
| 111 | 150 | ||||
| 151 | + | func optionalCommentID(id int64) string { | |||
| 152 | + | if id == 0 { | |||
| 153 | + | return "" | |||
| 154 | + | } | |||
| 155 | + | return strconv.FormatInt(id, 10) | |||
| 156 | + | } | |||
| 157 | + | ||||
| 112 | // commentID parses the opaque id of a comment. One that this server never | 158 | // commentID parses the opaque id of a comment. One that this server never | ||
| 113 | // issued is 0, which names no comment. | 159 | // issued is 0, which names no comment. | ||
| 114 | func commentID(s string) int64 { | 160 | func commentID(s string) int64 { | ||
| @@ -195,6 +241,9 @@ func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect. | |||||
| 195 | for _, c := range d.Comments { | 241 | for _, c := range d.Comments { | ||
| 196 | out.Comments = append(out.Comments, commentToPB(c)) | 242 | out.Comments = append(out.Comments, commentToPB(c)) | ||
| 197 | } | 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 | + | } | |||
| 198 | return connect.NewResponse(out), nil | 247 | return connect.NewResponse(out), nil | ||
| 199 | } | 248 | } | ||
| 200 | 249 | ||||
| @@ -207,7 +256,18 @@ func (s *mergeRequestService) AddComment(ctx context.Context, req *connect.Reque | |||||
| 207 | if err != nil { | 256 | if err != nil { | ||
| 208 | return nil, connectError(ctx, err) | 257 | return nil, connectError(ctx, err) | ||
| 209 | } | 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 | + | } | |||
| 210 | c, err := s.mrs.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.AddCommentParams{ | 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, | |||
| 211 | Path: req.Msg.GetPath(), Line: int(req.Msg.GetLine()), Side: side, Text: req.Msg.GetText(), | 271 | Path: req.Msg.GetPath(), Line: int(req.Msg.GetLine()), Side: side, Text: req.Msg.GetText(), | ||
| 212 | }) | 272 | }) | ||
| 213 | if err != nil { | 273 | if err != nil { | ||
A internal/adapters/rpc/mr_api_test.go +124 -0
| @@ -0,0 +1,124 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package rpc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 13 | + | "connectrpc.com/connect" | |
| 14 | + | "github.com/stretchr/testify/assert" | |
| 15 | + | "github.com/stretchr/testify/mock" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func TestNoteRPCFields(t *testing.T) { | |
| 20 | + | now := time.Now().UTC() | |
| 21 | + | note := domain.Comment{ID: 4, ReplyTo: 2, StartLine: 10, Line: 24, Verdict: domain.VerdictApprove, EditedAt: now, Deleted: true} | |
| 22 | + | got := commentToPB(note) | |
| 23 | + | assert.Equal(t, "2", got.GetReplyTo()) | |
| 24 | + | assert.EqualValues(t, 10, got.GetStartLine()) | |
| 25 | + | assert.Equal(t, pb.Verdict_VERDICT_APPROVE, got.GetVerdict()) | |
| 26 | + | assert.Equal(t, now, got.GetEditedAt().AsTime()) | |
| 27 | + | assert.True(t, got.GetDeleted()) | |
| 28 | + | assert.Empty(t, commentToPB(domain.Comment{}).GetReplyTo()) | |
| 29 | + | assert.Nil(t, commentToPB(domain.Comment{}).GetEditedAt()) | |
| 30 | + | for _, v := range []domain.Verdict{domain.VerdictNone, domain.VerdictApprove, domain.VerdictChangesRequested} { | |
| 31 | + | back, err := verdictFromPB(verdictToPB(v)) | |
| 32 | + | require.NoError(t, err) | |
| 33 | + | assert.Equal(t, v, back) | |
| 34 | + | } | |
| 35 | + | m := mocks.NewMergeRequestService(t) | |
| 36 | + | 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() | |
| 37 | + | 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})) | |
| 38 | + | require.NoError(t, err) | |
| 39 | + | assert.Equal(t, got, res.Msg.GetComment()) | |
| 40 | + | _, err = (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Verdict: pb.Verdict(99)})) | |
| 41 | + | requireCode(t, connect.CodeInvalidArgument, err) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestNoteMutationRPCCodes(t *testing.T) { | |
| 45 | + | for _, edit := range []bool{false, true} { | |
| 46 | + | for _, failure := range []error{nil, domain.ErrForbidden, domain.ErrPreconditionFailed, domain.ErrNotFound} { | |
| 47 | + | m := mocks.NewMergeRequestService(t) | |
| 48 | + | note := domain.Comment{ID: 2, Deleted: !edit} | |
| 49 | + | var err error | |
| 50 | + | if edit { | |
| 51 | + | m.EXPECT().EditComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), int64(2), "new").Return(note, failure).Once() | |
| 52 | + | _, err = (&mergeRequestService{m}).EditComment(ownerCtx(), connect.NewRequest(&pb.EditCommentRequest{Repo: "demo", Number: 1, CommentId: "2", Text: "new"})) | |
| 53 | + | } else { | |
| 54 | + | m.EXPECT().DeleteComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), int64(2)).Return(note, failure).Once() | |
| 55 | + | _, err = (&mergeRequestService{m}).DeleteComment(ownerCtx(), connect.NewRequest(&pb.DeleteCommentRequest{Repo: "demo", Number: 1, CommentId: "2"})) | |
| 56 | + | } | |
| 57 | + | switch failure { | |
| 58 | + | case nil: | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | case domain.ErrForbidden: | |
| 61 | + | requireCode(t, connect.CodePermissionDenied, err) | |
| 62 | + | case domain.ErrPreconditionFailed: | |
| 63 | + | requireCode(t, connect.CodeFailedPrecondition, err) | |
| 64 | + | case domain.ErrNotFound: | |
| 65 | + | requireCode(t, connect.CodeNotFound, err) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestNoteEventsRPC(t *testing.T) { | |
| 72 | + | repo := mustRepoName(t, "demo") | |
| 73 | + | note := domain.Comment{ID: 3, Verdict: domain.VerdictApprove, Deleted: true} | |
| 74 | + | at := time.Now().UTC() | |
| 75 | + | events := []domain.Event{ | |
| 76 | + | domain.CommentEdited{Repo: repo, Number: 1, Comment: note, At: at}, | |
| 77 | + | domain.CommentDeleted{Repo: repo, Number: 1, Comment: note, At: at}, | |
| 78 | + | domain.CommentResolved{Repo: repo, Number: 1, Comment: note, At: at}, | |
| 79 | + | } | |
| 80 | + | for i, ev := range events { | |
| 81 | + | got := watchEventToPB(ports.WatchEvent{ID: "e", Event: ev}) | |
| 82 | + | require.NotNil(t, got) | |
| 83 | + | assert.Equal(t, at, got.GetTime().AsTime()) | |
| 84 | + | var c *pb.Comment | |
| 85 | + | switch i { | |
| 86 | + | case 0: | |
| 87 | + | c = got.GetCommentEdited().GetComment() | |
| 88 | + | case 1: | |
| 89 | + | c = got.GetCommentDeleted().GetComment() | |
| 90 | + | case 2: | |
| 91 | + | c = got.GetCommentResolved().GetComment() | |
| 92 | + | } | |
| 93 | + | assert.Equal(t, "3", c.GetId()) | |
| 94 | + | assert.True(t, c.GetDeleted()) | |
| 95 | + | assert.Equal(t, pb.Verdict_VERDICT_APPROVE, c.GetVerdict()) | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestNoteReplyIDValidation(t *testing.T) { | |
| 100 | + | for _, id := range []string{"abc", "0", "-1", "01", "9223372036854775808"} { | |
| 101 | + | _, err := (&mergeRequestService{mocks.NewMergeRequestService(t)}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, ReplyTo: id, Text: "reply"})) | |
| 102 | + | requireCode(t, connect.CodeInvalidArgument, err) | |
| 103 | + | assert.Contains(t, err.Error(), "reply_to") | |
| 104 | + | assert.Contains(t, err.Error(), "is not a note id") | |
| 105 | + | } | |
| 106 | + | m := mocks.NewMergeRequestService(t) | |
| 107 | + | 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() | |
| 108 | + | res, err := (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, ReplyTo: "2", Text: "reply"})) | |
| 109 | + | require.NoError(t, err) | |
| 110 | + | assert.Equal(t, "2", res.Msg.GetComment().GetReplyTo()) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestNoteVerdictsDetailAndScope(t *testing.T) { | |
| 114 | + | m := mocks.NewMergeRequestService(t) | |
| 115 | + | 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() | |
| 116 | + | res, err := (&mergeRequestService{m}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 1})) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | require.Len(t, res.Msg.GetVerdicts(), 1) | |
| 119 | + | assert.Equal(t, "4", res.Msg.GetVerdicts()[0].GetCommentId()) | |
| 120 | + | assert.True(t, res.Msg.GetVerdicts()[0].GetStale()) | |
| 121 | + | m.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), ports.AddCommentParams{Verdict: domain.VerdictApprove}).Return(domain.Comment{}, domain.ErrForbidden).Once() | |
| 122 | + | _, err = (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, Verdict: pb.Verdict_VERDICT_APPROVE})) | |
| 123 | + | requireCode(t, connect.CodePermissionDenied, err) | |
| 124 | + | } | |
| @@ -0,0 +1,124 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package rpc | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | "bnema.dev/ermite/internal/ports" | |||
| 12 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 13 | + | "connectrpc.com/connect" | |||
| 14 | + | "github.com/stretchr/testify/assert" | |||
| 15 | + | "github.com/stretchr/testify/mock" | |||
| 16 | + | "github.com/stretchr/testify/require" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func TestNoteRPCFields(t *testing.T) { | |||
| 20 | + | now := time.Now().UTC() | |||
| 21 | + | note := domain.Comment{ID: 4, ReplyTo: 2, StartLine: 10, Line: 24, Verdict: domain.VerdictApprove, EditedAt: now, Deleted: true} | |||
| 22 | + | got := commentToPB(note) | |||
| 23 | + | assert.Equal(t, "2", got.GetReplyTo()) | |||
| 24 | + | assert.EqualValues(t, 10, got.GetStartLine()) | |||
| 25 | + | assert.Equal(t, pb.Verdict_VERDICT_APPROVE, got.GetVerdict()) | |||
| 26 | + | assert.Equal(t, now, got.GetEditedAt().AsTime()) | |||
| 27 | + | assert.True(t, got.GetDeleted()) | |||
| 28 | + | assert.Empty(t, commentToPB(domain.Comment{}).GetReplyTo()) | |||
| 29 | + | assert.Nil(t, commentToPB(domain.Comment{}).GetEditedAt()) | |||
| 30 | + | for _, v := range []domain.Verdict{domain.VerdictNone, domain.VerdictApprove, domain.VerdictChangesRequested} { | |||
| 31 | + | back, err := verdictFromPB(verdictToPB(v)) | |||
| 32 | + | require.NoError(t, err) | |||
| 33 | + | assert.Equal(t, v, back) | |||
| 34 | + | } | |||
| 35 | + | m := mocks.NewMergeRequestService(t) | |||
| 36 | + | 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() | |||
| 37 | + | 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})) | |||
| 38 | + | require.NoError(t, err) | |||
| 39 | + | assert.Equal(t, got, res.Msg.GetComment()) | |||
| 40 | + | _, err = (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Verdict: pb.Verdict(99)})) | |||
| 41 | + | requireCode(t, connect.CodeInvalidArgument, err) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestNoteMutationRPCCodes(t *testing.T) { | |||
| 45 | + | for _, edit := range []bool{false, true} { | |||
| 46 | + | for _, failure := range []error{nil, domain.ErrForbidden, domain.ErrPreconditionFailed, domain.ErrNotFound} { | |||
| 47 | + | m := mocks.NewMergeRequestService(t) | |||
| 48 | + | note := domain.Comment{ID: 2, Deleted: !edit} | |||
| 49 | + | var err error | |||
| 50 | + | if edit { | |||
| 51 | + | m.EXPECT().EditComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), int64(2), "new").Return(note, failure).Once() | |||
| 52 | + | _, err = (&mergeRequestService{m}).EditComment(ownerCtx(), connect.NewRequest(&pb.EditCommentRequest{Repo: "demo", Number: 1, CommentId: "2", Text: "new"})) | |||
| 53 | + | } else { | |||
| 54 | + | m.EXPECT().DeleteComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), int64(2)).Return(note, failure).Once() | |||
| 55 | + | _, err = (&mergeRequestService{m}).DeleteComment(ownerCtx(), connect.NewRequest(&pb.DeleteCommentRequest{Repo: "demo", Number: 1, CommentId: "2"})) | |||
| 56 | + | } | |||
| 57 | + | switch failure { | |||
| 58 | + | case nil: | |||
| 59 | + | require.NoError(t, err) | |||
| 60 | + | case domain.ErrForbidden: | |||
| 61 | + | requireCode(t, connect.CodePermissionDenied, err) | |||
| 62 | + | case domain.ErrPreconditionFailed: | |||
| 63 | + | requireCode(t, connect.CodeFailedPrecondition, err) | |||
| 64 | + | case domain.ErrNotFound: | |||
| 65 | + | requireCode(t, connect.CodeNotFound, err) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestNoteEventsRPC(t *testing.T) { | |||
| 72 | + | repo := mustRepoName(t, "demo") | |||
| 73 | + | note := domain.Comment{ID: 3, Verdict: domain.VerdictApprove, Deleted: true} | |||
| 74 | + | at := time.Now().UTC() | |||
| 75 | + | events := []domain.Event{ | |||
| 76 | + | domain.CommentEdited{Repo: repo, Number: 1, Comment: note, At: at}, | |||
| 77 | + | domain.CommentDeleted{Repo: repo, Number: 1, Comment: note, At: at}, | |||
| 78 | + | domain.CommentResolved{Repo: repo, Number: 1, Comment: note, At: at}, | |||
| 79 | + | } | |||
| 80 | + | for i, ev := range events { | |||
| 81 | + | got := watchEventToPB(ports.WatchEvent{ID: "e", Event: ev}) | |||
| 82 | + | require.NotNil(t, got) | |||
| 83 | + | assert.Equal(t, at, got.GetTime().AsTime()) | |||
| 84 | + | var c *pb.Comment | |||
| 85 | + | switch i { | |||
| 86 | + | case 0: | |||
| 87 | + | c = got.GetCommentEdited().GetComment() | |||
| 88 | + | case 1: | |||
| 89 | + | c = got.GetCommentDeleted().GetComment() | |||
| 90 | + | case 2: | |||
| 91 | + | c = got.GetCommentResolved().GetComment() | |||
| 92 | + | } | |||
| 93 | + | assert.Equal(t, "3", c.GetId()) | |||
| 94 | + | assert.True(t, c.GetDeleted()) | |||
| 95 | + | assert.Equal(t, pb.Verdict_VERDICT_APPROVE, c.GetVerdict()) | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func TestNoteReplyIDValidation(t *testing.T) { | |||
| 100 | + | for _, id := range []string{"abc", "0", "-1", "01", "9223372036854775808"} { | |||
| 101 | + | _, err := (&mergeRequestService{mocks.NewMergeRequestService(t)}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, ReplyTo: id, Text: "reply"})) | |||
| 102 | + | requireCode(t, connect.CodeInvalidArgument, err) | |||
| 103 | + | assert.Contains(t, err.Error(), "reply_to") | |||
| 104 | + | assert.Contains(t, err.Error(), "is not a note id") | |||
| 105 | + | } | |||
| 106 | + | m := mocks.NewMergeRequestService(t) | |||
| 107 | + | 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() | |||
| 108 | + | res, err := (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, ReplyTo: "2", Text: "reply"})) | |||
| 109 | + | require.NoError(t, err) | |||
| 110 | + | assert.Equal(t, "2", res.Msg.GetComment().GetReplyTo()) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestNoteVerdictsDetailAndScope(t *testing.T) { | |||
| 114 | + | m := mocks.NewMergeRequestService(t) | |||
| 115 | + | 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() | |||
| 116 | + | res, err := (&mergeRequestService{m}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 1})) | |||
| 117 | + | require.NoError(t, err) | |||
| 118 | + | require.Len(t, res.Msg.GetVerdicts(), 1) | |||
| 119 | + | assert.Equal(t, "4", res.Msg.GetVerdicts()[0].GetCommentId()) | |||
| 120 | + | assert.True(t, res.Msg.GetVerdicts()[0].GetStale()) | |||
| 121 | + | m.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), ports.AddCommentParams{Verdict: domain.VerdictApprove}).Return(domain.Comment{}, domain.ErrForbidden).Once() | |||
| 122 | + | _, err = (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, Verdict: pb.Verdict_VERDICT_APPROVE})) | |||
| 123 | + | requireCode(t, connect.CodePermissionDenied, err) | |||
| 124 | + | } | |||
M internal/app/cli_mr.go +221 -25
| @@ -141,12 +141,28 @@ type jsonComment struct { | |||
| 141 | 141 | Text string `json:"text"` | |
| 142 | 142 | Resolved bool `json:"resolved"` | |
| 143 | 143 | CreatedAt *string `json:"created_at"` | |
| 144 | + | ReplyTo string `json:"reply_to"` | |
| 145 | + | StartLine int32 `json:"start_line"` | |
| 146 | + | Verdict string `json:"verdict"` | |
| 147 | + | EditedAt *string `json:"edited_at"` | |
| 148 | + | Deleted bool `json:"deleted"` | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func verdictName(v ermite.Verdict) string { | |
| 152 | + | switch v { | |
| 153 | + | case ermite.Verdict_VERDICT_APPROVE: | |
| 154 | + | return "approve" | |
| 155 | + | case ermite.Verdict_VERDICT_CHANGES_REQUESTED: | |
| 156 | + | return "changes_requested" | |
| 157 | + | } | |
| 158 | + | return "" | |
| 144 | 159 | } | |
| 145 | 160 | ||
| 146 | 161 | func toJSONComment(c *ermite.Comment) jsonComment { | |
| 147 | 162 | return jsonComment{ | |
| 148 | 163 | ID: c.GetId(), Author: c.GetAuthor(), Path: c.GetPath(), Line: c.GetLine(), Side: sideName(c.GetSide()), | |
| 149 | 164 | CommitID: c.GetCommitId(), Text: c.GetText(), Resolved: c.GetResolved(), CreatedAt: jsonTime(c.GetCreatedAt()), | |
| 165 | + | ReplyTo: c.GetReplyTo(), StartLine: c.GetStartLine(), Verdict: verdictName(c.GetVerdict()), EditedAt: jsonTime(c.GetEditedAt()), Deleted: c.GetDeleted(), | |
| 150 | 166 | } | |
| 151 | 167 | } | |
| 152 | 168 | ||
| @@ -160,7 +176,8 @@ unless merged).` | |||
| 160 | 176 | const jsonCommentHelp = ` | |
| 161 | 177 | JSON fields of a comment: id, author, path, line and side ("old" or "new"; empty path, line 0 | |
| 162 | 178 | and empty side for a general comment), commit_id (the head when it was written), text, | |
| 163 | - | resolved, created_at.` | |
| 179 | + | resolved, created_at, reply_to (empty for roots), start_line (0 for a single line), | |
| 180 | + | verdict ("", "approve", "changes_requested"), edited_at (nullable RFC 3339), deleted.` | |
| 164 | 181 | ||
| 165 | 182 | func newMRCmd(env *cliEnv) *cobra.Command { | |
| 166 | 183 | mr := &cobra.Command{ | |
| @@ -172,7 +189,7 @@ func newMRCmd(env *cliEnv) *cobra.Command { | |||
| 172 | 189 | } | |
| 173 | 190 | mr.AddCommand( | |
| 174 | 191 | newMRCreateCmd(env), newMRListCmd(env), newMRShowCmd(env), newMRCommentCmd(env), | |
| 175 | - | newMRResolveCmd(env), newMRMergeCmd(env), newMRCloseCmd(env), | |
| 192 | + | newMRResolveCmd(env), newMREditCommentCmd(env), newMRDeleteCommentCmd(env), newMRMergeCmd(env), newMRCloseCmd(env), | |
| 176 | 193 | ) | |
| 177 | 194 | return mr | |
| 178 | 195 | } | |
| @@ -385,15 +402,25 @@ type jsonCommit struct { | |||
| 385 | 402 | // jsonMRDetail is the stable JSON shape of `mr show --json`. | |
| 386 | 403 | type jsonMRDetail struct { | |
| 387 | 404 | jsonMergeRequest | |
| 388 | - | Commits []jsonCommit `json:"commits"` | |
| 389 | - | CommitsTruncated bool `json:"commits_truncated"` | |
| 390 | - | DiffBaseID string `json:"diff_base_id"` | |
| 391 | - | Stats jsonStats `json:"stats"` | |
| 392 | - | DiffTruncated bool `json:"diff_truncated"` | |
| 393 | - | Files []jsonFile `json:"files"` | |
| 394 | - | Comments []jsonComment `json:"comments"` | |
| 395 | - | LastReadID string `json:"last_read_id"` | |
| 396 | - | LastReadLost bool `json:"last_read_lost"` | |
| 405 | + | Commits []jsonCommit `json:"commits"` | |
| 406 | + | CommitsTruncated bool `json:"commits_truncated"` | |
| 407 | + | DiffBaseID string `json:"diff_base_id"` | |
| 408 | + | Stats jsonStats `json:"stats"` | |
| 409 | + | DiffTruncated bool `json:"diff_truncated"` | |
| 410 | + | Files []jsonFile `json:"files"` | |
| 411 | + | Comments []jsonComment `json:"comments"` | |
| 412 | + | Verdicts []jsonAuthorVerdict `json:"verdicts"` | |
| 413 | + | LastReadID string `json:"last_read_id"` | |
| 414 | + | LastReadLost bool `json:"last_read_lost"` | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | type jsonAuthorVerdict struct { | |
| 418 | + | Author string `json:"author"` | |
| 419 | + | Verdict string `json:"verdict"` | |
| 420 | + | CommentID string `json:"comment_id"` | |
| 421 | + | CommitID string `json:"commit_id"` | |
| 422 | + | CreatedAt *string `json:"created_at"` | |
| 423 | + | Stale bool `json:"stale"` | |
| 397 | 424 | } | |
| 398 | 425 | ||
| 399 | 426 | func fileStatusName(s ermite.FileStatus) string { | |
| @@ -413,7 +440,7 @@ func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool) jsonMRDetail | |||
| 413 | 440 | FilesChanged: d.GetStats().GetFilesChanged(), Additions: d.GetStats().GetAdditions(), Deletions: d.GetStats().GetDeletions(), | |
| 414 | 441 | }, | |
| 415 | 442 | DiffTruncated: d.GetDiff().GetTruncated(), | |
| 416 | - | Files: []jsonFile{}, Comments: []jsonComment{}, | |
| 443 | + | Files: []jsonFile{}, Comments: []jsonComment{}, Verdicts: []jsonAuthorVerdict{}, | |
| 417 | 444 | LastReadID: d.GetLastReadId(), LastReadLost: d.GetLastReadLost(), | |
| 418 | 445 | } | |
| 419 | 446 | for _, c := range d.GetCommits() { | |
| @@ -443,6 +470,9 @@ func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool) jsonMRDetail | |||
| 443 | 470 | for _, c := range d.GetComments() { | |
| 444 | 471 | out.Comments = append(out.Comments, toJSONComment(c)) | |
| 445 | 472 | } | |
| 473 | + | for _, v := range d.GetVerdicts() { | |
| 474 | + | out.Verdicts = append(out.Verdicts, jsonAuthorVerdict{Author: v.GetAuthor(), Verdict: verdictName(v.GetVerdict()), CommentID: v.GetCommentId(), CommitID: v.GetCommitId(), CreatedAt: jsonTime(v.GetCreatedAt()), Stale: v.GetStale()}) | |
| 475 | + | } | |
| 446 | 476 | return out | |
| 447 | 477 | } | |
| 448 | 478 | ||
| @@ -461,7 +491,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command { | |||
| 461 | 491 | "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" + | |
| 462 | 492 | "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" + | |
| 463 | 493 | "with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" + | |
| 464 | - | "and last_read_lost.", | |
| 494 | + | "and last_read_lost; verdicts (author, verdict, comment_id, commit_id, created_at, stale).", | |
| 465 | 495 | Args: cobra.ExactArgs(2), | |
| 466 | 496 | ValidArgsFunction: completeMRRepoThenNumber(env), | |
| 467 | 497 | } | |
| @@ -529,6 +559,20 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin | |||
| 529 | 559 | if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED { | |
| 530 | 560 | pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"}) | |
| 531 | 561 | } | |
| 562 | + | if len(d.GetVerdicts()) > 0 { | |
| 563 | + | var verdicts []string | |
| 564 | + | for _, v := range d.GetVerdicts() { | |
| 565 | + | label := "changes requested" | |
| 566 | + | if v.GetVerdict() == ermite.Verdict_VERDICT_APPROVE { | |
| 567 | + | label = "approved" | |
| 568 | + | } | |
| 569 | + | if v.GetStale() { | |
| 570 | + | label += " (stale)" | |
| 571 | + | } | |
| 572 | + | verdicts = append(verdicts, v.GetAuthor()+" "+label) | |
| 573 | + | } | |
| 574 | + | pairs = append(pairs, [2]string{"verdicts", strings.Join(verdicts, ", ")}) | |
| 575 | + | } | |
| 532 | 576 | if err := fields(w, pairs); err != nil { | |
| 533 | 577 | return err | |
| 534 | 578 | } | |
| @@ -561,16 +605,46 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin | |||
| 561 | 605 | } | |
| 562 | 606 | ||
| 563 | 607 | _, _ = fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())) | |
| 564 | - | for _, c := range d.GetComments() { | |
| 608 | + | var ordered []*ermite.Comment | |
| 609 | + | for _, root := range d.GetComments() { | |
| 610 | + | if root.GetReplyTo() != "" { | |
| 611 | + | continue | |
| 612 | + | } | |
| 613 | + | ordered = append(ordered, root) | |
| 614 | + | for _, reply := range d.GetComments() { | |
| 615 | + | if reply.GetReplyTo() == root.GetId() { | |
| 616 | + | ordered = append(ordered, reply) | |
| 617 | + | } | |
| 618 | + | } | |
| 619 | + | } | |
| 620 | + | for _, c := range ordered { | |
| 565 | 621 | where := "general" | |
| 566 | 622 | if c.GetPath() != "" { | |
| 567 | 623 | where = fmt.Sprintf("%s:%d (%s)", clean(c.GetPath()), c.GetLine(), sideName(c.GetSide())) | |
| 568 | 624 | } | |
| 625 | + | if c.GetStartLine() > 0 { | |
| 626 | + | where = fmt.Sprintf("%s:lines %d-%d (%s)", clean(c.GetPath()), c.GetStartLine(), c.GetLine(), sideName(c.GetSide())) | |
| 627 | + | } | |
| 569 | 628 | mark := "" | |
| 629 | + | if c.GetEditedAt() != nil { | |
| 630 | + | mark += " (edited)" | |
| 631 | + | } | |
| 632 | + | if c.GetDeleted() { | |
| 633 | + | mark += " [deleted]" | |
| 634 | + | } | |
| 635 | + | if c.GetVerdict() != ermite.Verdict_VERDICT_UNSPECIFIED { | |
| 636 | + | mark += " [" + verdictName(c.GetVerdict()) + "]" | |
| 637 | + | } | |
| 570 | 638 | if c.GetResolved() { | |
| 571 | - | mark = " [resolved]" | |
| 639 | + | mark += " [resolved]" | |
| 572 | 640 | } | |
| 573 | - | _, _ = fmt.Fprintf(w, " #%s %s, %s, %s%s\n%s\n", c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), where, mark, indentText(c.GetText())) | |
| 641 | + | prefix := " " | |
| 642 | + | body := indentText(c.GetText()) | |
| 643 | + | if c.GetReplyTo() != "" { | |
| 644 | + | prefix += " " | |
| 645 | + | body = " " + strings.ReplaceAll(body, "\n", "\n ") | |
| 646 | + | } | |
| 647 | + | _, _ = fmt.Fprintf(w, "%s#%s %s, %s, %s%s\n%s\n", prefix, c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), where, mark, body) | |
| 574 | 648 | } | |
| 575 | 649 | if withDiff { | |
| 576 | 650 | printDiff(w, d.GetDiff()) | |
| @@ -643,12 +717,14 @@ func printDiff(w io.Writer, d *ermite.Diff) { | |||
| 643 | 717 | } | |
| 644 | 718 | ||
| 645 | 719 | func newMRCommentCmd(env *cliEnv) *cobra.Command { | |
| 646 | - | var message, path, side string | |
| 647 | - | var line int | |
| 720 | + | var message, path, side, line, reply string | |
| 721 | + | var approve, requestChanges bool | |
| 648 | 722 | cmd := &cobra.Command{ | |
| 649 | 723 | Use: "comment <repo> <number>", | |
| 650 | 724 | Short: "Comment on a merge request", | |
| 651 | - | Long: "Add a plain-text comment (at most 8000 characters, no control characters except line breaks).\n" + | |
| 725 | + | Long: "Add a Markdown note (at most 8000 characters); ^N refers to a note in this merge request.\n" + | |
| 726 | + | "--reply replies to a root note. --line accepts N or A-B. --approve and --request-changes\n" + | |
| 727 | + | "set a verdict (needs mr:review) and allow an empty message.\n" + | |
| 652 | 728 | "-m - reads it from standard input. Without --path it is a general comment. With --path, --line and\n" + | |
| 653 | 729 | "--side (default new) it is attached to a line of the current diff: the path as in `mr show --diff`\n" + | |
| 654 | 730 | "(the new path, the old one for a deleted file), the line number on the old or new side; a line\n" + | |
| @@ -656,11 +732,18 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command { | |||
| 656 | 732 | Args: cobra.ExactArgs(2), | |
| 657 | 733 | ValidArgsFunction: completeMRRepoThenNumber(env), | |
| 658 | 734 | } | |
| 659 | - | cmd.Flags().StringVarP(&message, "message", "m", "", "text of the comment, or - to read standard input (required)") | |
| 735 | + | cmd.Flags().StringVarP(&message, "message", "m", "", "Markdown text, or - to read standard input (required unless setting a verdict)") | |
| 660 | 736 | cmd.Flags().StringVar(&path, "path", "", "file the comment is attached to") | |
| 661 | - | cmd.Flags().IntVar(&line, "line", 0, "1-based line number on --side") | |
| 737 | + | cmd.Flags().StringVar(&line, "line", "", "1-based line N or range A-B on --side") | |
| 738 | + | cmd.Flags().StringVar(&reply, "reply", "", "reply to root note (^12, #12 or 12)") | |
| 739 | + | cmd.Flags().BoolVar(&approve, "approve", false, "approve (needs mr:review)") | |
| 740 | + | cmd.Flags().BoolVar(&requestChanges, "request-changes", false, "request changes (needs mr:review)") | |
| 662 | 741 | cmd.Flags().StringVar(&side, "side", sideNew, "side the line counts on: old or new") | |
| 663 | - | _ = cmd.MarkFlagRequired("message") | |
| 742 | + | cmd.MarkFlagsMutuallyExclusive("approve", "request-changes") | |
| 743 | + | for _, flag := range []string{"path", "line", "side", "approve", "request-changes"} { | |
| 744 | + | cmd.MarkFlagsMutuallyExclusive("reply", flag) | |
| 745 | + | } | |
| 746 | + | // A verdict note may omit its message. | |
| 664 | 747 | _ = cmd.RegisterFlagCompletionFunc("side", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |
| 665 | 748 | return []string{sideOld, sideNew}, cobra.ShellCompDirectiveNoFileComp | |
| 666 | 749 | }) | |
| @@ -674,17 +757,36 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command { | |||
| 674 | 757 | if err != nil { | |
| 675 | 758 | return err | |
| 676 | 759 | } | |
| 760 | + | if !cmd.Flags().Changed("message") && !approve && !requestChanges { | |
| 761 | + | return errors.New("--message is required unless --approve or --request-changes is set") | |
| 762 | + | } | |
| 677 | 763 | req := &ermite.AddCommentRequest{Repo: args[0], Number: n, Text: text} | |
| 764 | + | if approve { | |
| 765 | + | req.Verdict = ermite.Verdict_VERDICT_APPROVE | |
| 766 | + | } | |
| 767 | + | if requestChanges { | |
| 768 | + | req.Verdict = ermite.Verdict_VERDICT_CHANGES_REQUESTED | |
| 769 | + | } | |
| 770 | + | if cmd.Flags().Changed("reply") { | |
| 771 | + | req.ReplyTo, err = parseNoteID(reply) | |
| 772 | + | if err != nil { | |
| 773 | + | return err | |
| 774 | + | } | |
| 775 | + | } | |
| 678 | 776 | switch { | |
| 679 | 777 | case path != "": | |
| 680 | - | if line < 1 { | |
| 778 | + | if line == "" { | |
| 681 | 779 | return errors.New("--line is required with --path: give the line number") | |
| 682 | 780 | } | |
| 781 | + | req.StartLine, req.Line, err = parseLineRange(line) | |
| 782 | + | if err != nil { | |
| 783 | + | return err | |
| 784 | + | } | |
| 683 | 785 | if req.Side, err = parseSide(side); err != nil { | |
| 684 | 786 | return err | |
| 685 | 787 | } | |
| 686 | - | req.Path, req.Line = path, int32(line) //nolint:gosec // a line number typed by a person | |
| 687 | - | case line != 0 || cmd.Flags().Changed("side"): | |
| 788 | + | req.Path = path | |
| 789 | + | case cmd.Flags().Changed("line") || cmd.Flags().Changed("side"): | |
| 688 | 790 | return errors.New("--line and --side need --path") | |
| 689 | 791 | } | |
| 690 | 792 | c, err := env.client(cmd) | |
| @@ -704,6 +806,100 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command { | |||
| 704 | 806 | return cmd | |
| 705 | 807 | } | |
| 706 | 808 | ||
| 809 | + | func parseNoteID(s string) (string, error) { | |
| 810 | + | id := strings.TrimLeft(strings.TrimSpace(s), "^#") | |
| 811 | + | n, err := strconv.ParseInt(id, 10, 64) | |
| 812 | + | if err != nil || n < 1 { | |
| 813 | + | return "", fmt.Errorf("invalid note id %q: use ^12, #12 or 12", s) | |
| 814 | + | } | |
| 815 | + | return strconv.FormatInt(n, 10), nil | |
| 816 | + | } | |
| 817 | + | ||
| 818 | + | func parseLineRange(s string) (start, end int32, err error) { | |
| 819 | + | parts := strings.Split(s, "-") | |
| 820 | + | invalid := fmt.Errorf("invalid --line %q: use N or A-B with 1 <= A < B", s) | |
| 821 | + | if len(parts) < 1 || len(parts) > 2 { | |
| 822 | + | return 0, 0, invalid | |
| 823 | + | } | |
| 824 | + | parse := func(v string) (int32, error) { | |
| 825 | + | n, e := strconv.ParseInt(v, 10, 32) | |
| 826 | + | if e != nil || n < 1 { | |
| 827 | + | return 0, invalid | |
| 828 | + | } | |
| 829 | + | return int32(n), nil | |
| 830 | + | } | |
| 831 | + | end, err = parse(parts[len(parts)-1]) | |
| 832 | + | if err != nil { | |
| 833 | + | return 0, 0, err | |
| 834 | + | } | |
| 835 | + | if len(parts) == 2 { | |
| 836 | + | start, err = parse(parts[0]) | |
| 837 | + | if err != nil || start >= end { | |
| 838 | + | return 0, 0, invalid | |
| 839 | + | } | |
| 840 | + | } | |
| 841 | + | return start, end, nil | |
| 842 | + | } | |
| 843 | + | ||
| 844 | + | func newMREditCommentCmd(env *cliEnv) *cobra.Command { return newMRNoteMutationCmd(env, true) } | |
| 845 | + | func newMRDeleteCommentCmd(env *cliEnv) *cobra.Command { return newMRNoteMutationCmd(env, false) } | |
| 846 | + | ||
| 847 | + | func newMRNoteMutationCmd(env *cliEnv, edit bool) *cobra.Command { | |
| 848 | + | verb := "delete" | |
| 849 | + | if edit { | |
| 850 | + | verb = "edit" | |
| 851 | + | } | |
| 852 | + | var message string | |
| 853 | + | cmd := &cobra.Command{Use: verb + "-comment <repo> <number> <comment-id>", Short: verb + " your own merge request note", Long: "Edit or delete your own note on an open merge request. Needs mr:write. Verdicts are unchanged." + jsonCommentHelp, Args: cobra.ExactArgs(3), ValidArgsFunction: completeMRRepoThenNumber(env)} | |
| 854 | + | if edit { | |
| 855 | + | cmd.Flags().StringVarP(&message, "message", "m", "", "Markdown text, or - to read standard input (required)") | |
| 856 | + | _ = cmd.MarkFlagRequired("message") | |
| 857 | + | } | |
| 858 | + | asJSON := addJSONFlag(cmd) | |
| 859 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 860 | + | n, err := parseNumber(args[1]) | |
| 861 | + | if err != nil { | |
| 862 | + | return err | |
| 863 | + | } | |
| 864 | + | id, err := parseNoteID(args[2]) | |
| 865 | + | if err != nil { | |
| 866 | + | return err | |
| 867 | + | } | |
| 868 | + | text, err := readText(cmd, message) | |
| 869 | + | if err != nil { | |
| 870 | + | return err | |
| 871 | + | } | |
| 872 | + | c, err := env.client(cmd) | |
| 873 | + | if err != nil { | |
| 874 | + | return err | |
| 875 | + | } | |
| 876 | + | var note *ermite.Comment | |
| 877 | + | if edit { | |
| 878 | + | res, e := c.MergeRequests().EditComment(env.ctx(cmd), connect.NewRequest(&ermite.EditCommentRequest{Repo: args[0], Number: n, CommentId: id, Text: text})) | |
| 879 | + | if e != nil { | |
| 880 | + | return c.Describe(e) | |
| 881 | + | } | |
| 882 | + | note = res.Msg.GetComment() | |
| 883 | + | } else { | |
| 884 | + | res, e := c.MergeRequests().DeleteComment(env.ctx(cmd), connect.NewRequest(&ermite.DeleteCommentRequest{Repo: args[0], Number: n, CommentId: id})) | |
| 885 | + | if e != nil { | |
| 886 | + | return c.Describe(e) | |
| 887 | + | } | |
| 888 | + | note = res.Msg.GetComment() | |
| 889 | + | } | |
| 890 | + | if *asJSON { | |
| 891 | + | return writeJSON(cmd.OutOrStdout(), toJSONComment(note)) | |
| 892 | + | } | |
| 893 | + | past := "deleted" | |
| 894 | + | if edit { | |
| 895 | + | past = "edited" | |
| 896 | + | } | |
| 897 | + | _, err = fmt.Fprintf(cmd.OutOrStdout(), "comment %s of %s!%d %s\n", clean(id), clean(args[0]), n, past) | |
| 898 | + | return err | |
| 899 | + | } | |
| 900 | + | return cmd | |
| 901 | + | } | |
| 902 | + | ||
| 707 | 903 | func newMRResolveCmd(env *cliEnv) *cobra.Command { | |
| 708 | 904 | var undo bool | |
| 709 | 905 | cmd := &cobra.Command{ | |
| @@ -141,12 +141,28 @@ type jsonComment struct { | |||||
| 141 | Text string `json:"text"` | 141 | Text string `json:"text"` | ||
| 142 | Resolved bool `json:"resolved"` | 142 | Resolved bool `json:"resolved"` | ||
| 143 | CreatedAt *string `json:"created_at"` | 143 | CreatedAt *string `json:"created_at"` | ||
| 144 | + | ReplyTo string `json:"reply_to"` | |||
| 145 | + | StartLine int32 `json:"start_line"` | |||
| 146 | + | Verdict string `json:"verdict"` | |||
| 147 | + | EditedAt *string `json:"edited_at"` | |||
| 148 | + | Deleted bool `json:"deleted"` | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func verdictName(v ermite.Verdict) string { | |||
| 152 | + | switch v { | |||
| 153 | + | case ermite.Verdict_VERDICT_APPROVE: | |||
| 154 | + | return "approve" | |||
| 155 | + | case ermite.Verdict_VERDICT_CHANGES_REQUESTED: | |||
| 156 | + | return "changes_requested" | |||
| 157 | + | } | |||
| 158 | + | return "" | |||
| 144 | } | 159 | } | ||
| 145 | 160 | ||||
| 146 | func toJSONComment(c *ermite.Comment) jsonComment { | 161 | func toJSONComment(c *ermite.Comment) jsonComment { | ||
| 147 | return jsonComment{ | 162 | return jsonComment{ | ||
| 148 | ID: c.GetId(), Author: c.GetAuthor(), Path: c.GetPath(), Line: c.GetLine(), Side: sideName(c.GetSide()), | 163 | ID: c.GetId(), Author: c.GetAuthor(), Path: c.GetPath(), Line: c.GetLine(), Side: sideName(c.GetSide()), | ||
| 149 | CommitID: c.GetCommitId(), Text: c.GetText(), Resolved: c.GetResolved(), CreatedAt: jsonTime(c.GetCreatedAt()), | 164 | CommitID: c.GetCommitId(), Text: c.GetText(), Resolved: c.GetResolved(), CreatedAt: jsonTime(c.GetCreatedAt()), | ||
| 165 | + | ReplyTo: c.GetReplyTo(), StartLine: c.GetStartLine(), Verdict: verdictName(c.GetVerdict()), EditedAt: jsonTime(c.GetEditedAt()), Deleted: c.GetDeleted(), | |||
| 150 | } | 166 | } | ||
| 151 | } | 167 | } | ||
| 152 | 168 | ||||
| @@ -160,7 +176,8 @@ unless merged).` | |||||
| 160 | const jsonCommentHelp = ` | 176 | const jsonCommentHelp = ` | ||
| 161 | JSON fields of a comment: id, author, path, line and side ("old" or "new"; empty path, line 0 | 177 | JSON fields of a comment: id, author, path, line and side ("old" or "new"; empty path, line 0 | ||
| 162 | and empty side for a general comment), commit_id (the head when it was written), text, | 178 | and empty side for a general comment), commit_id (the head when it was written), text, | ||
| 163 | - | resolved, created_at.` | 179 | + | resolved, created_at, reply_to (empty for roots), start_line (0 for a single line), |
| 180 | + | verdict ("", "approve", "changes_requested"), edited_at (nullable RFC 3339), deleted.` | |||
| 164 | 181 | ||||
| 165 | func newMRCmd(env *cliEnv) *cobra.Command { | 182 | func newMRCmd(env *cliEnv) *cobra.Command { | ||
| 166 | mr := &cobra.Command{ | 183 | mr := &cobra.Command{ | ||
| @@ -172,7 +189,7 @@ func newMRCmd(env *cliEnv) *cobra.Command { | |||||
| 172 | } | 189 | } | ||
| 173 | mr.AddCommand( | 190 | mr.AddCommand( | ||
| 174 | newMRCreateCmd(env), newMRListCmd(env), newMRShowCmd(env), newMRCommentCmd(env), | 191 | newMRCreateCmd(env), newMRListCmd(env), newMRShowCmd(env), newMRCommentCmd(env), | ||
| 175 | - | newMRResolveCmd(env), newMRMergeCmd(env), newMRCloseCmd(env), | 192 | + | newMRResolveCmd(env), newMREditCommentCmd(env), newMRDeleteCommentCmd(env), newMRMergeCmd(env), newMRCloseCmd(env), |
| 176 | ) | 193 | ) | ||
| 177 | return mr | 194 | return mr | ||
| 178 | } | 195 | } | ||
| @@ -385,15 +402,25 @@ type jsonCommit struct { | |||||
| 385 | // jsonMRDetail is the stable JSON shape of `mr show --json`. | 402 | // jsonMRDetail is the stable JSON shape of `mr show --json`. | ||
| 386 | type jsonMRDetail struct { | 403 | type jsonMRDetail struct { | ||
| 387 | jsonMergeRequest | 404 | jsonMergeRequest | ||
| 388 | - | Commits []jsonCommit `json:"commits"` | 405 | + | Commits []jsonCommit `json:"commits"` |
| 389 | - | CommitsTruncated bool `json:"commits_truncated"` | 406 | + | CommitsTruncated bool `json:"commits_truncated"` |
| 390 | - | DiffBaseID string `json:"diff_base_id"` | 407 | + | DiffBaseID string `json:"diff_base_id"` |
| 391 | - | Stats jsonStats `json:"stats"` | 408 | + | Stats jsonStats `json:"stats"` |
| 392 | - | DiffTruncated bool `json:"diff_truncated"` | 409 | + | DiffTruncated bool `json:"diff_truncated"` |
| 393 | - | Files []jsonFile `json:"files"` | 410 | + | Files []jsonFile `json:"files"` |
| 394 | - | Comments []jsonComment `json:"comments"` | 411 | + | Comments []jsonComment `json:"comments"` |
| 395 | - | LastReadID string `json:"last_read_id"` | 412 | + | Verdicts []jsonAuthorVerdict `json:"verdicts"` |
| 396 | - | LastReadLost bool `json:"last_read_lost"` | 413 | + | LastReadID string `json:"last_read_id"` |
| 414 | + | LastReadLost bool `json:"last_read_lost"` | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | type jsonAuthorVerdict struct { | |||
| 418 | + | Author string `json:"author"` | |||
| 419 | + | Verdict string `json:"verdict"` | |||
| 420 | + | CommentID string `json:"comment_id"` | |||
| 421 | + | CommitID string `json:"commit_id"` | |||
| 422 | + | CreatedAt *string `json:"created_at"` | |||
| 423 | + | Stale bool `json:"stale"` | |||
| 397 | } | 424 | } | ||
| 398 | 425 | ||||
| 399 | func fileStatusName(s ermite.FileStatus) string { | 426 | func fileStatusName(s ermite.FileStatus) string { | ||
| @@ -413,7 +440,7 @@ func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool) jsonMRDetail | |||||
| 413 | FilesChanged: d.GetStats().GetFilesChanged(), Additions: d.GetStats().GetAdditions(), Deletions: d.GetStats().GetDeletions(), | 440 | FilesChanged: d.GetStats().GetFilesChanged(), Additions: d.GetStats().GetAdditions(), Deletions: d.GetStats().GetDeletions(), | ||
| 414 | }, | 441 | }, | ||
| 415 | DiffTruncated: d.GetDiff().GetTruncated(), | 442 | DiffTruncated: d.GetDiff().GetTruncated(), | ||
| 416 | - | Files: []jsonFile{}, Comments: []jsonComment{}, | 443 | + | Files: []jsonFile{}, Comments: []jsonComment{}, Verdicts: []jsonAuthorVerdict{}, |
| 417 | LastReadID: d.GetLastReadId(), LastReadLost: d.GetLastReadLost(), | 444 | LastReadID: d.GetLastReadId(), LastReadLost: d.GetLastReadLost(), | ||
| 418 | } | 445 | } | ||
| 419 | for _, c := range d.GetCommits() { | 446 | for _, c := range d.GetCommits() { | ||
| @@ -443,6 +470,9 @@ func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool) jsonMRDetail | |||||
| 443 | for _, c := range d.GetComments() { | 470 | for _, c := range d.GetComments() { | ||
| 444 | out.Comments = append(out.Comments, toJSONComment(c)) | 471 | out.Comments = append(out.Comments, toJSONComment(c)) | ||
| 445 | } | 472 | } | ||
| 473 | + | for _, v := range d.GetVerdicts() { | |||
| 474 | + | out.Verdicts = append(out.Verdicts, jsonAuthorVerdict{Author: v.GetAuthor(), Verdict: verdictName(v.GetVerdict()), CommentID: v.GetCommentId(), CommitID: v.GetCommitId(), CreatedAt: jsonTime(v.GetCreatedAt()), Stale: v.GetStale()}) | |||
| 475 | + | } | |||
| 446 | return out | 476 | return out | ||
| 447 | } | 477 | } | ||
| 448 | 478 | ||||
| @@ -461,7 +491,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command { | |||||
| 461 | "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" + | 491 | "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" + | ||
| 462 | "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" + | 492 | "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" + | ||
| 463 | "with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" + | 493 | "with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" + | ||
| 464 | - | "and last_read_lost.", | 494 | + | "and last_read_lost; verdicts (author, verdict, comment_id, commit_id, created_at, stale).", |
| 465 | Args: cobra.ExactArgs(2), | 495 | Args: cobra.ExactArgs(2), | ||
| 466 | ValidArgsFunction: completeMRRepoThenNumber(env), | 496 | ValidArgsFunction: completeMRRepoThenNumber(env), | ||
| 467 | } | 497 | } | ||
| @@ -529,6 +559,20 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin | |||||
| 529 | if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED { | 559 | if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED { | ||
| 530 | pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"}) | 560 | pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"}) | ||
| 531 | } | 561 | } | ||
| 562 | + | if len(d.GetVerdicts()) > 0 { | |||
| 563 | + | var verdicts []string | |||
| 564 | + | for _, v := range d.GetVerdicts() { | |||
| 565 | + | label := "changes requested" | |||
| 566 | + | if v.GetVerdict() == ermite.Verdict_VERDICT_APPROVE { | |||
| 567 | + | label = "approved" | |||
| 568 | + | } | |||
| 569 | + | if v.GetStale() { | |||
| 570 | + | label += " (stale)" | |||
| 571 | + | } | |||
| 572 | + | verdicts = append(verdicts, v.GetAuthor()+" "+label) | |||
| 573 | + | } | |||
| 574 | + | pairs = append(pairs, [2]string{"verdicts", strings.Join(verdicts, ", ")}) | |||
| 575 | + | } | |||
| 532 | if err := fields(w, pairs); err != nil { | 576 | if err := fields(w, pairs); err != nil { | ||
| 533 | return err | 577 | return err | ||
| 534 | } | 578 | } | ||
| @@ -561,16 +605,46 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin | |||||
| 561 | } | 605 | } | ||
| 562 | 606 | ||||
| 563 | _, _ = fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())) | 607 | _, _ = fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())) | ||
| 564 | - | for _, c := range d.GetComments() { | 608 | + | var ordered []*ermite.Comment |
| 609 | + | for _, root := range d.GetComments() { | |||
| 610 | + | if root.GetReplyTo() != "" { | |||
| 611 | + | continue | |||
| 612 | + | } | |||
| 613 | + | ordered = append(ordered, root) | |||
| 614 | + | for _, reply := range d.GetComments() { | |||
| 615 | + | if reply.GetReplyTo() == root.GetId() { | |||
| 616 | + | ordered = append(ordered, reply) | |||
| 617 | + | } | |||
| 618 | + | } | |||
| 619 | + | } | |||
| 620 | + | for _, c := range ordered { | |||
| 565 | where := "general" | 621 | where := "general" | ||
| 566 | if c.GetPath() != "" { | 622 | if c.GetPath() != "" { | ||
| 567 | where = fmt.Sprintf("%s:%d (%s)", clean(c.GetPath()), c.GetLine(), sideName(c.GetSide())) | 623 | where = fmt.Sprintf("%s:%d (%s)", clean(c.GetPath()), c.GetLine(), sideName(c.GetSide())) | ||
| 568 | } | 624 | } | ||
| 625 | + | if c.GetStartLine() > 0 { | |||
| 626 | + | where = fmt.Sprintf("%s:lines %d-%d (%s)", clean(c.GetPath()), c.GetStartLine(), c.GetLine(), sideName(c.GetSide())) | |||
| 627 | + | } | |||
| 569 | mark := "" | 628 | mark := "" | ||
| 629 | + | if c.GetEditedAt() != nil { | |||
| 630 | + | mark += " (edited)" | |||
| 631 | + | } | |||
| 632 | + | if c.GetDeleted() { | |||
| 633 | + | mark += " [deleted]" | |||
| 634 | + | } | |||
| 635 | + | if c.GetVerdict() != ermite.Verdict_VERDICT_UNSPECIFIED { | |||
| 636 | + | mark += " [" + verdictName(c.GetVerdict()) + "]" | |||
| 637 | + | } | |||
| 570 | if c.GetResolved() { | 638 | if c.GetResolved() { | ||
| 571 | - | mark = " [resolved]" | 639 | + | mark += " [resolved]" |
| 572 | } | 640 | } | ||
| 573 | - | _, _ = fmt.Fprintf(w, " #%s %s, %s, %s%s\n%s\n", c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), where, mark, indentText(c.GetText())) | 641 | + | prefix := " " |
| 642 | + | body := indentText(c.GetText()) | |||
| 643 | + | if c.GetReplyTo() != "" { | |||
| 644 | + | prefix += " " | |||
| 645 | + | body = " " + strings.ReplaceAll(body, "\n", "\n ") | |||
| 646 | + | } | |||
| 647 | + | _, _ = fmt.Fprintf(w, "%s#%s %s, %s, %s%s\n%s\n", prefix, c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), where, mark, body) | |||
| 574 | } | 648 | } | ||
| 575 | if withDiff { | 649 | if withDiff { | ||
| 576 | printDiff(w, d.GetDiff()) | 650 | printDiff(w, d.GetDiff()) | ||
| @@ -643,12 +717,14 @@ func printDiff(w io.Writer, d *ermite.Diff) { | |||||
| 643 | } | 717 | } | ||
| 644 | 718 | ||||
| 645 | func newMRCommentCmd(env *cliEnv) *cobra.Command { | 719 | func newMRCommentCmd(env *cliEnv) *cobra.Command { | ||
| 646 | - | var message, path, side string | 720 | + | var message, path, side, line, reply string |
| 647 | - | var line int | 721 | + | var approve, requestChanges bool |
| 648 | cmd := &cobra.Command{ | 722 | cmd := &cobra.Command{ | ||
| 649 | Use: "comment <repo> <number>", | 723 | Use: "comment <repo> <number>", | ||
| 650 | Short: "Comment on a merge request", | 724 | Short: "Comment on a merge request", | ||
| 651 | - | Long: "Add a plain-text comment (at most 8000 characters, no control characters except line breaks).\n" + | 725 | + | Long: "Add a Markdown note (at most 8000 characters); ^N refers to a note in this merge request.\n" + |
| 726 | + | "--reply replies to a root note. --line accepts N or A-B. --approve and --request-changes\n" + | |||
| 727 | + | "set a verdict (needs mr:review) and allow an empty message.\n" + | |||
| 652 | "-m - reads it from standard input. Without --path it is a general comment. With --path, --line and\n" + | 728 | "-m - reads it from standard input. Without --path it is a general comment. With --path, --line and\n" + | ||
| 653 | "--side (default new) it is attached to a line of the current diff: the path as in `mr show --diff`\n" + | 729 | "--side (default new) it is attached to a line of the current diff: the path as in `mr show --diff`\n" + | ||
| 654 | "(the new path, the old one for a deleted file), the line number on the old or new side; a line\n" + | 730 | "(the new path, the old one for a deleted file), the line number on the old or new side; a line\n" + | ||
| @@ -656,11 +732,18 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command { | |||||
| 656 | Args: cobra.ExactArgs(2), | 732 | Args: cobra.ExactArgs(2), | ||
| 657 | ValidArgsFunction: completeMRRepoThenNumber(env), | 733 | ValidArgsFunction: completeMRRepoThenNumber(env), | ||
| 658 | } | 734 | } | ||
| 659 | - | cmd.Flags().StringVarP(&message, "message", "m", "", "text of the comment, or - to read standard input (required)") | 735 | + | cmd.Flags().StringVarP(&message, "message", "m", "", "Markdown text, or - to read standard input (required unless setting a verdict)") |
| 660 | cmd.Flags().StringVar(&path, "path", "", "file the comment is attached to") | 736 | cmd.Flags().StringVar(&path, "path", "", "file the comment is attached to") | ||
| 661 | - | cmd.Flags().IntVar(&line, "line", 0, "1-based line number on --side") | 737 | + | cmd.Flags().StringVar(&line, "line", "", "1-based line N or range A-B on --side") |
| 738 | + | cmd.Flags().StringVar(&reply, "reply", "", "reply to root note (^12, #12 or 12)") | |||
| 739 | + | cmd.Flags().BoolVar(&approve, "approve", false, "approve (needs mr:review)") | |||
| 740 | + | cmd.Flags().BoolVar(&requestChanges, "request-changes", false, "request changes (needs mr:review)") | |||
| 662 | cmd.Flags().StringVar(&side, "side", sideNew, "side the line counts on: old or new") | 741 | cmd.Flags().StringVar(&side, "side", sideNew, "side the line counts on: old or new") | ||
| 663 | - | _ = cmd.MarkFlagRequired("message") | 742 | + | cmd.MarkFlagsMutuallyExclusive("approve", "request-changes") |
| 743 | + | for _, flag := range []string{"path", "line", "side", "approve", "request-changes"} { | |||
| 744 | + | cmd.MarkFlagsMutuallyExclusive("reply", flag) | |||
| 745 | + | } | |||
| 746 | + | // A verdict note may omit its message. | |||
| 664 | _ = cmd.RegisterFlagCompletionFunc("side", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | 747 | _ = cmd.RegisterFlagCompletionFunc("side", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | ||
| 665 | return []string{sideOld, sideNew}, cobra.ShellCompDirectiveNoFileComp | 748 | return []string{sideOld, sideNew}, cobra.ShellCompDirectiveNoFileComp | ||
| 666 | }) | 749 | }) | ||
| @@ -674,17 +757,36 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command { | |||||
| 674 | if err != nil { | 757 | if err != nil { | ||
| 675 | return err | 758 | return err | ||
| 676 | } | 759 | } | ||
| 760 | + | if !cmd.Flags().Changed("message") && !approve && !requestChanges { | |||
| 761 | + | return errors.New("--message is required unless --approve or --request-changes is set") | |||
| 762 | + | } | |||
| 677 | req := &ermite.AddCommentRequest{Repo: args[0], Number: n, Text: text} | 763 | req := &ermite.AddCommentRequest{Repo: args[0], Number: n, Text: text} | ||
| 764 | + | if approve { | |||
| 765 | + | req.Verdict = ermite.Verdict_VERDICT_APPROVE | |||
| 766 | + | } | |||
| 767 | + | if requestChanges { | |||
| 768 | + | req.Verdict = ermite.Verdict_VERDICT_CHANGES_REQUESTED | |||
| 769 | + | } | |||
| 770 | + | if cmd.Flags().Changed("reply") { | |||
| 771 | + | req.ReplyTo, err = parseNoteID(reply) | |||
| 772 | + | if err != nil { | |||
| 773 | + | return err | |||
| 774 | + | } | |||
| 775 | + | } | |||
| 678 | switch { | 776 | switch { | ||
| 679 | case path != "": | 777 | case path != "": | ||
| 680 | - | if line < 1 { | 778 | + | if line == "" { |
| 681 | return errors.New("--line is required with --path: give the line number") | 779 | return errors.New("--line is required with --path: give the line number") | ||
| 682 | } | 780 | } | ||
| 781 | + | req.StartLine, req.Line, err = parseLineRange(line) | |||
| 782 | + | if err != nil { | |||
| 783 | + | return err | |||
| 784 | + | } | |||
| 683 | if req.Side, err = parseSide(side); err != nil { | 785 | if req.Side, err = parseSide(side); err != nil { | ||
| 684 | return err | 786 | return err | ||
| 685 | } | 787 | } | ||
| 686 | - | req.Path, req.Line = path, int32(line) //nolint:gosec // a line number typed by a person | 788 | + | req.Path = path |
| 687 | - | case line != 0 || cmd.Flags().Changed("side"): | 789 | + | case cmd.Flags().Changed("line") || cmd.Flags().Changed("side"): |
| 688 | return errors.New("--line and --side need --path") | 790 | return errors.New("--line and --side need --path") | ||
| 689 | } | 791 | } | ||
| 690 | c, err := env.client(cmd) | 792 | c, err := env.client(cmd) | ||
| @@ -704,6 +806,100 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command { | |||||
| 704 | return cmd | 806 | return cmd | ||
| 705 | } | 807 | } | ||
| 706 | 808 | ||||
| 809 | + | func parseNoteID(s string) (string, error) { | |||
| 810 | + | id := strings.TrimLeft(strings.TrimSpace(s), "^#") | |||
| 811 | + | n, err := strconv.ParseInt(id, 10, 64) | |||
| 812 | + | if err != nil || n < 1 { | |||
| 813 | + | return "", fmt.Errorf("invalid note id %q: use ^12, #12 or 12", s) | |||
| 814 | + | } | |||
| 815 | + | return strconv.FormatInt(n, 10), nil | |||
| 816 | + | } | |||
| 817 | + | ||||
| 818 | + | func parseLineRange(s string) (start, end int32, err error) { | |||
| 819 | + | parts := strings.Split(s, "-") | |||
| 820 | + | invalid := fmt.Errorf("invalid --line %q: use N or A-B with 1 <= A < B", s) | |||
| 821 | + | if len(parts) < 1 || len(parts) > 2 { | |||
| 822 | + | return 0, 0, invalid | |||
| 823 | + | } | |||
| 824 | + | parse := func(v string) (int32, error) { | |||
| 825 | + | n, e := strconv.ParseInt(v, 10, 32) | |||
| 826 | + | if e != nil || n < 1 { | |||
| 827 | + | return 0, invalid | |||
| 828 | + | } | |||
| 829 | + | return int32(n), nil | |||
| 830 | + | } | |||
| 831 | + | end, err = parse(parts[len(parts)-1]) | |||
| 832 | + | if err != nil { | |||
| 833 | + | return 0, 0, err | |||
| 834 | + | } | |||
| 835 | + | if len(parts) == 2 { | |||
| 836 | + | start, err = parse(parts[0]) | |||
| 837 | + | if err != nil || start >= end { | |||
| 838 | + | return 0, 0, invalid | |||
| 839 | + | } | |||
| 840 | + | } | |||
| 841 | + | return start, end, nil | |||
| 842 | + | } | |||
| 843 | + | ||||
| 844 | + | func newMREditCommentCmd(env *cliEnv) *cobra.Command { return newMRNoteMutationCmd(env, true) } | |||
| 845 | + | func newMRDeleteCommentCmd(env *cliEnv) *cobra.Command { return newMRNoteMutationCmd(env, false) } | |||
| 846 | + | ||||
| 847 | + | func newMRNoteMutationCmd(env *cliEnv, edit bool) *cobra.Command { | |||
| 848 | + | verb := "delete" | |||
| 849 | + | if edit { | |||
| 850 | + | verb = "edit" | |||
| 851 | + | } | |||
| 852 | + | var message string | |||
| 853 | + | cmd := &cobra.Command{Use: verb + "-comment <repo> <number> <comment-id>", Short: verb + " your own merge request note", Long: "Edit or delete your own note on an open merge request. Needs mr:write. Verdicts are unchanged." + jsonCommentHelp, Args: cobra.ExactArgs(3), ValidArgsFunction: completeMRRepoThenNumber(env)} | |||
| 854 | + | if edit { | |||
| 855 | + | cmd.Flags().StringVarP(&message, "message", "m", "", "Markdown text, or - to read standard input (required)") | |||
| 856 | + | _ = cmd.MarkFlagRequired("message") | |||
| 857 | + | } | |||
| 858 | + | asJSON := addJSONFlag(cmd) | |||
| 859 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |||
| 860 | + | n, err := parseNumber(args[1]) | |||
| 861 | + | if err != nil { | |||
| 862 | + | return err | |||
| 863 | + | } | |||
| 864 | + | id, err := parseNoteID(args[2]) | |||
| 865 | + | if err != nil { | |||
| 866 | + | return err | |||
| 867 | + | } | |||
| 868 | + | text, err := readText(cmd, message) | |||
| 869 | + | if err != nil { | |||
| 870 | + | return err | |||
| 871 | + | } | |||
| 872 | + | c, err := env.client(cmd) | |||
| 873 | + | if err != nil { | |||
| 874 | + | return err | |||
| 875 | + | } | |||
| 876 | + | var note *ermite.Comment | |||
| 877 | + | if edit { | |||
| 878 | + | res, e := c.MergeRequests().EditComment(env.ctx(cmd), connect.NewRequest(&ermite.EditCommentRequest{Repo: args[0], Number: n, CommentId: id, Text: text})) | |||
| 879 | + | if e != nil { | |||
| 880 | + | return c.Describe(e) | |||
| 881 | + | } | |||
| 882 | + | note = res.Msg.GetComment() | |||
| 883 | + | } else { | |||
| 884 | + | res, e := c.MergeRequests().DeleteComment(env.ctx(cmd), connect.NewRequest(&ermite.DeleteCommentRequest{Repo: args[0], Number: n, CommentId: id})) | |||
| 885 | + | if e != nil { | |||
| 886 | + | return c.Describe(e) | |||
| 887 | + | } | |||
| 888 | + | note = res.Msg.GetComment() | |||
| 889 | + | } | |||
| 890 | + | if *asJSON { | |||
| 891 | + | return writeJSON(cmd.OutOrStdout(), toJSONComment(note)) | |||
| 892 | + | } | |||
| 893 | + | past := "deleted" | |||
| 894 | + | if edit { | |||
| 895 | + | past = "edited" | |||
| 896 | + | } | |||
| 897 | + | _, err = fmt.Fprintf(cmd.OutOrStdout(), "comment %s of %s!%d %s\n", clean(id), clean(args[0]), n, past) | |||
| 898 | + | return err | |||
| 899 | + | } | |||
| 900 | + | return cmd | |||
| 901 | + | } | |||
| 902 | + | ||||
| 707 | func newMRResolveCmd(env *cliEnv) *cobra.Command { | 903 | func newMRResolveCmd(env *cliEnv) *cobra.Command { | ||
| 708 | var undo bool | 904 | var undo bool | ||
| 709 | cmd := &cobra.Command{ | 905 | cmd := &cobra.Command{ | ||
M internal/app/cli_mr_e2e_test.go +72 -0
| @@ -420,3 +420,75 @@ func (w *watchProc) nextOfType(t *testing.T, typ string) eventOut { | |||
| 420 | 420 | t.Fatalf("no %s event", typ) | |
| 421 | 421 | return eventOut{} | |
| 422 | 422 | } | |
| 423 | + | ||
| 424 | + | func TestMergeRequestNotesCLIEndToEnd(t *testing.T) { | |
| 425 | + | c := newCLIHarness(t) | |
| 426 | + | c.cli(t, adminToken, "repo", "create", "notes", "--visibility", "public").ok(t) | |
| 427 | + | wt := c.workTree("main", "README.md") | |
| 428 | + | c.mustGit(wt, "checkout", "-q", "-b", "feat/notes") | |
| 429 | + | c.writeFile(wt, "notes.txt", []byte("one\ntwo\nthree\nfour\n")) | |
| 430 | + | c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/notes.git"), "main", "feat/notes") | |
| 431 | + | c.cli(t, adminToken, "mr", "create", "notes", "--source", "feat/notes", "--title", "Notes").ok(t) | |
| 432 | + | w := c.startWatch(t, adminToken) | |
| 433 | + | res := c.cli(t, adminToken, "mr", "comment", "notes", "1", "--path", "notes.txt", "--line", "1-3", "-m", "**range**", "--json").ok(t) | |
| 434 | + | root := decode[jsonComment](t, res.stdout) | |
| 435 | + | assert.EqualValues(t, 1, root.StartLine) | |
| 436 | + | assert.EqualValues(t, 3, root.Line) | |
| 437 | + | w.nextOfType(t, "comment_added") | |
| 438 | + | res = c.cli(t, adminToken, "mr", "comment", "notes", "1", "--reply", "^"+root.ID, "-m", "reply to ^"+root.ID, "--json").ok(t) | |
| 439 | + | reply := decode[jsonComment](t, res.stdout) | |
| 440 | + | assert.Equal(t, root.ID, reply.ReplyTo) | |
| 441 | + | assert.Equal(t, root.Path, reply.Path) | |
| 442 | + | assert.Equal(t, root.StartLine, reply.StartLine) | |
| 443 | + | assert.Equal(t, root.Line, reply.Line) | |
| 444 | + | w.nextOfType(t, "comment_added") | |
| 445 | + | require.Error(t, c.cli(t, adminToken, "mr", "comment", "notes", "1", "--reply", reply.ID, "-m", "nested").err) | |
| 446 | + | require.Error(t, c.cli(t, adminToken, "mr", "resolve", "notes", "1", reply.ID).err) | |
| 447 | + | writer := c.token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite}) | |
| 448 | + | denied := c.cli(t, writer, "mr", "comment", "notes", "1", "--approve") | |
| 449 | + | require.Error(t, denied.err) | |
| 450 | + | assert.Contains(t, denied.err.Error(), "mr:review") | |
| 451 | + | res = c.cli(t, adminToken, "mr", "comment", "notes", "1", "--approve", "--json").ok(t) | |
| 452 | + | verdict := decode[jsonComment](t, res.stdout) | |
| 453 | + | assert.Equal(t, "approve", verdict.Verdict) | |
| 454 | + | assert.True(t, verdict.Resolved) | |
| 455 | + | assert.Empty(t, verdict.Text) | |
| 456 | + | w.nextOfType(t, "comment_added") | |
| 457 | + | require.Error(t, c.cli(t, writer, "mr", "edit-comment", "notes", "1", verdict.ID, "-m", "not mine").err) | |
| 458 | + | res = runCLI(t, c.vars(adminToken), "edited from stdin\n", "mr", "edit-comment", "notes", "1", "#"+reply.ID, "-m", "-", "--json").ok(t) | |
| 459 | + | edited := decode[jsonComment](t, res.stdout) | |
| 460 | + | assert.Equal(t, "edited from stdin", edited.Text) | |
| 461 | + | require.NotNil(t, edited.EditedAt) | |
| 462 | + | w.nextOfType(t, "comment_edited") | |
| 463 | + | res = c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", verdict.ID, "--json").ok(t) | |
| 464 | + | deleted := decode[jsonComment](t, res.stdout) | |
| 465 | + | assert.True(t, deleted.Deleted) | |
| 466 | + | assert.Equal(t, "approve", deleted.Verdict) | |
| 467 | + | assert.Empty(t, deleted.Text) | |
| 468 | + | require.NotNil(t, deleted.EditedAt) | |
| 469 | + | w.nextOfType(t, "comment_deleted") | |
| 470 | + | c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", verdict.ID).ok(t) | |
| 471 | + | c.cli(t, adminToken, "mr", "resolve", "notes", "1", root.ID).ok(t) | |
| 472 | + | w.nextOfType(t, "comment_resolved") | |
| 473 | + | res = c.cli(t, "", "mr", "show", "notes", "1", "--json").ok(t) | |
| 474 | + | detail := decode[jsonMRDetail](t, res.stdout) | |
| 475 | + | require.Len(t, detail.Comments, 3) | |
| 476 | + | require.Len(t, detail.Verdicts, 1) | |
| 477 | + | assert.Equal(t, verdict.ID, detail.Verdicts[0].CommentID) | |
| 478 | + | assert.Equal(t, "approve", detail.Verdicts[0].Verdict) | |
| 479 | + | assert.False(t, detail.Verdicts[0].Stale) | |
| 480 | + | res = c.cli(t, "", "mr", "show", "notes", "1").ok(t) | |
| 481 | + | assert.Contains(t, res.stdout, "lines 1-3") | |
| 482 | + | assert.Contains(t, res.stdout, "(edited)") | |
| 483 | + | assert.Contains(t, res.stdout, "[deleted]") | |
| 484 | + | assert.Contains(t, res.stdout, "approved") | |
| 485 | + | assert.Contains(t, res.stdout, " #"+reply.ID) | |
| 486 | + | c.cli(t, adminToken, "mr", "comment", "notes", "1", "--request-changes", "-m", "needs work").ok(t) | |
| 487 | + | res = c.cli(t, "", "mr", "show", "notes", "1", "--json").ok(t) | |
| 488 | + | detail = decode[jsonMRDetail](t, res.stdout) | |
| 489 | + | require.Len(t, detail.Verdicts, 1) | |
| 490 | + | assert.Equal(t, "changes_requested", detail.Verdicts[0].Verdict) | |
| 491 | + | c.cli(t, adminToken, "mr", "close", "notes", "1").ok(t) | |
| 492 | + | require.Error(t, c.cli(t, adminToken, "mr", "edit-comment", "notes", "1", root.ID, "-m", "late").err) | |
| 493 | + | require.Error(t, c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", root.ID).err) | |
| 494 | + | } | |
| @@ -420,3 +420,75 @@ func (w *watchProc) nextOfType(t *testing.T, typ string) eventOut { | |||||
| 420 | t.Fatalf("no %s event", typ) | 420 | t.Fatalf("no %s event", typ) | ||
| 421 | return eventOut{} | 421 | return eventOut{} | ||
| 422 | } | 422 | } | ||
| 423 | + | ||||
| 424 | + | func TestMergeRequestNotesCLIEndToEnd(t *testing.T) { | |||
| 425 | + | c := newCLIHarness(t) | |||
| 426 | + | c.cli(t, adminToken, "repo", "create", "notes", "--visibility", "public").ok(t) | |||
| 427 | + | wt := c.workTree("main", "README.md") | |||
| 428 | + | c.mustGit(wt, "checkout", "-q", "-b", "feat/notes") | |||
| 429 | + | c.writeFile(wt, "notes.txt", []byte("one\ntwo\nthree\nfour\n")) | |||
| 430 | + | c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/notes.git"), "main", "feat/notes") | |||
| 431 | + | c.cli(t, adminToken, "mr", "create", "notes", "--source", "feat/notes", "--title", "Notes").ok(t) | |||
| 432 | + | w := c.startWatch(t, adminToken) | |||
| 433 | + | res := c.cli(t, adminToken, "mr", "comment", "notes", "1", "--path", "notes.txt", "--line", "1-3", "-m", "**range**", "--json").ok(t) | |||
| 434 | + | root := decode[jsonComment](t, res.stdout) | |||
| 435 | + | assert.EqualValues(t, 1, root.StartLine) | |||
| 436 | + | assert.EqualValues(t, 3, root.Line) | |||
| 437 | + | w.nextOfType(t, "comment_added") | |||
| 438 | + | res = c.cli(t, adminToken, "mr", "comment", "notes", "1", "--reply", "^"+root.ID, "-m", "reply to ^"+root.ID, "--json").ok(t) | |||
| 439 | + | reply := decode[jsonComment](t, res.stdout) | |||
| 440 | + | assert.Equal(t, root.ID, reply.ReplyTo) | |||
| 441 | + | assert.Equal(t, root.Path, reply.Path) | |||
| 442 | + | assert.Equal(t, root.StartLine, reply.StartLine) | |||
| 443 | + | assert.Equal(t, root.Line, reply.Line) | |||
| 444 | + | w.nextOfType(t, "comment_added") | |||
| 445 | + | require.Error(t, c.cli(t, adminToken, "mr", "comment", "notes", "1", "--reply", reply.ID, "-m", "nested").err) | |||
| 446 | + | require.Error(t, c.cli(t, adminToken, "mr", "resolve", "notes", "1", reply.ID).err) | |||
| 447 | + | writer := c.token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite}) | |||
| 448 | + | denied := c.cli(t, writer, "mr", "comment", "notes", "1", "--approve") | |||
| 449 | + | require.Error(t, denied.err) | |||
| 450 | + | assert.Contains(t, denied.err.Error(), "mr:review") | |||
| 451 | + | res = c.cli(t, adminToken, "mr", "comment", "notes", "1", "--approve", "--json").ok(t) | |||
| 452 | + | verdict := decode[jsonComment](t, res.stdout) | |||
| 453 | + | assert.Equal(t, "approve", verdict.Verdict) | |||
| 454 | + | assert.True(t, verdict.Resolved) | |||
| 455 | + | assert.Empty(t, verdict.Text) | |||
| 456 | + | w.nextOfType(t, "comment_added") | |||
| 457 | + | require.Error(t, c.cli(t, writer, "mr", "edit-comment", "notes", "1", verdict.ID, "-m", "not mine").err) | |||
| 458 | + | res = runCLI(t, c.vars(adminToken), "edited from stdin\n", "mr", "edit-comment", "notes", "1", "#"+reply.ID, "-m", "-", "--json").ok(t) | |||
| 459 | + | edited := decode[jsonComment](t, res.stdout) | |||
| 460 | + | assert.Equal(t, "edited from stdin", edited.Text) | |||
| 461 | + | require.NotNil(t, edited.EditedAt) | |||
| 462 | + | w.nextOfType(t, "comment_edited") | |||
| 463 | + | res = c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", verdict.ID, "--json").ok(t) | |||
| 464 | + | deleted := decode[jsonComment](t, res.stdout) | |||
| 465 | + | assert.True(t, deleted.Deleted) | |||
| 466 | + | assert.Equal(t, "approve", deleted.Verdict) | |||
| 467 | + | assert.Empty(t, deleted.Text) | |||
| 468 | + | require.NotNil(t, deleted.EditedAt) | |||
| 469 | + | w.nextOfType(t, "comment_deleted") | |||
| 470 | + | c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", verdict.ID).ok(t) | |||
| 471 | + | c.cli(t, adminToken, "mr", "resolve", "notes", "1", root.ID).ok(t) | |||
| 472 | + | w.nextOfType(t, "comment_resolved") | |||
| 473 | + | res = c.cli(t, "", "mr", "show", "notes", "1", "--json").ok(t) | |||
| 474 | + | detail := decode[jsonMRDetail](t, res.stdout) | |||
| 475 | + | require.Len(t, detail.Comments, 3) | |||
| 476 | + | require.Len(t, detail.Verdicts, 1) | |||
| 477 | + | assert.Equal(t, verdict.ID, detail.Verdicts[0].CommentID) | |||
| 478 | + | assert.Equal(t, "approve", detail.Verdicts[0].Verdict) | |||
| 479 | + | assert.False(t, detail.Verdicts[0].Stale) | |||
| 480 | + | res = c.cli(t, "", "mr", "show", "notes", "1").ok(t) | |||
| 481 | + | assert.Contains(t, res.stdout, "lines 1-3") | |||
| 482 | + | assert.Contains(t, res.stdout, "(edited)") | |||
| 483 | + | assert.Contains(t, res.stdout, "[deleted]") | |||
| 484 | + | assert.Contains(t, res.stdout, "approved") | |||
| 485 | + | assert.Contains(t, res.stdout, " #"+reply.ID) | |||
| 486 | + | c.cli(t, adminToken, "mr", "comment", "notes", "1", "--request-changes", "-m", "needs work").ok(t) | |||
| 487 | + | res = c.cli(t, "", "mr", "show", "notes", "1", "--json").ok(t) | |||
| 488 | + | detail = decode[jsonMRDetail](t, res.stdout) | |||
| 489 | + | require.Len(t, detail.Verdicts, 1) | |||
| 490 | + | assert.Equal(t, "changes_requested", detail.Verdicts[0].Verdict) | |||
| 491 | + | c.cli(t, adminToken, "mr", "close", "notes", "1").ok(t) | |||
| 492 | + | require.Error(t, c.cli(t, adminToken, "mr", "edit-comment", "notes", "1", root.ID, "-m", "late").err) | |||
| 493 | + | require.Error(t, c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", root.ID).err) | |||
| 494 | + | } | |||
M internal/app/cli_mr_test.go +81 -0
| @@ -381,3 +381,84 @@ func TestSigningKeyShowOutput(t *testing.T) { | |||
| 381 | 381 | res = runCLI(t, envFor(url), "", "signing-key", "show", "--json").ok(t) | |
| 382 | 382 | assert.JSONEq(t, `{"public_key":"ssh-ed25519 AAAAC3Nz","fingerprint":"SHA256:abc","committer_name":"ermite","committer_email":"ermite@git.example.org","allowed_signers":"ermite@git.example.org ssh-ed25519 AAAAC3Nz"}`, res.stdout) | |
| 383 | 383 | } | |
| 384 | + | ||
| 385 | + | func TestNoteRangesAndIDs(t *testing.T) { | |
| 386 | + | for _, tc := range []struct { | |
| 387 | + | input string | |
| 388 | + | start, end int32 | |
| 389 | + | }{{"1", 0, 1}, {"10-24", 10, 24}, {"2147483647", 0, 2147483647}} { | |
| 390 | + | start, end, err := parseLineRange(tc.input) | |
| 391 | + | require.NoError(t, err) | |
| 392 | + | assert.Equal(t, tc.start, start) | |
| 393 | + | assert.Equal(t, tc.end, end) | |
| 394 | + | } | |
| 395 | + | for _, input := range []string{"", "0", "-1", "1-1", "24-10", "0-2", "1-2-3", "2147483648", "a", "1-"} { | |
| 396 | + | _, _, err := parseLineRange(input) | |
| 397 | + | require.Error(t, err, input) | |
| 398 | + | } | |
| 399 | + | for _, input := range []string{"12", "^12", "#12"} { | |
| 400 | + | got, err := parseNoteID(input) | |
| 401 | + | require.NoError(t, err) | |
| 402 | + | assert.Equal(t, "12", got) | |
| 403 | + | } | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | func TestNoteFlags(t *testing.T) { | |
| 407 | + | f := &fakeAPI{addComment: func(r *ermite.AddCommentRequest) (*ermite.AddCommentResponse, error) { | |
| 408 | + | return &ermite.AddCommentResponse{Comment: &ermite.Comment{Id: "1", ReplyTo: r.GetReplyTo(), StartLine: r.GetStartLine(), Line: r.GetLine(), Verdict: r.GetVerdict()}}, nil | |
| 409 | + | }} | |
| 410 | + | url := f.serve(t) | |
| 411 | + | for _, tc := range []struct { | |
| 412 | + | flags []string | |
| 413 | + | reply string | |
| 414 | + | start int32 | |
| 415 | + | verdict string | |
| 416 | + | }{ | |
| 417 | + | {[]string{"--reply", "^12", "-m", "reply"}, "12", 0, ""}, | |
| 418 | + | {[]string{"--path", "x", "--line", "10-24", "-m", "range"}, "", 10, ""}, | |
| 419 | + | {[]string{"--approve"}, "", 0, "approve"}, | |
| 420 | + | {[]string{"--request-changes"}, "", 0, "changes_requested"}, | |
| 421 | + | } { | |
| 422 | + | res := runCLI(t, envFor(url), "", append([]string{"mr", "comment", "alpha", "1", "--json"}, tc.flags...)...).ok(t) | |
| 423 | + | got := decode[jsonComment](t, res.stdout) | |
| 424 | + | assert.Equal(t, tc.reply, got.ReplyTo) | |
| 425 | + | assert.Equal(t, tc.start, got.StartLine) | |
| 426 | + | assert.Equal(t, tc.verdict, got.Verdict) | |
| 427 | + | } | |
| 428 | + | for _, flags := range [][]string{{"--approve", "--request-changes"}, {"--reply", "1", "--path", "x"}, {"--reply", "1", "--line", "2"}, {"--reply", "1", "--side", "new"}, {"--reply", "1", "--approve"}, {"--reply", "1", "--request-changes"}} { | |
| 429 | + | require.Error(t, runCLI(t, envFor(url), "", append([]string{"mr", "comment", "alpha", "1", "-m", "x"}, flags...)...).err) | |
| 430 | + | } | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | func TestNoteDetailOutput(t *testing.T) { | |
| 434 | + | d := &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Comments: []*ermite.Comment{ | |
| 435 | + | {Id: "1", Path: "x", StartLine: 10, Line: 24, Text: "root"}, | |
| 436 | + | {Id: "3", Text: "other", Deleted: true}, | |
| 437 | + | {Id: "2", ReplyTo: "1", Text: "reply", EditedAt: ts("2026-10-10T12:00:00Z")}, | |
| 438 | + | }, Verdicts: []*ermite.AuthorVerdict{ | |
| 439 | + | {Author: "owner", Verdict: ermite.Verdict_VERDICT_APPROVE, CommentId: "1", Stale: true}, | |
| 440 | + | {Author: "bot", Verdict: ermite.Verdict_VERDICT_CHANGES_REQUESTED, CommentId: "3"}, | |
| 441 | + | }} | |
| 442 | + | var out strings.Builder | |
| 443 | + | require.NoError(t, printMRDetail(&out, d, false, false)) | |
| 444 | + | text := out.String() | |
| 445 | + | assert.Contains(t, text, "owner approved (stale), bot changes requested") | |
| 446 | + | assert.Contains(t, text, "lines 10-24") | |
| 447 | + | assert.Contains(t, text, "(edited)") | |
| 448 | + | assert.Contains(t, text, "[deleted]") | |
| 449 | + | assert.Less(t, strings.Index(text, " #2"), strings.Index(text, " #3")) | |
| 450 | + | got := toJSONDetail(d, false) | |
| 451 | + | require.Len(t, got.Verdicts, 2) | |
| 452 | + | assert.True(t, got.Verdicts[0].Stale) | |
| 453 | + | assert.Equal(t, "changes_requested", got.Verdicts[1].Verdict) | |
| 454 | + | assert.Equal(t, "1", got.Comments[2].ReplyTo) | |
| 455 | + | assert.NotNil(t, got.Comments[2].EditedAt) | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | func TestMRReviewScopeCompletion(t *testing.T) { | |
| 459 | + | cmd := newTokenCreateCmd(&cliEnv{}) | |
| 460 | + | complete, ok := cmd.GetFlagCompletionFunc("scope") | |
| 461 | + | require.True(t, ok) | |
| 462 | + | values, _ := complete(cmd, nil, "") | |
| 463 | + | assert.Contains(t, values, "mr:review") | |
| 464 | + | } | |
| @@ -381,3 +381,84 @@ func TestSigningKeyShowOutput(t *testing.T) { | |||||
| 381 | res = runCLI(t, envFor(url), "", "signing-key", "show", "--json").ok(t) | 381 | res = runCLI(t, envFor(url), "", "signing-key", "show", "--json").ok(t) | ||
| 382 | assert.JSONEq(t, `{"public_key":"ssh-ed25519 AAAAC3Nz","fingerprint":"SHA256:abc","committer_name":"ermite","committer_email":"ermite@git.example.org","allowed_signers":"ermite@git.example.org ssh-ed25519 AAAAC3Nz"}`, res.stdout) | 382 | assert.JSONEq(t, `{"public_key":"ssh-ed25519 AAAAC3Nz","fingerprint":"SHA256:abc","committer_name":"ermite","committer_email":"ermite@git.example.org","allowed_signers":"ermite@git.example.org ssh-ed25519 AAAAC3Nz"}`, res.stdout) | ||
| 383 | } | 383 | } | ||
| 384 | + | ||||
| 385 | + | func TestNoteRangesAndIDs(t *testing.T) { | |||
| 386 | + | for _, tc := range []struct { | |||
| 387 | + | input string | |||
| 388 | + | start, end int32 | |||
| 389 | + | }{{"1", 0, 1}, {"10-24", 10, 24}, {"2147483647", 0, 2147483647}} { | |||
| 390 | + | start, end, err := parseLineRange(tc.input) | |||
| 391 | + | require.NoError(t, err) | |||
| 392 | + | assert.Equal(t, tc.start, start) | |||
| 393 | + | assert.Equal(t, tc.end, end) | |||
| 394 | + | } | |||
| 395 | + | for _, input := range []string{"", "0", "-1", "1-1", "24-10", "0-2", "1-2-3", "2147483648", "a", "1-"} { | |||
| 396 | + | _, _, err := parseLineRange(input) | |||
| 397 | + | require.Error(t, err, input) | |||
| 398 | + | } | |||
| 399 | + | for _, input := range []string{"12", "^12", "#12"} { | |||
| 400 | + | got, err := parseNoteID(input) | |||
| 401 | + | require.NoError(t, err) | |||
| 402 | + | assert.Equal(t, "12", got) | |||
| 403 | + | } | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | func TestNoteFlags(t *testing.T) { | |||
| 407 | + | f := &fakeAPI{addComment: func(r *ermite.AddCommentRequest) (*ermite.AddCommentResponse, error) { | |||
| 408 | + | return &ermite.AddCommentResponse{Comment: &ermite.Comment{Id: "1", ReplyTo: r.GetReplyTo(), StartLine: r.GetStartLine(), Line: r.GetLine(), Verdict: r.GetVerdict()}}, nil | |||
| 409 | + | }} | |||
| 410 | + | url := f.serve(t) | |||
| 411 | + | for _, tc := range []struct { | |||
| 412 | + | flags []string | |||
| 413 | + | reply string | |||
| 414 | + | start int32 | |||
| 415 | + | verdict string | |||
| 416 | + | }{ | |||
| 417 | + | {[]string{"--reply", "^12", "-m", "reply"}, "12", 0, ""}, | |||
| 418 | + | {[]string{"--path", "x", "--line", "10-24", "-m", "range"}, "", 10, ""}, | |||
| 419 | + | {[]string{"--approve"}, "", 0, "approve"}, | |||
| 420 | + | {[]string{"--request-changes"}, "", 0, "changes_requested"}, | |||
| 421 | + | } { | |||
| 422 | + | res := runCLI(t, envFor(url), "", append([]string{"mr", "comment", "alpha", "1", "--json"}, tc.flags...)...).ok(t) | |||
| 423 | + | got := decode[jsonComment](t, res.stdout) | |||
| 424 | + | assert.Equal(t, tc.reply, got.ReplyTo) | |||
| 425 | + | assert.Equal(t, tc.start, got.StartLine) | |||
| 426 | + | assert.Equal(t, tc.verdict, got.Verdict) | |||
| 427 | + | } | |||
| 428 | + | for _, flags := range [][]string{{"--approve", "--request-changes"}, {"--reply", "1", "--path", "x"}, {"--reply", "1", "--line", "2"}, {"--reply", "1", "--side", "new"}, {"--reply", "1", "--approve"}, {"--reply", "1", "--request-changes"}} { | |||
| 429 | + | require.Error(t, runCLI(t, envFor(url), "", append([]string{"mr", "comment", "alpha", "1", "-m", "x"}, flags...)...).err) | |||
| 430 | + | } | |||
| 431 | + | } | |||
| 432 | + | ||||
| 433 | + | func TestNoteDetailOutput(t *testing.T) { | |||
| 434 | + | d := &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Comments: []*ermite.Comment{ | |||
| 435 | + | {Id: "1", Path: "x", StartLine: 10, Line: 24, Text: "root"}, | |||
| 436 | + | {Id: "3", Text: "other", Deleted: true}, | |||
| 437 | + | {Id: "2", ReplyTo: "1", Text: "reply", EditedAt: ts("2026-10-10T12:00:00Z")}, | |||
| 438 | + | }, Verdicts: []*ermite.AuthorVerdict{ | |||
| 439 | + | {Author: "owner", Verdict: ermite.Verdict_VERDICT_APPROVE, CommentId: "1", Stale: true}, | |||
| 440 | + | {Author: "bot", Verdict: ermite.Verdict_VERDICT_CHANGES_REQUESTED, CommentId: "3"}, | |||
| 441 | + | }} | |||
| 442 | + | var out strings.Builder | |||
| 443 | + | require.NoError(t, printMRDetail(&out, d, false, false)) | |||
| 444 | + | text := out.String() | |||
| 445 | + | assert.Contains(t, text, "owner approved (stale), bot changes requested") | |||
| 446 | + | assert.Contains(t, text, "lines 10-24") | |||
| 447 | + | assert.Contains(t, text, "(edited)") | |||
| 448 | + | assert.Contains(t, text, "[deleted]") | |||
| 449 | + | assert.Less(t, strings.Index(text, " #2"), strings.Index(text, " #3")) | |||
| 450 | + | got := toJSONDetail(d, false) | |||
| 451 | + | require.Len(t, got.Verdicts, 2) | |||
| 452 | + | assert.True(t, got.Verdicts[0].Stale) | |||
| 453 | + | assert.Equal(t, "changes_requested", got.Verdicts[1].Verdict) | |||
| 454 | + | assert.Equal(t, "1", got.Comments[2].ReplyTo) | |||
| 455 | + | assert.NotNil(t, got.Comments[2].EditedAt) | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | func TestMRReviewScopeCompletion(t *testing.T) { | |||
| 459 | + | cmd := newTokenCreateCmd(&cliEnv{}) | |||
| 460 | + | complete, ok := cmd.GetFlagCompletionFunc("scope") | |||
| 461 | + | require.True(t, ok) | |||
| 462 | + | values, _ := complete(cmd, nil, "") | |||
| 463 | + | assert.Contains(t, values, "mr:review") | |||
| 464 | + | } | |||
M internal/app/cli_token.go +1 -1
| @@ -86,7 +86,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||
| 86 | 86 | "or store it right away); details go to standard error. With --json, standard output is one\n" + | |
| 87 | 87 | "object holding the token fields and the secret.\n\n" + | |
| 88 | 88 | "What the token may do is chosen with --preset and/or --scope (they add up); there is no default:\n" + | |
| 89 | - | " cli every scope, for your own daily use\n" + | |
| 89 | + | " cli every scope, including mr:review for verdicts, for your own daily use\n" + | |
| 90 | 90 | " agent repo:write, mr:write\n" + | |
| 91 | 91 | " runner runner:register (a CI runner host)\n" + | |
| 92 | 92 | " ci repo:read\n" + | |
| @@ -86,7 +86,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command { | |||||
| 86 | "or store it right away); details go to standard error. With --json, standard output is one\n" + | 86 | "or store it right away); details go to standard error. With --json, standard output is one\n" + | ||
| 87 | "object holding the token fields and the secret.\n\n" + | 87 | "object holding the token fields and the secret.\n\n" + | ||
| 88 | "What the token may do is chosen with --preset and/or --scope (they add up); there is no default:\n" + | 88 | "What the token may do is chosen with --preset and/or --scope (they add up); there is no default:\n" + | ||
| 89 | - | " cli every scope, for your own daily use\n" + | 89 | + | " cli every scope, including mr:review for verdicts, for your own daily use\n" + |
| 90 | " agent repo:write, mr:write\n" + | 90 | " agent repo:write, mr:write\n" + | ||
| 91 | " runner runner:register (a CI runner host)\n" + | 91 | " runner runner:register (a CI runner host)\n" + | ||
| 92 | " ci repo:read\n" + | 92 | " ci repo:read\n" + | ||
M internal/app/cli_watch.go +21 -8
| @@ -45,10 +45,13 @@ const ( | |||
| 45 | 45 | eventPushed = "pushed" | |
| 46 | 46 | eventGap = "gap" | |
| 47 | 47 | ||
| 48 | - | eventMROpened = "merge_request_opened" | |
| 49 | - | eventMRMerged = "merge_request_merged" | |
| 50 | - | eventMRClosed = "merge_request_closed" | |
| 51 | - | eventCommentAdded = "comment_added" | |
| 48 | + | eventMROpened = "merge_request_opened" | |
| 49 | + | eventMRMerged = "merge_request_merged" | |
| 50 | + | eventMRClosed = "merge_request_closed" | |
| 51 | + | eventCommentAdded = "comment_added" | |
| 52 | + | eventCommentEdited = "comment_edited" | |
| 53 | + | eventCommentDeleted = "comment_deleted" | |
| 54 | + | eventCommentResolved = "comment_resolved" | |
| 52 | 55 | ) | |
| 53 | 56 | ||
| 54 | 57 | // toJSONEvent maps an event; ok is false for kinds this client does not know, | |
| @@ -82,6 +85,15 @@ func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) { | |||
| 82 | 85 | case *ermite.Event_CommentAdded: | |
| 83 | 86 | c := toJSONComment(k.CommentAdded.GetComment()) | |
| 84 | 87 | out.Type, out.Repo, out.Number, out.Comment = eventCommentAdded, k.CommentAdded.GetRepo(), k.CommentAdded.GetNumber(), &c | |
| 88 | + | case *ermite.Event_CommentEdited: | |
| 89 | + | c := toJSONComment(k.CommentEdited.GetComment()) | |
| 90 | + | out.Type, out.Repo, out.Number, out.Comment = eventCommentEdited, k.CommentEdited.GetRepo(), k.CommentEdited.GetNumber(), &c | |
| 91 | + | case *ermite.Event_CommentDeleted: | |
| 92 | + | c := toJSONComment(k.CommentDeleted.GetComment()) | |
| 93 | + | out.Type, out.Repo, out.Number, out.Comment = eventCommentDeleted, k.CommentDeleted.GetRepo(), k.CommentDeleted.GetNumber(), &c | |
| 94 | + | case *ermite.Event_CommentResolved: | |
| 95 | + | c := toJSONComment(k.CommentResolved.GetComment()) | |
| 96 | + | out.Type, out.Repo, out.Number, out.Comment = eventCommentResolved, k.CommentResolved.GetRepo(), k.CommentResolved.GetNumber(), &c | |
| 85 | 97 | case *ermite.Event_Gap: | |
| 86 | 98 | out.Type = eventGap | |
| 87 | 99 | default: | |
| @@ -112,7 +124,7 @@ func eventLine(e jsonEvent) string { | |||
| 112 | 124 | detail = e.Repo + " " + strings.Join(parts, ", ") | |
| 113 | 125 | case eventMROpened, eventMRMerged, eventMRClosed: | |
| 114 | 126 | detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch) | |
| 115 | - | case eventCommentAdded: | |
| 127 | + | case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved: | |
| 116 | 128 | detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author) | |
| 117 | 129 | case eventGap: | |
| 118 | 130 | detail = "events were lost, reload what you display" | |
| @@ -142,17 +154,18 @@ func newWatchCmd(env *cliEnv) *cobra.Command { | |||
| 142 | 154 | Use: "watch [repo]", | |
| 143 | 155 | Short: "Print repository events as they happen", | |
| 144 | 156 | Long: "Print one line per event (repository created, updated or deleted, refs pushed, merge requests\n" + | |
| 145 | - | "opened, merged or closed, comments added) for every\n" + | |
| 157 | + | "opened, merged or closed, notes added, edited, deleted or resolved) for every\n" + | |
| 146 | 158 | "repository you can see, or only for [repo]. It runs until interrupted. When the connection drops it\n" + | |
| 147 | 159 | "reconnects with a growing delay and resumes after the last event it printed; if the server no longer\n" + | |
| 148 | 160 | "has that event it prints a gap event: reload what you display. Connection notices go to standard\n" + | |
| 149 | 161 | "error.\n\n" + | |
| 150 | 162 | "--json prints one JSON object per line (NDJSON). Fields: id, time (RFC 3339 UTC), type\n" + | |
| 151 | 163 | "(repo_created, repo_updated, repo_deleted, pushed, merge_request_opened, merge_request_merged,\n" + | |
| 152 | - | "merge_request_closed, comment_added or gap) and, depending on the type, repo (name),\n" + | |
| 164 | + | "merge_request_closed, comment_added, comment_edited, comment_deleted, comment_resolved or gap)\n" + | |
| 165 | + | "and, depending on the type, repo (name),\n" + | |
| 153 | 166 | "repository (the repository fields of `repo show --json`), ref_updates (array of ref, old_id,\n" + | |
| 154 | 167 | "new_id; an empty id means the ref was created or deleted), merge_request (the fields of\n" + | |
| 155 | - | "`mr show --json`) and, for comment_added, number (of the merge request) and comment. Unknown\n" + | |
| 168 | + | "`mr show --json`) and, for comment events, number (of the merge request) and comment. Unknown\n" + | |
| 156 | 169 | "event types are skipped.", | |
| 157 | 170 | Args: cobra.MaximumNArgs(1), | |
| 158 | 171 | ValidArgsFunction: completeRepoNames(env), | |
| @@ -45,10 +45,13 @@ const ( | |||||
| 45 | eventPushed = "pushed" | 45 | eventPushed = "pushed" | ||
| 46 | eventGap = "gap" | 46 | eventGap = "gap" | ||
| 47 | 47 | ||||
| 48 | - | eventMROpened = "merge_request_opened" | 48 | + | eventMROpened = "merge_request_opened" |
| 49 | - | eventMRMerged = "merge_request_merged" | 49 | + | eventMRMerged = "merge_request_merged" |
| 50 | - | eventMRClosed = "merge_request_closed" | 50 | + | eventMRClosed = "merge_request_closed" |
| 51 | - | eventCommentAdded = "comment_added" | 51 | + | eventCommentAdded = "comment_added" |
| 52 | + | eventCommentEdited = "comment_edited" | |||
| 53 | + | eventCommentDeleted = "comment_deleted" | |||
| 54 | + | eventCommentResolved = "comment_resolved" | |||
| 52 | ) | 55 | ) | ||
| 53 | 56 | ||||
| 54 | // toJSONEvent maps an event; ok is false for kinds this client does not know, | 57 | // toJSONEvent maps an event; ok is false for kinds this client does not know, | ||
| @@ -82,6 +85,15 @@ func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) { | |||||
| 82 | case *ermite.Event_CommentAdded: | 85 | case *ermite.Event_CommentAdded: | ||
| 83 | c := toJSONComment(k.CommentAdded.GetComment()) | 86 | c := toJSONComment(k.CommentAdded.GetComment()) | ||
| 84 | out.Type, out.Repo, out.Number, out.Comment = eventCommentAdded, k.CommentAdded.GetRepo(), k.CommentAdded.GetNumber(), &c | 87 | out.Type, out.Repo, out.Number, out.Comment = eventCommentAdded, k.CommentAdded.GetRepo(), k.CommentAdded.GetNumber(), &c | ||
| 88 | + | case *ermite.Event_CommentEdited: | |||
| 89 | + | c := toJSONComment(k.CommentEdited.GetComment()) | |||
| 90 | + | out.Type, out.Repo, out.Number, out.Comment = eventCommentEdited, k.CommentEdited.GetRepo(), k.CommentEdited.GetNumber(), &c | |||
| 91 | + | case *ermite.Event_CommentDeleted: | |||
| 92 | + | c := toJSONComment(k.CommentDeleted.GetComment()) | |||
| 93 | + | out.Type, out.Repo, out.Number, out.Comment = eventCommentDeleted, k.CommentDeleted.GetRepo(), k.CommentDeleted.GetNumber(), &c | |||
| 94 | + | case *ermite.Event_CommentResolved: | |||
| 95 | + | c := toJSONComment(k.CommentResolved.GetComment()) | |||
| 96 | + | out.Type, out.Repo, out.Number, out.Comment = eventCommentResolved, k.CommentResolved.GetRepo(), k.CommentResolved.GetNumber(), &c | |||
| 85 | case *ermite.Event_Gap: | 97 | case *ermite.Event_Gap: | ||
| 86 | out.Type = eventGap | 98 | out.Type = eventGap | ||
| 87 | default: | 99 | default: | ||
| @@ -112,7 +124,7 @@ func eventLine(e jsonEvent) string { | |||||
| 112 | detail = e.Repo + " " + strings.Join(parts, ", ") | 124 | detail = e.Repo + " " + strings.Join(parts, ", ") | ||
| 113 | case eventMROpened, eventMRMerged, eventMRClosed: | 125 | case eventMROpened, eventMRMerged, eventMRClosed: | ||
| 114 | detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch) | 126 | detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch) | ||
| 115 | - | case eventCommentAdded: | 127 | + | case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved: |
| 116 | detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author) | 128 | detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author) | ||
| 117 | case eventGap: | 129 | case eventGap: | ||
| 118 | detail = "events were lost, reload what you display" | 130 | detail = "events were lost, reload what you display" | ||
| @@ -142,17 +154,18 @@ func newWatchCmd(env *cliEnv) *cobra.Command { | |||||
| 142 | Use: "watch [repo]", | 154 | Use: "watch [repo]", | ||
| 143 | Short: "Print repository events as they happen", | 155 | Short: "Print repository events as they happen", | ||
| 144 | Long: "Print one line per event (repository created, updated or deleted, refs pushed, merge requests\n" + | 156 | Long: "Print one line per event (repository created, updated or deleted, refs pushed, merge requests\n" + | ||
| 145 | - | "opened, merged or closed, comments added) for every\n" + | 157 | + | "opened, merged or closed, notes added, edited, deleted or resolved) for every\n" + |
| 146 | "repository you can see, or only for [repo]. It runs until interrupted. When the connection drops it\n" + | 158 | "repository you can see, or only for [repo]. It runs until interrupted. When the connection drops it\n" + | ||
| 147 | "reconnects with a growing delay and resumes after the last event it printed; if the server no longer\n" + | 159 | "reconnects with a growing delay and resumes after the last event it printed; if the server no longer\n" + | ||
| 148 | "has that event it prints a gap event: reload what you display. Connection notices go to standard\n" + | 160 | "has that event it prints a gap event: reload what you display. Connection notices go to standard\n" + | ||
| 149 | "error.\n\n" + | 161 | "error.\n\n" + | ||
| 150 | "--json prints one JSON object per line (NDJSON). Fields: id, time (RFC 3339 UTC), type\n" + | 162 | "--json prints one JSON object per line (NDJSON). Fields: id, time (RFC 3339 UTC), type\n" + | ||
| 151 | "(repo_created, repo_updated, repo_deleted, pushed, merge_request_opened, merge_request_merged,\n" + | 163 | "(repo_created, repo_updated, repo_deleted, pushed, merge_request_opened, merge_request_merged,\n" + | ||
| 152 | - | "merge_request_closed, comment_added or gap) and, depending on the type, repo (name),\n" + | 164 | + | "merge_request_closed, comment_added, comment_edited, comment_deleted, comment_resolved or gap)\n" + |
| 165 | + | "and, depending on the type, repo (name),\n" + | |||
| 153 | "repository (the repository fields of `repo show --json`), ref_updates (array of ref, old_id,\n" + | 166 | "repository (the repository fields of `repo show --json`), ref_updates (array of ref, old_id,\n" + | ||
| 154 | "new_id; an empty id means the ref was created or deleted), merge_request (the fields of\n" + | 167 | "new_id; an empty id means the ref was created or deleted), merge_request (the fields of\n" + | ||
| 155 | - | "`mr show --json`) and, for comment_added, number (of the merge request) and comment. Unknown\n" + | 168 | + | "`mr show --json`) and, for comment events, number (of the merge request) and comment. Unknown\n" + |
| 156 | "event types are skipped.", | 169 | "event types are skipped.", | ||
| 157 | Args: cobra.MaximumNArgs(1), | 170 | Args: cobra.MaximumNArgs(1), | ||
| 158 | ValidArgsFunction: completeRepoNames(env), | 171 | ValidArgsFunction: completeRepoNames(env), | ||
M internal/app/cli_watch_test.go +24 -0
| @@ -199,3 +199,27 @@ func TestWatchMergeRequestEvents(t *testing.T) { | |||
| 199 | 199 | assert.Equal(t, "merge_request_merged", decode[eventOut](t, lines[2]).Type) | |
| 200 | 200 | assert.Equal(t, "merge_request_closed", decode[eventOut](t, lines[3]).Type) | |
| 201 | 201 | } | |
| 202 | + | ||
| 203 | + | func TestNoteWatchEvents(t *testing.T) { | |
| 204 | + | note := &ermite.Comment{Id: "4", ReplyTo: "2", StartLine: 10, Line: 24, Verdict: ermite.Verdict_VERDICT_APPROVE, EditedAt: ts("2026-10-10T12:00:00Z"), Deleted: true, Author: "owner"} | |
| 205 | + | events := []*ermite.Event{ | |
| 206 | + | {Kind: &ermite.Event_CommentEdited{CommentEdited: &ermite.CommentEdited{Repo: "alpha", Number: 1, Comment: note}}}, | |
| 207 | + | {Kind: &ermite.Event_CommentDeleted{CommentDeleted: &ermite.CommentDeleted{Repo: "alpha", Number: 1, Comment: note}}}, | |
| 208 | + | {Kind: &ermite.Event_CommentResolved{CommentResolved: &ermite.CommentResolved{Repo: "alpha", Number: 1, Comment: note}}}, | |
| 209 | + | } | |
| 210 | + | for i, ev := range events { | |
| 211 | + | got, ok := toJSONEvent(ev) | |
| 212 | + | require.True(t, ok) | |
| 213 | + | assert.Equal(t, []string{"comment_edited", "comment_deleted", "comment_resolved"}[i], got.Type) | |
| 214 | + | assert.Equal(t, "alpha", got.Repo) | |
| 215 | + | assert.EqualValues(t, 1, got.Number) | |
| 216 | + | require.NotNil(t, got.Comment) | |
| 217 | + | assert.True(t, got.Comment.Deleted) | |
| 218 | + | assert.Equal(t, "2", got.Comment.ReplyTo) | |
| 219 | + | assert.Equal(t, "approve", got.Comment.Verdict) | |
| 220 | + | assert.Contains(t, eventLine(got), got.Type+" alpha!1 by owner") | |
| 221 | + | raw, err := json.Marshal(got) | |
| 222 | + | require.NoError(t, err) | |
| 223 | + | assert.Contains(t, string(raw), `"edited_at":"2026-10-10T12:00:00Z"`) | |
| 224 | + | } | |
| 225 | + | } | |
| @@ -199,3 +199,27 @@ func TestWatchMergeRequestEvents(t *testing.T) { | |||||
| 199 | assert.Equal(t, "merge_request_merged", decode[eventOut](t, lines[2]).Type) | 199 | assert.Equal(t, "merge_request_merged", decode[eventOut](t, lines[2]).Type) | ||
| 200 | assert.Equal(t, "merge_request_closed", decode[eventOut](t, lines[3]).Type) | 200 | assert.Equal(t, "merge_request_closed", decode[eventOut](t, lines[3]).Type) | ||
| 201 | } | 201 | } | ||
| 202 | + | ||||
| 203 | + | func TestNoteWatchEvents(t *testing.T) { | |||
| 204 | + | note := &ermite.Comment{Id: "4", ReplyTo: "2", StartLine: 10, Line: 24, Verdict: ermite.Verdict_VERDICT_APPROVE, EditedAt: ts("2026-10-10T12:00:00Z"), Deleted: true, Author: "owner"} | |||
| 205 | + | events := []*ermite.Event{ | |||
| 206 | + | {Kind: &ermite.Event_CommentEdited{CommentEdited: &ermite.CommentEdited{Repo: "alpha", Number: 1, Comment: note}}}, | |||
| 207 | + | {Kind: &ermite.Event_CommentDeleted{CommentDeleted: &ermite.CommentDeleted{Repo: "alpha", Number: 1, Comment: note}}}, | |||
| 208 | + | {Kind: &ermite.Event_CommentResolved{CommentResolved: &ermite.CommentResolved{Repo: "alpha", Number: 1, Comment: note}}}, | |||
| 209 | + | } | |||
| 210 | + | for i, ev := range events { | |||
| 211 | + | got, ok := toJSONEvent(ev) | |||
| 212 | + | require.True(t, ok) | |||
| 213 | + | assert.Equal(t, []string{"comment_edited", "comment_deleted", "comment_resolved"}[i], got.Type) | |||
| 214 | + | assert.Equal(t, "alpha", got.Repo) | |||
| 215 | + | assert.EqualValues(t, 1, got.Number) | |||
| 216 | + | require.NotNil(t, got.Comment) | |||
| 217 | + | assert.True(t, got.Comment.Deleted) | |||
| 218 | + | assert.Equal(t, "2", got.Comment.ReplyTo) | |||
| 219 | + | assert.Equal(t, "approve", got.Comment.Verdict) | |||
| 220 | + | assert.Contains(t, eventLine(got), got.Type+" alpha!1 by owner") | |||
| 221 | + | raw, err := json.Marshal(got) | |||
| 222 | + | require.NoError(t, err) | |||
| 223 | + | assert.Contains(t, string(raw), `"edited_at":"2026-10-10T12:00:00Z"`) | |||
| 224 | + | } | |||
| 225 | + | } | |||