detached 3a784d4

feat(mr): note model with threads, ranges, verdicts and the mr:review scope

commit
3a784d4efe9143a78b0e3dd2f02c0d980edd031d
author
brice · · 2026-10-19 05:26 UTC
parent
0039612
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
23 files · +385 -49
M gen/ermite/storage/merge_request.pb.go +122 -18 generated file: collapsed
M internal/adapters/client/scopes_test.go +1 -0
@@ -16,6 +16,7 @@ import (
1616
1717 func TestScopeWireNames(t *testing.T) {
1818 want := map[ermite.Scope]string{
19+ ermite.Scope_SCOPE_MR_REVIEW: "mr:review",
1920 ermite.Scope_SCOPE_REPO_READ: "repo:read",
2021 ermite.Scope_SCOPE_REPO_WRITE: "repo:write",
2122 ermite.Scope_SCOPE_REPO_ADMIN: "repo:admin",
@@ -16,6 +16,7 @@ import (
16 16
17 func TestScopeWireNames(t *testing.T) {17 func TestScopeWireNames(t *testing.T) {
18 want := map[ermite.Scope]string{18 want := map[ermite.Scope]string{
19+ ermite.Scope_SCOPE_MR_REVIEW: "mr:review",
19 ermite.Scope_SCOPE_REPO_READ: "repo:read",20 ermite.Scope_SCOPE_REPO_READ: "repo:read",
20 ermite.Scope_SCOPE_REPO_WRITE: "repo:write",21 ermite.Scope_SCOPE_REPO_WRITE: "repo:write",
21 ermite.Scope_SCOPE_REPO_ADMIN: "repo:admin",22 ermite.Scope_SCOPE_REPO_ADMIN: "repo:admin",
M internal/adapters/kvstore/mergerequests.go +6 -0
@@ -570,6 +570,11 @@ func encodeComment(c domain.Comment) ([]byte, error) {
570570 return nil, err
571571 }
572572 return mustMarshal(&storagepb.Comment{
573+ ReplyTo: c.ReplyTo,
574+ StartLine: int32(c.StartLine), //nolint:gosec // validated against the diff
575+ Verdict: storagepb.Verdict(c.Verdict),
576+ EditedAt: encodeTime(c.EditedAt),
577+ Deleted: c.Deleted,
573578 Id: c.ID,
574579 Author: c.Author,
575580 Path: c.Path,
@@ -593,6 +598,7 @@ func decodeComment(item kv.Item) (domain.Comment, error) {
593598 return domain.Comment{}, fmt.Errorf("decode comment %d: %w", pb.GetId(), err)
594599 }
595600 c := domain.Comment{
601+ ReplyTo: pb.GetReplyTo(), StartLine: int(pb.GetStartLine()), Verdict: domain.Verdict(pb.GetVerdict()), EditedAt: decodeTime(pb.GetEditedAt()), Deleted: pb.GetDeleted(),
596602 ID: pb.GetId(), Author: pb.GetAuthor(), Path: pb.GetPath(), Line: int(pb.GetLine()),
597603 Side: decodeSide(pb.GetSide()), Text: text, Resolved: pb.GetResolved(),
598604 CreatedAt: decodeTime(pb.GetCreatedAt()), Version: item.Version,
@@ -570,6 +570,11 @@ func encodeComment(c domain.Comment) ([]byte, error) {
570 return nil, err570 return nil, err
571 }571 }
572 return mustMarshal(&storagepb.Comment{572 return mustMarshal(&storagepb.Comment{
573+ ReplyTo: c.ReplyTo,
574+ StartLine: int32(c.StartLine), //nolint:gosec // validated against the diff
575+ Verdict: storagepb.Verdict(c.Verdict),
576+ EditedAt: encodeTime(c.EditedAt),
577+ Deleted: c.Deleted,
573 Id: c.ID,578 Id: c.ID,
574 Author: c.Author,579 Author: c.Author,
575 Path: c.Path,580 Path: c.Path,
@@ -593,6 +598,7 @@ func decodeComment(item kv.Item) (domain.Comment, error) {
593 return domain.Comment{}, fmt.Errorf("decode comment %d: %w", pb.GetId(), err)598 return domain.Comment{}, fmt.Errorf("decode comment %d: %w", pb.GetId(), err)
594 }599 }
595 c := domain.Comment{600 c := domain.Comment{
601+ ReplyTo: pb.GetReplyTo(), StartLine: int(pb.GetStartLine()), Verdict: domain.Verdict(pb.GetVerdict()), EditedAt: decodeTime(pb.GetEditedAt()), Deleted: pb.GetDeleted(),
596 ID: pb.GetId(), Author: pb.GetAuthor(), Path: pb.GetPath(), Line: int(pb.GetLine()),602 ID: pb.GetId(), Author: pb.GetAuthor(), Path: pb.GetPath(), Line: int(pb.GetLine()),
597 Side: decodeSide(pb.GetSide()), Text: text, Resolved: pb.GetResolved(),603 Side: decodeSide(pb.GetSide()), Text: text, Resolved: pb.GetResolved(),
598 CreatedAt: decodeTime(pb.GetCreatedAt()), Version: item.Version,604 CreatedAt: decodeTime(pb.GetCreatedAt()), Version: item.Version,
M internal/adapters/kvstore/mergerequests_test.go +25 -0
@@ -547,3 +547,28 @@ func TestSigningSeedRejectsBadGenerators(t *testing.T) {
547547 _, err = s.SigningSeed(ctx, func() ([]byte, error) { return nil, assert.AnError }, created)
548548 require.ErrorIs(t, err, assert.AnError)
549549 }
550+
551+func TestNoteFieldsRoundTrip(t *testing.T) {
552+ ctx := context.Background()
553+ _, store, name := repoWithMRs(t)
554+ mr, err := store.Create(ctx, newMR(t, name, "notes"))
555+ require.NoError(t, err)
556+ for id := int64(1); id <= 2; id++ {
557+ mr, err = store.Get(ctx, name, mr.Number)
558+ require.NoError(t, err)
559+ c := newComment(t, id, "note")
560+ if id == 2 {
561+ c.ReplyTo = 1
562+ c.StartLine = 1
563+ c.Verdict = domain.VerdictApprove
564+ c.EditedAt = created
565+ c.Deleted = true
566+ }
567+ mr.CommentSeq = id
568+ require.NoError(t, store.SaveComment(ctx, mr, c))
569+ got, err := store.GetComment(ctx, name, mr.Number, id)
570+ require.NoError(t, err)
571+ c.Version = got.Version
572+ assert.Equal(t, c, got)
573+ }
574+}
@@ -547,3 +547,28 @@ func TestSigningSeedRejectsBadGenerators(t *testing.T) {
547 _, err = s.SigningSeed(ctx, func() ([]byte, error) { return nil, assert.AnError }, created)547 _, err = s.SigningSeed(ctx, func() ([]byte, error) { return nil, assert.AnError }, created)
548 require.ErrorIs(t, err, assert.AnError)548 require.ErrorIs(t, err, assert.AnError)
549 }549 }
550+
551+func TestNoteFieldsRoundTrip(t *testing.T) {
552+ ctx := context.Background()
553+ _, store, name := repoWithMRs(t)
554+ mr, err := store.Create(ctx, newMR(t, name, "notes"))
555+ require.NoError(t, err)
556+ for id := int64(1); id <= 2; id++ {
557+ mr, err = store.Get(ctx, name, mr.Number)
558+ require.NoError(t, err)
559+ c := newComment(t, id, "note")
560+ if id == 2 {
561+ c.ReplyTo = 1
562+ c.StartLine = 1
563+ c.Verdict = domain.VerdictApprove
564+ c.EditedAt = created
565+ c.Deleted = true
566+ }
567+ mr.CommentSeq = id
568+ require.NoError(t, store.SaveComment(ctx, mr, c))
569+ got, err := store.GetComment(ctx, name, mr.Number, id)
570+ require.NoError(t, err)
571+ c.Version = got.Version
572+ assert.Equal(t, c, got)
573+ }
574+}
M internal/adapters/rpc/convert.go +1 -0
@@ -124,6 +124,7 @@ func mergeModesFromPB(in []pb.MergeMode) ([]domain.MergeMode, error) {
124124 }
125125
126126 var scopeToPB = map[domain.Scope]pb.Scope{
127+ domain.ScopeMRReview: pb.Scope_SCOPE_MR_REVIEW,
127128 domain.ScopeRepoRead: pb.Scope_SCOPE_REPO_READ,
128129 domain.ScopeRepoWrite: pb.Scope_SCOPE_REPO_WRITE,
129130 domain.ScopeRepoAdmin: pb.Scope_SCOPE_REPO_ADMIN,
@@ -124,6 +124,7 @@ func mergeModesFromPB(in []pb.MergeMode) ([]domain.MergeMode, error) {
124 }124 }
125 125
126 var scopeToPB = map[domain.Scope]pb.Scope{126 var scopeToPB = map[domain.Scope]pb.Scope{
127+ domain.ScopeMRReview: pb.Scope_SCOPE_MR_REVIEW,
127 domain.ScopeRepoRead: pb.Scope_SCOPE_REPO_READ,128 domain.ScopeRepoRead: pb.Scope_SCOPE_REPO_READ,
128 domain.ScopeRepoWrite: pb.Scope_SCOPE_REPO_WRITE,129 domain.ScopeRepoWrite: pb.Scope_SCOPE_REPO_WRITE,
129 domain.ScopeRepoAdmin: pb.Scope_SCOPE_REPO_ADMIN,130 domain.ScopeRepoAdmin: pb.Scope_SCOPE_REPO_ADMIN,
M internal/adapters/rpc/mergerequest.go +10 -0
@@ -17,6 +17,16 @@ import (
1717 // mergeRequestService implements ermiteconnect.MergeRequestServiceHandler.
1818 type mergeRequestService struct{ mrs ports.MergeRequestService }
1919
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"))
23+}
24+
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"))
28+}
29+
2030 func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState {
2131 switch s {
2232 case domain.MRStateOpen:
@@ -17,6 +17,16 @@ 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.
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"))
23+}
24+
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"))
28+}
29+
20 func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState {30 func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState {
21 switch s {31 switch s {
22 case domain.MRStateOpen:32 case domain.MRStateOpen:
M internal/domain/event.go +39 -0
@@ -173,6 +173,45 @@ type CommentAdded struct {
173173 // EventKind implements Event.
174174 func (CommentAdded) EventKind() string { return "mr.comment_added" }
175175
176+// CommentEdited is published when a comment was added to a merge request.
177+type CommentEdited struct {
178+ Repo RepoName
179+ Number int64
180+ Comment Comment
181+ At time.Time
182+ Visibility Visibility
183+ CreatedAt time.Time
184+}
185+
186+// EventKind implements Event.
187+func (CommentEdited) EventKind() string { return "mr.comment_edited" }
188+
189+// CommentDeleted is published when a comment was added to a merge request.
190+type CommentDeleted struct {
191+ Repo RepoName
192+ Number int64
193+ Comment Comment
194+ At time.Time
195+ Visibility Visibility
196+ CreatedAt time.Time
197+}
198+
199+// EventKind implements Event.
200+func (CommentDeleted) EventKind() string { return "mr.comment_deleted" }
201+
202+// CommentResolved is published when a comment was added to a merge request.
203+type CommentResolved struct {
204+ Repo RepoName
205+ Number int64
206+ Comment Comment
207+ At time.Time
208+ Visibility Visibility
209+ CreatedAt time.Time
210+}
211+
212+// EventKind implements Event.
213+func (CommentResolved) EventKind() string { return "mr.comment_resolved" }
214+
176215 // EventGap tells a subscriber that it missed events because it was too slow;
177216 // it must refresh its whole view.
178217 type EventGap struct{}
@@ -173,6 +173,45 @@ type CommentAdded struct {
173 // EventKind implements Event.173 // EventKind implements Event.
174 func (CommentAdded) EventKind() string { return "mr.comment_added" }174 func (CommentAdded) EventKind() string { return "mr.comment_added" }
175 175
176+// CommentEdited is published when a comment was added to a merge request.
177+type CommentEdited struct {
178+ Repo RepoName
179+ Number int64
180+ Comment Comment
181+ At time.Time
182+ Visibility Visibility
183+ CreatedAt time.Time
184+}
185+
186+// EventKind implements Event.
187+func (CommentEdited) EventKind() string { return "mr.comment_edited" }
188+
189+// CommentDeleted is published when a comment was added to a merge request.
190+type CommentDeleted struct {
191+ Repo RepoName
192+ Number int64
193+ Comment Comment
194+ At time.Time
195+ Visibility Visibility
196+ CreatedAt time.Time
197+}
198+
199+// EventKind implements Event.
200+func (CommentDeleted) EventKind() string { return "mr.comment_deleted" }
201+
202+// CommentResolved is published when a comment was added to a merge request.
203+type CommentResolved struct {
204+ Repo RepoName
205+ Number int64
206+ Comment Comment
207+ At time.Time
208+ Visibility Visibility
209+ CreatedAt time.Time
210+}
211+
212+// EventKind implements Event.
213+func (CommentResolved) EventKind() string { return "mr.comment_resolved" }
214+
176 // EventGap tells a subscriber that it missed events because it was too slow;215 // EventGap tells a subscriber that it missed events because it was too slow;
177 // it must refresh its whole view.216 // it must refresh its whole view.
178 type EventGap struct{}217 type EventGap struct{}
M internal/domain/mergerequest.go +80 -0
@@ -186,6 +186,11 @@ func (m MergeRequest) IsOpen() bool { return m.State == MRStateOpen }
186186
187187 // Comment is a note on a merge request, general or on a line of its diff.
188188 type Comment struct {
189+ ReplyTo int64
190+ StartLine int
191+ Verdict Verdict
192+ EditedAt time.Time
193+ Deleted bool
189194 // ID is unique within the merge request, in order of creation.
190195 ID int64
191196 Author string
@@ -348,3 +353,78 @@ func singleLine(s string) string {
348353 return r
349354 }, s)
350355 }
356+
357+// Verdict is an immutable review decision on a root note.
358+type Verdict uint8
359+
360+// Review verdicts.
361+const (
362+ VerdictNone Verdict = iota
363+ VerdictApprove
364+ VerdictChangesRequested
365+)
366+
367+// String returns the wire name of a verdict.
368+func (v Verdict) String() string {
369+ switch v {
370+ case VerdictApprove:
371+ return "approve"
372+ case VerdictChangesRequested:
373+ return "changes_requested"
374+ }
375+ return ""
376+}
377+
378+// ParseVerdict parses a review decision.
379+func ParseVerdict(s string) (Verdict, error) {
380+ switch s {
381+ case "":
382+ return VerdictNone, nil
383+ case "approve":
384+ return VerdictApprove, nil
385+ case "changes_requested":
386+ return VerdictChangesRequested, nil
387+ }
388+ return VerdictNone, fmt.Errorf("%w: verdict %q", ErrInvalid, s)
389+}
390+
391+// IsReply reports whether this note replies to a root.
392+func (c Comment) IsReply() bool { return c.ReplyTo != 0 }
393+
394+// IsRange reports whether this note anchors a range.
395+func (c Comment) IsRange() bool { return c.StartLine != 0 }
396+
397+// AuthorVerdict is an author's latest review decision.
398+type AuthorVerdict struct {
399+ Author string
400+ Verdict Verdict
401+ CommentID int64
402+ CommitID ObjectID
403+ At time.Time
404+ Stale bool
405+}
406+
407+// CurrentVerdicts returns the latest verdict per author, including deleted notes,
408+// in note ID order. Only approvals become stale when the head changes.
409+func CurrentVerdicts(comments []Comment, head ObjectID) []AuthorVerdict {
410+ latest := make(map[string]Comment)
411+ for _, c := range comments {
412+ if c.Verdict != VerdictNone && c.ID > latest[c.Author].ID {
413+ latest[c.Author] = c
414+ }
415+ }
416+ out := make([]AuthorVerdict, 0, len(latest))
417+ for _, c := range latest {
418+ out = append(out, AuthorVerdict{Author: c.Author, Verdict: c.Verdict, CommentID: c.ID, CommitID: c.CommitID, At: c.CreatedAt, Stale: c.Verdict == VerdictApprove && (head == (ObjectID{}) || c.CommitID != head)})
419+ }
420+ slices.SortFunc(out, func(a, b AuthorVerdict) int {
421+ if a.CommentID < b.CommentID {
422+ return -1
423+ }
424+ if a.CommentID > b.CommentID {
425+ return 1
426+ }
427+ return 0
428+ })
429+ return out
430+}
@@ -186,6 +186,11 @@ func (m MergeRequest) IsOpen() bool { return m.State == MRStateOpen }
186 186
187 // Comment is a note on a merge request, general or on a line of its diff.187 // Comment is a note on a merge request, general or on a line of its diff.
188 type Comment struct {188 type Comment struct {
189+ ReplyTo int64
190+ StartLine int
191+ Verdict Verdict
192+ EditedAt time.Time
193+ Deleted bool
189 // ID is unique within the merge request, in order of creation.194 // ID is unique within the merge request, in order of creation.
190 ID int64195 ID int64
191 Author string196 Author string
@@ -348,3 +353,78 @@ func singleLine(s string) string {
348 return r353 return r
349 }, s)354 }, s)
350 }355 }
356+
357+// Verdict is an immutable review decision on a root note.
358+type Verdict uint8
359+
360+// Review verdicts.
361+const (
362+ VerdictNone Verdict = iota
363+ VerdictApprove
364+ VerdictChangesRequested
365+)
366+
367+// String returns the wire name of a verdict.
368+func (v Verdict) String() string {
369+ switch v {
370+ case VerdictApprove:
371+ return "approve"
372+ case VerdictChangesRequested:
373+ return "changes_requested"
374+ }
375+ return ""
376+}
377+
378+// ParseVerdict parses a review decision.
379+func ParseVerdict(s string) (Verdict, error) {
380+ switch s {
381+ case "":
382+ return VerdictNone, nil
383+ case "approve":
384+ return VerdictApprove, nil
385+ case "changes_requested":
386+ return VerdictChangesRequested, nil
387+ }
388+ return VerdictNone, fmt.Errorf("%w: verdict %q", ErrInvalid, s)
389+}
390+
391+// IsReply reports whether this note replies to a root.
392+func (c Comment) IsReply() bool { return c.ReplyTo != 0 }
393+
394+// IsRange reports whether this note anchors a range.
395+func (c Comment) IsRange() bool { return c.StartLine != 0 }
396+
397+// AuthorVerdict is an author's latest review decision.
398+type AuthorVerdict struct {
399+ Author string
400+ Verdict Verdict
401+ CommentID int64
402+ CommitID ObjectID
403+ At time.Time
404+ Stale bool
405+}
406+
407+// CurrentVerdicts returns the latest verdict per author, including deleted notes,
408+// in note ID order. Only approvals become stale when the head changes.
409+func CurrentVerdicts(comments []Comment, head ObjectID) []AuthorVerdict {
410+ latest := make(map[string]Comment)
411+ for _, c := range comments {
412+ if c.Verdict != VerdictNone && c.ID > latest[c.Author].ID {
413+ latest[c.Author] = c
414+ }
415+ }
416+ out := make([]AuthorVerdict, 0, len(latest))
417+ for _, c := range latest {
418+ out = append(out, AuthorVerdict{Author: c.Author, Verdict: c.Verdict, CommentID: c.ID, CommitID: c.CommitID, At: c.CreatedAt, Stale: c.Verdict == VerdictApprove && (head == (ObjectID{}) || c.CommitID != head)})
419+ }
420+ slices.SortFunc(out, func(a, b AuthorVerdict) int {
421+ if a.CommentID < b.CommentID {
422+ return -1
423+ }
424+ if a.CommentID > b.CommentID {
425+ return 1
426+ }
427+ return 0
428+ })
429+ return out
430+}
M internal/domain/mergerequest_test.go +48 -0
@@ -235,3 +235,51 @@ func TestMergeRequestDraft(t *testing.T) {
235235 assert.Equal(t, tc.ready, ready.String(), tc.title)
236236 }
237237 }
238+
239+func TestNoteVerdicts(t *testing.T) {
240+ head, old := oid(t, 'a'), oid(t, 'b')
241+ for _, tc := range []struct {
242+ text string
243+ verdict domain.Verdict
244+ }{{"", domain.VerdictNone}, {"approve", domain.VerdictApprove}, {"changes_requested", domain.VerdictChangesRequested}} {
245+ v, err := domain.ParseVerdict(tc.text)
246+ require.NoError(t, err)
247+ assert.Equal(t, tc.verdict, v)
248+ assert.Equal(t, tc.text, v.String())
249+ }
250+ _, err := domain.ParseVerdict("unknown")
251+ require.ErrorIs(t, err, domain.ErrInvalid)
252+ comments := []domain.Comment{{ID: 3, Author: "a", Verdict: domain.VerdictApprove, CommitID: old, Deleted: true}, {ID: 1, Author: "a", Verdict: domain.VerdictChangesRequested}, {ID: 2, Author: "b", Verdict: domain.VerdictChangesRequested, CommitID: old}, {ID: 4, Author: "a"}}
253+ for _, h := range []domain.ObjectID{head, {}} {
254+ got := domain.CurrentVerdicts(comments, h)
255+ require.Len(t, got, 2)
256+ assert.Equal(t, int64(2), got[0].CommentID)
257+ assert.False(t, got[0].Stale)
258+ assert.Equal(t, int64(3), got[1].CommentID)
259+ assert.True(t, got[1].Stale)
260+ }
261+ comments[0].CommitID = head
262+ assert.False(t, domain.CurrentVerdicts(comments, head)[1].Stale)
263+ assert.True(t, (domain.Comment{ReplyTo: 1}).IsReply())
264+ assert.True(t, (domain.Comment{StartLine: 1}).IsRange())
265+}
266+
267+func TestReviewScopeOwnerRights(t *testing.T) {
268+ var old []domain.Scope
269+ for _, s := range domain.AllScopes() {
270+ if s != domain.ScopeMRReview {
271+ old = append(old, s)
272+ }
273+ }
274+ assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: old}).HasOwnerRights())
275+ assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes()}).HasOwnerRights())
276+ s, err := domain.ParseScope("mr:review")
277+ require.NoError(t, err)
278+ assert.Equal(t, domain.ScopeMRReview, s)
279+ for _, tc := range []struct {
280+ p domain.Principal
281+ want string
282+ }{{domain.Root(), "root"}, {domain.Owner(), "owner"}, {domain.Anonymous(), "owner"}, {domain.TokenPrincipal(domain.Token{Label: "bot"}), "bot"}} {
283+ assert.Equal(t, tc.want, domain.AuthorName(tc.p))
284+ }
285+}
@@ -235,3 +235,51 @@ func TestMergeRequestDraft(t *testing.T) {
235 assert.Equal(t, tc.ready, ready.String(), tc.title)235 assert.Equal(t, tc.ready, ready.String(), tc.title)
236 }236 }
237 }237 }
238+
239+func TestNoteVerdicts(t *testing.T) {
240+ head, old := oid(t, 'a'), oid(t, 'b')
241+ for _, tc := range []struct {
242+ text string
243+ verdict domain.Verdict
244+ }{{"", domain.VerdictNone}, {"approve", domain.VerdictApprove}, {"changes_requested", domain.VerdictChangesRequested}} {
245+ v, err := domain.ParseVerdict(tc.text)
246+ require.NoError(t, err)
247+ assert.Equal(t, tc.verdict, v)
248+ assert.Equal(t, tc.text, v.String())
249+ }
250+ _, err := domain.ParseVerdict("unknown")
251+ require.ErrorIs(t, err, domain.ErrInvalid)
252+ comments := []domain.Comment{{ID: 3, Author: "a", Verdict: domain.VerdictApprove, CommitID: old, Deleted: true}, {ID: 1, Author: "a", Verdict: domain.VerdictChangesRequested}, {ID: 2, Author: "b", Verdict: domain.VerdictChangesRequested, CommitID: old}, {ID: 4, Author: "a"}}
253+ for _, h := range []domain.ObjectID{head, {}} {
254+ got := domain.CurrentVerdicts(comments, h)
255+ require.Len(t, got, 2)
256+ assert.Equal(t, int64(2), got[0].CommentID)
257+ assert.False(t, got[0].Stale)
258+ assert.Equal(t, int64(3), got[1].CommentID)
259+ assert.True(t, got[1].Stale)
260+ }
261+ comments[0].CommitID = head
262+ assert.False(t, domain.CurrentVerdicts(comments, head)[1].Stale)
263+ assert.True(t, (domain.Comment{ReplyTo: 1}).IsReply())
264+ assert.True(t, (domain.Comment{StartLine: 1}).IsRange())
265+}
266+
267+func TestReviewScopeOwnerRights(t *testing.T) {
268+ var old []domain.Scope
269+ for _, s := range domain.AllScopes() {
270+ if s != domain.ScopeMRReview {
271+ old = append(old, s)
272+ }
273+ }
274+ assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: old}).HasOwnerRights())
275+ assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes()}).HasOwnerRights())
276+ s, err := domain.ParseScope("mr:review")
277+ require.NoError(t, err)
278+ assert.Equal(t, domain.ScopeMRReview, s)
279+ for _, tc := range []struct {
280+ p domain.Principal
281+ want string
282+ }{{domain.Root(), "root"}, {domain.Owner(), "owner"}, {domain.Anonymous(), "owner"}, {domain.TokenPrincipal(domain.Token{Label: "bot"}), "bot"}} {
283+ assert.Equal(t, tc.want, domain.AuthorName(tc.p))
284+ }
285+}
M internal/domain/principal.go +15 -0
@@ -230,3 +230,18 @@ func (p Principal) ArchiveGrant() (repo RepoName, sha string, ok bool) {
230230 }
231231 return p.archiveRepo, p.archiveSha, true
232232 }
233+
234+// AuthorName names who acts, for the author of a merge request or a comment: the
235+// label of the token, "root" for the root token, "owner" for the owner signed
236+// in on the web and for any other caller without a label.
237+func AuthorName(p Principal) string {
238+ switch {
239+ case p.IsOwner():
240+ return "owner"
241+ case p.IsRoot():
242+ return "root"
243+ case p.TokenLabel() != "":
244+ return p.TokenLabel()
245+ }
246+ return "owner"
247+}
@@ -230,3 +230,18 @@ func (p Principal) ArchiveGrant() (repo RepoName, sha string, ok bool) {
230 }230 }
231 return p.archiveRepo, p.archiveSha, true231 return p.archiveRepo, p.archiveSha, true
232 }232 }
233+
234+// AuthorName names who acts, for the author of a merge request or a comment: the
235+// label of the token, "root" for the root token, "owner" for the owner signed
236+// in on the web and for any other caller without a label.
237+func AuthorName(p Principal) string {
238+ switch {
239+ case p.IsOwner():
240+ return "owner"
241+ case p.IsRoot():
242+ return "root"
243+ case p.TokenLabel() != "":
244+ return p.TokenLabel()
245+ }
246+ return "owner"
247+}
M internal/domain/token.go +4 -2
@@ -30,6 +30,8 @@ const (
3030 ScopeMRWrite Scope = "mr:write"
3131 // ScopeMRMerge merges, and deletes the merged source branch on request.
3232 ScopeMRMerge Scope = "mr:merge"
33+ // ScopeMRReview sets a verdict on a merge request.
34+ ScopeMRReview Scope = "mr:review"
3335 // ScopeCIWrite re-runs or cancels CI runs.
3436 ScopeCIWrite Scope = "ci:write"
3537 // ScopeSecretsWrite sets and deletes secrets and variables.
@@ -40,11 +42,11 @@ const (
4042 ScopeTokenAdmin Scope = "token:admin"
4143 )
4244
43-// AllScopes returns the nine scopes in a stable order.
45+// AllScopes returns the ten scopes in a stable order.
4446 func AllScopes() []Scope {
4547 return []Scope{
4648 ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,
47- ScopeMRWrite, ScopeMRMerge,
49+ ScopeMRWrite, ScopeMRMerge, ScopeMRReview,
4850 ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,
4951 ScopeTokenAdmin,
5052 }
@@ -30,6 +30,8 @@ const (
30 ScopeMRWrite Scope = "mr:write"30 ScopeMRWrite Scope = "mr:write"
31 // ScopeMRMerge merges, and deletes the merged source branch on request.31 // ScopeMRMerge merges, and deletes the merged source branch on request.
32 ScopeMRMerge Scope = "mr:merge"32 ScopeMRMerge Scope = "mr:merge"
33+ // ScopeMRReview sets a verdict on a merge request.
34+ ScopeMRReview Scope = "mr:review"
33 // ScopeCIWrite re-runs or cancels CI runs.35 // ScopeCIWrite re-runs or cancels CI runs.
34 ScopeCIWrite Scope = "ci:write"36 ScopeCIWrite Scope = "ci:write"
35 // ScopeSecretsWrite sets and deletes secrets and variables.37 // ScopeSecretsWrite sets and deletes secrets and variables.
@@ -40,11 +42,11 @@ const (
40 ScopeTokenAdmin Scope = "token:admin"42 ScopeTokenAdmin Scope = "token:admin"
41 )43 )
42 44
43-// AllScopes returns the nine scopes in a stable order.45+// AllScopes returns the ten scopes in a stable order.
44 func AllScopes() []Scope {46 func AllScopes() []Scope {
45 return []Scope{47 return []Scope{
46 ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,48 ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,
47- ScopeMRWrite, ScopeMRMerge,49+ ScopeMRWrite, ScopeMRMerge, ScopeMRReview,
48 ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,50 ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,
49 ScopeTokenAdmin,51 ScopeTokenAdmin,
50 }52 }
M internal/domain/token_test.go +1 -1
@@ -16,7 +16,7 @@ import (
1616 func TestScopes(t *testing.T) {
1717 t.Parallel()
1818 all := domain.AllScopes()
19- assert.Len(t, all, 9)
19+ assert.Len(t, all, 10)
2020 for _, s := range all {
2121 got, err := domain.ParseScope(s.String())
2222 require.NoError(t, err)
@@ -16,7 +16,7 @@ import (
16 func TestScopes(t *testing.T) {16 func TestScopes(t *testing.T) {
17 t.Parallel()17 t.Parallel()
18 all := domain.AllScopes()18 all := domain.AllScopes()
19- assert.Len(t, all, 9)19+ assert.Len(t, all, 10)
20 for _, s := range all {20 for _, s := range all {
21 got, err := domain.ParseScope(s.String())21 got, err := domain.ParseScope(s.String())
22 require.NoError(t, err)22 require.NoError(t, err)
M internal/ports/dto.go +8 -0
@@ -212,6 +212,8 @@ type MergeRequestDetail struct {
212212 Diff domain.Diff
213213 // Comments are the comments, oldest first.
214214 Comments []domain.Comment
215+ // Verdicts are the latest verdict per author in note ID order.
216+ Verdicts []domain.AuthorVerdict
215217 // LastReadID is the head recorded by MarkRead; zero when none.
216218 LastReadID domain.ObjectID
217219 // LastReadLost tells that since-last-read was asked for, a head was
@@ -241,6 +243,12 @@ type FileWindow struct{ From, To int }
241243
242244 // AddCommentParams describes the comment to add.
243245 type AddCommentParams struct {
246+ // ReplyTo names a non-deleted root; replies cannot specify an anchor or verdict.
247+ ReplyTo int64
248+ // StartLine starts an inclusive range, requiring a path and 1 <= StartLine < Line.
249+ StartLine int
250+ // Verdict requires mr:review and is only permitted on roots.
251+ Verdict domain.Verdict
244252 // Path, Line and Side designate a line of the diff; all empty for a
245253 // general comment.
246254 Path string
@@ -212,6 +212,8 @@ type MergeRequestDetail struct {
212 Diff domain.Diff212 Diff domain.Diff
213 // Comments are the comments, oldest first.213 // Comments are the comments, oldest first.
214 Comments []domain.Comment214 Comments []domain.Comment
215+ // Verdicts are the latest verdict per author in note ID order.
216+ Verdicts []domain.AuthorVerdict
215 // LastReadID is the head recorded by MarkRead; zero when none.217 // LastReadID is the head recorded by MarkRead; zero when none.
216 LastReadID domain.ObjectID218 LastReadID domain.ObjectID
217 // LastReadLost tells that since-last-read was asked for, a head was219 // LastReadLost tells that since-last-read was asked for, a head was
@@ -241,6 +243,12 @@ type FileWindow struct{ From, To int }
241 243
242 // AddCommentParams describes the comment to add.244 // AddCommentParams describes the comment to add.
243 type AddCommentParams struct {245 type AddCommentParams struct {
246+ // ReplyTo names a non-deleted root; replies cannot specify an anchor or verdict.
247+ ReplyTo int64
248+ // StartLine starts an inclusive range, requiring a path and 1 <= StartLine < Line.
249+ StartLine int
250+ // Verdict requires mr:review and is only permitted on roots.
251+ Verdict domain.Verdict
244 // Path, Line and Side designate a line of the diff; all empty for a252 // Path, Line and Side designate a line of the diff; all empty for a
245 // general comment.253 // general comment.
246 Path string254 Path string
M internal/usecase/access.go +0 -15
@@ -91,18 +91,3 @@ func scopeAccess(p domain.Principal, repo domain.Repo, scope domain.Scope) error
9191 }
9292 return fmt.Errorf("%w: this needs the %s scope on %s", domain.ErrForbidden, scope, repo.Name)
9393 }
94-
95-// authorOf names who acts, for the author of a merge request or a comment: the
96-// label of the token, "root" for the root token, "owner" for the owner signed
97-// in on the web and for any other caller without a label.
98-func authorOf(p domain.Principal) string {
99- switch {
100- case p.IsOwner():
101- return "owner"
102- case p.IsRoot():
103- return "root"
104- case p.TokenLabel() != "":
105- return p.TokenLabel()
106- }
107- return "owner"
108-}
@@ -91,18 +91,3 @@ func scopeAccess(p domain.Principal, repo domain.Repo, scope domain.Scope) error
91 }91 }
92 return fmt.Errorf("%w: this needs the %s scope on %s", domain.ErrForbidden, scope, repo.Name)92 return fmt.Errorf("%w: this needs the %s scope on %s", domain.ErrForbidden, scope, repo.Name)
93 }93 }
94-
95-// authorOf names who acts, for the author of a merge request or a comment: the
96-// label of the token, "root" for the root token, "owner" for the owner signed
97-// in on the web and for any other caller without a label.
98-func authorOf(p domain.Principal) string {
99- switch {
100- case p.IsOwner():
101- return "owner"
102- case p.IsRoot():
103- return "root"
104- case p.TokenLabel() != "":
105- return p.TokenLabel()
106- }
107- return "owner"
108-}
M internal/usecase/ci_dispatch.go +1 -1
@@ -135,7 +135,7 @@ func (c *CI) dispatchCaller(ctx context.Context, p domain.Principal, name domain
135135 if err != nil {
136136 return repo, "", pusher, false, err
137137 }
138- return repo, authorOf(p), domain.PusherOf(p), domain.TrustedActor(p), nil
138+ return repo, domain.AuthorName(p), domain.PusherOf(p), domain.TrustedActor(p), nil
139139 }
140140
141141 // dispatchPath checks the workflow file of a dispatch: a file of
@@ -135,7 +135,7 @@ func (c *CI) dispatchCaller(ctx context.Context, p domain.Principal, name domain
135 if err != nil {135 if err != nil {
136 return repo, "", pusher, false, err136 return repo, "", pusher, false, err
137 }137 }
138- return repo, authorOf(p), domain.PusherOf(p), domain.TrustedActor(p), nil138+ return repo, domain.AuthorName(p), domain.PusherOf(p), domain.TrustedActor(p), nil
139 }139 }
140 140
141 // dispatchPath checks the workflow file of a dispatch: a file of141 // dispatchPath checks the workflow file of a dispatch: a file of
M internal/usecase/ci_pull.go +1 -1
@@ -203,7 +203,7 @@ func (c *CI) OnPullRequest(ctx context.Context, repo domain.RepoName, ev ports.P
203203 }
204204 e := pullEvent{
205205 repo: repo, number: ev.Number, action: ev.Action, head: ev.Head, base: ev.Base,
206- actor: authorOf(p), pusher: domain.PusherOf(p), run: !p.ViaJobToken(), log: zerowrap.FromCtx(ctx),
206+ actor: domain.AuthorName(p), pusher: domain.PusherOf(p), run: !p.ViaJobToken(), log: zerowrap.FromCtx(ctx),
207207 actorTrusted: domain.TrustedActor(p), target: c.defaultTipOf(ctx, repo),
208208 }
209209 if e.actorTrusted {
@@ -203,7 +203,7 @@ func (c *CI) OnPullRequest(ctx context.Context, repo domain.RepoName, ev ports.P
203 }203 }
204 e := pullEvent{204 e := pullEvent{
205 repo: repo, number: ev.Number, action: ev.Action, head: ev.Head, base: ev.Base,205 repo: repo, number: ev.Number, action: ev.Action, head: ev.Head, base: ev.Base,
206- actor: authorOf(p), pusher: domain.PusherOf(p), run: !p.ViaJobToken(), log: zerowrap.FromCtx(ctx),206+ actor: domain.AuthorName(p), pusher: domain.PusherOf(p), run: !p.ViaJobToken(), log: zerowrap.FromCtx(ctx),
207 actorTrusted: domain.TrustedActor(p), target: c.defaultTipOf(ctx, repo),207 actorTrusted: domain.TrustedActor(p), target: c.defaultTipOf(ctx, repo),
208 }208 }
209 if e.actorTrusted {209 if e.actorTrusted {
M internal/usecase/ci_runs.go +1 -1
@@ -332,7 +332,7 @@ func (c *CI) CancelRun(ctx context.Context, p domain.Principal, name domain.Repo
332332 }
333333 if !rs.run.CancelRequested {
334334 rs.run.CancelRequested, rs.run.CancelRequestedAt, rs.runDirty = true, c.now(), true
335- rs.cancelAll("Canceled by " + authorOf(p))
335+ rs.cancelAll("Canceled by " + domain.AuthorName(p))
336336 }
337337 if err := rs.flush(ctx); err != nil {
338338 return err
@@ -332,7 +332,7 @@ func (c *CI) CancelRun(ctx context.Context, p domain.Principal, name domain.Repo
332 }332 }
333 if !rs.run.CancelRequested {333 if !rs.run.CancelRequested {
334 rs.run.CancelRequested, rs.run.CancelRequestedAt, rs.runDirty = true, c.now(), true334 rs.run.CancelRequested, rs.run.CancelRequestedAt, rs.runDirty = true, c.now(), true
335- rs.cancelAll("Canceled by " + authorOf(p))335+ rs.cancelAll("Canceled by " + domain.AuthorName(p))
336 }336 }
337 if err := rs.flush(ctx); err != nil {337 if err := rs.flush(ctx); err != nil {
338 return err338 return err
M internal/usecase/ci_tips.go +1 -1
@@ -85,7 +85,7 @@ func (c *CI) scheduleActor(ctx context.Context, repo domain.RepoName, branch str
8585 if t, ok := c.refTip(ctx, repo, "refs/heads/"+branch, tip); ok {
8686 return t.Actor, t.Pusher, t.Trusted
8787 }
88- return authorOf(domain.Owner()), domain.CIPusher{}, false
88+ return domain.AuthorName(domain.Owner()), domain.CIPusher{}, false
8989 }
9090
9191 // refTrusted reports whether the ref was set to exactly the pinned object by a
@@ -85,7 +85,7 @@ func (c *CI) scheduleActor(ctx context.Context, repo domain.RepoName, branch str
85 if t, ok := c.refTip(ctx, repo, "refs/heads/"+branch, tip); ok {85 if t, ok := c.refTip(ctx, repo, "refs/heads/"+branch, tip); ok {
86 return t.Actor, t.Pusher, t.Trusted86 return t.Actor, t.Pusher, t.Trusted
87 }87 }
88- return authorOf(domain.Owner()), domain.CIPusher{}, false88+ return domain.AuthorName(domain.Owner()), domain.CIPusher{}, false
89 }89 }
90 90
91 // refTrusted reports whether the ref was set to exactly the pinned object by a91 // refTrusted reports whether the ref was set to exactly the pinned object by a
M internal/usecase/ci_trigger.go +4 -4
@@ -59,13 +59,13 @@ func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.
5959 c.markSchedulesDirty(repo, updates)
6060 // The merge requests follow the push whoever made it (their refs); only a
6161 // push that is not a job's starts pull_request runs.
62- defer c.onBranchesMoved(ctx, repo, updates, authorOf(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher), !pusher.ViaJobToken())
62+ defer c.onBranchesMoved(ctx, repo, updates, domain.AuthorName(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher), !pusher.ViaJobToken())
6363 if pusher.ViaJobToken() {
6464 log := zerowrap.FromCtx(ctx)
6565 log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("push by a job token starts no run")
6666 return
6767 }
68- c.enqueue(ctx, repo, updates, authorOf(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher))
68+ c.enqueue(ctx, repo, updates, domain.AuthorName(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher))
6969 }
7070
7171 // OnMerge queues the move of a target branch by a merge. The actor of the run is
@@ -79,13 +79,13 @@ func (c *CI) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain
7979 // The merge moved a target branch: the test merges of the other requests
8080 // into it are computed again (a merge starts no synchronize run), after the
8181 // push workflows are queued.
82- defer c.onBranchesMoved(ctx, repo, updates, authorOf(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger), false)
82+ defer c.onBranchesMoved(ctx, repo, updates, domain.AuthorName(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger), false)
8383 if merger.ViaJobToken() {
8484 log := zerowrap.FromCtx(ctx)
8585 log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("merge by a job token starts no run")
8686 return
8787 }
88- c.enqueue(ctx, repo, updates, authorOf(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger))
88+ c.enqueue(ctx, repo, updates, domain.AuthorName(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger))
8989 }
9090
9191 func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, actor string, pusher domain.CIPusher, trusted bool) {
@@ -59,13 +59,13 @@ func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.
59 c.markSchedulesDirty(repo, updates)59 c.markSchedulesDirty(repo, updates)
60 // The merge requests follow the push whoever made it (their refs); only a60 // The merge requests follow the push whoever made it (their refs); only a
61 // push that is not a job's starts pull_request runs.61 // push that is not a job's starts pull_request runs.
62- defer c.onBranchesMoved(ctx, repo, updates, authorOf(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher), !pusher.ViaJobToken())62+ defer c.onBranchesMoved(ctx, repo, updates, domain.AuthorName(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher), !pusher.ViaJobToken())
63 if pusher.ViaJobToken() {63 if pusher.ViaJobToken() {
64 log := zerowrap.FromCtx(ctx)64 log := zerowrap.FromCtx(ctx)
65 log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("push by a job token starts no run")65 log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("push by a job token starts no run")
66 return66 return
67 }67 }
68- c.enqueue(ctx, repo, updates, authorOf(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher))68+ c.enqueue(ctx, repo, updates, domain.AuthorName(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher))
69 }69 }
70 70
71 // OnMerge queues the move of a target branch by a merge. The actor of the run is71 // OnMerge queues the move of a target branch by a merge. The actor of the run is
@@ -79,13 +79,13 @@ func (c *CI) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain
79 // The merge moved a target branch: the test merges of the other requests79 // The merge moved a target branch: the test merges of the other requests
80 // into it are computed again (a merge starts no synchronize run), after the80 // into it are computed again (a merge starts no synchronize run), after the
81 // push workflows are queued.81 // push workflows are queued.
82- defer c.onBranchesMoved(ctx, repo, updates, authorOf(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger), false)82+ defer c.onBranchesMoved(ctx, repo, updates, domain.AuthorName(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger), false)
83 if merger.ViaJobToken() {83 if merger.ViaJobToken() {
84 log := zerowrap.FromCtx(ctx)84 log := zerowrap.FromCtx(ctx)
85 log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("merge by a job token starts no run")85 log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("merge by a job token starts no run")
86 return86 return
87 }87 }
88- c.enqueue(ctx, repo, updates, authorOf(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger))88+ c.enqueue(ctx, repo, updates, domain.AuthorName(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger))
89 }89 }
90 90
91 func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, actor string, pusher domain.CIPusher, trusted bool) {91 func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, actor string, pusher domain.CIPusher, trusted bool) {
M internal/usecase/export_test.go +1 -1
@@ -16,7 +16,7 @@ import (
1616 )
1717
1818 // AuthorOf exposes authorOf to the tests.
19-func AuthorOf(p domain.Principal) string { return authorOf(p) }
19+func AuthorOf(p domain.Principal) string { return domain.AuthorName(p) }
2020
2121 // Sweep exposes the daily sweep to the tests.
2222 func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }
@@ -16,7 +16,7 @@ import (
16 )16 )
17 17
18 // AuthorOf exposes authorOf to the tests.18 // AuthorOf exposes authorOf to the tests.
19-func AuthorOf(p domain.Principal) string { return authorOf(p) }19+func AuthorOf(p domain.Principal) string { return domain.AuthorName(p) }
20 20
21 // Sweep exposes the daily sweep to the tests.21 // Sweep exposes the daily sweep to the tests.
22 func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }22 func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }
M internal/usecase/mergerequests.go +3 -3
@@ -241,7 +241,7 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom
241241 now := m.clock.Now().UTC()
242242 mr, err := m.store.Create(ctx, domain.MergeRequest{
243243 Repo: name, Title: title, Description: desc, Source: source, Target: target,
244- State: domain.MRStateOpen, Author: authorOf(p), CreatedAt: now, UpdatedAt: now,
244+ State: domain.MRStateOpen, Author: domain.AuthorName(p), CreatedAt: now, UpdatedAt: now,
245245 })
246246 if err != nil {
247247 return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", name, err)
@@ -600,7 +600,7 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
600600 return domain.Comment{}, fmt.Errorf("%w: !%d already has %d comments", domain.ErrResourceExhausted, number, MaxCommentsPerMergeRequest)
601601 }
602602 c = domain.Comment{
603- ID: mr.CommentSeq + 1, Author: authorOf(p), Path: params.Path, Line: params.Line, Side: params.Side,
603+ ID: mr.CommentSeq + 1, Author: domain.AuthorName(p), Path: params.Path, Line: params.Line, Side: params.Side,
604604 CommitID: mr.HeadID, Text: text, CreatedAt: m.clock.Now().UTC(),
605605 }
606606 mr.CommentSeq, mr.UpdatedAt = c.ID, c.CreatedAt
@@ -1090,7 +1090,7 @@ func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest,
10901090 // does, under the merge lock (domain.CIRefTip). The run shows the owner, as a
10911091 // merge's push does on GitHub.
10921092 if err := recordRefTips(ctx, m.tips, mr.Repo, []domain.RefUpdate{{Old: tip.String(), New: newTip.String(), Ref: "refs/heads/" + mr.Target}},
1093- authorOf(domain.Owner()), domain.PusherOf(p), domain.TrustedActor(p)); err != nil {
1093+ domain.AuthorName(domain.Owner()), domain.PusherOf(p), domain.TrustedActor(p)); err != nil {
10941094 return domain.ObjectID{}, err
10951095 }
10961096 err := m.git.UpdateBranch(ctx, mr.Repo, mr.Target, tip, newTip)
@@ -241,7 +241,7 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom
241 now := m.clock.Now().UTC()241 now := m.clock.Now().UTC()
242 mr, err := m.store.Create(ctx, domain.MergeRequest{242 mr, err := m.store.Create(ctx, domain.MergeRequest{
243 Repo: name, Title: title, Description: desc, Source: source, Target: target,243 Repo: name, Title: title, Description: desc, Source: source, Target: target,
244- State: domain.MRStateOpen, Author: authorOf(p), CreatedAt: now, UpdatedAt: now,244+ State: domain.MRStateOpen, Author: domain.AuthorName(p), CreatedAt: now, UpdatedAt: now,
245 })245 })
246 if err != nil {246 if err != nil {
247 return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", name, err)247 return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", name, err)
@@ -600,7 +600,7 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
600 return domain.Comment{}, fmt.Errorf("%w: !%d already has %d comments", domain.ErrResourceExhausted, number, MaxCommentsPerMergeRequest)600 return domain.Comment{}, fmt.Errorf("%w: !%d already has %d comments", domain.ErrResourceExhausted, number, MaxCommentsPerMergeRequest)
601 }601 }
602 c = domain.Comment{602 c = domain.Comment{
603- ID: mr.CommentSeq + 1, Author: authorOf(p), Path: params.Path, Line: params.Line, Side: params.Side,603+ ID: mr.CommentSeq + 1, Author: domain.AuthorName(p), Path: params.Path, Line: params.Line, Side: params.Side,
604 CommitID: mr.HeadID, Text: text, CreatedAt: m.clock.Now().UTC(),604 CommitID: mr.HeadID, Text: text, CreatedAt: m.clock.Now().UTC(),
605 }605 }
606 mr.CommentSeq, mr.UpdatedAt = c.ID, c.CreatedAt606 mr.CommentSeq, mr.UpdatedAt = c.ID, c.CreatedAt
@@ -1090,7 +1090,7 @@ func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest,
1090 // does, under the merge lock (domain.CIRefTip). The run shows the owner, as a1090 // does, under the merge lock (domain.CIRefTip). The run shows the owner, as a
1091 // merge's push does on GitHub.1091 // merge's push does on GitHub.
1092 if err := recordRefTips(ctx, m.tips, mr.Repo, []domain.RefUpdate{{Old: tip.String(), New: newTip.String(), Ref: "refs/heads/" + mr.Target}},1092 if err := recordRefTips(ctx, m.tips, mr.Repo, []domain.RefUpdate{{Old: tip.String(), New: newTip.String(), Ref: "refs/heads/" + mr.Target}},
1093- authorOf(domain.Owner()), domain.PusherOf(p), domain.TrustedActor(p)); err != nil {1093+ domain.AuthorName(domain.Owner()), domain.PusherOf(p), domain.TrustedActor(p)); err != nil {
1094 return domain.ObjectID{}, err1094 return domain.ObjectID{}, err
1095 }1095 }
1096 err := m.git.UpdateBranch(ctx, mr.Repo, mr.Target, tip, newTip)1096 err := m.git.UpdateBranch(ctx, mr.Repo, mr.Target, tip, newTip)
M internal/usecase/pushes.go +1 -1
@@ -179,7 +179,7 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo
179179 // recorded before Git moves them, so that a tip never has the record of an
180180 // earlier pusher at the same commit (domain.CIRefTip). A record that cannot
181181 // be written refuses the push, as it cannot be run later with a known trust.
182- if err := recordRefTips(ctx, u.tips, name, updates, authorOf(p), domain.PusherOf(p), domain.TrustedActor(p)); err != nil {
182+ if err := recordRefTips(ctx, u.tips, name, updates, domain.AuthorName(p), domain.PusherOf(p), domain.TrustedActor(p)); err != nil {
183183 return err
184184 }
185185 log := zerowrap.FromCtx(ctx)
@@ -179,7 +179,7 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo
179 // recorded before Git moves them, so that a tip never has the record of an179 // recorded before Git moves them, so that a tip never has the record of an
180 // earlier pusher at the same commit (domain.CIRefTip). A record that cannot180 // earlier pusher at the same commit (domain.CIRefTip). A record that cannot
181 // be written refuses the push, as it cannot be run later with a known trust.181 // be written refuses the push, as it cannot be run later with a known trust.
182- if err := recordRefTips(ctx, u.tips, name, updates, authorOf(p), domain.PusherOf(p), domain.TrustedActor(p)); err != nil {182+ if err := recordRefTips(ctx, u.tips, name, updates, domain.AuthorName(p), domain.PusherOf(p), domain.TrustedActor(p)); err != nil {
183 return err183 return err
184 }184 }
185 log := zerowrap.FromCtx(ctx)185 log := zerowrap.FromCtx(ctx)
M proto/ermite/storage/merge_request.proto +12 -0
@@ -64,6 +64,13 @@ message MergeRequest {
6464 int64 comment_seq = 14;
6565 }
6666
67+// Verdict is the immutable review decision of a note.
68+enum Verdict {
69+ VERDICT_UNSPECIFIED = 0;
70+ VERDICT_APPROVE = 1;
71+ VERDICT_CHANGES_REQUESTED = 2;
72+}
73+
6774 // Comment is stored under the key "mrc/<repo>/<number:010d>/<id:010d>".
6875 message Comment {
6976 int64 id = 1;
@@ -75,6 +82,11 @@ message Comment {
7582 string text = 7;
7683 bool resolved = 8;
7784 google.protobuf.Timestamp created_at = 9;
85+ int64 reply_to = 10;
86+ int32 start_line = 11;
87+ Verdict verdict = 12;
88+ google.protobuf.Timestamp edited_at = 13;
89+ bool deleted = 14;
7890 }
7991
8092 // Sequence is stored under the key "mrseq/<repo>": the last merge request
@@ -64,6 +64,13 @@ message MergeRequest {
64 int64 comment_seq = 14;64 int64 comment_seq = 14;
65 }65 }
66 66
67+// Verdict is the immutable review decision of a note.
68+enum Verdict {
69+ VERDICT_UNSPECIFIED = 0;
70+ VERDICT_APPROVE = 1;
71+ VERDICT_CHANGES_REQUESTED = 2;
72+}
73+
67 // Comment is stored under the key "mrc/<repo>/<number:010d>/<id:010d>".74 // Comment is stored under the key "mrc/<repo>/<number:010d>/<id:010d>".
68 message Comment {75 message Comment {
69 int64 id = 1;76 int64 id = 1;
@@ -75,6 +82,11 @@ message Comment {
75 string text = 7;82 string text = 7;
76 bool resolved = 8;83 bool resolved = 8;
77 google.protobuf.Timestamp created_at = 9;84 google.protobuf.Timestamp created_at = 9;
85+ int64 reply_to = 10;
86+ int32 start_line = 11;
87+ Verdict verdict = 12;
88+ google.protobuf.Timestamp edited_at = 13;
89+ bool deleted = 14;
78 }90 }
79 91
80 // Sequence is stored under the key "mrseq/<repo>": the last merge request92 // Sequence is stored under the key "mrseq/<repo>": the last merge request

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close