detached 9784691

Merge 86ac875add464391373c8352a8777dc283bc2016 into d9a8fff1292ab5098cf29ccdbf269fc9eb79c7d4

commit
978469142a0533567d711f60bb610ecaa6b15b5c
author
ermite · · 2026-10-19 10:13 UTC
merge of
d9a8fff + 86ac875 · diff against the first parent
signature
unsigned
62 files · +3086 -650
M docs/cli.md +2 -1
@@ -55,7 +55,8 @@ ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo
5555 ermite token list | revoke <id>
5656
5757 ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff]
58-ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment>
58+ermite mr comment <repo> <n> -m … [--path … --line N|A-B --side old|new] [--reply <id>] [--approve|--request-changes]
59+ermite mr resolve <repo> <n> <comment> | edit-comment <repo> <n> <comment> -m … | delete-comment <repo> <n> <comment>
5960 ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>
6061
6162 ermite run list <repo> [--workflow …] [--branch …] [--status …]
@@ -55,7 +55,8 @@ ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo
55 ermite token list | revoke <id>55 ermite token list | revoke <id>
56 56
57 ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff]57 ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff]
58-ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment>58+ermite mr comment <repo> <n> -m … [--path … --line N|A-B --side old|new] [--reply <id>] [--approve|--request-changes]
59+ermite mr resolve <repo> <n> <comment> | edit-comment <repo> <n> <comment> -m … | delete-comment <repo> <n> <comment>
59 ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>60 ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>
60 61
61 ermite run list <repo> [--workflow …] [--branch …] [--status …]62 ermite run list <repo> [--workflow …] [--branch …] [--status …]
M docs/merge-requests.md +30 -1
@@ -17,10 +17,39 @@ merge request web URL on its own line. With `--json`, merge request objects
1717 - With `failing_checks_block` on (default), the merge is refused while a check of the head failed or is still running.
1818 - `--delete-branch` deletes the source branch, unless it is the default branch or another open request uses it.
1919 - `ermite repo edit --merge-modes` limits the modes per repository.
20-- Scopes: `mr:write` to open and comment, `mr:merge` to merge.
20+- Scopes: `mr:write` to open and comment, `mr:review` to give a verdict, `mr:merge` to merge.
2121
2222 Verify squash commits with the `allowed_signers` line from `ermite signing-key show`:
2323
2424 ```sh
2525 git verify-commit <commit>
2626 ```
27+
28+## Notes
29+
30+A note is the only review object. It is published as soon as it is written: there is no draft and no submit step.
31+
32+```sh
33+ermite mr comment myrepo 1 -m "General remark"
34+ermite mr comment myrepo 1 -m "Why?" --path main.go --line 10-14 --side new
35+ermite mr comment myrepo 1 -m "Fixed in ^3" --reply 2
36+ermite mr resolve myrepo 1 2
37+ermite mr edit-comment myrepo 1 2 -m "New text"
38+ermite mr delete-comment myrepo 1 2
39+```
40+
41+- Notes are Markdown, up to 8000 characters, rendered without raw HTML. Quote with `> `.
42+- `^N` in a note or a description links to note N of the same merge request. A reference to a missing note stays plain text.
43+- A note is general, on one line, or on a range `A-B` of lines on one side (`old` or `new`) of the diff. The lines must be in the diff.
44+- Threads have one level: a reply (`--reply <id>`) takes the anchor of its root and cannot carry a verdict. Resolving or reopening is done on the root and covers its replies.
45+- Only the author edits or deletes a note. An edited note shows "edited". A deleted note loses its text; a deleted root with replies stays as "deleted note".
46+
47+## Verdicts
48+
49+A root note can carry a verdict: `--approve` or `--request-changes`. The text may then be empty. A verdict needs the `mr:review` scope (or the owner); `mr:write` comments but cannot give one.
50+
51+- A verdict ends a review: agents and `ermite watch` act on it, not on each note.
52+- Verdicts are informative: none blocks or allows a merge.
53+- `mr show` lists the latest verdict of each author (`verdicts` in `--json`).
54+- An approval is stale once the branch head differs from the head it was given on. Changes requested stay current until their author gives a new verdict, whatever is pushed.
55+- Verdicts are immutable: editing or deleting the note keeps its verdict. Only a new verdict of the same author replaces it.
@@ -17,10 +17,39 @@ merge request web URL on its own line. With `--json`, merge request objects
17 - With `failing_checks_block` on (default), the merge is refused while a check of the head failed or is still running.17 - With `failing_checks_block` on (default), the merge is refused while a check of the head failed or is still running.
18 - `--delete-branch` deletes the source branch, unless it is the default branch or another open request uses it.18 - `--delete-branch` deletes the source branch, unless it is the default branch or another open request uses it.
19 - `ermite repo edit --merge-modes` limits the modes per repository.19 - `ermite repo edit --merge-modes` limits the modes per repository.
20-- Scopes: `mr:write` to open and comment, `mr:merge` to merge.20+- Scopes: `mr:write` to open and comment, `mr:review` to give a verdict, `mr:merge` to merge.
21 21
22 Verify squash commits with the `allowed_signers` line from `ermite signing-key show`:22 Verify squash commits with the `allowed_signers` line from `ermite signing-key show`:
23 23
24 ```sh24 ```sh
25 git verify-commit <commit>25 git verify-commit <commit>
26 ```26 ```
27+
28+## Notes
29+
30+A note is the only review object. It is published as soon as it is written: there is no draft and no submit step.
31+
32+```sh
33+ermite mr comment myrepo 1 -m "General remark"
34+ermite mr comment myrepo 1 -m "Why?" --path main.go --line 10-14 --side new
35+ermite mr comment myrepo 1 -m "Fixed in ^3" --reply 2
36+ermite mr resolve myrepo 1 2
37+ermite mr edit-comment myrepo 1 2 -m "New text"
38+ermite mr delete-comment myrepo 1 2
39+```
40+
41+- Notes are Markdown, up to 8000 characters, rendered without raw HTML. Quote with `> `.
42+- `^N` in a note or a description links to note N of the same merge request. A reference to a missing note stays plain text.
43+- A note is general, on one line, or on a range `A-B` of lines on one side (`old` or `new`) of the diff. The lines must be in the diff.
44+- Threads have one level: a reply (`--reply <id>`) takes the anchor of its root and cannot carry a verdict. Resolving or reopening is done on the root and covers its replies.
45+- Only the author edits or deletes a note. An edited note shows "edited". A deleted note loses its text; a deleted root with replies stays as "deleted note".
46+
47+## Verdicts
48+
49+A root note can carry a verdict: `--approve` or `--request-changes`. The text may then be empty. A verdict needs the `mr:review` scope (or the owner); `mr:write` comments but cannot give one.
50+
51+- A verdict ends a review: agents and `ermite watch` act on it, not on each note.
52+- Verdicts are informative: none blocks or allows a merge.
53+- `mr show` lists the latest verdict of each author (`verdicts` in `--json`).
54+- An approval is stale once the branch head differs from the head it was given on. Changes requested stay current until their author gives a new verdict, whatever is pushed.
55+- Verdicts are immutable: editing or deleting the note keeps its verdict. Only a new verdict of the same author replaces it.
M docs/tokens.md +2 -2
@@ -6,7 +6,7 @@ ermite token list
66 ermite token revoke <id>
77 ```
88
9-The secret is printed once. Presets and `--scope` add up. The full scope list is in [`skills/ermite/CLI.md`](../skills/ermite/CLI.md).
9+The secret is printed once. Presets and `--scope` add up. The full scope list is in [`skills/ermite/CLI.md`](../skills/ermite/CLI.md). `mr:review` lets a token give a merge request verdict (`--approve`, `--request-changes`); the `cli` preset includes it, the `agent` preset does not. A token created before `mr:review` existed lacks it: recreate it with `--preset cli` using the root token or from a signed-in owner web session (an old token lacking `mr:review` cannot grant it), then replace the stored CLI secret and revoke the old token to keep the owner's rights.
1010
1111 | Preset | For |
1212 |---|---|
@@ -28,7 +28,7 @@ ermite token create claude --preset agent --scope repo:read --repo myrepo --bran
2828 The token **can**:
2929
3030 - push, force-push and delete branches under its prefixes;
31-- open, comment on and close merge requests;
31+- open, comment on and close merge requests; with `mr:review`, give verdicts;
3232 - with `mr:merge`, merge into a branch under its prefixes;
3333 - read CI runs, logs and checks; with `ci:write`, cancel and re-run runs of its own branches;
3434 - create tokens whose prefixes lie within its own.
@@ -6,7 +6,7 @@ ermite token list
6 ermite token revoke <id>6 ermite token revoke <id>
7 ```7 ```
8 8
9-The secret is printed once. Presets and `--scope` add up. The full scope list is in [`skills/ermite/CLI.md`](../skills/ermite/CLI.md).9+The secret is printed once. Presets and `--scope` add up. The full scope list is in [`skills/ermite/CLI.md`](../skills/ermite/CLI.md). `mr:review` lets a token give a merge request verdict (`--approve`, `--request-changes`); the `cli` preset includes it, the `agent` preset does not. A token created before `mr:review` existed lacks it: recreate it with `--preset cli` using the root token or from a signed-in owner web session (an old token lacking `mr:review` cannot grant it), then replace the stored CLI secret and revoke the old token to keep the owner's rights.
10 10
11 | Preset | For |11 | Preset | For |
12 |---|---|12 |---|---|
@@ -28,7 +28,7 @@ ermite token create claude --preset agent --scope repo:read --repo myrepo --bran
28 The token **can**:28 The token **can**:
29 29
30 - push, force-push and delete branches under its prefixes;30 - push, force-push and delete branches under its prefixes;
31-- open, comment on and close merge requests;31+- open, comment on and close merge requests; with `mr:review`, give verdicts;
32 - with `mr:merge`, merge into a branch under its prefixes;32 - with `mr:merge`, merge into a branch under its prefixes;
33 - read CI runs, logs and checks; with `ci:write`, cancel and re-run runs of its own branches;33 - read CI runs, logs and checks; with `ci:write`, cancel and re-run runs of its own branches;
34 - create tokens whose prefixes lie within its own.34 - create tokens whose prefixes lie within its own.
M gen/ermite/storage/merge_request.pb.go +122 -18 generated file: collapsed
M go.mod +1 -1
@@ -13,7 +13,7 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.7.0
16+ bnema.dev/ermite-api v0.8.0
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.1.0
1919 connectrpc.com/connect v1.21.0
@@ -13,7 +13,7 @@ tool (
13 13
14 require (14 require (
15 bnema.dev/ermite-actions v0.2.015 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.7.016+ bnema.dev/ermite-api v0.8.0
17 bnema.dev/ermite-git v0.1.017 bnema.dev/ermite-git v0.1.0
18 bnema.dev/ermite-kv v0.1.018 bnema.dev/ermite-kv v0.1.0
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
M go.sum +2 -2 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 +10 -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,
@@ -471,6 +472,15 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
471472 case domain.CommentAdded:
472473 out.Time = timestamp(ev.At)
473474 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)}}
474484 case domain.RunUpdated:
475485 out.Time = timestamp(ev.At)
476486 out.Kind = runEventToPB(ev)
@@ -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,
@@ -471,6 +472,15 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
471 case domain.CommentAdded:472 case domain.CommentAdded:
472 out.Time = timestamp(ev.At)473 out.Time = timestamp(ev.At)
473 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)}}
474 case domain.RunUpdated:484 case domain.RunUpdated:
475 out.Time = timestamp(ev.At)485 out.Time = timestamp(ev.At)
476 out.Kind = runEventToPB(ev)486 out.Kind = runEventToPB(ev)
M internal/adapters/rpc/mergerequest.go +70 -0
@@ -17,6 +17,52 @@ import (
1717 // mergeRequestService implements ermiteconnect.MergeRequestServiceHandler.
1818 type mergeRequestService struct{ mrs ports.MergeRequestService }
1919
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
42+}
43+
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)
64+}
65+
2066 func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState {
2167 switch s {
2268 case domain.MRStateOpen:
@@ -96,9 +142,19 @@ func commentToPB(c domain.Comment) *pb.Comment {
96142 Text: c.Text.String(),
97143 Resolved: c.Resolved,
98144 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,
99148 }
100149 }
101150
151+func optionalCommentID(id int64) string {
152+ if id == 0 {
153+ return ""
154+ }
155+ return strconv.FormatInt(id, 10)
156+}
157+
102158 // commentID parses the opaque id of a comment. One that this server never
103159 // issued is 0, which names no comment.
104160 func commentID(s string) int64 {
@@ -185,6 +241,9 @@ func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect.
185241 for _, c := range d.Comments {
186242 out.Comments = append(out.Comments, commentToPB(c))
187243 }
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+ }
188247 return connect.NewResponse(out), nil
189248 }
190249
@@ -197,7 +256,18 @@ func (s *mergeRequestService) AddComment(ctx context.Context, req *connect.Reque
197256 if err != nil {
198257 return nil, connectError(ctx, err)
199258 }
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+ }
200269 c, err := s.mrs.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.AddCommentParams{
270+ ReplyTo: replyTo, StartLine: int(req.Msg.GetStartLine()), Verdict: verdict,
201271 Path: req.Msg.GetPath(), Line: int(req.Msg.GetLine()), Side: side, Text: req.Msg.GetText(),
202272 })
203273 if err != nil {
@@ -17,6 +17,52 @@ 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+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
42+}
43+
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)
64+}
65+
20 func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState {66 func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState {
21 switch s {67 switch s {
22 case domain.MRStateOpen:68 case domain.MRStateOpen:
@@ -96,9 +142,19 @@ func commentToPB(c domain.Comment) *pb.Comment {
96 Text: c.Text.String(),142 Text: c.Text.String(),
97 Resolved: c.Resolved,143 Resolved: c.Resolved,
98 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,
99 }148 }
100 }149 }
101 150
151+func optionalCommentID(id int64) string {
152+ if id == 0 {
153+ return ""
154+ }
155+ return strconv.FormatInt(id, 10)
156+}
157+
102 // commentID parses the opaque id of a comment. One that this server never158 // commentID parses the opaque id of a comment. One that this server never
103 // issued is 0, which names no comment.159 // issued is 0, which names no comment.
104 func commentID(s string) int64 {160 func commentID(s string) int64 {
@@ -185,6 +241,9 @@ func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect.
185 for _, c := range d.Comments {241 for _, c := range d.Comments {
186 out.Comments = append(out.Comments, commentToPB(c))242 out.Comments = append(out.Comments, commentToPB(c))
187 }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+ }
188 return connect.NewResponse(out), nil247 return connect.NewResponse(out), nil
189 }248 }
190 249
@@ -197,7 +256,18 @@ func (s *mergeRequestService) AddComment(ctx context.Context, req *connect.Reque
197 if err != nil {256 if err != nil {
198 return nil, connectError(ctx, err)257 return nil, connectError(ctx, err)
199 }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+ }
200 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,
201 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(),
202 })272 })
203 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/adapters/web/diff.go +1 -0
@@ -30,6 +30,7 @@ type Seg struct {
3030 // DiffLine is one line of a hunk, in the unified view and, paired, in the
3131 // split view.
3232 type DiffLine struct {
33+ Selected bool
3334 Kind string // "ctx", "add", "del"
3435 Mark string // " ", "+", "-": every change keeps its marker, not only a colour
3536 Old, New string // line numbers, "" when the side has none
@@ -30,6 +30,7 @@ type Seg struct {
30 // DiffLine is one line of a hunk, in the unified view and, paired, in the30 // DiffLine is one line of a hunk, in the unified view and, paired, in the
31 // split view.31 // split view.
32 type DiffLine struct {32 type DiffLine struct {
33+ Selected bool
33 Kind string // "ctx", "add", "del"34 Kind string // "ctx", "add", "del"
34 Mark string // " ", "+", "-": every change keeps its marker, not only a colour35 Mark string // " ", "+", "-": every change keeps its marker, not only a colour
35 Old, New string // line numbers, "" when the side has none36 Old, New string // line numbers, "" when the side has none
M internal/adapters/web/diff.templ +4 -4
@@ -232,7 +232,7 @@ templ DiffFileView(f DiffFile, p DiffView) {
232232 }
233233 <tr class="hunk"><td colspan="4">{ h.Header }</td></tr>
234234 for _, ln := range h.Lines {
235- <tr class={ ln.Kind }>
235+ <tr class={ ln.Kind, templ.KV("sel", ln.Selected) }>
236236 <td class="ln">@gutter(ln.Old, ln.OldHref)</td>
237237 <td class="ln">@gutter(ln.New, ln.NewHref)</td>
238238 <td class="m">{ ln.Mark }</td>
@@ -369,15 +369,15 @@ templ splitSide(ln *DiffLine, old bool) {
369369 <td class="m none"></td>
370370 <td class="tx none"></td>
371371 } else {
372- <td class={ "ln", ln.Kind }>
372+ <td class={ "ln", ln.Kind, templ.KV("sel", ln.Selected) }>
373373 if old {
374374 @gutter(ln.Old, ln.OldHref)
375375 } else {
376376 @gutter(ln.New, ln.NewHref)
377377 }
378378 </td>
379- <td class={ "m", ln.Kind }>{ ln.Mark }</td>
380- <td class={ "tx", "chroma", ln.Kind }>@codeLine(*ln)</td>
379+ <td class={ "m", ln.Kind, templ.KV("sel", ln.Selected) }>{ ln.Mark }</td>
380+ <td class={ "tx", "chroma", ln.Kind, templ.KV("sel", ln.Selected) }>@codeLine(*ln)</td>
381381 }
382382 }
383383
@@ -232,7 +232,7 @@ templ DiffFileView(f DiffFile, p DiffView) {
232 }232 }
233 <tr class="hunk"><td colspan="4">{ h.Header }</td></tr>233 <tr class="hunk"><td colspan="4">{ h.Header }</td></tr>
234 for _, ln := range h.Lines {234 for _, ln := range h.Lines {
235- <tr class={ ln.Kind }>235+ <tr class={ ln.Kind, templ.KV("sel", ln.Selected) }>
236 <td class="ln">@gutter(ln.Old, ln.OldHref)</td>236 <td class="ln">@gutter(ln.Old, ln.OldHref)</td>
237 <td class="ln">@gutter(ln.New, ln.NewHref)</td>237 <td class="ln">@gutter(ln.New, ln.NewHref)</td>
238 <td class="m">{ ln.Mark }</td>238 <td class="m">{ ln.Mark }</td>
@@ -369,15 +369,15 @@ templ splitSide(ln *DiffLine, old bool) {
369 <td class="m none"></td>369 <td class="m none"></td>
370 <td class="tx none"></td>370 <td class="tx none"></td>
371 } else {371 } else {
372- <td class={ "ln", ln.Kind }>372+ <td class={ "ln", ln.Kind, templ.KV("sel", ln.Selected) }>
373 if old {373 if old {
374 @gutter(ln.Old, ln.OldHref)374 @gutter(ln.Old, ln.OldHref)
375 } else {375 } else {
376 @gutter(ln.New, ln.NewHref)376 @gutter(ln.New, ln.NewHref)
377 }377 }
378 </td>378 </td>
379- <td class={ "m", ln.Kind }>{ ln.Mark }</td>379+ <td class={ "m", ln.Kind, templ.KV("sel", ln.Selected) }>{ ln.Mark }</td>
380- <td class={ "tx", "chroma", ln.Kind }>@codeLine(*ln)</td>380+ <td class={ "tx", "chroma", ln.Kind, templ.KV("sel", ln.Selected) }>@codeLine(*ln)</td>
381 }381 }
382 }382 }
383 383
M internal/adapters/web/diff_templ.go +5 -5 generated file: collapsed
M internal/adapters/web/fragments.go +7 -1
@@ -58,7 +58,7 @@ func liveEvent(we ports.WatchEvent) bool {
5858 }
5959 switch we.Event.(type) {
6060 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
61- domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded,
61+ domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
6262 domain.RunUpdated, domain.JobUpdated:
6363 return true
6464 }
@@ -164,6 +164,12 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
164164 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
165165 case domain.MergeRequestReady:
166166 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")
167+ case domain.CommentEdited:
168+ return l.mrNotice(ctx, ev.Number, "a note was edited — reload")
169+ case domain.CommentDeleted:
170+ return l.mrNotice(ctx, ev.Number, "a note was deleted — reload")
171+ case domain.CommentResolved:
172+ return l.mrNotice(ctx, ev.Number, "a thread was resolved — reload")
167173 case domain.CommentAdded:
168174 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")
169175 case domain.PushEvent:
@@ -58,7 +58,7 @@ func liveEvent(we ports.WatchEvent) bool {
58 }58 }
59 switch we.Event.(type) {59 switch we.Event.(type) {
60 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,60 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
61- domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded,61+ domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
62 domain.RunUpdated, domain.JobUpdated:62 domain.RunUpdated, domain.JobUpdated:
63 return true63 return true
64 }64 }
@@ -164,6 +164,12 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
164 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")164 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
165 case domain.MergeRequestReady:165 case domain.MergeRequestReady:
166 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")166 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")
167+ case domain.CommentEdited:
168+ return l.mrNotice(ctx, ev.Number, "a note was edited — reload")
169+ case domain.CommentDeleted:
170+ return l.mrNotice(ctx, ev.Number, "a note was deleted — reload")
171+ case domain.CommentResolved:
172+ return l.mrNotice(ctx, ev.Number, "a thread was resolved — reload")
167 case domain.CommentAdded:173 case domain.CommentAdded:
168 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")174 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")
169 case domain.PushEvent:175 case domain.PushEvent:
M internal/adapters/web/jstest/mr.test.mjs +56 -0
@@ -36,3 +36,59 @@ test('step: stays in the list', () => {
3636 assert.equal(C.step(-1, -1, 3), 2);
3737 assert.equal(C.step(0, 1, 0), -1);
3838 });
39+
40+test('rangeURL requires the same file, side and MR and sorts endpoints', () => {
41+ const base = 'https://forge.test/demo/merge-requests/1';
42+ const a = base+'?on=new:24:dir%2Fa.go#f1';
43+ const b = base+'?from=25&on=new:10:dir%2Fa.go#f1';
44+ const u = new URL(C.rangeURL(a,b,base));
45+ assert.equal(u.searchParams.get('on'),'new:10-24:dir/a.go');
46+ assert.equal(u.searchParams.get('from'),'25');
47+ assert.equal(u.hash,'#f1');
48+ assert.equal(C.rangeURL(a,a,base),null);
49+ assert.equal(C.rangeURL(a,b.replace('new:','old:'),base),null);
50+ assert.equal(C.rangeURL(a,b.replace('a.go','b.go'),base),null);
51+ assert.equal(C.rangeURL(a,b.replace('/1?','/2?'),base),null);
52+});
53+
54+test('quote opens the thread reply and prefills and focuses its textarea', () => {
55+ let focused=false;
56+ const field={value:'',focus(){focused=true;}};
57+ const details={open:false,querySelector(){return field;}};
58+ const thread={querySelector(){return details;}};
59+ const button={dataset:{quote:'> source\n\n^2 '},closest(){return {closest(){return thread;}};}};
60+ assert.equal(C.quote(button),true);
61+ assert.equal(details.open,true);
62+ assert.equal(field.value,'> source\n\n^2 ');
63+ assert.equal(focused,true);
64+});
65+
66+for (const dataset of [{diff:'split'}, {view:'split'}]) test(`reveal selects the split note (${JSON.stringify(dataset)}), including after lazy loading`, async () => {
67+ const saved = globalThis.document;
68+ const savedRAF = globalThis.requestAnimationFrame;
69+ let scrolled = '';
70+ const details = {open:false};
71+ const node = id => ({id, querySelector(){return null;}, closest(){return details;}, scrollIntoView(){scrolled=id;}});
72+ const nodes = new Map([['c12',node('c12')],['c12-s',node('c12-s')]]);
73+ const ph = {dataset:{notes:'c12'}};
74+ globalThis.document = {
75+ querySelector: sel => sel === '#diffs' ? {dataset} : null,
76+ querySelectorAll: () => [ph],
77+ getElementById: id => nodes.get(id),
78+ };
79+ globalThis.requestAnimationFrame = fn => fn();
80+ try {
81+ assert.equal(window.ermiteMR.reveal('c12'),false);
82+ assert.equal(scrolled,'c12-s');
83+ assert.equal(details.open,true);
84+ nodes.clear(); scrolled=''; details.open=false;
85+ assert.equal(window.ermiteMR.reveal('c12'),true);
86+ nodes.set('c12',node('c12')); nodes.set('c12-s',node('c12-s'));
87+ await Promise.resolve();
88+ assert.equal(scrolled,'c12-s');
89+ assert.equal(details.open,true);
90+ } finally {
91+ globalThis.document=saved;
92+ globalThis.requestAnimationFrame=savedRAF;
93+ }
94+});
@@ -36,3 +36,59 @@ test('step: stays in the list', () => {
36 assert.equal(C.step(-1, -1, 3), 2);36 assert.equal(C.step(-1, -1, 3), 2);
37 assert.equal(C.step(0, 1, 0), -1);37 assert.equal(C.step(0, 1, 0), -1);
38 });38 });
39+
40+test('rangeURL requires the same file, side and MR and sorts endpoints', () => {
41+ const base = 'https://forge.test/demo/merge-requests/1';
42+ const a = base+'?on=new:24:dir%2Fa.go#f1';
43+ const b = base+'?from=25&on=new:10:dir%2Fa.go#f1';
44+ const u = new URL(C.rangeURL(a,b,base));
45+ assert.equal(u.searchParams.get('on'),'new:10-24:dir/a.go');
46+ assert.equal(u.searchParams.get('from'),'25');
47+ assert.equal(u.hash,'#f1');
48+ assert.equal(C.rangeURL(a,a,base),null);
49+ assert.equal(C.rangeURL(a,b.replace('new:','old:'),base),null);
50+ assert.equal(C.rangeURL(a,b.replace('a.go','b.go'),base),null);
51+ assert.equal(C.rangeURL(a,b.replace('/1?','/2?'),base),null);
52+});
53+
54+test('quote opens the thread reply and prefills and focuses its textarea', () => {
55+ let focused=false;
56+ const field={value:'',focus(){focused=true;}};
57+ const details={open:false,querySelector(){return field;}};
58+ const thread={querySelector(){return details;}};
59+ const button={dataset:{quote:'> source\n\n^2 '},closest(){return {closest(){return thread;}};}};
60+ assert.equal(C.quote(button),true);
61+ assert.equal(details.open,true);
62+ assert.equal(field.value,'> source\n\n^2 ');
63+ assert.equal(focused,true);
64+});
65+
66+for (const dataset of [{diff:'split'}, {view:'split'}]) test(`reveal selects the split note (${JSON.stringify(dataset)}), including after lazy loading`, async () => {
67+ const saved = globalThis.document;
68+ const savedRAF = globalThis.requestAnimationFrame;
69+ let scrolled = '';
70+ const details = {open:false};
71+ const node = id => ({id, querySelector(){return null;}, closest(){return details;}, scrollIntoView(){scrolled=id;}});
72+ const nodes = new Map([['c12',node('c12')],['c12-s',node('c12-s')]]);
73+ const ph = {dataset:{notes:'c12'}};
74+ globalThis.document = {
75+ querySelector: sel => sel === '#diffs' ? {dataset} : null,
76+ querySelectorAll: () => [ph],
77+ getElementById: id => nodes.get(id),
78+ };
79+ globalThis.requestAnimationFrame = fn => fn();
80+ try {
81+ assert.equal(window.ermiteMR.reveal('c12'),false);
82+ assert.equal(scrolled,'c12-s');
83+ assert.equal(details.open,true);
84+ nodes.clear(); scrolled=''; details.open=false;
85+ assert.equal(window.ermiteMR.reveal('c12'),true);
86+ nodes.set('c12',node('c12')); nodes.set('c12-s',node('c12-s'));
87+ await Promise.resolve();
88+ assert.equal(scrolled,'c12-s');
89+ assert.equal(details.open,true);
90+ } finally {
91+ globalThis.document=saved;
92+ globalThis.requestAnimationFrame=savedRAF;
93+ }
94+});
M internal/adapters/web/markdown.go +79 -0
@@ -7,6 +7,8 @@ import (
77 "html/template"
88 "net/url"
99 "path"
10+ "regexp"
11+ "strconv"
1012 "strings"
1113
1214 "github.com/yuin/goldmark"
@@ -62,10 +64,40 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML,
6264 // #fragment links chosen, so that several documents on one page (the summaries
6365 // of a run) do not share ids.
6466 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {
67+ return m.renderDocument(source, repo, ref, dir, idPrefix, nil)
68+}
69+
70+// renderNote uses the repository Markdown policy, then adds local note links.
71+func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes func(int64) bool) (template.HTML, error) {
72+ h, _, err := m.renderDocument(source, repo, ref, "", idPrefix, notes)
73+ return h, err
74+}
75+
76+func (m *markdown) renderDocument(source []byte, repo, ref, dir, idPrefix string, notes func(int64) bool) (template.HTML, []Heading, error) {
6577 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: idPrefix, inner: parser.NewContext().IDs()}))
6678 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
6779 pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source, idPrefix: idPrefix}
80+ // Collect eligible text before the policy unwraps rejected links: their
81+ // labels must not accidentally become references either.
82+ var refs []*ast.Text
83+ if notes != nil {
84+ _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
85+ if !entering {
86+ return ast.WalkContinue, nil
87+ }
88+ switch n := n.(type) {
89+ case *ast.CodeSpan, *ast.Link, *ast.AutoLink, *ast.Image, *ast.CodeBlock, *ast.FencedCodeBlock:
90+ return ast.WalkSkipChildren, nil
91+ case *ast.Text:
92+ refs = append(refs, n)
93+ }
94+ return ast.WalkContinue, nil
95+ })
96+ }
6897 pol.apply(doc)
98+ for _, t := range refs {
99+ noteReferences(t, source, notes)
100+ }
69101
70102 var buf bytes.Buffer
71103 if err := m.md.Renderer().Render(&buf, source, doc); err != nil {
@@ -291,3 +323,50 @@ func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
291323 return ast.WalkContinue, nil
292324 })
293325 }
326+
327+var noteReferencePattern = regexp.MustCompile(`(^|[^0-9A-Za-z_^])\^([1-9][0-9]{0,5})\b`)
328+
329+// noteReferences changes only text segments; code and link labels never enter
330+// this function. New fragment links deliberately bypass heading-prefix rewriting.
331+func noteReferences(t *ast.Text, source []byte, exists func(int64) bool) {
332+ parent := t.Parent()
333+ if parent == nil {
334+ return
335+ }
336+ value := t.Value(source)
337+ offset := 0
338+ for _, match := range noteReferencePattern.FindAllSubmatchIndex(value, -1) {
339+ id := string(value[match[4]:match[5]])
340+ n, err := strconv.ParseInt(id, 10, 64)
341+ if err != nil || !exists(n) {
342+ continue
343+ }
344+ start := match[4] - 1
345+ // Markdown punctuation may split adjacent text into separate AST nodes.
346+ // Check the original preceding byte as well as the segment-local regex.
347+ if at := t.Segment.Start + start; at > 0 {
348+ prev := source[at-1]
349+ if prev >= '0' && prev <= '9' || prev >= 'A' && prev <= 'Z' || prev >= 'a' && prev <= 'z' || prev == '_' || prev == '^' {
350+ continue
351+ }
352+ }
353+ if start > offset {
354+ parent.InsertBefore(parent, t, ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Start+start)))
355+ }
356+ link := ast.NewLink()
357+ link.Destination = []byte("#c" + id)
358+ link.Title = []byte("note " + id)
359+ link.SetAttributeString("class", []byte("ref"))
360+ link.AppendChild(link, ast.NewString(value[start:match[5]]))
361+ parent.InsertBefore(parent, t, link)
362+ offset = match[5]
363+ }
364+ if offset == 0 {
365+ return
366+ }
367+ tail := ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Stop))
368+ tail.SetSoftLineBreak(t.SoftLineBreak())
369+ tail.SetHardLineBreak(t.HardLineBreak())
370+ parent.InsertBefore(parent, t, tail)
371+ parent.RemoveChild(parent, t)
372+}
@@ -7,6 +7,8 @@ import (
7 "html/template"7 "html/template"
8 "net/url"8 "net/url"
9 "path"9 "path"
10+ "regexp"
11+ "strconv"
10 "strings"12 "strings"
11 13
12 "github.com/yuin/goldmark"14 "github.com/yuin/goldmark"
@@ -62,10 +64,40 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML,
62 // #fragment links chosen, so that several documents on one page (the summaries64 // #fragment links chosen, so that several documents on one page (the summaries
63 // of a run) do not share ids.65 // of a run) do not share ids.
64 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {66 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {
67+ return m.renderDocument(source, repo, ref, dir, idPrefix, nil)
68+}
69+
70+// renderNote uses the repository Markdown policy, then adds local note links.
71+func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes func(int64) bool) (template.HTML, error) {
72+ h, _, err := m.renderDocument(source, repo, ref, "", idPrefix, notes)
73+ return h, err
74+}
75+
76+func (m *markdown) renderDocument(source []byte, repo, ref, dir, idPrefix string, notes func(int64) bool) (template.HTML, []Heading, error) {
65 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: idPrefix, inner: parser.NewContext().IDs()}))77 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: idPrefix, inner: parser.NewContext().IDs()}))
66 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))78 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
67 pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source, idPrefix: idPrefix}79 pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source, idPrefix: idPrefix}
80+ // Collect eligible text before the policy unwraps rejected links: their
81+ // labels must not accidentally become references either.
82+ var refs []*ast.Text
83+ if notes != nil {
84+ _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
85+ if !entering {
86+ return ast.WalkContinue, nil
87+ }
88+ switch n := n.(type) {
89+ case *ast.CodeSpan, *ast.Link, *ast.AutoLink, *ast.Image, *ast.CodeBlock, *ast.FencedCodeBlock:
90+ return ast.WalkSkipChildren, nil
91+ case *ast.Text:
92+ refs = append(refs, n)
93+ }
94+ return ast.WalkContinue, nil
95+ })
96+ }
68 pol.apply(doc)97 pol.apply(doc)
98+ for _, t := range refs {
99+ noteReferences(t, source, notes)
100+ }
69 101
70 var buf bytes.Buffer102 var buf bytes.Buffer
71 if err := m.md.Renderer().Render(&buf, source, doc); err != nil {103 if err := m.md.Renderer().Render(&buf, source, doc); err != nil {
@@ -291,3 +323,50 @@ func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
291 return ast.WalkContinue, nil323 return ast.WalkContinue, nil
292 })324 })
293 }325 }
326+
327+var noteReferencePattern = regexp.MustCompile(`(^|[^0-9A-Za-z_^])\^([1-9][0-9]{0,5})\b`)
328+
329+// noteReferences changes only text segments; code and link labels never enter
330+// this function. New fragment links deliberately bypass heading-prefix rewriting.
331+func noteReferences(t *ast.Text, source []byte, exists func(int64) bool) {
332+ parent := t.Parent()
333+ if parent == nil {
334+ return
335+ }
336+ value := t.Value(source)
337+ offset := 0
338+ for _, match := range noteReferencePattern.FindAllSubmatchIndex(value, -1) {
339+ id := string(value[match[4]:match[5]])
340+ n, err := strconv.ParseInt(id, 10, 64)
341+ if err != nil || !exists(n) {
342+ continue
343+ }
344+ start := match[4] - 1
345+ // Markdown punctuation may split adjacent text into separate AST nodes.
346+ // Check the original preceding byte as well as the segment-local regex.
347+ if at := t.Segment.Start + start; at > 0 {
348+ prev := source[at-1]
349+ if prev >= '0' && prev <= '9' || prev >= 'A' && prev <= 'Z' || prev >= 'a' && prev <= 'z' || prev == '_' || prev == '^' {
350+ continue
351+ }
352+ }
353+ if start > offset {
354+ parent.InsertBefore(parent, t, ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Start+start)))
355+ }
356+ link := ast.NewLink()
357+ link.Destination = []byte("#c" + id)
358+ link.Title = []byte("note " + id)
359+ link.SetAttributeString("class", []byte("ref"))
360+ link.AppendChild(link, ast.NewString(value[start:match[5]]))
361+ parent.InsertBefore(parent, t, link)
362+ offset = match[5]
363+ }
364+ if offset == 0 {
365+ return
366+ }
367+ tail := ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Stop))
368+ tail.SetSoftLineBreak(t.SoftLineBreak())
369+ tail.SetHardLineBreak(t.HardLineBreak())
370+ parent.InsertBefore(parent, t, tail)
371+ parent.RemoveChild(parent, t)
372+}
M internal/adapters/web/markdown_test.go +29 -0
@@ -136,3 +136,32 @@ func TestMarkdownGFM(t *testing.T) {
136136 assert.Contains(t, out, "<del>gone</del>")
137137 assert.Contains(t, out, `type="checkbox"`)
138138 }
139+
140+func TestNoteMarkdown(t *testing.T) {
141+ t.Parallel()
142+ tests := []struct {
143+ source string
144+ links int
145+ }{
146+ {"^2 ^99 a^2 _^2 ^^2 1^2", 1},
147+ {"`^2` [^2](https://example.org) [^2](javascript:alert(1))", 0},
148+ {"```\n^2\n```", 0},
149+ {"^2, ^2\n^2", 3},
150+ }
151+ for _, tt := range tests {
152+ t.Run(tt.source, func(t *testing.T) {
153+ t.Parallel()
154+ h, err := newMarkdown().renderNote([]byte(tt.source), "demo", "main", "c1-", func(n int64) bool { return n == 2 })
155+ require.NoError(t, err)
156+ assert.Equal(t, tt.links, strings.Count(string(h), `href="#c2"`), string(h))
157+ assert.NotContains(t, string(h), `href="#c99"`)
158+ assert.NotContains(t, string(h), "javascript:")
159+ })
160+ }
161+ h, err := newMarkdown().renderNote([]byte("# Heading\n\n<script>alert(1)</script>\n\n**bold** ^2"), "demo", "main", "c1-", func(n int64) bool { return n == 2 })
162+ require.NoError(t, err)
163+ assert.Contains(t, string(h), `id="c1-heading"`)
164+ assert.Contains(t, string(h), "<strong>bold</strong>")
165+ assert.NotContains(t, string(h), "<script")
166+ assert.Contains(t, string(h), `title="note 2"`)
167+}
@@ -136,3 +136,32 @@ func TestMarkdownGFM(t *testing.T) {
136 assert.Contains(t, out, "<del>gone</del>")136 assert.Contains(t, out, "<del>gone</del>")
137 assert.Contains(t, out, `type="checkbox"`)137 assert.Contains(t, out, `type="checkbox"`)
138 }138 }
139+
140+func TestNoteMarkdown(t *testing.T) {
141+ t.Parallel()
142+ tests := []struct {
143+ source string
144+ links int
145+ }{
146+ {"^2 ^99 a^2 _^2 ^^2 1^2", 1},
147+ {"`^2` [^2](https://example.org) [^2](javascript:alert(1))", 0},
148+ {"```\n^2\n```", 0},
149+ {"^2, ^2\n^2", 3},
150+ }
151+ for _, tt := range tests {
152+ t.Run(tt.source, func(t *testing.T) {
153+ t.Parallel()
154+ h, err := newMarkdown().renderNote([]byte(tt.source), "demo", "main", "c1-", func(n int64) bool { return n == 2 })
155+ require.NoError(t, err)
156+ assert.Equal(t, tt.links, strings.Count(string(h), `href="#c2"`), string(h))
157+ assert.NotContains(t, string(h), `href="#c99"`)
158+ assert.NotContains(t, string(h), "javascript:")
159+ })
160+ }
161+ h, err := newMarkdown().renderNote([]byte("# Heading\n\n<script>alert(1)</script>\n\n**bold** ^2"), "demo", "main", "c1-", func(n int64) bool { return n == 2 })
162+ require.NoError(t, err)
163+ assert.Contains(t, string(h), `id="c1-heading"`)
164+ assert.Contains(t, string(h), "<strong>bold</strong>")
165+ assert.NotContains(t, string(h), "<script")
166+ assert.Contains(t, string(h), `title="note 2"`)
167+}
M internal/adapters/web/mr.go +129 -25
@@ -101,13 +101,19 @@ type MRListPage struct {
101101
102102 // CommentView is a comment of a merge request, ready to show.
103103 type CommentView struct {
104- ID string
105- Author string
106- When string
107- WhenAt string
108- Abs string
109- Parts []TextPart
110- Resolved bool
104+ ID string
105+ Author string
106+ When string
107+ WhenAt string
108+ Abs string
109+ Body, BodySplit template.HTML
110+ Replies []CommentView
111+ Edited, Deleted bool
112+ Verdict, Range, Quote string
113+ Edit *EditForm
114+ Delete *DeleteForm
115+ Reply *CommentForm
116+ Resolved bool
111117 // Outdated is true when the head moved since the comment was written, so
112118 // its line may have moved too.
113119 Outdated bool
@@ -119,6 +125,19 @@ type CommentView struct {
119125 Resolve *ResolveForm
120126 }
121127
128+type EditForm struct {
129+ Action, Text string
130+ From int
131+}
132+type DeleteForm struct {
133+ Action string
134+ From int
135+}
136+type VerdictView struct {
137+ Author, Label, Href, Short string
138+ Stale bool
139+}
140+
122141 // ResolveForm is the POST that changes the resolved state of a comment.
123142 type ResolveForm struct {
124143 Action string
@@ -135,9 +154,10 @@ type CommentForm struct {
135154 Action string
136155 Title string
137156 // Path, Line and Side designate the line; empty for a general comment.
138- Path, Line, Side string
139- Cancel string
140- Focus bool
157+ Path, Line, Side string
158+ StartLine, ReplyTo string
159+ Cancel string
160+ Focus bool
141161 // From is the window of the page the form is on (see mrWindow), sent
142162 // back so that the answer returns to it; 0 for the first.
143163 From int
@@ -192,6 +212,7 @@ type MRPage struct {
192212 State string
193213 Open bool
194214 Signed bool
215+ NoteAuthor string
195216 Source, Target string
196217 SourceHref string
197218 TargetHref string
@@ -226,6 +247,7 @@ type MRPage struct {
226247 NComments int
227248 NUnresolved int
228249 GeneralForm *CommentForm
250+ Verdicts []VerdictView
229251
230252 Commits []MRCommit
231253 CommitsNote string
@@ -471,7 +493,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
471493
472494 p := MRPage{
473495 Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()),
474- State: mrStateName(m), Open: open, Signed: signed,
496+ State: mrStateName(m), Open: open, Signed: signed, NoteAuthor: domain.AuthorName(principal(ctx)),
475497 Source: visibleText(m.Source), Target: visibleText(m.Target),
476498 Author: s.who(m.Author),
477499 Head: head, HeadShort: short(head),
@@ -485,12 +507,15 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
485507 // Relative links point into the source branch as it is now (its head
486508 // commit); the heading ids carry their own prefix, apart from the
487509 // ids of the diff.
488- h, _, err := s.md.renderWithIDs([]byte(desc), d.name(), head, "", "mr-desc-")
510+ h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))
489511 if err != nil {
490512 return nil, 0, err
491513 }
492514 p.Desc = h
493515 }
516+ for _, v := range res.Verdicts {
517+ p.Verdicts = append(p.Verdicts, VerdictView{Author: s.who(v.Author), Label: v.Verdict.String(), Stale: v.Stale, Href: "#c" + strconv.FormatInt(v.CommentID, 10), Short: short(v.CommitID.String())})
518+ }
494519 p.Opened.Name = p.Author
495520 p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)
496521 if !m.CreatedAt.IsZero() {
@@ -684,9 +709,10 @@ func shellWord(s string) string {
684709 // lineRef designates a line of the diff in the query of the comment form:
685710 // "?on=new:12:path/to/file".
686711 type lineRef struct {
687- side string
688- line int
689- path string
712+ side string
713+ line int
714+ start int
715+ path string
690716 }
691717
692718 func parseOn(s string) *lineRef {
@@ -695,11 +721,21 @@ func parseOn(s string) *lineRef {
695721 return nil
696722 }
697723 num, path, ok := strings.Cut(rest, ":")
724+ start := 0
725+ if a, b, rangeOK := strings.Cut(num, "-"); rangeOK {
726+ var e error
727+ start, e = strconv.Atoi(a)
728+ end, e2 := strconv.Atoi(b)
729+ if e != nil || e2 != nil || start < 1 || start >= end {
730+ return nil
731+ }
732+ num = b
733+ }
698734 line, err := strconv.Atoi(num)
699735 if !ok || err != nil || line < 1 || path == "" {
700736 return nil
701737 }
702- return &lineRef{side: side, line: line, path: path}
738+ return &lineRef{side: side, line: line, start: start, path: path}
703739 }
704740
705741 // placeComments puts every comment under its line when the diff shown has it,
@@ -757,21 +793,66 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
757793 return index[lineKey{path, side, line}]
758794 }
759795
796+ exists := noteIDs(comments)
797+ children := map[int64][]domain.Comment{}
760798 for _, c := range comments {
799+ if c.ReplyTo != 0 && !c.Deleted {
800+ children[c.ReplyTo] = append(children[c.ReplyTo], c)
801+ }
802+ if !c.Deleted {
803+ p.NComments++
804+ }
805+ if c.ReplyTo == 0 && !c.Deleted && !c.Resolved {
806+ p.NUnresolved++
807+ }
808+ }
809+ var view func(domain.Comment, bool) CommentView
810+ view = func(c domain.Comment, replyable bool) CommentView {
761811 cid := strconv.FormatInt(c.ID, 10)
762- v := CommentView{
763- ID: cid, Author: s.who(c.Author),
764- Parts: plainParts(c.Text.String()), Resolved: c.Resolved,
765- Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head,
812+ body, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"-", exists)
813+ if err != nil {
814+ body = ""
766815 }
816+ bodySplit, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"s-", exists)
817+ if err != nil {
818+ bodySplit = ""
819+ }
820+ v := CommentView{BodySplit: bodySplit, ID: cid, Author: s.who(c.Author), Body: body, Resolved: c.Resolved, Deleted: c.Deleted, Edited: !c.EditedAt.IsZero(), Verdict: c.Verdict.String(), Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head}
767821 v.When, v.WhenAt = whenOf(now, c.CreatedAt)
768822 if !c.CreatedAt.IsZero() {
769823 v.Abs = absolute(c.CreatedAt)
770824 }
771- p.NComments++
772- if !c.Resolved {
773- p.NUnresolved++
825+ if c.StartLine > 0 {
826+ v.Range = "lines " + itoa(c.StartLine) + "–" + itoa(c.Line)
774827 }
828+ if canWrite && !c.Deleted {
829+ from := windowStart(fileAt[c.Path])
830+ root := cid
831+ if c.ReplyTo != 0 {
832+ root = strconv.FormatInt(c.ReplyTo, 10)
833+ }
834+ if replyable {
835+ v.Quote = "> " + strings.ReplaceAll(c.Text.String(), "\n", "\n> ") + "\n\n^" + cid + " "
836+ }
837+ if c.ReplyTo == 0 {
838+ v.Reply = &CommentForm{ID: "reply-" + cid, Action: mrHref(repo, n, "comments"), Title: "Reply", ReplyTo: root, From: from}
839+ }
840+ if c.Author == p.NoteAuthor {
841+ v.Edit = &EditForm{Action: mrHref(repo, n, "comments", cid, "edit"), Text: c.Text.String(), From: from}
842+ v.Delete = &DeleteForm{Action: mrHref(repo, n, "comments", cid, "delete"), From: from}
843+ }
844+ }
845+ for _, reply := range children[c.ID] {
846+ v.Replies = append(v.Replies, view(reply, replyable))
847+ }
848+ return v
849+ }
850+ for _, c := range comments {
851+ if c.ReplyTo != 0 || c.Deleted && c.Verdict == domain.VerdictNone && len(children[c.ID]) == 0 {
852+ continue
853+ }
854+ cid := strconv.FormatInt(c.ID, 10)
855+ v := view(c, canWrite && !c.Deleted)
775856 general := c.Path == ""
776857 fi, listed := fileAt[c.Path]
777858 if !general && listed && p.Files[fi].Lazy {
@@ -789,7 +870,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
789870 v.Where = visibleText(c.Path) + ":" + itoa(c.Line) + " (" + side + ")"
790871 }
791872 }
792- if canWrite {
873+ if canWrite && !c.Deleted {
793874 v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"}
794875 if !general && listed {
795876 v.Resolve.From = windowStart(fi)
@@ -815,9 +896,18 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
815896 if on.side == "old" {
816897 side = domain.SideOld
817898 }
899+ start := on.start
900+ if start == 0 {
901+ start = on.line
902+ }
903+ for key, ln := range index {
904+ if key.path == on.path && key.side == side && key.line >= start && key.line <= on.line {
905+ ln.Selected = true
906+ }
907+ }
818908 if ln := find(on.path, side, on.line); ln != nil {
819909 ln.Form = &CommentForm{
820- ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
910+ StartLine: optionalLine(on.start), ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
821911 Title: "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",
822912 Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true,
823913 From: windowStart(fileAt[on.path]),
@@ -842,3 +932,17 @@ func userText(parts []TextPart) templ.Component {
842932 return err
843933 })
844934 }
935+
936+func noteIDs(comments []domain.Comment) func(int64) bool {
937+ ids := make(map[int64]bool, len(comments))
938+ for _, c := range comments {
939+ ids[c.ID] = true
940+ }
941+ return func(n int64) bool { return ids[n] }
942+}
943+func optionalLine(n int) string {
944+ if n == 0 {
945+ return ""
946+ }
947+ return itoa(n)
948+}
@@ -101,13 +101,19 @@ type MRListPage struct {
101 101
102 // CommentView is a comment of a merge request, ready to show.102 // CommentView is a comment of a merge request, ready to show.
103 type CommentView struct {103 type CommentView struct {
104- ID string104+ ID string
105- Author string105+ Author string
106- When string106+ When string
107- WhenAt string107+ WhenAt string
108- Abs string108+ Abs string
109- Parts []TextPart109+ Body, BodySplit template.HTML
110- Resolved bool110+ Replies []CommentView
111+ Edited, Deleted bool
112+ Verdict, Range, Quote string
113+ Edit *EditForm
114+ Delete *DeleteForm
115+ Reply *CommentForm
116+ Resolved bool
111 // Outdated is true when the head moved since the comment was written, so117 // Outdated is true when the head moved since the comment was written, so
112 // its line may have moved too.118 // its line may have moved too.
113 Outdated bool119 Outdated bool
@@ -119,6 +125,19 @@ type CommentView struct {
119 Resolve *ResolveForm125 Resolve *ResolveForm
120 }126 }
121 127
128+type EditForm struct {
129+ Action, Text string
130+ From int
131+}
132+type DeleteForm struct {
133+ Action string
134+ From int
135+}
136+type VerdictView struct {
137+ Author, Label, Href, Short string
138+ Stale bool
139+}
140+
122 // ResolveForm is the POST that changes the resolved state of a comment.141 // ResolveForm is the POST that changes the resolved state of a comment.
123 type ResolveForm struct {142 type ResolveForm struct {
124 Action string143 Action string
@@ -135,9 +154,10 @@ type CommentForm struct {
135 Action string154 Action string
136 Title string155 Title string
137 // Path, Line and Side designate the line; empty for a general comment.156 // Path, Line and Side designate the line; empty for a general comment.
138- Path, Line, Side string157+ Path, Line, Side string
139- Cancel string158+ StartLine, ReplyTo string
140- Focus bool159+ Cancel string
160+ Focus bool
141 // From is the window of the page the form is on (see mrWindow), sent161 // From is the window of the page the form is on (see mrWindow), sent
142 // back so that the answer returns to it; 0 for the first.162 // back so that the answer returns to it; 0 for the first.
143 From int163 From int
@@ -192,6 +212,7 @@ type MRPage struct {
192 State string212 State string
193 Open bool213 Open bool
194 Signed bool214 Signed bool
215+ NoteAuthor string
195 Source, Target string216 Source, Target string
196 SourceHref string217 SourceHref string
197 TargetHref string218 TargetHref string
@@ -226,6 +247,7 @@ type MRPage struct {
226 NComments int247 NComments int
227 NUnresolved int248 NUnresolved int
228 GeneralForm *CommentForm249 GeneralForm *CommentForm
250+ Verdicts []VerdictView
229 251
230 Commits []MRCommit252 Commits []MRCommit
231 CommitsNote string253 CommitsNote string
@@ -471,7 +493,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
471 493
472 p := MRPage{494 p := MRPage{
473 Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()),495 Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()),
474- State: mrStateName(m), Open: open, Signed: signed,496+ State: mrStateName(m), Open: open, Signed: signed, NoteAuthor: domain.AuthorName(principal(ctx)),
475 Source: visibleText(m.Source), Target: visibleText(m.Target),497 Source: visibleText(m.Source), Target: visibleText(m.Target),
476 Author: s.who(m.Author),498 Author: s.who(m.Author),
477 Head: head, HeadShort: short(head),499 Head: head, HeadShort: short(head),
@@ -485,12 +507,15 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
485 // Relative links point into the source branch as it is now (its head507 // Relative links point into the source branch as it is now (its head
486 // commit); the heading ids carry their own prefix, apart from the508 // commit); the heading ids carry their own prefix, apart from the
487 // ids of the diff.509 // ids of the diff.
488- h, _, err := s.md.renderWithIDs([]byte(desc), d.name(), head, "", "mr-desc-")510+ h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))
489 if err != nil {511 if err != nil {
490 return nil, 0, err512 return nil, 0, err
491 }513 }
492 p.Desc = h514 p.Desc = h
493 }515 }
516+ for _, v := range res.Verdicts {
517+ p.Verdicts = append(p.Verdicts, VerdictView{Author: s.who(v.Author), Label: v.Verdict.String(), Stale: v.Stale, Href: "#c" + strconv.FormatInt(v.CommentID, 10), Short: short(v.CommitID.String())})
518+ }
494 p.Opened.Name = p.Author519 p.Opened.Name = p.Author
495 p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)520 p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)
496 if !m.CreatedAt.IsZero() {521 if !m.CreatedAt.IsZero() {
@@ -684,9 +709,10 @@ func shellWord(s string) string {
684 // lineRef designates a line of the diff in the query of the comment form:709 // lineRef designates a line of the diff in the query of the comment form:
685 // "?on=new:12:path/to/file".710 // "?on=new:12:path/to/file".
686 type lineRef struct {711 type lineRef struct {
687- side string712+ side string
688- line int713+ line int
689- path string714+ start int
715+ path string
690 }716 }
691 717
692 func parseOn(s string) *lineRef {718 func parseOn(s string) *lineRef {
@@ -695,11 +721,21 @@ func parseOn(s string) *lineRef {
695 return nil721 return nil
696 }722 }
697 num, path, ok := strings.Cut(rest, ":")723 num, path, ok := strings.Cut(rest, ":")
724+ start := 0
725+ if a, b, rangeOK := strings.Cut(num, "-"); rangeOK {
726+ var e error
727+ start, e = strconv.Atoi(a)
728+ end, e2 := strconv.Atoi(b)
729+ if e != nil || e2 != nil || start < 1 || start >= end {
730+ return nil
731+ }
732+ num = b
733+ }
698 line, err := strconv.Atoi(num)734 line, err := strconv.Atoi(num)
699 if !ok || err != nil || line < 1 || path == "" {735 if !ok || err != nil || line < 1 || path == "" {
700 return nil736 return nil
701 }737 }
702- return &lineRef{side: side, line: line, path: path}738+ return &lineRef{side: side, line: line, start: start, path: path}
703 }739 }
704 740
705 // placeComments puts every comment under its line when the diff shown has it,741 // placeComments puts every comment under its line when the diff shown has it,
@@ -757,21 +793,66 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
757 return index[lineKey{path, side, line}]793 return index[lineKey{path, side, line}]
758 }794 }
759 795
796+ exists := noteIDs(comments)
797+ children := map[int64][]domain.Comment{}
760 for _, c := range comments {798 for _, c := range comments {
799+ if c.ReplyTo != 0 && !c.Deleted {
800+ children[c.ReplyTo] = append(children[c.ReplyTo], c)
801+ }
802+ if !c.Deleted {
803+ p.NComments++
804+ }
805+ if c.ReplyTo == 0 && !c.Deleted && !c.Resolved {
806+ p.NUnresolved++
807+ }
808+ }
809+ var view func(domain.Comment, bool) CommentView
810+ view = func(c domain.Comment, replyable bool) CommentView {
761 cid := strconv.FormatInt(c.ID, 10)811 cid := strconv.FormatInt(c.ID, 10)
762- v := CommentView{812+ body, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"-", exists)
763- ID: cid, Author: s.who(c.Author),813+ if err != nil {
764- Parts: plainParts(c.Text.String()), Resolved: c.Resolved,814+ body = ""
765- Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head,
766 }815 }
816+ bodySplit, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"s-", exists)
817+ if err != nil {
818+ bodySplit = ""
819+ }
820+ v := CommentView{BodySplit: bodySplit, ID: cid, Author: s.who(c.Author), Body: body, Resolved: c.Resolved, Deleted: c.Deleted, Edited: !c.EditedAt.IsZero(), Verdict: c.Verdict.String(), Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head}
767 v.When, v.WhenAt = whenOf(now, c.CreatedAt)821 v.When, v.WhenAt = whenOf(now, c.CreatedAt)
768 if !c.CreatedAt.IsZero() {822 if !c.CreatedAt.IsZero() {
769 v.Abs = absolute(c.CreatedAt)823 v.Abs = absolute(c.CreatedAt)
770 }824 }
771- p.NComments++825+ if c.StartLine > 0 {
772- if !c.Resolved {826+ v.Range = "lines " + itoa(c.StartLine) + "–" + itoa(c.Line)
773- p.NUnresolved++
774 }827 }
828+ if canWrite && !c.Deleted {
829+ from := windowStart(fileAt[c.Path])
830+ root := cid
831+ if c.ReplyTo != 0 {
832+ root = strconv.FormatInt(c.ReplyTo, 10)
833+ }
834+ if replyable {
835+ v.Quote = "> " + strings.ReplaceAll(c.Text.String(), "\n", "\n> ") + "\n\n^" + cid + " "
836+ }
837+ if c.ReplyTo == 0 {
838+ v.Reply = &CommentForm{ID: "reply-" + cid, Action: mrHref(repo, n, "comments"), Title: "Reply", ReplyTo: root, From: from}
839+ }
840+ if c.Author == p.NoteAuthor {
841+ v.Edit = &EditForm{Action: mrHref(repo, n, "comments", cid, "edit"), Text: c.Text.String(), From: from}
842+ v.Delete = &DeleteForm{Action: mrHref(repo, n, "comments", cid, "delete"), From: from}
843+ }
844+ }
845+ for _, reply := range children[c.ID] {
846+ v.Replies = append(v.Replies, view(reply, replyable))
847+ }
848+ return v
849+ }
850+ for _, c := range comments {
851+ if c.ReplyTo != 0 || c.Deleted && c.Verdict == domain.VerdictNone && len(children[c.ID]) == 0 {
852+ continue
853+ }
854+ cid := strconv.FormatInt(c.ID, 10)
855+ v := view(c, canWrite && !c.Deleted)
775 general := c.Path == ""856 general := c.Path == ""
776 fi, listed := fileAt[c.Path]857 fi, listed := fileAt[c.Path]
777 if !general && listed && p.Files[fi].Lazy {858 if !general && listed && p.Files[fi].Lazy {
@@ -789,7 +870,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
789 v.Where = visibleText(c.Path) + ":" + itoa(c.Line) + " (" + side + ")"870 v.Where = visibleText(c.Path) + ":" + itoa(c.Line) + " (" + side + ")"
790 }871 }
791 }872 }
792- if canWrite {873+ if canWrite && !c.Deleted {
793 v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"}874 v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"}
794 if !general && listed {875 if !general && listed {
795 v.Resolve.From = windowStart(fi)876 v.Resolve.From = windowStart(fi)
@@ -815,9 +896,18 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
815 if on.side == "old" {896 if on.side == "old" {
816 side = domain.SideOld897 side = domain.SideOld
817 }898 }
899+ start := on.start
900+ if start == 0 {
901+ start = on.line
902+ }
903+ for key, ln := range index {
904+ if key.path == on.path && key.side == side && key.line >= start && key.line <= on.line {
905+ ln.Selected = true
906+ }
907+ }
818 if ln := find(on.path, side, on.line); ln != nil {908 if ln := find(on.path, side, on.line); ln != nil {
819 ln.Form = &CommentForm{909 ln.Form = &CommentForm{
820- ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,910+ StartLine: optionalLine(on.start), ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
821 Title: "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",911 Title: "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",
822 Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true,912 Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true,
823 From: windowStart(fileAt[on.path]),913 From: windowStart(fileAt[on.path]),
@@ -842,3 +932,17 @@ func userText(parts []TextPart) templ.Component {
842 return err932 return err
843 })933 })
844 }934 }
935+
936+func noteIDs(comments []domain.Comment) func(int64) bool {
937+ ids := make(map[int64]bool, len(comments))
938+ for _, c := range comments {
939+ ids[c.ID] = true
940+ }
941+ return func(n int64) bool { return ids[n] }
942+}
943+func optionalLine(n int) string {
944+ if n == 0 {
945+ return ""
946+ }
947+ return itoa(n)
948+}
M internal/adapters/web/mr.templ +66 -6
@@ -229,10 +229,9 @@ templ lineNotes(notes []CommentView, form *CommentForm, suffix string) {
229229 }
230230
231231 // commentView is a note: a ruled block, under its line in the diff or in the
232-// list of comments. The text is plain: escaped, its line breaks kept, its
233-// http(s) addresses linked.
232+// list of comments. Body comes only from the safe Markdown renderer.
234233 templ commentView(c CommentView, suffix string) {
235- <div class="cmt" id={ "c" + c.ID + suffix } data-resolved={ boolText(c.Resolved) }>
234+ <div class="cmt" id={ "c" + c.ID + suffix } data-resolved={ boolText(c.Resolved) } data-thread?={ c.Reply != nil }>
236235 <p class="c-head">
237236 <span class="c-who">{ c.Author }</span>
238237 if c.WhenAt != "" {
@@ -248,7 +247,47 @@ templ commentView(c CommentView, suffix string) {
248247 { " " }<span class="c-where mute">{ c.Where }</span>
249248 }
250249 </p>
251- <p class="c-text">@userText(c.Parts)</p>
250+ if c.Deleted {
251+ if c.Verdict != "" {
252+ <p class="mute">text deleted</p>
253+ } else {
254+ <p class="mute">deleted note</p>
255+ }
256+ } else {
257+ <div class="c-body prose">
258+ if suffix == "-s" {
259+ @templ.Raw(string(c.BodySplit))
260+ } else {
261+ @templ.Raw(string(c.Body))
262+ }
263+ </div>
264+ }
265+ if c.Edited {
266+ <span class="mute">edited</span>
267+ }
268+ if c.Verdict != "" {
269+ <span class="c-state">{ c.Verdict }</span>
270+ }
271+ if c.Range != "" {
272+ <span class="mute">{ c.Range }</span>
273+ }
274+ if c.Quote != "" {
275+ <button type="button" class="btn" data-js data-quote={ c.Quote }>Quote</button>
276+ }
277+ if c.Edit != nil {
278+ <details><summary>Edit</summary><form method="post" action={ templ.SafeURL(c.Edit.Action) }><input type="hidden" name="from" value={ itoa(c.Edit.From) }/><textarea name="text" maxlength="8000">{ c.Edit.Text }</textarea><button class="btn" type="submit">Save</button></form></details>
279+ }
280+ if c.Delete != nil {
281+ <details><summary>Delete</summary><form method="post" action={ templ.SafeURL(c.Delete.Action) }><input type="hidden" name="from" value={ itoa(c.Delete.From) }/><p>Delete this note?</p><button class="btn" type="submit">Confirm delete</button></form></details>
282+ }
283+ for _, reply := range c.Replies {
284+ @commentView(reply, suffix)
285+ }
286+ if c.Reply != nil {
287+ <details data-reply>
288+ <summary>Reply</summary>@commentForm(*c.Reply, suffix)
289+ </details>
290+ }
252291 if c.Resolve != nil {
253292 <form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>
254293 <input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/>
@@ -277,8 +316,7 @@ templ commentForm(f CommentForm, suffix string) {
277316 name="text"
278317 rows="3"
279318 maxlength="8000"
280- required
281- placeholder="plain text, 8000 characters at most"
319+ placeholder="Markdown, 8000 characters at most"
282320 if f.Focus {
283321 autofocus
284322 }
@@ -287,6 +325,13 @@ templ commentForm(f CommentForm, suffix string) {
287325 <input type="hidden" name="path" value={ f.Path }/>
288326 <input type="hidden" name="line" value={ f.Line }/>
289327 <input type="hidden" name="side" value={ f.Side }/>
328+ <label>from line <input type="number" name="start_line" min="1" value={ f.StartLine }/></label>
329+ }
330+ if f.ReplyTo != "" {
331+ <input type="hidden" name="reply_to" value={ f.ReplyTo }/>
332+ }
333+ if f.Path == "" && f.ReplyTo == "" {
334+ <fieldset><legend>Verdict</legend><label><input type="radio" name="verdict" value="" checked/>Note</label><label><input type="radio" name="verdict" value="approve"/>Approve</label><label><input type="radio" name="verdict" value="changes_requested"/>Request changes</label></fieldset>
290335 }
291336 if f.From > 0 {
292337 <input type="hidden" name="from" value={ itoa(f.From) }/>
@@ -310,6 +355,21 @@ templ mrRail(p MRPage) {
310355 </h2>
311356 @FileList(p.Files)
312357 </section>
358+ if len(p.Verdicts) > 0 {
359+ <section>
360+ <h2>Verdicts</h2>
361+ <ul>
362+ for _, v := range p.Verdicts {
363+ <li>
364+ <a href={ templ.SafeURL(v.Href) }>{ v.Author }: { v.Label }</a>
365+ if v.Stale {
366+ <span class="mute">(stale)</span>
367+ }
368+ </li>
369+ }
370+ </ul>
371+ </section>
372+ }
313373 @MRChecks(p.Checks)
314374 <section aria-labelledby="r-commits">
315375 <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2>
@@ -229,10 +229,9 @@ templ lineNotes(notes []CommentView, form *CommentForm, suffix string) {
229 }229 }
230 230
231 // commentView is a note: a ruled block, under its line in the diff or in the231 // commentView is a note: a ruled block, under its line in the diff or in the
232-// list of comments. The text is plain: escaped, its line breaks kept, its232+// list of comments. Body comes only from the safe Markdown renderer.
233-// http(s) addresses linked.
234 templ commentView(c CommentView, suffix string) {233 templ commentView(c CommentView, suffix string) {
235- <div class="cmt" id={ "c" + c.ID + suffix } data-resolved={ boolText(c.Resolved) }>234+ <div class="cmt" id={ "c" + c.ID + suffix } data-resolved={ boolText(c.Resolved) } data-thread?={ c.Reply != nil }>
236 <p class="c-head">235 <p class="c-head">
237 <span class="c-who">{ c.Author }</span>236 <span class="c-who">{ c.Author }</span>
238 if c.WhenAt != "" {237 if c.WhenAt != "" {
@@ -248,7 +247,47 @@ templ commentView(c CommentView, suffix string) {
248 { " " }<span class="c-where mute">{ c.Where }</span>247 { " " }<span class="c-where mute">{ c.Where }</span>
249 }248 }
250 </p>249 </p>
251- <p class="c-text">@userText(c.Parts)</p>250+ if c.Deleted {
251+ if c.Verdict != "" {
252+ <p class="mute">text deleted</p>
253+ } else {
254+ <p class="mute">deleted note</p>
255+ }
256+ } else {
257+ <div class="c-body prose">
258+ if suffix == "-s" {
259+ @templ.Raw(string(c.BodySplit))
260+ } else {
261+ @templ.Raw(string(c.Body))
262+ }
263+ </div>
264+ }
265+ if c.Edited {
266+ <span class="mute">edited</span>
267+ }
268+ if c.Verdict != "" {
269+ <span class="c-state">{ c.Verdict }</span>
270+ }
271+ if c.Range != "" {
272+ <span class="mute">{ c.Range }</span>
273+ }
274+ if c.Quote != "" {
275+ <button type="button" class="btn" data-js data-quote={ c.Quote }>Quote</button>
276+ }
277+ if c.Edit != nil {
278+ <details><summary>Edit</summary><form method="post" action={ templ.SafeURL(c.Edit.Action) }><input type="hidden" name="from" value={ itoa(c.Edit.From) }/><textarea name="text" maxlength="8000">{ c.Edit.Text }</textarea><button class="btn" type="submit">Save</button></form></details>
279+ }
280+ if c.Delete != nil {
281+ <details><summary>Delete</summary><form method="post" action={ templ.SafeURL(c.Delete.Action) }><input type="hidden" name="from" value={ itoa(c.Delete.From) }/><p>Delete this note?</p><button class="btn" type="submit">Confirm delete</button></form></details>
282+ }
283+ for _, reply := range c.Replies {
284+ @commentView(reply, suffix)
285+ }
286+ if c.Reply != nil {
287+ <details data-reply>
288+ <summary>Reply</summary>@commentForm(*c.Reply, suffix)
289+ </details>
290+ }
252 if c.Resolve != nil {291 if c.Resolve != nil {
253 <form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>292 <form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>
254 <input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/>293 <input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/>
@@ -277,8 +316,7 @@ templ commentForm(f CommentForm, suffix string) {
277 name="text"316 name="text"
278 rows="3"317 rows="3"
279 maxlength="8000"318 maxlength="8000"
280- required319+ placeholder="Markdown, 8000 characters at most"
281- placeholder="plain text, 8000 characters at most"
282 if f.Focus {320 if f.Focus {
283 autofocus321 autofocus
284 }322 }
@@ -287,6 +325,13 @@ templ commentForm(f CommentForm, suffix string) {
287 <input type="hidden" name="path" value={ f.Path }/>325 <input type="hidden" name="path" value={ f.Path }/>
288 <input type="hidden" name="line" value={ f.Line }/>326 <input type="hidden" name="line" value={ f.Line }/>
289 <input type="hidden" name="side" value={ f.Side }/>327 <input type="hidden" name="side" value={ f.Side }/>
328+ <label>from line <input type="number" name="start_line" min="1" value={ f.StartLine }/></label>
329+ }
330+ if f.ReplyTo != "" {
331+ <input type="hidden" name="reply_to" value={ f.ReplyTo }/>
332+ }
333+ if f.Path == "" && f.ReplyTo == "" {
334+ <fieldset><legend>Verdict</legend><label><input type="radio" name="verdict" value="" checked/>Note</label><label><input type="radio" name="verdict" value="approve"/>Approve</label><label><input type="radio" name="verdict" value="changes_requested"/>Request changes</label></fieldset>
290 }335 }
291 if f.From > 0 {336 if f.From > 0 {
292 <input type="hidden" name="from" value={ itoa(f.From) }/>337 <input type="hidden" name="from" value={ itoa(f.From) }/>
@@ -310,6 +355,21 @@ templ mrRail(p MRPage) {
310 </h2>355 </h2>
311 @FileList(p.Files)356 @FileList(p.Files)
312 </section>357 </section>
358+ if len(p.Verdicts) > 0 {
359+ <section>
360+ <h2>Verdicts</h2>
361+ <ul>
362+ for _, v := range p.Verdicts {
363+ <li>
364+ <a href={ templ.SafeURL(v.Href) }>{ v.Author }: { v.Label }</a>
365+ if v.Stale {
366+ <span class="mute">(stale)</span>
367+ }
368+ </li>
369+ }
370+ </ul>
371+ </section>
372+ }
313 @MRChecks(p.Checks)373 @MRChecks(p.Checks)
314 <section aria-labelledby="r-commits">374 <section aria-labelledby="r-commits">
315 <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2>375 <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2>
M internal/adapters/web/mr_files.go +5 -3
@@ -131,8 +131,10 @@ func countNotes(files []DiffFile, comments []domain.Comment) {
131131 if c.Path == "" || !ok {
132132 continue
133133 }
134- files[i].Notes++
135- if !c.Resolved {
134+ if c.ReplyTo == 0 && !c.Deleted {
135+ files[i].Notes++
136+ }
137+ if c.ReplyTo == 0 && !c.Deleted && !c.Resolved {
136138 files[i].OpenNotes++
137139 }
138140 ids[i] = append(ids[i], "c"+strconv.FormatInt(c.ID, 10))
@@ -196,7 +198,7 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
196198 m := res.MergeRequest
197199 signed := SignedIn(ctx)
198200 canWrite := signed && m.State == domain.MRStateOpen && !since
199- p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true}
201+ p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true, NoteAuthor: domain.AuthorName(principal(ctx))}
200202 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))
201203 s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
202204 window := p.Files[from:min(from+mrWindow, len(p.Files))]
@@ -131,8 +131,10 @@ func countNotes(files []DiffFile, comments []domain.Comment) {
131 if c.Path == "" || !ok {131 if c.Path == "" || !ok {
132 continue132 continue
133 }133 }
134- files[i].Notes++134+ if c.ReplyTo == 0 && !c.Deleted {
135- if !c.Resolved {135+ files[i].Notes++
136+ }
137+ if c.ReplyTo == 0 && !c.Deleted && !c.Resolved {
136 files[i].OpenNotes++138 files[i].OpenNotes++
137 }139 }
138 ids[i] = append(ids[i], "c"+strconv.FormatInt(c.ID, 10))140 ids[i] = append(ids[i], "c"+strconv.FormatInt(c.ID, 10))
@@ -196,7 +198,7 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
196 m := res.MergeRequest198 m := res.MergeRequest
197 signed := SignedIn(ctx)199 signed := SignedIn(ctx)
198 canWrite := signed && m.State == domain.MRStateOpen && !since200 canWrite := signed && m.State == domain.MRStateOpen && !since
199- p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true}201+ p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true, NoteAuthor: domain.AuthorName(principal(ctx))}
200 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))202 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))
201 s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)203 s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
202 window := p.Files[from:min(from+mrWindow, len(p.Files))]204 window := p.Files[from:min(from+mrWindow, len(p.Files))]
A internal/adapters/web/mr_notes_test.go +162 -0
@@ -0,0 +1,162 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bnema.dev/ermite/internal/ports/mocks"
7+ "bytes"
8+ "context"
9+ "github.com/stretchr/testify/mock"
10+ "net/http"
11+ "net/http/httptest"
12+ "net/url"
13+ "strings"
14+ "testing"
15+ "time"
16+
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "github.com/stretchr/testify/assert"
20+ "github.com/stretchr/testify/require"
21+)
22+
23+func TestParseOnRange(t *testing.T) {
24+ t.Parallel()
25+ for _, raw := range []string{"new:0-2:a", "new:2-2:a", "new:3-2:a", "new:1-x:a", "new:1-2:", "other:1-2:a"} {
26+ assert.Nil(t, parseOn(raw), raw)
27+ }
28+ assert.Equal(t, &lineRef{side: "new", start: 10, line: 24, path: "dir:a.go"}, parseOn("new:10-24:dir:a.go"))
29+ assert.Equal(t, &lineRef{side: "old", line: 10, path: "a"}, parseOn("old:10:a"))
30+}
31+
32+func TestNoteThreads(t *testing.T) {
33+ t.Parallel()
34+ text, err := domain.ParsePlainText("**hello** ^2")
35+ require.NoError(t, err)
36+ s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
37+ comments := []domain.Comment{
38+ {ID: 1, Author: "alice", Text: text},
39+ {ID: 2, Author: "bob", ReplyTo: 1, Text: text},
40+ {ID: 3, Author: "alice", Deleted: true},
41+ {ID: 4, Author: "alice", Deleted: true},
42+ {ID: 5, Author: "bob", ReplyTo: 4, Text: text},
43+ {ID: 6, Author: "alice", Text: text, Verdict: domain.VerdictApprove, Resolved: true},
44+ }
45+ p := MRPage{NoteAuthor: "alice"}
46+ s.placeComments(&p, "demo", 1, comments, "", true, true, nil)
47+ require.Len(t, p.General, 3)
48+ assert.Equal(t, 4, p.NComments)
49+ assert.Equal(t, 1, p.NUnresolved)
50+ require.Len(t, p.General[0].Replies, 1)
51+ assert.NotNil(t, p.General[0].Edit)
52+ assert.Nil(t, p.General[0].Replies[0].Edit)
53+ assert.Nil(t, p.General[0].Replies[0].Resolve)
54+ assert.Contains(t, string(p.General[0].Body), "<strong>hello</strong>")
55+ assert.Contains(t, string(p.General[0].Body), `href="#c2"`)
56+ assert.True(t, p.General[1].Deleted)
57+ var b bytes.Buffer
58+ require.NoError(t, commentView(p.General[0], "").Render(context.Background(), &b))
59+ assert.Contains(t, b.String(), `name="reply_to" value="1"`)
60+ assert.Contains(t, b.String(), `data-js data-quote=`)
61+ assert.Contains(t, b.String(), `/comments/1/edit`)
62+ assert.NotContains(t, b.String(), `/comments/2/edit`)
63+}
64+
65+func TestNoteLiveEvents(t *testing.T) {
66+ t.Parallel()
67+ l := liveStream{view: "mr", n: "1"}
68+ events := []domain.Event{domain.CommentEdited{Number: 1}, domain.CommentDeleted{Number: 1}, domain.CommentResolved{Number: 1}}
69+ for _, ev := range events {
70+ we := ports.WatchEvent{Event: ev}
71+ assert.True(t, liveEvent(we))
72+ fs, err := l.repoPage(context.Background(), we)
73+ require.NoError(t, err)
74+ require.Len(t, fs, 1)
75+ assert.Contains(t, fs[0].html, "reload")
76+ }
77+}
78+
79+func TestNotePosts(t *testing.T) {
80+ t.Parallel()
81+ svc := mocks.NewMergeRequestService(t)
82+ repo := repoName(t, "demo")
83+ s := &Server{cfg: Config{MergeRequests: svc}}
84+ ctx := domain.ContextWithPrincipal(context.Background(), domain.Owner())
85+ request := func(values url.Values) *http.Request {
86+ r := httptest.NewRequest("POST", "/demo/merge-requests/1/comments", strings.NewReader(values.Encode())).WithContext(ctx)
87+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
88+ r.SetPathValue("repo", "demo")
89+ r.SetPathValue("n", "1")
90+ r.SetPathValue("id", "2")
91+ require.NoError(t, r.ParseForm())
92+ return r
93+ }
94+ svc.EXPECT().AddComment(mock.Anything, mock.Anything, repo, int64(1), mock.MatchedBy(func(p ports.AddCommentParams) bool { return p.ReplyTo == 2 && p.Text == "reply" })).Return(domain.Comment{ID: 7}, nil).Once()
95+ to, _, err := s.mrComment(ctx, request(url.Values{"reply_to": {"2"}, "text": {"reply"}}))
96+ require.NoError(t, err)
97+ assert.Equal(t, "/demo/merge-requests/1#c7", to)
98+ svc.EXPECT().AddComment(mock.Anything, mock.Anything, repo, int64(1), mock.MatchedBy(func(p ports.AddCommentParams) bool {
99+ return p.StartLine == 10 && p.Line == 24 && p.Path == "a.go" && p.Verdict == domain.VerdictApprove
100+ })).Return(domain.Comment{ID: 8}, nil).Once()
101+ to, _, err = s.mrComment(ctx, request(url.Values{"path": {"a.go"}, "line": {"24"}, "start_line": {"10"}, "side": {"new"}, "verdict": {"approve"}}))
102+ require.NoError(t, err)
103+ assert.Equal(t, "/demo/merge-requests/1#c8", to)
104+ svc.EXPECT().EditComment(mock.Anything, mock.Anything, repo, int64(1), int64(2), "new\ntext").Return(domain.Comment{ID: 2}, nil).Once()
105+ to, _, err = s.mrEditComment(ctx, request(url.Values{"text": {"new\r\ntext"}}))
106+ require.NoError(t, err)
107+ assert.Equal(t, "/demo/merge-requests/1#c2", to)
108+ svc.EXPECT().DeleteComment(mock.Anything, mock.Anything, repo, int64(1), int64(2)).Return(domain.Comment{ID: 2}, nil).Once()
109+ to, _, err = s.mrDeleteComment(ctx, request(nil))
110+ require.NoError(t, err)
111+ assert.Equal(t, "/demo/merge-requests/1#c2", to)
112+}
113+
114+func TestDeletedNoteRendering(t *testing.T) {
115+ t.Parallel()
116+ text, err := domain.ParsePlainText("surviving reply")
117+ require.NoError(t, err)
118+ s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
119+ p := MRPage{}
120+ s.placeComments(&p, "demo", 1, []domain.Comment{
121+ {ID: 1, Author: "reviewer", Deleted: true, Verdict: domain.VerdictApprove},
122+ {ID: 2, Deleted: true},
123+ {ID: 3, ReplyTo: 2, Deleted: true},
124+ {ID: 4, Deleted: true},
125+ {ID: 5, ReplyTo: 4, Text: text},
126+ }, "", true, true, nil)
127+ require.Len(t, p.General, 2)
128+ assert.Equal(t, "1", p.General[0].ID)
129+ assert.Equal(t, "4", p.General[1].ID)
130+ var b bytes.Buffer
131+ require.NoError(t, commentView(p.General[0], "").Render(context.Background(), &b))
132+ assert.Contains(t, b.String(), `<span class="c-who">owner</span>`)
133+ assert.Contains(t, b.String(), "approve")
134+ assert.Contains(t, b.String(), "text deleted")
135+ b.Reset()
136+ require.NoError(t, commentView(p.General[1], "").Render(context.Background(), &b))
137+ assert.Contains(t, b.String(), "surviving reply")
138+ assert.NotContains(t, b.String(), "Quote")
139+ assert.NotContains(t, b.String(), "data-quote")
140+}
141+
142+func TestInlineNoteHeadingIDs(t *testing.T) {
143+ t.Parallel()
144+ // "S H" would get the id the split copy of "H" gets if the prefixes of the
145+ // two copies overlapped.
146+ text, err := domain.ParsePlainText("# H\n\n# S H\n\n[jump](#h)")
147+ require.NoError(t, err)
148+ s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
149+ p := MRPage{Files: []DiffFile{{Key: "a", ID: "f1", Hunks: []DiffHunk{{Lines: []DiffLine{{Kind: "add", newN: 1}}}}}}}
150+ s.placeComments(&p, "demo", 1, []domain.Comment{{ID: 12, Path: "a", Side: domain.SideNew, Line: 1, Text: text}}, "", true, true, nil)
151+ notes := p.Files[0].Hunks[0].Lines[0].Notes
152+ require.Len(t, notes, 1)
153+ var b bytes.Buffer
154+ require.NoError(t, commentView(notes[0], "").Render(context.Background(), &b))
155+ require.NoError(t, commentView(notes[0], "-s").Render(context.Background(), &b))
156+ html := b.String()
157+ for _, id := range []string{"c12-h", "c12-s-h", "c12s-h", "c12s-s-h"} {
158+ assert.Equal(t, 1, strings.Count(html, `id="`+id+`"`), id)
159+ }
160+ assert.Contains(t, html, `href="#c12-h"`)
161+ assert.Contains(t, html, `href="#c12s-h"`)
162+}
@@ -0,0 +1,162 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bnema.dev/ermite/internal/ports/mocks"
7+ "bytes"
8+ "context"
9+ "github.com/stretchr/testify/mock"
10+ "net/http"
11+ "net/http/httptest"
12+ "net/url"
13+ "strings"
14+ "testing"
15+ "time"
16+
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "github.com/stretchr/testify/assert"
20+ "github.com/stretchr/testify/require"
21+)
22+
23+func TestParseOnRange(t *testing.T) {
24+ t.Parallel()
25+ for _, raw := range []string{"new:0-2:a", "new:2-2:a", "new:3-2:a", "new:1-x:a", "new:1-2:", "other:1-2:a"} {
26+ assert.Nil(t, parseOn(raw), raw)
27+ }
28+ assert.Equal(t, &lineRef{side: "new", start: 10, line: 24, path: "dir:a.go"}, parseOn("new:10-24:dir:a.go"))
29+ assert.Equal(t, &lineRef{side: "old", line: 10, path: "a"}, parseOn("old:10:a"))
30+}
31+
32+func TestNoteThreads(t *testing.T) {
33+ t.Parallel()
34+ text, err := domain.ParsePlainText("**hello** ^2")
35+ require.NoError(t, err)
36+ s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
37+ comments := []domain.Comment{
38+ {ID: 1, Author: "alice", Text: text},
39+ {ID: 2, Author: "bob", ReplyTo: 1, Text: text},
40+ {ID: 3, Author: "alice", Deleted: true},
41+ {ID: 4, Author: "alice", Deleted: true},
42+ {ID: 5, Author: "bob", ReplyTo: 4, Text: text},
43+ {ID: 6, Author: "alice", Text: text, Verdict: domain.VerdictApprove, Resolved: true},
44+ }
45+ p := MRPage{NoteAuthor: "alice"}
46+ s.placeComments(&p, "demo", 1, comments, "", true, true, nil)
47+ require.Len(t, p.General, 3)
48+ assert.Equal(t, 4, p.NComments)
49+ assert.Equal(t, 1, p.NUnresolved)
50+ require.Len(t, p.General[0].Replies, 1)
51+ assert.NotNil(t, p.General[0].Edit)
52+ assert.Nil(t, p.General[0].Replies[0].Edit)
53+ assert.Nil(t, p.General[0].Replies[0].Resolve)
54+ assert.Contains(t, string(p.General[0].Body), "<strong>hello</strong>")
55+ assert.Contains(t, string(p.General[0].Body), `href="#c2"`)
56+ assert.True(t, p.General[1].Deleted)
57+ var b bytes.Buffer
58+ require.NoError(t, commentView(p.General[0], "").Render(context.Background(), &b))
59+ assert.Contains(t, b.String(), `name="reply_to" value="1"`)
60+ assert.Contains(t, b.String(), `data-js data-quote=`)
61+ assert.Contains(t, b.String(), `/comments/1/edit`)
62+ assert.NotContains(t, b.String(), `/comments/2/edit`)
63+}
64+
65+func TestNoteLiveEvents(t *testing.T) {
66+ t.Parallel()
67+ l := liveStream{view: "mr", n: "1"}
68+ events := []domain.Event{domain.CommentEdited{Number: 1}, domain.CommentDeleted{Number: 1}, domain.CommentResolved{Number: 1}}
69+ for _, ev := range events {
70+ we := ports.WatchEvent{Event: ev}
71+ assert.True(t, liveEvent(we))
72+ fs, err := l.repoPage(context.Background(), we)
73+ require.NoError(t, err)
74+ require.Len(t, fs, 1)
75+ assert.Contains(t, fs[0].html, "reload")
76+ }
77+}
78+
79+func TestNotePosts(t *testing.T) {
80+ t.Parallel()
81+ svc := mocks.NewMergeRequestService(t)
82+ repo := repoName(t, "demo")
83+ s := &Server{cfg: Config{MergeRequests: svc}}
84+ ctx := domain.ContextWithPrincipal(context.Background(), domain.Owner())
85+ request := func(values url.Values) *http.Request {
86+ r := httptest.NewRequest("POST", "/demo/merge-requests/1/comments", strings.NewReader(values.Encode())).WithContext(ctx)
87+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
88+ r.SetPathValue("repo", "demo")
89+ r.SetPathValue("n", "1")
90+ r.SetPathValue("id", "2")
91+ require.NoError(t, r.ParseForm())
92+ return r
93+ }
94+ svc.EXPECT().AddComment(mock.Anything, mock.Anything, repo, int64(1), mock.MatchedBy(func(p ports.AddCommentParams) bool { return p.ReplyTo == 2 && p.Text == "reply" })).Return(domain.Comment{ID: 7}, nil).Once()
95+ to, _, err := s.mrComment(ctx, request(url.Values{"reply_to": {"2"}, "text": {"reply"}}))
96+ require.NoError(t, err)
97+ assert.Equal(t, "/demo/merge-requests/1#c7", to)
98+ svc.EXPECT().AddComment(mock.Anything, mock.Anything, repo, int64(1), mock.MatchedBy(func(p ports.AddCommentParams) bool {
99+ return p.StartLine == 10 && p.Line == 24 && p.Path == "a.go" && p.Verdict == domain.VerdictApprove
100+ })).Return(domain.Comment{ID: 8}, nil).Once()
101+ to, _, err = s.mrComment(ctx, request(url.Values{"path": {"a.go"}, "line": {"24"}, "start_line": {"10"}, "side": {"new"}, "verdict": {"approve"}}))
102+ require.NoError(t, err)
103+ assert.Equal(t, "/demo/merge-requests/1#c8", to)
104+ svc.EXPECT().EditComment(mock.Anything, mock.Anything, repo, int64(1), int64(2), "new\ntext").Return(domain.Comment{ID: 2}, nil).Once()
105+ to, _, err = s.mrEditComment(ctx, request(url.Values{"text": {"new\r\ntext"}}))
106+ require.NoError(t, err)
107+ assert.Equal(t, "/demo/merge-requests/1#c2", to)
108+ svc.EXPECT().DeleteComment(mock.Anything, mock.Anything, repo, int64(1), int64(2)).Return(domain.Comment{ID: 2}, nil).Once()
109+ to, _, err = s.mrDeleteComment(ctx, request(nil))
110+ require.NoError(t, err)
111+ assert.Equal(t, "/demo/merge-requests/1#c2", to)
112+}
113+
114+func TestDeletedNoteRendering(t *testing.T) {
115+ t.Parallel()
116+ text, err := domain.ParsePlainText("surviving reply")
117+ require.NoError(t, err)
118+ s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
119+ p := MRPage{}
120+ s.placeComments(&p, "demo", 1, []domain.Comment{
121+ {ID: 1, Author: "reviewer", Deleted: true, Verdict: domain.VerdictApprove},
122+ {ID: 2, Deleted: true},
123+ {ID: 3, ReplyTo: 2, Deleted: true},
124+ {ID: 4, Deleted: true},
125+ {ID: 5, ReplyTo: 4, Text: text},
126+ }, "", true, true, nil)
127+ require.Len(t, p.General, 2)
128+ assert.Equal(t, "1", p.General[0].ID)
129+ assert.Equal(t, "4", p.General[1].ID)
130+ var b bytes.Buffer
131+ require.NoError(t, commentView(p.General[0], "").Render(context.Background(), &b))
132+ assert.Contains(t, b.String(), `<span class="c-who">owner</span>`)
133+ assert.Contains(t, b.String(), "approve")
134+ assert.Contains(t, b.String(), "text deleted")
135+ b.Reset()
136+ require.NoError(t, commentView(p.General[1], "").Render(context.Background(), &b))
137+ assert.Contains(t, b.String(), "surviving reply")
138+ assert.NotContains(t, b.String(), "Quote")
139+ assert.NotContains(t, b.String(), "data-quote")
140+}
141+
142+func TestInlineNoteHeadingIDs(t *testing.T) {
143+ t.Parallel()
144+ // "S H" would get the id the split copy of "H" gets if the prefixes of the
145+ // two copies overlapped.
146+ text, err := domain.ParsePlainText("# H\n\n# S H\n\n[jump](#h)")
147+ require.NoError(t, err)
148+ s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
149+ p := MRPage{Files: []DiffFile{{Key: "a", ID: "f1", Hunks: []DiffHunk{{Lines: []DiffLine{{Kind: "add", newN: 1}}}}}}}
150+ s.placeComments(&p, "demo", 1, []domain.Comment{{ID: 12, Path: "a", Side: domain.SideNew, Line: 1, Text: text}}, "", true, true, nil)
151+ notes := p.Files[0].Hunks[0].Lines[0].Notes
152+ require.Len(t, notes, 1)
153+ var b bytes.Buffer
154+ require.NoError(t, commentView(notes[0], "").Render(context.Background(), &b))
155+ require.NoError(t, commentView(notes[0], "-s").Render(context.Background(), &b))
156+ html := b.String()
157+ for _, id := range []string{"c12-h", "c12-s-h", "c12s-h", "c12s-s-h"} {
158+ assert.Equal(t, 1, strings.Count(html, `id="`+id+`"`), id)
159+ }
160+ assert.Contains(t, html, `href="#c12-h"`)
161+ assert.Contains(t, html, `href="#c12s-h"`)
162+}
M internal/adapters/web/mr_post.go +47 -0
@@ -177,6 +177,23 @@ func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string
177177 return "", back, formError("side must be new or old")
178178 }
179179 }
180+ if raw := r.PostFormValue("reply_to"); raw != "" {
181+ params.ReplyTo = commentID(raw)
182+ if params.ReplyTo == 0 {
183+ return "", back, formError("reply_to is not a note id")
184+ }
185+ }
186+ if raw := r.PostFormValue("start_line"); raw != "" {
187+ v, e := strconv.ParseInt(raw, 10, 32)
188+ if e != nil {
189+ return "", back, formError("start_line is not a number")
190+ }
191+ params.StartLine = int(v)
192+ }
193+ params.Verdict, err = domain.ParseVerdict(r.PostFormValue("verdict"))
194+ if err != nil {
195+ return "", back, err
196+ }
180197 name, err := domain.ParseRepoName(repo)
181198 if err != nil {
182199 return "", back, err
@@ -356,3 +373,33 @@ func formBool(s string) (bool, error) {
356373 func normalizeNewlines(s string) string {
357374 return strings.ReplaceAll(s, "\r\n", "\n")
358375 }
376+
377+// mrEditComment passes source text to the author-checked use case.
378+func (s *Server) mrEditComment(ctx context.Context, r *http.Request) (string, string, error) {
379+ n, err := number(r)
380+ if err != nil {
381+ return "", "", err
382+ }
383+ back := withFrom(mrHref(r.PathValue("repo"), n), formFrom(r))
384+ name, err := domain.ParseRepoName(r.PathValue("repo"))
385+ if err != nil {
386+ return "", back, err
387+ }
388+ c, err := s.cfg.MergeRequests.EditComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), normalizeNewlines(r.PostFormValue("text")))
389+ return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
390+}
391+
392+// mrDeleteComment passes the note id to the author-checked use case.
393+func (s *Server) mrDeleteComment(ctx context.Context, r *http.Request) (string, string, error) {
394+ n, err := number(r)
395+ if err != nil {
396+ return "", "", err
397+ }
398+ back := withFrom(mrHref(r.PathValue("repo"), n), formFrom(r))
399+ name, err := domain.ParseRepoName(r.PathValue("repo"))
400+ if err != nil {
401+ return "", back, err
402+ }
403+ c, err := s.cfg.MergeRequests.DeleteComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")))
404+ return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
405+}
@@ -177,6 +177,23 @@ func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string
177 return "", back, formError("side must be new or old")177 return "", back, formError("side must be new or old")
178 }178 }
179 }179 }
180+ if raw := r.PostFormValue("reply_to"); raw != "" {
181+ params.ReplyTo = commentID(raw)
182+ if params.ReplyTo == 0 {
183+ return "", back, formError("reply_to is not a note id")
184+ }
185+ }
186+ if raw := r.PostFormValue("start_line"); raw != "" {
187+ v, e := strconv.ParseInt(raw, 10, 32)
188+ if e != nil {
189+ return "", back, formError("start_line is not a number")
190+ }
191+ params.StartLine = int(v)
192+ }
193+ params.Verdict, err = domain.ParseVerdict(r.PostFormValue("verdict"))
194+ if err != nil {
195+ return "", back, err
196+ }
180 name, err := domain.ParseRepoName(repo)197 name, err := domain.ParseRepoName(repo)
181 if err != nil {198 if err != nil {
182 return "", back, err199 return "", back, err
@@ -356,3 +373,33 @@ func formBool(s string) (bool, error) {
356 func normalizeNewlines(s string) string {373 func normalizeNewlines(s string) string {
357 return strings.ReplaceAll(s, "\r\n", "\n")374 return strings.ReplaceAll(s, "\r\n", "\n")
358 }375 }
376+
377+// mrEditComment passes source text to the author-checked use case.
378+func (s *Server) mrEditComment(ctx context.Context, r *http.Request) (string, string, error) {
379+ n, err := number(r)
380+ if err != nil {
381+ return "", "", err
382+ }
383+ back := withFrom(mrHref(r.PathValue("repo"), n), formFrom(r))
384+ name, err := domain.ParseRepoName(r.PathValue("repo"))
385+ if err != nil {
386+ return "", back, err
387+ }
388+ c, err := s.cfg.MergeRequests.EditComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), normalizeNewlines(r.PostFormValue("text")))
389+ return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
390+}
391+
392+// mrDeleteComment passes the note id to the author-checked use case.
393+func (s *Server) mrDeleteComment(ctx context.Context, r *http.Request) (string, string, error) {
394+ n, err := number(r)
395+ if err != nil {
396+ return "", "", err
397+ }
398+ back := withFrom(mrHref(r.PathValue("repo"), n), formFrom(r))
399+ name, err := domain.ParseRepoName(r.PathValue("repo"))
400+ if err != nil {
401+ return "", back, err
402+ }
403+ c, err := s.cfg.MergeRequests.DeleteComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")))
404+ return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
405+}
M internal/adapters/web/mr_templ.go +777 -480 generated file: collapsed
M internal/adapters/web/static/css/55-diff.css +4 -0
@@ -6,3 +6,7 @@
66 }
77 .eol { color: var(--ink-mute); padding-left: 1ch; user-select: none; }
88 }
9+
10+@layer components {
11+ .sel { outline: 1px solid var(--ink-dim); outline-offset: -1px; }
12+}
@@ -6,3 +6,7 @@
6 }6 }
7 .eol { color: var(--ink-mute); padding-left: 1ch; user-select: none; }7 .eol { color: var(--ink-mute); padding-left: 1ch; user-select: none; }
8 }8 }
9+
10+@layer components {
11+ .sel { outline: 1px solid var(--ink-dim); outline-offset: -1px; }
12+}
M internal/adapters/web/static/css/60-mr.css +8 -3
@@ -11,7 +11,6 @@
1111 .mr-desc.prose { max-width: none; padding: var(--s4) var(--s5); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); }
1212 .mr-desc.prose > * { max-width: var(--measure); }
1313 .mr-desc.prose > pre, .mr-desc.prose > table { max-width: 100%; }
14- .c-text a { text-decoration: underline; }
1514
1615 .since {
1716 display: inline-flex; flex-wrap: wrap; gap: var(--s1); padding: var(--s1);
@@ -147,12 +146,11 @@
147146 & p { white-space: normal; }
148147 & .c-who { font-weight: bold; }
149148 & .c-state { color: var(--ink-dim); }
150- & .c-text { white-space: pre-wrap; overflow-wrap: anywhere; }
151149 & .c-where { font-size: var(--fs-s); overflow-wrap: anywhere; }
152150 & .c-act { margin-top: var(--s2); }
153151 & .c-act button { height: 26px; font-size: var(--fs-xs); }
154152 &[data-resolved="true"] { border-left-color: var(--border-strong); }
155- &[data-resolved="true"] .c-text { color: var(--ink-dim); }
153+ &[data-resolved="true"] .c-body { color: var(--ink-dim); }
156154 }
157155 .dt .fnote td { background: var(--bg-canvas); padding: var(--s2) var(--s3); white-space: normal; }
158156 .dt .fnote .cmt, .dt .fnote .cform { margin: 0; }
@@ -190,3 +188,10 @@
190188 .list.mrs .c-who { display: none; }
191189 }
192190 }
191+
192+@layer components {
193+ .c-body { white-space: normal; overflow-wrap: anywhere; }
194+ .c-body.prose > * { margin-block: .5em; }
195+ .c-body .ref { font-variant-numeric: tabular-nums; text-decoration: underline; }
196+ .cmt > .cmt { margin-inline-start: 2ch; }
197+}
@@ -11,7 +11,6 @@
11 .mr-desc.prose { max-width: none; padding: var(--s4) var(--s5); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); }11 .mr-desc.prose { max-width: none; padding: var(--s4) var(--s5); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); }
12 .mr-desc.prose > * { max-width: var(--measure); }12 .mr-desc.prose > * { max-width: var(--measure); }
13 .mr-desc.prose > pre, .mr-desc.prose > table { max-width: 100%; }13 .mr-desc.prose > pre, .mr-desc.prose > table { max-width: 100%; }
14- .c-text a { text-decoration: underline; }
15 14
16 .since {15 .since {
17 display: inline-flex; flex-wrap: wrap; gap: var(--s1); padding: var(--s1);16 display: inline-flex; flex-wrap: wrap; gap: var(--s1); padding: var(--s1);
@@ -147,12 +146,11 @@
147 & p { white-space: normal; }146 & p { white-space: normal; }
148 & .c-who { font-weight: bold; }147 & .c-who { font-weight: bold; }
149 & .c-state { color: var(--ink-dim); }148 & .c-state { color: var(--ink-dim); }
150- & .c-text { white-space: pre-wrap; overflow-wrap: anywhere; }
151 & .c-where { font-size: var(--fs-s); overflow-wrap: anywhere; }149 & .c-where { font-size: var(--fs-s); overflow-wrap: anywhere; }
152 & .c-act { margin-top: var(--s2); }150 & .c-act { margin-top: var(--s2); }
153 & .c-act button { height: 26px; font-size: var(--fs-xs); }151 & .c-act button { height: 26px; font-size: var(--fs-xs); }
154 &[data-resolved="true"] { border-left-color: var(--border-strong); }152 &[data-resolved="true"] { border-left-color: var(--border-strong); }
155- &[data-resolved="true"] .c-text { color: var(--ink-dim); }153+ &[data-resolved="true"] .c-body { color: var(--ink-dim); }
156 }154 }
157 .dt .fnote td { background: var(--bg-canvas); padding: var(--s2) var(--s3); white-space: normal; }155 .dt .fnote td { background: var(--bg-canvas); padding: var(--s2) var(--s3); white-space: normal; }
158 .dt .fnote .cmt, .dt .fnote .cform { margin: 0; }156 .dt .fnote .cmt, .dt .fnote .cform { margin: 0; }
@@ -190,3 +188,10 @@
190 .list.mrs .c-who { display: none; }188 .list.mrs .c-who { display: none; }
191 }189 }
192 }190 }
191+
192+@layer components {
193+ .c-body { white-space: normal; overflow-wrap: anywhere; }
194+ .c-body.prose > * { margin-block: .5em; }
195+ .c-body .ref { font-variant-numeric: tabular-nums; text-decoration: underline; }
196+ .cmt > .cmt { margin-inline-start: 2ch; }
197+}
M internal/adapters/web/static/js/mr.js +51 -2
@@ -18,6 +18,25 @@
1818 window.ermiteMR = (() => {
1919 // core: the decisions, without the DOM.
2020 const core = {
21+ rangeURL(previous, current, base) {
22+ const a = new URL(previous, base), b = new URL(current, base);
23+ const parse = (u) => /^(new|old):(\d+):(.+)$/.exec(u.searchParams.get('on') || '');
24+ const x = parse(a), y = parse(b);
25+ if (!x || !y || a.pathname !== b.pathname || x[1] !== y[1] || x[3] !== y[3] || x[2] === y[2]) return null;
26+ const nums = [Number(x[2]), Number(y[2])].sort((a,b) => a-b);
27+ b.searchParams.set('on', `${y[1]}:${nums[0]}-${nums[1]}:${y[3]}`);
28+ return b.href;
29+ },
30+ quote(button) {
31+ const thread = button.closest('.cmt').closest('[data-thread]');
32+ const details = thread?.querySelector(':scope > details[data-reply]');
33+ const field = details?.querySelector('textarea');
34+ if (!field) return false;
35+ details.open = true;
36+ field.value = button.dataset.quote;
37+ field.focus();
38+ return true;
39+ },
2140 // windowOf is the start of the window of file i (files are f1, f2, ...).
2241 windowOf(id, size) {
2342 const n = Number(String(id).replace(/^f/, '')) - 1;
@@ -107,16 +126,34 @@ window.ermiteMR = (() => {
107126 function reveal(id, scroll = true) {
108127 // Any navigation, loaded or not, makes the reveals still loading stale.
109128 const gen = ++revealGen;
129+ const target = () => {
130+ // A page with both layouts says which shows in data-diff; a large diff
131+ // rendered in one layout only says it in data-view.
132+ const diffs = $('#diffs')?.dataset;
133+ if (/^c\d+$/.test(id) && (diffs?.diff || diffs?.view) === 'split') {
134+ const split = document.getElementById(id + '-s');
135+ if (split) return split;
136+ }
137+ return document.getElementById(id);
138+ };
110139 let ph = document.getElementById(id)?.querySelector(':scope > .dlazy');
111140 if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {
112141 const base = id.replace(/-s$/, '');
113142 ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));
114143 }
115- if (!ph) return false;
144+ if (!ph) {
145+ const el = target();
146+ if (el && el.id !== id) {
147+ const d = el.closest('details');
148+ if (d) d.open = true;
149+ if (scroll) el.scrollIntoView({ block: 'start' });
150+ }
151+ return false;
152+ }
116153 load(ph).then(() => {
117154 // A later reveal (another file asked meanwhile) owns the view.
118155 if (gen !== revealGen) return;
119- const el = document.getElementById(id);
156+ const el = target();
120157 if (!el) return;
121158 const d = el.closest('details');
122159 if (d) d.open = true;
@@ -262,6 +299,18 @@ window.ermiteMR = (() => {
262299 };
263300 addEventListener('hashchange', hash);
264301 hash();
302+ let lastLine = null;
303+ try { lastLine = sessionStorage.getItem('mr-last-line'); } catch { /* storage optional */ }
304+ addEventListener('click', (e) => {
305+ const quote = e.target.closest?.('[data-quote]');
306+ if (quote) { core.quote(quote); return; }
307+ const link = e.target.closest?.('a[href*="on="]');
308+ if (!link || e.ctrlKey || e.metaKey || e.altKey) return;
309+ const next = e.shiftKey && lastLine && core.rangeURL(lastLine, link.href, location.href);
310+ lastLine = link.href;
311+ try { sessionStorage.setItem('mr-last-line', lastLine); } catch { /* storage optional */ }
312+ if (next) { e.preventDefault(); location.assign(next); }
313+ });
265314 // A click outside an open menu closes it.
266315 addEventListener('click', (e) => {
267316 for (const d of $$('details.menu[open]')) if (!d.contains(e.target)) d.open = false;
@@ -18,6 +18,25 @@
18 window.ermiteMR = (() => {18 window.ermiteMR = (() => {
19 // core: the decisions, without the DOM.19 // core: the decisions, without the DOM.
20 const core = {20 const core = {
21+ rangeURL(previous, current, base) {
22+ const a = new URL(previous, base), b = new URL(current, base);
23+ const parse = (u) => /^(new|old):(\d+):(.+)$/.exec(u.searchParams.get('on') || '');
24+ const x = parse(a), y = parse(b);
25+ if (!x || !y || a.pathname !== b.pathname || x[1] !== y[1] || x[3] !== y[3] || x[2] === y[2]) return null;
26+ const nums = [Number(x[2]), Number(y[2])].sort((a,b) => a-b);
27+ b.searchParams.set('on', `${y[1]}:${nums[0]}-${nums[1]}:${y[3]}`);
28+ return b.href;
29+ },
30+ quote(button) {
31+ const thread = button.closest('.cmt').closest('[data-thread]');
32+ const details = thread?.querySelector(':scope > details[data-reply]');
33+ const field = details?.querySelector('textarea');
34+ if (!field) return false;
35+ details.open = true;
36+ field.value = button.dataset.quote;
37+ field.focus();
38+ return true;
39+ },
21 // windowOf is the start of the window of file i (files are f1, f2, ...).40 // windowOf is the start of the window of file i (files are f1, f2, ...).
22 windowOf(id, size) {41 windowOf(id, size) {
23 const n = Number(String(id).replace(/^f/, '')) - 1;42 const n = Number(String(id).replace(/^f/, '')) - 1;
@@ -107,16 +126,34 @@ window.ermiteMR = (() => {
107 function reveal(id, scroll = true) {126 function reveal(id, scroll = true) {
108 // Any navigation, loaded or not, makes the reveals still loading stale.127 // Any navigation, loaded or not, makes the reveals still loading stale.
109 const gen = ++revealGen;128 const gen = ++revealGen;
129+ const target = () => {
130+ // A page with both layouts says which shows in data-diff; a large diff
131+ // rendered in one layout only says it in data-view.
132+ const diffs = $('#diffs')?.dataset;
133+ if (/^c\d+$/.test(id) && (diffs?.diff || diffs?.view) === 'split') {
134+ const split = document.getElementById(id + '-s');
135+ if (split) return split;
136+ }
137+ return document.getElementById(id);
138+ };
110 let ph = document.getElementById(id)?.querySelector(':scope > .dlazy');139 let ph = document.getElementById(id)?.querySelector(':scope > .dlazy');
111 if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {140 if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {
112 const base = id.replace(/-s$/, '');141 const base = id.replace(/-s$/, '');
113 ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));142 ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));
114 }143 }
115- if (!ph) return false;144+ if (!ph) {
145+ const el = target();
146+ if (el && el.id !== id) {
147+ const d = el.closest('details');
148+ if (d) d.open = true;
149+ if (scroll) el.scrollIntoView({ block: 'start' });
150+ }
151+ return false;
152+ }
116 load(ph).then(() => {153 load(ph).then(() => {
117 // A later reveal (another file asked meanwhile) owns the view.154 // A later reveal (another file asked meanwhile) owns the view.
118 if (gen !== revealGen) return;155 if (gen !== revealGen) return;
119- const el = document.getElementById(id);156+ const el = target();
120 if (!el) return;157 if (!el) return;
121 const d = el.closest('details');158 const d = el.closest('details');
122 if (d) d.open = true;159 if (d) d.open = true;
@@ -262,6 +299,18 @@ window.ermiteMR = (() => {
262 };299 };
263 addEventListener('hashchange', hash);300 addEventListener('hashchange', hash);
264 hash();301 hash();
302+ let lastLine = null;
303+ try { lastLine = sessionStorage.getItem('mr-last-line'); } catch { /* storage optional */ }
304+ addEventListener('click', (e) => {
305+ const quote = e.target.closest?.('[data-quote]');
306+ if (quote) { core.quote(quote); return; }
307+ const link = e.target.closest?.('a[href*="on="]');
308+ if (!link || e.ctrlKey || e.metaKey || e.altKey) return;
309+ const next = e.shiftKey && lastLine && core.rangeURL(lastLine, link.href, location.href);
310+ lastLine = link.href;
311+ try { sessionStorage.setItem('mr-last-line', lastLine); } catch { /* storage optional */ }
312+ if (next) { e.preventDefault(); location.assign(next); }
313+ });
265 // A click outside an open menu closes it.314 // A click outside an open menu closes it.
266 addEventListener('click', (e) => {315 addEventListener('click', (e) => {
267 for (const d of $$('details.menu[open]')) if (!d.contains(e.target)) d.open = false;316 for (const d of $$('details.menu[open]')) if (!d.contains(e.target)) d.open = false;
M internal/adapters/web/web.go +2 -0
@@ -190,6 +190,8 @@ func (s *Server) routes() *http.ServeMux {
190190 mux.HandleFunc("POST /{repo}/merge-requests", s.mrAction(s.mrCreate))
191191 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments", s.mrAction(s.mrComment))
192192 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/resolve", s.mrAction(s.mrResolve))
193+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/edit", s.mrAction(s.mrEditComment))
194+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/delete", s.mrAction(s.mrDeleteComment))
193195 mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))
194196 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
195197 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
@@ -190,6 +190,8 @@ func (s *Server) routes() *http.ServeMux {
190 mux.HandleFunc("POST /{repo}/merge-requests", s.mrAction(s.mrCreate))190 mux.HandleFunc("POST /{repo}/merge-requests", s.mrAction(s.mrCreate))
191 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments", s.mrAction(s.mrComment))191 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments", s.mrAction(s.mrComment))
192 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/resolve", s.mrAction(s.mrResolve))192 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/resolve", s.mrAction(s.mrResolve))
193+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/edit", s.mrAction(s.mrEditComment))
194+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/delete", s.mrAction(s.mrDeleteComment))
193 mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))195 mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))
194 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))196 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
195 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))197 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
M internal/app/cli_mr.go +226 -25
@@ -144,12 +144,28 @@ type jsonComment struct {
144144 Text string `json:"text"`
145145 Resolved bool `json:"resolved"`
146146 CreatedAt *string `json:"created_at"`
147+ ReplyTo string `json:"reply_to"`
148+ StartLine int32 `json:"start_line"`
149+ Verdict string `json:"verdict"`
150+ EditedAt *string `json:"edited_at"`
151+ Deleted bool `json:"deleted"`
152+}
153+
154+func verdictName(v ermite.Verdict) string {
155+ switch v {
156+ case ermite.Verdict_VERDICT_APPROVE:
157+ return "approve"
158+ case ermite.Verdict_VERDICT_CHANGES_REQUESTED:
159+ return "changes_requested"
160+ }
161+ return ""
147162 }
148163
149164 func toJSONComment(c *ermite.Comment) jsonComment {
150165 return jsonComment{
151166 ID: c.GetId(), Author: c.GetAuthor(), Path: c.GetPath(), Line: c.GetLine(), Side: sideName(c.GetSide()),
152167 CommitID: c.GetCommitId(), Text: c.GetText(), Resolved: c.GetResolved(), CreatedAt: jsonTime(c.GetCreatedAt()),
168+ ReplyTo: c.GetReplyTo(), StartLine: c.GetStartLine(), Verdict: verdictName(c.GetVerdict()), EditedAt: jsonTime(c.GetEditedAt()), Deleted: c.GetDeleted(),
153169 }
154170 }
155171
@@ -163,7 +179,8 @@ unless merged).`
163179 const jsonCommentHelp = `
164180 JSON fields of a comment: id, author, path, line and side ("old" or "new"; empty path, line 0
165181 and empty side for a general comment), commit_id (the head when it was written), text,
166-resolved, created_at.`
182+resolved, created_at, reply_to (empty for roots), start_line (0 for a single line),
183+verdict ("", "approve", "changes_requested"), edited_at (nullable RFC 3339), deleted.`
167184
168185 func newMRCmd(env *cliEnv) *cobra.Command {
169186 mr := &cobra.Command{
@@ -175,7 +192,7 @@ func newMRCmd(env *cliEnv) *cobra.Command {
175192 }
176193 mr.AddCommand(
177194 newMRCreateCmd(env), newMRListCmd(env), newMRShowCmd(env), newMRCommentCmd(env),
178- newMRResolveCmd(env), newMRMergeCmd(env), newMRCloseCmd(env),
195+ newMRResolveCmd(env), newMREditCommentCmd(env), newMRDeleteCommentCmd(env), newMRMergeCmd(env), newMRCloseCmd(env),
179196 )
180197 return mr
181198 }
@@ -395,15 +412,25 @@ type jsonCommit struct {
395412 // jsonMRDetail is the stable JSON shape of `mr show --json`.
396413 type jsonMRDetail struct {
397414 jsonMergeRequest
398- Commits []jsonCommit `json:"commits"`
399- CommitsTruncated bool `json:"commits_truncated"`
400- DiffBaseID string `json:"diff_base_id"`
401- Stats jsonStats `json:"stats"`
402- DiffTruncated bool `json:"diff_truncated"`
403- Files []jsonFile `json:"files"`
404- Comments []jsonComment `json:"comments"`
405- LastReadID string `json:"last_read_id"`
406- LastReadLost bool `json:"last_read_lost"`
415+ Commits []jsonCommit `json:"commits"`
416+ CommitsTruncated bool `json:"commits_truncated"`
417+ DiffBaseID string `json:"diff_base_id"`
418+ Stats jsonStats `json:"stats"`
419+ DiffTruncated bool `json:"diff_truncated"`
420+ Files []jsonFile `json:"files"`
421+ Comments []jsonComment `json:"comments"`
422+ Verdicts []jsonAuthorVerdict `json:"verdicts"`
423+ LastReadID string `json:"last_read_id"`
424+ LastReadLost bool `json:"last_read_lost"`
425+}
426+
427+type jsonAuthorVerdict struct {
428+ Author string `json:"author"`
429+ Verdict string `json:"verdict"`
430+ CommentID string `json:"comment_id"`
431+ CommitID string `json:"commit_id"`
432+ CreatedAt *string `json:"created_at"`
433+ Stale bool `json:"stale"`
407434 }
408435
409436 func fileStatusName(s ermite.FileStatus) string {
@@ -423,7 +450,7 @@ func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL stri
423450 FilesChanged: d.GetStats().GetFilesChanged(), Additions: d.GetStats().GetAdditions(), Deletions: d.GetStats().GetDeletions(),
424451 },
425452 DiffTruncated: d.GetDiff().GetTruncated(),
426- Files: []jsonFile{}, Comments: []jsonComment{},
453+ Files: []jsonFile{}, Comments: []jsonComment{}, Verdicts: []jsonAuthorVerdict{},
427454 LastReadID: d.GetLastReadId(), LastReadLost: d.GetLastReadLost(),
428455 }
429456 for _, c := range d.GetCommits() {
@@ -453,6 +480,9 @@ func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL stri
453480 for _, c := range d.GetComments() {
454481 out.Comments = append(out.Comments, toJSONComment(c))
455482 }
483+ for _, v := range d.GetVerdicts() {
484+ 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()})
485+ }
456486 return out
457487 }
458488
@@ -471,7 +501,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
471501 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +
472502 "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" +
473503 "with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" +
474- "and last_read_lost.",
504+ "and last_read_lost; verdicts (author, verdict, comment_id, commit_id, created_at, stale).",
475505 Args: cobra.ExactArgs(2),
476506 ValidArgsFunction: completeMRRepoThenNumber(env),
477507 }
@@ -539,6 +569,25 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
539569 if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED {
540570 pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"})
541571 }
572+ if len(d.GetVerdicts()) > 0 {
573+ var verdicts []string
574+ for _, v := range d.GetVerdicts() {
575+ var label string
576+ switch v.GetVerdict() {
577+ case ermite.Verdict_VERDICT_APPROVE:
578+ label = "approved"
579+ case ermite.Verdict_VERDICT_CHANGES_REQUESTED:
580+ label = "changes requested"
581+ default:
582+ label = fmt.Sprintf("verdict %d", v.GetVerdict())
583+ }
584+ if v.GetStale() {
585+ label += " (stale)"
586+ }
587+ verdicts = append(verdicts, v.GetAuthor()+" "+label)
588+ }
589+ pairs = append(pairs, [2]string{"verdicts", strings.Join(verdicts, ", ")})
590+ }
542591 if err := fields(w, pairs); err != nil {
543592 return err
544593 }
@@ -571,16 +620,46 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
571620 }
572621
573622 _, _ = fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments()))
574- for _, c := range d.GetComments() {
623+ var ordered []*ermite.Comment
624+ for _, root := range d.GetComments() {
625+ if root.GetReplyTo() != "" {
626+ continue
627+ }
628+ ordered = append(ordered, root)
629+ for _, reply := range d.GetComments() {
630+ if reply.GetReplyTo() == root.GetId() {
631+ ordered = append(ordered, reply)
632+ }
633+ }
634+ }
635+ for _, c := range ordered {
575636 where := "general"
576637 if c.GetPath() != "" {
577638 where = fmt.Sprintf("%s:%d (%s)", clean(c.GetPath()), c.GetLine(), sideName(c.GetSide()))
578639 }
640+ if c.GetStartLine() > 0 {
641+ where = fmt.Sprintf("%s:lines %d-%d (%s)", clean(c.GetPath()), c.GetStartLine(), c.GetLine(), sideName(c.GetSide()))
642+ }
579643 mark := ""
644+ if c.GetEditedAt() != nil {
645+ mark += " (edited)"
646+ }
647+ if c.GetDeleted() {
648+ mark += " [deleted]"
649+ }
650+ if c.GetVerdict() != ermite.Verdict_VERDICT_UNSPECIFIED {
651+ mark += " [" + verdictName(c.GetVerdict()) + "]"
652+ }
580653 if c.GetResolved() {
581- mark = " [resolved]"
654+ mark += " [resolved]"
582655 }
583- _, _ = fmt.Fprintf(w, " #%s %s, %s, %s%s\n%s\n", c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), where, mark, indentText(c.GetText()))
656+ prefix := " "
657+ body := indentText(c.GetText())
658+ if c.GetReplyTo() != "" {
659+ prefix += " "
660+ body = " " + strings.ReplaceAll(body, "\n", "\n ")
661+ }
662+ _, _ = fmt.Fprintf(w, "%s#%s %s, %s, %s%s\n%s\n", prefix, c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), where, mark, body)
584663 }
585664 if withDiff {
586665 printDiff(w, d.GetDiff())
@@ -653,12 +732,14 @@ func printDiff(w io.Writer, d *ermite.Diff) {
653732 }
654733
655734 func newMRCommentCmd(env *cliEnv) *cobra.Command {
656- var message, path, side string
657- var line int
735+ var message, path, side, line, reply string
736+ var approve, requestChanges bool
658737 cmd := &cobra.Command{
659738 Use: "comment <repo> <number>",
660739 Short: "Comment on a merge request",
661- Long: "Add a plain-text comment (at most 8000 characters, no control characters except line breaks).\n" +
740+ Long: "Add a Markdown note (at most 8000 characters); ^N refers to a note in this merge request.\n" +
741+ "--reply replies to a root note. --line accepts N or A-B. --approve and --request-changes\n" +
742+ "set a verdict (needs mr:review) and allow an empty message.\n" +
662743 "-m - reads it from standard input. Without --path it is a general comment. With --path, --line and\n" +
663744 "--side (default new) it is attached to a line of the current diff: the path as in `mr show --diff`\n" +
664745 "(the new path, the old one for a deleted file), the line number on the old or new side; a line\n" +
@@ -666,11 +747,18 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command {
666747 Args: cobra.ExactArgs(2),
667748 ValidArgsFunction: completeMRRepoThenNumber(env),
668749 }
669- cmd.Flags().StringVarP(&message, "message", "m", "", "text of the comment, or - to read standard input (required)")
750+ cmd.Flags().StringVarP(&message, "message", "m", "", "Markdown text, or - to read standard input (required unless setting a verdict)")
670751 cmd.Flags().StringVar(&path, "path", "", "file the comment is attached to")
671- cmd.Flags().IntVar(&line, "line", 0, "1-based line number on --side")
752+ cmd.Flags().StringVar(&line, "line", "", "1-based line N or range A-B on --side")
753+ cmd.Flags().StringVar(&reply, "reply", "", "reply to root note (^12, #12 or 12)")
754+ cmd.Flags().BoolVar(&approve, "approve", false, "approve (needs mr:review)")
755+ cmd.Flags().BoolVar(&requestChanges, "request-changes", false, "request changes (needs mr:review)")
672756 cmd.Flags().StringVar(&side, "side", sideNew, "side the line counts on: old or new")
673- _ = cmd.MarkFlagRequired("message")
757+ cmd.MarkFlagsMutuallyExclusive("approve", "request-changes")
758+ for _, flag := range []string{"path", "line", "side", "approve", "request-changes"} {
759+ cmd.MarkFlagsMutuallyExclusive("reply", flag)
760+ }
761+ // A verdict note may omit its message.
674762 _ = cmd.RegisterFlagCompletionFunc("side", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
675763 return []string{sideOld, sideNew}, cobra.ShellCompDirectiveNoFileComp
676764 })
@@ -684,17 +772,36 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command {
684772 if err != nil {
685773 return err
686774 }
775+ if !cmd.Flags().Changed("message") && !approve && !requestChanges {
776+ return errors.New("--message is required unless --approve or --request-changes is set")
777+ }
687778 req := &ermite.AddCommentRequest{Repo: args[0], Number: n, Text: text}
779+ if approve {
780+ req.Verdict = ermite.Verdict_VERDICT_APPROVE
781+ }
782+ if requestChanges {
783+ req.Verdict = ermite.Verdict_VERDICT_CHANGES_REQUESTED
784+ }
785+ if cmd.Flags().Changed("reply") {
786+ req.ReplyTo, err = parseNoteID(reply)
787+ if err != nil {
788+ return err
789+ }
790+ }
688791 switch {
689792 case path != "":
690- if line < 1 {
793+ if line == "" {
691794 return errors.New("--line is required with --path: give the line number")
692795 }
796+ req.StartLine, req.Line, err = parseLineRange(line)
797+ if err != nil {
798+ return err
799+ }
693800 if req.Side, err = parseSide(side); err != nil {
694801 return err
695802 }
696- req.Path, req.Line = path, int32(line) //nolint:gosec // a line number typed by a person
697- case line != 0 || cmd.Flags().Changed("side"):
803+ req.Path = path
804+ case cmd.Flags().Changed("line") || cmd.Flags().Changed("side"):
698805 return errors.New("--line and --side need --path")
699806 }
700807 c, err := env.client(cmd)
@@ -714,6 +821,100 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command {
714821 return cmd
715822 }
716823
824+func parseNoteID(s string) (string, error) {
825+ id := strings.TrimLeft(strings.TrimSpace(s), "^#")
826+ n, err := strconv.ParseInt(id, 10, 64)
827+ if err != nil || n < 1 {
828+ return "", fmt.Errorf("invalid note id %q: use ^12, #12 or 12", s)
829+ }
830+ return strconv.FormatInt(n, 10), nil
831+}
832+
833+func parseLineRange(s string) (start, end int32, err error) {
834+ parts := strings.Split(s, "-")
835+ invalid := fmt.Errorf("invalid --line %q: use N or A-B with 1 <= A < B", s)
836+ if len(parts) < 1 || len(parts) > 2 {
837+ return 0, 0, invalid
838+ }
839+ parse := func(v string) (int32, error) {
840+ n, e := strconv.ParseInt(v, 10, 32)
841+ if e != nil || n < 1 {
842+ return 0, invalid
843+ }
844+ return int32(n), nil
845+ }
846+ end, err = parse(parts[len(parts)-1])
847+ if err != nil {
848+ return 0, 0, err
849+ }
850+ if len(parts) == 2 {
851+ start, err = parse(parts[0])
852+ if err != nil || start >= end {
853+ return 0, 0, invalid
854+ }
855+ }
856+ return start, end, nil
857+}
858+
859+func newMREditCommentCmd(env *cliEnv) *cobra.Command { return newMRNoteMutationCmd(env, true) }
860+func newMRDeleteCommentCmd(env *cliEnv) *cobra.Command { return newMRNoteMutationCmd(env, false) }
861+
862+func newMRNoteMutationCmd(env *cliEnv, edit bool) *cobra.Command {
863+ verb := "delete"
864+ if edit {
865+ verb = "edit"
866+ }
867+ var message string
868+ 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)}
869+ if edit {
870+ cmd.Flags().StringVarP(&message, "message", "m", "", "Markdown text, or - to read standard input (required)")
871+ _ = cmd.MarkFlagRequired("message")
872+ }
873+ asJSON := addJSONFlag(cmd)
874+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
875+ n, err := parseNumber(args[1])
876+ if err != nil {
877+ return err
878+ }
879+ id, err := parseNoteID(args[2])
880+ if err != nil {
881+ return err
882+ }
883+ text, err := readText(cmd, message)
884+ if err != nil {
885+ return err
886+ }
887+ c, err := env.client(cmd)
888+ if err != nil {
889+ return err
890+ }
891+ var note *ermite.Comment
892+ if edit {
893+ res, e := c.MergeRequests().EditComment(env.ctx(cmd), connect.NewRequest(&ermite.EditCommentRequest{Repo: args[0], Number: n, CommentId: id, Text: text}))
894+ if e != nil {
895+ return c.Describe(e)
896+ }
897+ note = res.Msg.GetComment()
898+ } else {
899+ res, e := c.MergeRequests().DeleteComment(env.ctx(cmd), connect.NewRequest(&ermite.DeleteCommentRequest{Repo: args[0], Number: n, CommentId: id}))
900+ if e != nil {
901+ return c.Describe(e)
902+ }
903+ note = res.Msg.GetComment()
904+ }
905+ if *asJSON {
906+ return writeJSON(cmd.OutOrStdout(), toJSONComment(note))
907+ }
908+ past := "deleted"
909+ if edit {
910+ past = "edited"
911+ }
912+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "comment %s of %s!%d %s\n", clean(id), clean(args[0]), n, past)
913+ return err
914+ }
915+ return cmd
916+}
917+
717918 func newMRResolveCmd(env *cliEnv) *cobra.Command {
718919 var undo bool
719920 cmd := &cobra.Command{
@@ -144,12 +144,28 @@ type jsonComment struct {
144 Text string `json:"text"`144 Text string `json:"text"`
145 Resolved bool `json:"resolved"`145 Resolved bool `json:"resolved"`
146 CreatedAt *string `json:"created_at"`146 CreatedAt *string `json:"created_at"`
147+ ReplyTo string `json:"reply_to"`
148+ StartLine int32 `json:"start_line"`
149+ Verdict string `json:"verdict"`
150+ EditedAt *string `json:"edited_at"`
151+ Deleted bool `json:"deleted"`
152+}
153+
154+func verdictName(v ermite.Verdict) string {
155+ switch v {
156+ case ermite.Verdict_VERDICT_APPROVE:
157+ return "approve"
158+ case ermite.Verdict_VERDICT_CHANGES_REQUESTED:
159+ return "changes_requested"
160+ }
161+ return ""
147 }162 }
148 163
149 func toJSONComment(c *ermite.Comment) jsonComment {164 func toJSONComment(c *ermite.Comment) jsonComment {
150 return jsonComment{165 return jsonComment{
151 ID: c.GetId(), Author: c.GetAuthor(), Path: c.GetPath(), Line: c.GetLine(), Side: sideName(c.GetSide()),166 ID: c.GetId(), Author: c.GetAuthor(), Path: c.GetPath(), Line: c.GetLine(), Side: sideName(c.GetSide()),
152 CommitID: c.GetCommitId(), Text: c.GetText(), Resolved: c.GetResolved(), CreatedAt: jsonTime(c.GetCreatedAt()),167 CommitID: c.GetCommitId(), Text: c.GetText(), Resolved: c.GetResolved(), CreatedAt: jsonTime(c.GetCreatedAt()),
168+ ReplyTo: c.GetReplyTo(), StartLine: c.GetStartLine(), Verdict: verdictName(c.GetVerdict()), EditedAt: jsonTime(c.GetEditedAt()), Deleted: c.GetDeleted(),
153 }169 }
154 }170 }
155 171
@@ -163,7 +179,8 @@ unless merged).`
163 const jsonCommentHelp = `179 const jsonCommentHelp = `
164 JSON fields of a comment: id, author, path, line and side ("old" or "new"; empty path, line 0180 JSON fields of a comment: id, author, path, line and side ("old" or "new"; empty path, line 0
165 and empty side for a general comment), commit_id (the head when it was written), text,181 and empty side for a general comment), commit_id (the head when it was written), text,
166-resolved, created_at.`182+resolved, created_at, reply_to (empty for roots), start_line (0 for a single line),
183+verdict ("", "approve", "changes_requested"), edited_at (nullable RFC 3339), deleted.`
167 184
168 func newMRCmd(env *cliEnv) *cobra.Command {185 func newMRCmd(env *cliEnv) *cobra.Command {
169 mr := &cobra.Command{186 mr := &cobra.Command{
@@ -175,7 +192,7 @@ func newMRCmd(env *cliEnv) *cobra.Command {
175 }192 }
176 mr.AddCommand(193 mr.AddCommand(
177 newMRCreateCmd(env), newMRListCmd(env), newMRShowCmd(env), newMRCommentCmd(env),194 newMRCreateCmd(env), newMRListCmd(env), newMRShowCmd(env), newMRCommentCmd(env),
178- newMRResolveCmd(env), newMRMergeCmd(env), newMRCloseCmd(env),195+ newMRResolveCmd(env), newMREditCommentCmd(env), newMRDeleteCommentCmd(env), newMRMergeCmd(env), newMRCloseCmd(env),
179 )196 )
180 return mr197 return mr
181 }198 }
@@ -395,15 +412,25 @@ type jsonCommit struct {
395 // jsonMRDetail is the stable JSON shape of `mr show --json`.412 // jsonMRDetail is the stable JSON shape of `mr show --json`.
396 type jsonMRDetail struct {413 type jsonMRDetail struct {
397 jsonMergeRequest414 jsonMergeRequest
398- Commits []jsonCommit `json:"commits"`415+ Commits []jsonCommit `json:"commits"`
399- CommitsTruncated bool `json:"commits_truncated"`416+ CommitsTruncated bool `json:"commits_truncated"`
400- DiffBaseID string `json:"diff_base_id"`417+ DiffBaseID string `json:"diff_base_id"`
401- Stats jsonStats `json:"stats"`418+ Stats jsonStats `json:"stats"`
402- DiffTruncated bool `json:"diff_truncated"`419+ DiffTruncated bool `json:"diff_truncated"`
403- Files []jsonFile `json:"files"`420+ Files []jsonFile `json:"files"`
404- Comments []jsonComment `json:"comments"`421+ Comments []jsonComment `json:"comments"`
405- LastReadID string `json:"last_read_id"`422+ Verdicts []jsonAuthorVerdict `json:"verdicts"`
406- LastReadLost bool `json:"last_read_lost"`423+ LastReadID string `json:"last_read_id"`
424+ LastReadLost bool `json:"last_read_lost"`
425+}
426+
427+type jsonAuthorVerdict struct {
428+ Author string `json:"author"`
429+ Verdict string `json:"verdict"`
430+ CommentID string `json:"comment_id"`
431+ CommitID string `json:"commit_id"`
432+ CreatedAt *string `json:"created_at"`
433+ Stale bool `json:"stale"`
407 }434 }
408 435
409 func fileStatusName(s ermite.FileStatus) string {436 func fileStatusName(s ermite.FileStatus) string {
@@ -423,7 +450,7 @@ func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL stri
423 FilesChanged: d.GetStats().GetFilesChanged(), Additions: d.GetStats().GetAdditions(), Deletions: d.GetStats().GetDeletions(),450 FilesChanged: d.GetStats().GetFilesChanged(), Additions: d.GetStats().GetAdditions(), Deletions: d.GetStats().GetDeletions(),
424 },451 },
425 DiffTruncated: d.GetDiff().GetTruncated(),452 DiffTruncated: d.GetDiff().GetTruncated(),
426- Files: []jsonFile{}, Comments: []jsonComment{},453+ Files: []jsonFile{}, Comments: []jsonComment{}, Verdicts: []jsonAuthorVerdict{},
427 LastReadID: d.GetLastReadId(), LastReadLost: d.GetLastReadLost(),454 LastReadID: d.GetLastReadId(), LastReadLost: d.GetLastReadLost(),
428 }455 }
429 for _, c := range d.GetCommits() {456 for _, c := range d.GetCommits() {
@@ -453,6 +480,9 @@ func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL stri
453 for _, c := range d.GetComments() {480 for _, c := range d.GetComments() {
454 out.Comments = append(out.Comments, toJSONComment(c))481 out.Comments = append(out.Comments, toJSONComment(c))
455 }482 }
483+ for _, v := range d.GetVerdicts() {
484+ 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()})
485+ }
456 return out486 return out
457 }487 }
458 488
@@ -471,7 +501,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
471 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +501 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +
472 "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" +502 "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" +
473 "with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" +503 "with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" +
474- "and last_read_lost.",504+ "and last_read_lost; verdicts (author, verdict, comment_id, commit_id, created_at, stale).",
475 Args: cobra.ExactArgs(2),505 Args: cobra.ExactArgs(2),
476 ValidArgsFunction: completeMRRepoThenNumber(env),506 ValidArgsFunction: completeMRRepoThenNumber(env),
477 }507 }
@@ -539,6 +569,25 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
539 if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED {569 if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED {
540 pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"})570 pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"})
541 }571 }
572+ if len(d.GetVerdicts()) > 0 {
573+ var verdicts []string
574+ for _, v := range d.GetVerdicts() {
575+ var label string
576+ switch v.GetVerdict() {
577+ case ermite.Verdict_VERDICT_APPROVE:
578+ label = "approved"
579+ case ermite.Verdict_VERDICT_CHANGES_REQUESTED:
580+ label = "changes requested"
581+ default:
582+ label = fmt.Sprintf("verdict %d", v.GetVerdict())
583+ }
584+ if v.GetStale() {
585+ label += " (stale)"
586+ }
587+ verdicts = append(verdicts, v.GetAuthor()+" "+label)
588+ }
589+ pairs = append(pairs, [2]string{"verdicts", strings.Join(verdicts, ", ")})
590+ }
542 if err := fields(w, pairs); err != nil {591 if err := fields(w, pairs); err != nil {
543 return err592 return err
544 }593 }
@@ -571,16 +620,46 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
571 }620 }
572 621
573 _, _ = fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments()))622 _, _ = fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments()))
574- for _, c := range d.GetComments() {623+ var ordered []*ermite.Comment
624+ for _, root := range d.GetComments() {
625+ if root.GetReplyTo() != "" {
626+ continue
627+ }
628+ ordered = append(ordered, root)
629+ for _, reply := range d.GetComments() {
630+ if reply.GetReplyTo() == root.GetId() {
631+ ordered = append(ordered, reply)
632+ }
633+ }
634+ }
635+ for _, c := range ordered {
575 where := "general"636 where := "general"
576 if c.GetPath() != "" {637 if c.GetPath() != "" {
577 where = fmt.Sprintf("%s:%d (%s)", clean(c.GetPath()), c.GetLine(), sideName(c.GetSide()))638 where = fmt.Sprintf("%s:%d (%s)", clean(c.GetPath()), c.GetLine(), sideName(c.GetSide()))
578 }639 }
640+ if c.GetStartLine() > 0 {
641+ where = fmt.Sprintf("%s:lines %d-%d (%s)", clean(c.GetPath()), c.GetStartLine(), c.GetLine(), sideName(c.GetSide()))
642+ }
579 mark := ""643 mark := ""
644+ if c.GetEditedAt() != nil {
645+ mark += " (edited)"
646+ }
647+ if c.GetDeleted() {
648+ mark += " [deleted]"
649+ }
650+ if c.GetVerdict() != ermite.Verdict_VERDICT_UNSPECIFIED {
651+ mark += " [" + verdictName(c.GetVerdict()) + "]"
652+ }
580 if c.GetResolved() {653 if c.GetResolved() {
581- mark = " [resolved]"654+ mark += " [resolved]"
582 }655 }
583- _, _ = fmt.Fprintf(w, " #%s %s, %s, %s%s\n%s\n", c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), where, mark, indentText(c.GetText()))656+ prefix := " "
657+ body := indentText(c.GetText())
658+ if c.GetReplyTo() != "" {
659+ prefix += " "
660+ body = " " + strings.ReplaceAll(body, "\n", "\n ")
661+ }
662+ _, _ = fmt.Fprintf(w, "%s#%s %s, %s, %s%s\n%s\n", prefix, c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), where, mark, body)
584 }663 }
585 if withDiff {664 if withDiff {
586 printDiff(w, d.GetDiff())665 printDiff(w, d.GetDiff())
@@ -653,12 +732,14 @@ func printDiff(w io.Writer, d *ermite.Diff) {
653 }732 }
654 733
655 func newMRCommentCmd(env *cliEnv) *cobra.Command {734 func newMRCommentCmd(env *cliEnv) *cobra.Command {
656- var message, path, side string735+ var message, path, side, line, reply string
657- var line int736+ var approve, requestChanges bool
658 cmd := &cobra.Command{737 cmd := &cobra.Command{
659 Use: "comment <repo> <number>",738 Use: "comment <repo> <number>",
660 Short: "Comment on a merge request",739 Short: "Comment on a merge request",
661- Long: "Add a plain-text comment (at most 8000 characters, no control characters except line breaks).\n" +740+ Long: "Add a Markdown note (at most 8000 characters); ^N refers to a note in this merge request.\n" +
741+ "--reply replies to a root note. --line accepts N or A-B. --approve and --request-changes\n" +
742+ "set a verdict (needs mr:review) and allow an empty message.\n" +
662 "-m - reads it from standard input. Without --path it is a general comment. With --path, --line and\n" +743 "-m - reads it from standard input. Without --path it is a general comment. With --path, --line and\n" +
663 "--side (default new) it is attached to a line of the current diff: the path as in `mr show --diff`\n" +744 "--side (default new) it is attached to a line of the current diff: the path as in `mr show --diff`\n" +
664 "(the new path, the old one for a deleted file), the line number on the old or new side; a line\n" +745 "(the new path, the old one for a deleted file), the line number on the old or new side; a line\n" +
@@ -666,11 +747,18 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command {
666 Args: cobra.ExactArgs(2),747 Args: cobra.ExactArgs(2),
667 ValidArgsFunction: completeMRRepoThenNumber(env),748 ValidArgsFunction: completeMRRepoThenNumber(env),
668 }749 }
669- cmd.Flags().StringVarP(&message, "message", "m", "", "text of the comment, or - to read standard input (required)")750+ cmd.Flags().StringVarP(&message, "message", "m", "", "Markdown text, or - to read standard input (required unless setting a verdict)")
670 cmd.Flags().StringVar(&path, "path", "", "file the comment is attached to")751 cmd.Flags().StringVar(&path, "path", "", "file the comment is attached to")
671- cmd.Flags().IntVar(&line, "line", 0, "1-based line number on --side")752+ cmd.Flags().StringVar(&line, "line", "", "1-based line N or range A-B on --side")
753+ cmd.Flags().StringVar(&reply, "reply", "", "reply to root note (^12, #12 or 12)")
754+ cmd.Flags().BoolVar(&approve, "approve", false, "approve (needs mr:review)")
755+ cmd.Flags().BoolVar(&requestChanges, "request-changes", false, "request changes (needs mr:review)")
672 cmd.Flags().StringVar(&side, "side", sideNew, "side the line counts on: old or new")756 cmd.Flags().StringVar(&side, "side", sideNew, "side the line counts on: old or new")
673- _ = cmd.MarkFlagRequired("message")757+ cmd.MarkFlagsMutuallyExclusive("approve", "request-changes")
758+ for _, flag := range []string{"path", "line", "side", "approve", "request-changes"} {
759+ cmd.MarkFlagsMutuallyExclusive("reply", flag)
760+ }
761+ // A verdict note may omit its message.
674 _ = cmd.RegisterFlagCompletionFunc("side", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {762 _ = cmd.RegisterFlagCompletionFunc("side", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
675 return []string{sideOld, sideNew}, cobra.ShellCompDirectiveNoFileComp763 return []string{sideOld, sideNew}, cobra.ShellCompDirectiveNoFileComp
676 })764 })
@@ -684,17 +772,36 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command {
684 if err != nil {772 if err != nil {
685 return err773 return err
686 }774 }
775+ if !cmd.Flags().Changed("message") && !approve && !requestChanges {
776+ return errors.New("--message is required unless --approve or --request-changes is set")
777+ }
687 req := &ermite.AddCommentRequest{Repo: args[0], Number: n, Text: text}778 req := &ermite.AddCommentRequest{Repo: args[0], Number: n, Text: text}
779+ if approve {
780+ req.Verdict = ermite.Verdict_VERDICT_APPROVE
781+ }
782+ if requestChanges {
783+ req.Verdict = ermite.Verdict_VERDICT_CHANGES_REQUESTED
784+ }
785+ if cmd.Flags().Changed("reply") {
786+ req.ReplyTo, err = parseNoteID(reply)
787+ if err != nil {
788+ return err
789+ }
790+ }
688 switch {791 switch {
689 case path != "":792 case path != "":
690- if line < 1 {793+ if line == "" {
691 return errors.New("--line is required with --path: give the line number")794 return errors.New("--line is required with --path: give the line number")
692 }795 }
796+ req.StartLine, req.Line, err = parseLineRange(line)
797+ if err != nil {
798+ return err
799+ }
693 if req.Side, err = parseSide(side); err != nil {800 if req.Side, err = parseSide(side); err != nil {
694 return err801 return err
695 }802 }
696- req.Path, req.Line = path, int32(line) //nolint:gosec // a line number typed by a person803+ req.Path = path
697- case line != 0 || cmd.Flags().Changed("side"):804+ case cmd.Flags().Changed("line") || cmd.Flags().Changed("side"):
698 return errors.New("--line and --side need --path")805 return errors.New("--line and --side need --path")
699 }806 }
700 c, err := env.client(cmd)807 c, err := env.client(cmd)
@@ -714,6 +821,100 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command {
714 return cmd821 return cmd
715 }822 }
716 823
824+func parseNoteID(s string) (string, error) {
825+ id := strings.TrimLeft(strings.TrimSpace(s), "^#")
826+ n, err := strconv.ParseInt(id, 10, 64)
827+ if err != nil || n < 1 {
828+ return "", fmt.Errorf("invalid note id %q: use ^12, #12 or 12", s)
829+ }
830+ return strconv.FormatInt(n, 10), nil
831+}
832+
833+func parseLineRange(s string) (start, end int32, err error) {
834+ parts := strings.Split(s, "-")
835+ invalid := fmt.Errorf("invalid --line %q: use N or A-B with 1 <= A < B", s)
836+ if len(parts) < 1 || len(parts) > 2 {
837+ return 0, 0, invalid
838+ }
839+ parse := func(v string) (int32, error) {
840+ n, e := strconv.ParseInt(v, 10, 32)
841+ if e != nil || n < 1 {
842+ return 0, invalid
843+ }
844+ return int32(n), nil
845+ }
846+ end, err = parse(parts[len(parts)-1])
847+ if err != nil {
848+ return 0, 0, err
849+ }
850+ if len(parts) == 2 {
851+ start, err = parse(parts[0])
852+ if err != nil || start >= end {
853+ return 0, 0, invalid
854+ }
855+ }
856+ return start, end, nil
857+}
858+
859+func newMREditCommentCmd(env *cliEnv) *cobra.Command { return newMRNoteMutationCmd(env, true) }
860+func newMRDeleteCommentCmd(env *cliEnv) *cobra.Command { return newMRNoteMutationCmd(env, false) }
861+
862+func newMRNoteMutationCmd(env *cliEnv, edit bool) *cobra.Command {
863+ verb := "delete"
864+ if edit {
865+ verb = "edit"
866+ }
867+ var message string
868+ 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)}
869+ if edit {
870+ cmd.Flags().StringVarP(&message, "message", "m", "", "Markdown text, or - to read standard input (required)")
871+ _ = cmd.MarkFlagRequired("message")
872+ }
873+ asJSON := addJSONFlag(cmd)
874+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
875+ n, err := parseNumber(args[1])
876+ if err != nil {
877+ return err
878+ }
879+ id, err := parseNoteID(args[2])
880+ if err != nil {
881+ return err
882+ }
883+ text, err := readText(cmd, message)
884+ if err != nil {
885+ return err
886+ }
887+ c, err := env.client(cmd)
888+ if err != nil {
889+ return err
890+ }
891+ var note *ermite.Comment
892+ if edit {
893+ res, e := c.MergeRequests().EditComment(env.ctx(cmd), connect.NewRequest(&ermite.EditCommentRequest{Repo: args[0], Number: n, CommentId: id, Text: text}))
894+ if e != nil {
895+ return c.Describe(e)
896+ }
897+ note = res.Msg.GetComment()
898+ } else {
899+ res, e := c.MergeRequests().DeleteComment(env.ctx(cmd), connect.NewRequest(&ermite.DeleteCommentRequest{Repo: args[0], Number: n, CommentId: id}))
900+ if e != nil {
901+ return c.Describe(e)
902+ }
903+ note = res.Msg.GetComment()
904+ }
905+ if *asJSON {
906+ return writeJSON(cmd.OutOrStdout(), toJSONComment(note))
907+ }
908+ past := "deleted"
909+ if edit {
910+ past = "edited"
911+ }
912+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "comment %s of %s!%d %s\n", clean(id), clean(args[0]), n, past)
913+ return err
914+ }
915+ return cmd
916+}
917+
717 func newMRResolveCmd(env *cliEnv) *cobra.Command {918 func newMRResolveCmd(env *cliEnv) *cobra.Command {
718 var undo bool919 var undo bool
719 cmd := &cobra.Command{920 cmd := &cobra.Command{
M internal/app/cli_mr_e2e_test.go +72 -0
@@ -421,3 +421,75 @@ func (w *watchProc) nextOfType(t *testing.T, typ string) eventOut {
421421 t.Fatalf("no %s event", typ)
422422 return eventOut{}
423423 }
424+
425+func TestMergeRequestNotesCLIEndToEnd(t *testing.T) {
426+ c := newCLIHarness(t)
427+ c.cli(t, adminToken, "repo", "create", "notes", "--visibility", "public").ok(t)
428+ wt := c.workTree("main", "README.md")
429+ c.mustGit(wt, "checkout", "-q", "-b", "feat/notes")
430+ c.writeFile(wt, "notes.txt", []byte("one\ntwo\nthree\nfour\n"))
431+ c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/notes.git"), "main", "feat/notes")
432+ c.cli(t, adminToken, "mr", "create", "notes", "--source", "feat/notes", "--title", "Notes").ok(t)
433+ w := c.startWatch(t, adminToken)
434+ res := c.cli(t, adminToken, "mr", "comment", "notes", "1", "--path", "notes.txt", "--line", "1-3", "-m", "**range**", "--json").ok(t)
435+ root := decode[jsonComment](t, res.stdout)
436+ assert.EqualValues(t, 1, root.StartLine)
437+ assert.EqualValues(t, 3, root.Line)
438+ w.nextOfType(t, "comment_added")
439+ res = c.cli(t, adminToken, "mr", "comment", "notes", "1", "--reply", "^"+root.ID, "-m", "reply to ^"+root.ID, "--json").ok(t)
440+ reply := decode[jsonComment](t, res.stdout)
441+ assert.Equal(t, root.ID, reply.ReplyTo)
442+ assert.Equal(t, root.Path, reply.Path)
443+ assert.Equal(t, root.StartLine, reply.StartLine)
444+ assert.Equal(t, root.Line, reply.Line)
445+ w.nextOfType(t, "comment_added")
446+ require.Error(t, c.cli(t, adminToken, "mr", "comment", "notes", "1", "--reply", reply.ID, "-m", "nested").err)
447+ require.Error(t, c.cli(t, adminToken, "mr", "resolve", "notes", "1", reply.ID).err)
448+ writer := c.token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite})
449+ denied := c.cli(t, writer, "mr", "comment", "notes", "1", "--approve")
450+ require.Error(t, denied.err)
451+ assert.Contains(t, denied.err.Error(), "mr:review")
452+ res = c.cli(t, adminToken, "mr", "comment", "notes", "1", "--approve", "--json").ok(t)
453+ verdict := decode[jsonComment](t, res.stdout)
454+ assert.Equal(t, "approve", verdict.Verdict)
455+ assert.True(t, verdict.Resolved)
456+ assert.Empty(t, verdict.Text)
457+ w.nextOfType(t, "comment_added")
458+ require.Error(t, c.cli(t, writer, "mr", "edit-comment", "notes", "1", verdict.ID, "-m", "not mine").err)
459+ res = runCLI(t, c.vars(adminToken), "edited from stdin\n", "mr", "edit-comment", "notes", "1", "#"+reply.ID, "-m", "-", "--json").ok(t)
460+ edited := decode[jsonComment](t, res.stdout)
461+ assert.Equal(t, "edited from stdin", edited.Text)
462+ require.NotNil(t, edited.EditedAt)
463+ w.nextOfType(t, "comment_edited")
464+ res = c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", verdict.ID, "--json").ok(t)
465+ deleted := decode[jsonComment](t, res.stdout)
466+ assert.True(t, deleted.Deleted)
467+ assert.Equal(t, "approve", deleted.Verdict)
468+ assert.Empty(t, deleted.Text)
469+ require.NotNil(t, deleted.EditedAt)
470+ w.nextOfType(t, "comment_deleted")
471+ c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", verdict.ID).ok(t)
472+ c.cli(t, adminToken, "mr", "resolve", "notes", "1", root.ID).ok(t)
473+ w.nextOfType(t, "comment_resolved")
474+ res = c.cli(t, "", "mr", "show", "notes", "1", "--json").ok(t)
475+ detail := decode[jsonMRDetail](t, res.stdout)
476+ require.Len(t, detail.Comments, 3)
477+ require.Len(t, detail.Verdicts, 1)
478+ assert.Equal(t, verdict.ID, detail.Verdicts[0].CommentID)
479+ assert.Equal(t, "approve", detail.Verdicts[0].Verdict)
480+ assert.False(t, detail.Verdicts[0].Stale)
481+ res = c.cli(t, "", "mr", "show", "notes", "1").ok(t)
482+ assert.Contains(t, res.stdout, "lines 1-3")
483+ assert.Contains(t, res.stdout, "(edited)")
484+ assert.Contains(t, res.stdout, "[deleted]")
485+ assert.Contains(t, res.stdout, "approved")
486+ assert.Contains(t, res.stdout, " #"+reply.ID)
487+ c.cli(t, adminToken, "mr", "comment", "notes", "1", "--request-changes", "-m", "needs work").ok(t)
488+ res = c.cli(t, "", "mr", "show", "notes", "1", "--json").ok(t)
489+ detail = decode[jsonMRDetail](t, res.stdout)
490+ require.Len(t, detail.Verdicts, 1)
491+ assert.Equal(t, "changes_requested", detail.Verdicts[0].Verdict)
492+ c.cli(t, adminToken, "mr", "close", "notes", "1").ok(t)
493+ require.Error(t, c.cli(t, adminToken, "mr", "edit-comment", "notes", "1", root.ID, "-m", "late").err)
494+ require.Error(t, c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", root.ID).err)
495+}
@@ -421,3 +421,75 @@ func (w *watchProc) nextOfType(t *testing.T, typ string) eventOut {
421 t.Fatalf("no %s event", typ)421 t.Fatalf("no %s event", typ)
422 return eventOut{}422 return eventOut{}
423 }423 }
424+
425+func TestMergeRequestNotesCLIEndToEnd(t *testing.T) {
426+ c := newCLIHarness(t)
427+ c.cli(t, adminToken, "repo", "create", "notes", "--visibility", "public").ok(t)
428+ wt := c.workTree("main", "README.md")
429+ c.mustGit(wt, "checkout", "-q", "-b", "feat/notes")
430+ c.writeFile(wt, "notes.txt", []byte("one\ntwo\nthree\nfour\n"))
431+ c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/notes.git"), "main", "feat/notes")
432+ c.cli(t, adminToken, "mr", "create", "notes", "--source", "feat/notes", "--title", "Notes").ok(t)
433+ w := c.startWatch(t, adminToken)
434+ res := c.cli(t, adminToken, "mr", "comment", "notes", "1", "--path", "notes.txt", "--line", "1-3", "-m", "**range**", "--json").ok(t)
435+ root := decode[jsonComment](t, res.stdout)
436+ assert.EqualValues(t, 1, root.StartLine)
437+ assert.EqualValues(t, 3, root.Line)
438+ w.nextOfType(t, "comment_added")
439+ res = c.cli(t, adminToken, "mr", "comment", "notes", "1", "--reply", "^"+root.ID, "-m", "reply to ^"+root.ID, "--json").ok(t)
440+ reply := decode[jsonComment](t, res.stdout)
441+ assert.Equal(t, root.ID, reply.ReplyTo)
442+ assert.Equal(t, root.Path, reply.Path)
443+ assert.Equal(t, root.StartLine, reply.StartLine)
444+ assert.Equal(t, root.Line, reply.Line)
445+ w.nextOfType(t, "comment_added")
446+ require.Error(t, c.cli(t, adminToken, "mr", "comment", "notes", "1", "--reply", reply.ID, "-m", "nested").err)
447+ require.Error(t, c.cli(t, adminToken, "mr", "resolve", "notes", "1", reply.ID).err)
448+ writer := c.token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite})
449+ denied := c.cli(t, writer, "mr", "comment", "notes", "1", "--approve")
450+ require.Error(t, denied.err)
451+ assert.Contains(t, denied.err.Error(), "mr:review")
452+ res = c.cli(t, adminToken, "mr", "comment", "notes", "1", "--approve", "--json").ok(t)
453+ verdict := decode[jsonComment](t, res.stdout)
454+ assert.Equal(t, "approve", verdict.Verdict)
455+ assert.True(t, verdict.Resolved)
456+ assert.Empty(t, verdict.Text)
457+ w.nextOfType(t, "comment_added")
458+ require.Error(t, c.cli(t, writer, "mr", "edit-comment", "notes", "1", verdict.ID, "-m", "not mine").err)
459+ res = runCLI(t, c.vars(adminToken), "edited from stdin\n", "mr", "edit-comment", "notes", "1", "#"+reply.ID, "-m", "-", "--json").ok(t)
460+ edited := decode[jsonComment](t, res.stdout)
461+ assert.Equal(t, "edited from stdin", edited.Text)
462+ require.NotNil(t, edited.EditedAt)
463+ w.nextOfType(t, "comment_edited")
464+ res = c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", verdict.ID, "--json").ok(t)
465+ deleted := decode[jsonComment](t, res.stdout)
466+ assert.True(t, deleted.Deleted)
467+ assert.Equal(t, "approve", deleted.Verdict)
468+ assert.Empty(t, deleted.Text)
469+ require.NotNil(t, deleted.EditedAt)
470+ w.nextOfType(t, "comment_deleted")
471+ c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", verdict.ID).ok(t)
472+ c.cli(t, adminToken, "mr", "resolve", "notes", "1", root.ID).ok(t)
473+ w.nextOfType(t, "comment_resolved")
474+ res = c.cli(t, "", "mr", "show", "notes", "1", "--json").ok(t)
475+ detail := decode[jsonMRDetail](t, res.stdout)
476+ require.Len(t, detail.Comments, 3)
477+ require.Len(t, detail.Verdicts, 1)
478+ assert.Equal(t, verdict.ID, detail.Verdicts[0].CommentID)
479+ assert.Equal(t, "approve", detail.Verdicts[0].Verdict)
480+ assert.False(t, detail.Verdicts[0].Stale)
481+ res = c.cli(t, "", "mr", "show", "notes", "1").ok(t)
482+ assert.Contains(t, res.stdout, "lines 1-3")
483+ assert.Contains(t, res.stdout, "(edited)")
484+ assert.Contains(t, res.stdout, "[deleted]")
485+ assert.Contains(t, res.stdout, "approved")
486+ assert.Contains(t, res.stdout, " #"+reply.ID)
487+ c.cli(t, adminToken, "mr", "comment", "notes", "1", "--request-changes", "-m", "needs work").ok(t)
488+ res = c.cli(t, "", "mr", "show", "notes", "1", "--json").ok(t)
489+ detail = decode[jsonMRDetail](t, res.stdout)
490+ require.Len(t, detail.Verdicts, 1)
491+ assert.Equal(t, "changes_requested", detail.Verdicts[0].Verdict)
492+ c.cli(t, adminToken, "mr", "close", "notes", "1").ok(t)
493+ require.Error(t, c.cli(t, adminToken, "mr", "edit-comment", "notes", "1", root.ID, "-m", "late").err)
494+ require.Error(t, c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", root.ID).err)
495+}
M internal/app/cli_mr_test.go +89 -0
@@ -388,6 +388,95 @@ func TestSigningKeyShowOutput(t *testing.T) {
388388 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)
389389 }
390390
391+func TestNoteRangesAndIDs(t *testing.T) {
392+ for _, tc := range []struct {
393+ input string
394+ start, end int32
395+ }{{"1", 0, 1}, {"10-24", 10, 24}, {"2147483647", 0, 2147483647}} {
396+ start, end, err := parseLineRange(tc.input)
397+ require.NoError(t, err)
398+ assert.Equal(t, tc.start, start)
399+ assert.Equal(t, tc.end, end)
400+ }
401+ for _, input := range []string{"", "0", "-1", "1-1", "24-10", "0-2", "1-2-3", "2147483648", "a", "1-"} {
402+ _, _, err := parseLineRange(input)
403+ require.Error(t, err, input)
404+ }
405+ for _, input := range []string{"12", "^12", "#12"} {
406+ got, err := parseNoteID(input)
407+ require.NoError(t, err)
408+ assert.Equal(t, "12", got)
409+ }
410+}
411+
412+func TestNoteFlags(t *testing.T) {
413+ f := &fakeAPI{addComment: func(r *ermite.AddCommentRequest) (*ermite.AddCommentResponse, error) {
414+ return &ermite.AddCommentResponse{Comment: &ermite.Comment{Id: "1", ReplyTo: r.GetReplyTo(), StartLine: r.GetStartLine(), Line: r.GetLine(), Verdict: r.GetVerdict()}}, nil
415+ }}
416+ url := f.serve(t)
417+ for _, tc := range []struct {
418+ flags []string
419+ reply string
420+ start int32
421+ verdict string
422+ }{
423+ {[]string{"--reply", "^12", "-m", "reply"}, "12", 0, ""},
424+ {[]string{"--path", "x", "--line", "10-24", "-m", "range"}, "", 10, ""},
425+ {[]string{"--approve"}, "", 0, "approve"},
426+ {[]string{"--request-changes"}, "", 0, "changes_requested"},
427+ } {
428+ res := runCLI(t, envFor(url), "", append([]string{"mr", "comment", "alpha", "1", "--json"}, tc.flags...)...).ok(t)
429+ got := decode[jsonComment](t, res.stdout)
430+ assert.Equal(t, tc.reply, got.ReplyTo)
431+ assert.Equal(t, tc.start, got.StartLine)
432+ assert.Equal(t, tc.verdict, got.Verdict)
433+ }
434+ 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"}} {
435+ require.Error(t, runCLI(t, envFor(url), "", append([]string{"mr", "comment", "alpha", "1", "-m", "x"}, flags...)...).err)
436+ }
437+}
438+
439+func TestNoteDetailOutput(t *testing.T) {
440+ d := &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Comments: []*ermite.Comment{
441+ {Id: "1", Path: "x", StartLine: 10, Line: 24, Text: "root"},
442+ {Id: "3", Text: "other", Deleted: true},
443+ {Id: "2", ReplyTo: "1", Text: "reply", EditedAt: ts("2026-10-10T12:00:00Z")},
444+ }, Verdicts: []*ermite.AuthorVerdict{
445+ {Author: "owner", Verdict: ermite.Verdict_VERDICT_APPROVE, CommentId: "1", Stale: true},
446+ {Author: "bot", Verdict: ermite.Verdict_VERDICT_CHANGES_REQUESTED, CommentId: "3"},
447+ }}
448+ var out strings.Builder
449+ require.NoError(t, printMRDetail(&out, d, false, false))
450+ text := out.String()
451+ assert.Contains(t, text, "owner approved (stale), bot changes requested")
452+ assert.Contains(t, text, "lines 10-24")
453+ assert.Contains(t, text, "(edited)")
454+ assert.Contains(t, text, "[deleted]")
455+ assert.Less(t, strings.Index(text, " #2"), strings.Index(text, " #3"))
456+ got := toJSONDetail(d, false, "https://git.example.org")
457+ require.Len(t, got.Verdicts, 2)
458+ assert.True(t, got.Verdicts[0].Stale)
459+ assert.Equal(t, "changes_requested", got.Verdicts[1].Verdict)
460+ assert.Equal(t, "1", got.Comments[2].ReplyTo)
461+ assert.NotNil(t, got.Comments[2].EditedAt)
462+}
463+
464+func TestUnknownVerdictDetailOutput(t *testing.T) {
465+ var out strings.Builder
466+ require.NoError(t, printMRDetail(&out, &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Verdicts: []*ermite.AuthorVerdict{{Author: "future", Verdict: ermite.Verdict(99)}}}, false, false))
467+ assert.Contains(t, out.String(), "future verdict 99")
468+ assert.NotContains(t, out.String(), "changes requested")
469+ assert.NotContains(t, out.String(), "approved")
470+}
471+
472+func TestMRReviewScopeCompletion(t *testing.T) {
473+ cmd := newTokenCreateCmd(&cliEnv{})
474+ complete, ok := cmd.GetFlagCompletionFunc("scope")
475+ require.True(t, ok)
476+ values, _ := complete(cmd, nil, "")
477+ assert.Contains(t, values, "mr:review")
478+}
479+
391480 func TestMRWebURL(t *testing.T) {
392481 for _, tt := range []struct{ name, base, repo, want string }{
393482 {"plain", "https://git.example.org", "my-repo", "https://git.example.org/my-repo/merge-requests/42"},
@@ -388,6 +388,95 @@ func TestSigningKeyShowOutput(t *testing.T) {
388 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)388 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)
389 }389 }
390 390
391+func TestNoteRangesAndIDs(t *testing.T) {
392+ for _, tc := range []struct {
393+ input string
394+ start, end int32
395+ }{{"1", 0, 1}, {"10-24", 10, 24}, {"2147483647", 0, 2147483647}} {
396+ start, end, err := parseLineRange(tc.input)
397+ require.NoError(t, err)
398+ assert.Equal(t, tc.start, start)
399+ assert.Equal(t, tc.end, end)
400+ }
401+ for _, input := range []string{"", "0", "-1", "1-1", "24-10", "0-2", "1-2-3", "2147483648", "a", "1-"} {
402+ _, _, err := parseLineRange(input)
403+ require.Error(t, err, input)
404+ }
405+ for _, input := range []string{"12", "^12", "#12"} {
406+ got, err := parseNoteID(input)
407+ require.NoError(t, err)
408+ assert.Equal(t, "12", got)
409+ }
410+}
411+
412+func TestNoteFlags(t *testing.T) {
413+ f := &fakeAPI{addComment: func(r *ermite.AddCommentRequest) (*ermite.AddCommentResponse, error) {
414+ return &ermite.AddCommentResponse{Comment: &ermite.Comment{Id: "1", ReplyTo: r.GetReplyTo(), StartLine: r.GetStartLine(), Line: r.GetLine(), Verdict: r.GetVerdict()}}, nil
415+ }}
416+ url := f.serve(t)
417+ for _, tc := range []struct {
418+ flags []string
419+ reply string
420+ start int32
421+ verdict string
422+ }{
423+ {[]string{"--reply", "^12", "-m", "reply"}, "12", 0, ""},
424+ {[]string{"--path", "x", "--line", "10-24", "-m", "range"}, "", 10, ""},
425+ {[]string{"--approve"}, "", 0, "approve"},
426+ {[]string{"--request-changes"}, "", 0, "changes_requested"},
427+ } {
428+ res := runCLI(t, envFor(url), "", append([]string{"mr", "comment", "alpha", "1", "--json"}, tc.flags...)...).ok(t)
429+ got := decode[jsonComment](t, res.stdout)
430+ assert.Equal(t, tc.reply, got.ReplyTo)
431+ assert.Equal(t, tc.start, got.StartLine)
432+ assert.Equal(t, tc.verdict, got.Verdict)
433+ }
434+ 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"}} {
435+ require.Error(t, runCLI(t, envFor(url), "", append([]string{"mr", "comment", "alpha", "1", "-m", "x"}, flags...)...).err)
436+ }
437+}
438+
439+func TestNoteDetailOutput(t *testing.T) {
440+ d := &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Comments: []*ermite.Comment{
441+ {Id: "1", Path: "x", StartLine: 10, Line: 24, Text: "root"},
442+ {Id: "3", Text: "other", Deleted: true},
443+ {Id: "2", ReplyTo: "1", Text: "reply", EditedAt: ts("2026-10-10T12:00:00Z")},
444+ }, Verdicts: []*ermite.AuthorVerdict{
445+ {Author: "owner", Verdict: ermite.Verdict_VERDICT_APPROVE, CommentId: "1", Stale: true},
446+ {Author: "bot", Verdict: ermite.Verdict_VERDICT_CHANGES_REQUESTED, CommentId: "3"},
447+ }}
448+ var out strings.Builder
449+ require.NoError(t, printMRDetail(&out, d, false, false))
450+ text := out.String()
451+ assert.Contains(t, text, "owner approved (stale), bot changes requested")
452+ assert.Contains(t, text, "lines 10-24")
453+ assert.Contains(t, text, "(edited)")
454+ assert.Contains(t, text, "[deleted]")
455+ assert.Less(t, strings.Index(text, " #2"), strings.Index(text, " #3"))
456+ got := toJSONDetail(d, false, "https://git.example.org")
457+ require.Len(t, got.Verdicts, 2)
458+ assert.True(t, got.Verdicts[0].Stale)
459+ assert.Equal(t, "changes_requested", got.Verdicts[1].Verdict)
460+ assert.Equal(t, "1", got.Comments[2].ReplyTo)
461+ assert.NotNil(t, got.Comments[2].EditedAt)
462+}
463+
464+func TestUnknownVerdictDetailOutput(t *testing.T) {
465+ var out strings.Builder
466+ require.NoError(t, printMRDetail(&out, &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Verdicts: []*ermite.AuthorVerdict{{Author: "future", Verdict: ermite.Verdict(99)}}}, false, false))
467+ assert.Contains(t, out.String(), "future verdict 99")
468+ assert.NotContains(t, out.String(), "changes requested")
469+ assert.NotContains(t, out.String(), "approved")
470+}
471+
472+func TestMRReviewScopeCompletion(t *testing.T) {
473+ cmd := newTokenCreateCmd(&cliEnv{})
474+ complete, ok := cmd.GetFlagCompletionFunc("scope")
475+ require.True(t, ok)
476+ values, _ := complete(cmd, nil, "")
477+ assert.Contains(t, values, "mr:review")
478+}
479+
391 func TestMRWebURL(t *testing.T) {480 func TestMRWebURL(t *testing.T) {
392 for _, tt := range []struct{ name, base, repo, want string }{481 for _, tt := range []struct{ name, base, repo, want string }{
393 {"plain", "https://git.example.org", "my-repo", "https://git.example.org/my-repo/merge-requests/42"},482 {"plain", "https://git.example.org", "my-repo", "https://git.example.org/my-repo/merge-requests/42"},
M internal/app/cli_token.go +1 -1
@@ -86,7 +86,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
8686 "or store it right away); details go to standard error. With --json, standard output is one\n" +
8787 "object holding the token fields and the secret.\n\n" +
8888 "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" +
9090 " agent repo:write, mr:write\n" +
9191 " runner runner:register (a CI runner host)\n" +
9292 " 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 (
4545 eventPushed = "pushed"
4646 eventGap = "gap"
4747
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"
5255 )
5356
5457 // toJSONEvent maps an event; ok is false for kinds this client does not know,
@@ -82,6 +85,15 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) {
8285 case *ermite.Event_CommentAdded:
8386 c := toJSONComment(k.CommentAdded.GetComment())
8487 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
8597 case *ermite.Event_Gap:
8698 out.Type = eventGap
8799 default:
@@ -112,7 +124,7 @@ func eventLine(e jsonEvent) string {
112124 detail = e.Repo + " " + strings.Join(parts, ", ")
113125 case eventMROpened, eventMRMerged, eventMRClosed:
114126 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:
116128 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)
117129 case eventGap:
118130 detail = "events were lost, reload what you display"
@@ -142,17 +154,18 @@ func newWatchCmd(env *cliEnv) *cobra.Command {
142154 Use: "watch [repo]",
143155 Short: "Print repository events as they happen",
144156 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" +
146158 "repository you can see, or only for [repo]. It runs until interrupted. When the connection drops it\n" +
147159 "reconnects with a growing delay and resumes after the last event it printed; if the server no longer\n" +
148160 "has that event it prints a gap event: reload what you display. Connection notices go to standard\n" +
149161 "error.\n\n" +
150162 "--json prints one JSON object per line (NDJSON). Fields: id, time (RFC 3339 UTC), type\n" +
151163 "(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" +
153166 "repository (the repository fields of `repo show --json`), ref_updates (array of ref, old_id,\n" +
154167 "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" +
156169 "event types are skipped.",
157170 Args: cobra.MaximumNArgs(1),
158171 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, baseURL string) (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(), &c87 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 = eventGap98 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) {
199199 assert.Equal(t, "merge_request_merged", decode[eventOut](t, lines[2]).Type)
200200 assert.Equal(t, "merge_request_closed", decode[eventOut](t, lines[3]).Type)
201201 }
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, "https://git.example.org")
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, "https://git.example.org")
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+}
M internal/app/web_mr_test.go +6 -3
@@ -284,7 +284,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
284284 require.GreaterOrEqual(t, i, 0, "a note row under the line")
285285 note := anon.body[i:]
286286 assert.Less(t, strings.Index(note, "Why &lt;i&gt;empty&lt;/i&gt;?"), strings.Index(note, "</tr>"), "the note sits in the row under its line")
287- assert.Contains(t, anon.body, `<a href="https://example.org/a" rel="noopener nofollow ugc">https://example.org/a</a>.`)
287+ assert.Contains(t, anon.body, `<a href="https://example.org/a" rel="nofollow noopener noreferrer">https://example.org/a</a>.`)
288288 assert.NotContains(t, anon.body, "<i>empty</i>")
289289 assert.Contains(t, anon.body, "Looks fine overall.")
290290 assert.Equal(t, 2, len(reNoteID.FindAllString(anon.body, -1)))
@@ -338,7 +338,8 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
338338 assert.Contains(t, since.body, "changes since "+head1[:7])
339339 assert.Contains(t, since.body, "extra</td>")
340340 assert.NotContains(t, since.body, `<span class="kn">package</span> search</td>`, "only what changed after the read head")
341- assert.Contains(t, since.body, "Why &lt;i&gt;empty&lt;/i&gt;?", "the notes are listed below, their lines are not in this diff")
341+ assert.Contains(t, since.body, "empty", "the notes are listed below, their lines are not in this diff")
342+ assert.NotContains(t, since.body, "<i>", "raw HTML is dropped")
342343 assert.Contains(t, since.body, "search.go:3 (new)")
343344 assert.NotContains(t, since.body, `<form class="cform"`, "no comment form on this diff")
344345
@@ -554,7 +555,9 @@ func TestWebMergeRequestEscapesText(t *testing.T) {
554555 assert.NotContains(t, body, "<img src=x")
555556 assert.NotContains(t, body, "<script>x</script>")
556557 assert.Contains(t, html.UnescapeString(body), `"><script>alert(1)</script>`)
557- assert.Contains(t, body, "&lt;script&gt;x&lt;/script&gt; &amp; more")
558+ // A note is safe Markdown: a line that opens with an HTML tag is an HTML
559+ // block, dropped whole.
560+ assert.Contains(t, body, `class="c-body prose"><!-- raw HTML omitted -->`)
558561 assertNoInline(t, body)
559562 assert.NotContains(t, body, `onmouseover="alert`, "an address stops at a quote")
560563 }
@@ -284,7 +284,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
284 require.GreaterOrEqual(t, i, 0, "a note row under the line")284 require.GreaterOrEqual(t, i, 0, "a note row under the line")
285 note := anon.body[i:]285 note := anon.body[i:]
286 assert.Less(t, strings.Index(note, "Why &lt;i&gt;empty&lt;/i&gt;?"), strings.Index(note, "</tr>"), "the note sits in the row under its line")286 assert.Less(t, strings.Index(note, "Why &lt;i&gt;empty&lt;/i&gt;?"), strings.Index(note, "</tr>"), "the note sits in the row under its line")
287- assert.Contains(t, anon.body, `<a href="https://example.org/a" rel="noopener nofollow ugc">https://example.org/a</a>.`)287+ assert.Contains(t, anon.body, `<a href="https://example.org/a" rel="nofollow noopener noreferrer">https://example.org/a</a>.`)
288 assert.NotContains(t, anon.body, "<i>empty</i>")288 assert.NotContains(t, anon.body, "<i>empty</i>")
289 assert.Contains(t, anon.body, "Looks fine overall.")289 assert.Contains(t, anon.body, "Looks fine overall.")
290 assert.Equal(t, 2, len(reNoteID.FindAllString(anon.body, -1)))290 assert.Equal(t, 2, len(reNoteID.FindAllString(anon.body, -1)))
@@ -338,7 +338,8 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
338 assert.Contains(t, since.body, "changes since "+head1[:7])338 assert.Contains(t, since.body, "changes since "+head1[:7])
339 assert.Contains(t, since.body, "extra</td>")339 assert.Contains(t, since.body, "extra</td>")
340 assert.NotContains(t, since.body, `<span class="kn">package</span> search</td>`, "only what changed after the read head")340 assert.NotContains(t, since.body, `<span class="kn">package</span> search</td>`, "only what changed after the read head")
341- assert.Contains(t, since.body, "Why &lt;i&gt;empty&lt;/i&gt;?", "the notes are listed below, their lines are not in this diff")341+ assert.Contains(t, since.body, "empty", "the notes are listed below, their lines are not in this diff")
342+ assert.NotContains(t, since.body, "<i>", "raw HTML is dropped")
342 assert.Contains(t, since.body, "search.go:3 (new)")343 assert.Contains(t, since.body, "search.go:3 (new)")
343 assert.NotContains(t, since.body, `<form class="cform"`, "no comment form on this diff")344 assert.NotContains(t, since.body, `<form class="cform"`, "no comment form on this diff")
344 345
@@ -554,7 +555,9 @@ func TestWebMergeRequestEscapesText(t *testing.T) {
554 assert.NotContains(t, body, "<img src=x")555 assert.NotContains(t, body, "<img src=x")
555 assert.NotContains(t, body, "<script>x</script>")556 assert.NotContains(t, body, "<script>x</script>")
556 assert.Contains(t, html.UnescapeString(body), `"><script>alert(1)</script>`)557 assert.Contains(t, html.UnescapeString(body), `"><script>alert(1)</script>`)
557- assert.Contains(t, body, "&lt;script&gt;x&lt;/script&gt; &amp; more")558+ // A note is safe Markdown: a line that opens with an HTML tag is an HTML
559+ // block, dropped whole.
560+ assert.Contains(t, body, `class="c-body prose"><!-- raw HTML omitted -->`)
558 assertNoInline(t, body)561 assertNoInline(t, body)
559 assert.NotContains(t, body, `onmouseover="alert`, "an address stops at a quote")562 assert.NotContains(t, body, `onmouseover="alert`, "an address stops at a quote")
560 }563 }
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 the text of a note was edited.
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 the text of a note was deleted.
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 thread was resolved or reopened.
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 the text of a note was edited.
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 the text of a note was deleted.
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 thread was resolved or reopened.
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/ports/mocks/MergeRequestService.go +174 -0
@@ -360,6 +360,180 @@ func (_c *MergeRequestService_Create_Call) RunAndReturn(run func(ctx context.Con
360360 return _c
361361 }
362362
363+// DeleteComment provides a mock function for the type MergeRequestService
364+func (_mock *MergeRequestService) DeleteComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64) (domain.Comment, error) {
365+ ret := _mock.Called(ctx, p, name, number, commentID)
366+
367+ if len(ret) == 0 {
368+ panic("no return value specified for DeleteComment")
369+ }
370+
371+ var r0 domain.Comment
372+ var r1 error
373+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int64) (domain.Comment, error)); ok {
374+ return returnFunc(ctx, p, name, number, commentID)
375+ }
376+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int64) domain.Comment); ok {
377+ r0 = returnFunc(ctx, p, name, number, commentID)
378+ } else {
379+ r0 = ret.Get(0).(domain.Comment)
380+ }
381+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, int64) error); ok {
382+ r1 = returnFunc(ctx, p, name, number, commentID)
383+ } else {
384+ r1 = ret.Error(1)
385+ }
386+ return r0, r1
387+}
388+
389+// MergeRequestService_DeleteComment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteComment'
390+type MergeRequestService_DeleteComment_Call struct {
391+ *mock.Call
392+}
393+
394+// DeleteComment is a helper method to define mock.On call
395+// - ctx context.Context
396+// - p domain.Principal
397+// - name domain.RepoName
398+// - number int64
399+// - commentID int64
400+func (_e *MergeRequestService_Expecter) DeleteComment(ctx any, p any, name any, number any, commentID any) *MergeRequestService_DeleteComment_Call {
401+ return &MergeRequestService_DeleteComment_Call{Call: _e.mock.On("DeleteComment", ctx, p, name, number, commentID)}
402+}
403+
404+func (_c *MergeRequestService_DeleteComment_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64)) *MergeRequestService_DeleteComment_Call {
405+ _c.Call.Run(func(args mock.Arguments) {
406+ var arg0 context.Context
407+ if args[0] != nil {
408+ arg0 = args[0].(context.Context)
409+ }
410+ var arg1 domain.Principal
411+ if args[1] != nil {
412+ arg1 = args[1].(domain.Principal)
413+ }
414+ var arg2 domain.RepoName
415+ if args[2] != nil {
416+ arg2 = args[2].(domain.RepoName)
417+ }
418+ var arg3 int64
419+ if args[3] != nil {
420+ arg3 = args[3].(int64)
421+ }
422+ var arg4 int64
423+ if args[4] != nil {
424+ arg4 = args[4].(int64)
425+ }
426+ run(
427+ arg0,
428+ arg1,
429+ arg2,
430+ arg3,
431+ arg4,
432+ )
433+ })
434+ return _c
435+}
436+
437+func (_c *MergeRequestService_DeleteComment_Call) Return(comment domain.Comment, err error) *MergeRequestService_DeleteComment_Call {
438+ _c.Call.Return(comment, err)
439+ return _c
440+}
441+
442+func (_c *MergeRequestService_DeleteComment_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64) (domain.Comment, error)) *MergeRequestService_DeleteComment_Call {
443+ _c.Call.Return(run)
444+ return _c
445+}
446+
447+// EditComment provides a mock function for the type MergeRequestService
448+func (_mock *MergeRequestService) EditComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, text string) (domain.Comment, error) {
449+ ret := _mock.Called(ctx, p, name, number, commentID, text)
450+
451+ if len(ret) == 0 {
452+ panic("no return value specified for EditComment")
453+ }
454+
455+ var r0 domain.Comment
456+ var r1 error
457+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int64, string) (domain.Comment, error)); ok {
458+ return returnFunc(ctx, p, name, number, commentID, text)
459+ }
460+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int64, string) domain.Comment); ok {
461+ r0 = returnFunc(ctx, p, name, number, commentID, text)
462+ } else {
463+ r0 = ret.Get(0).(domain.Comment)
464+ }
465+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, int64, string) error); ok {
466+ r1 = returnFunc(ctx, p, name, number, commentID, text)
467+ } else {
468+ r1 = ret.Error(1)
469+ }
470+ return r0, r1
471+}
472+
473+// MergeRequestService_EditComment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EditComment'
474+type MergeRequestService_EditComment_Call struct {
475+ *mock.Call
476+}
477+
478+// EditComment is a helper method to define mock.On call
479+// - ctx context.Context
480+// - p domain.Principal
481+// - name domain.RepoName
482+// - number int64
483+// - commentID int64
484+// - text string
485+func (_e *MergeRequestService_Expecter) EditComment(ctx any, p any, name any, number any, commentID any, text any) *MergeRequestService_EditComment_Call {
486+ return &MergeRequestService_EditComment_Call{Call: _e.mock.On("EditComment", ctx, p, name, number, commentID, text)}
487+}
488+
489+func (_c *MergeRequestService_EditComment_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, text string)) *MergeRequestService_EditComment_Call {
490+ _c.Call.Run(func(args mock.Arguments) {
491+ var arg0 context.Context
492+ if args[0] != nil {
493+ arg0 = args[0].(context.Context)
494+ }
495+ var arg1 domain.Principal
496+ if args[1] != nil {
497+ arg1 = args[1].(domain.Principal)
498+ }
499+ var arg2 domain.RepoName
500+ if args[2] != nil {
501+ arg2 = args[2].(domain.RepoName)
502+ }
503+ var arg3 int64
504+ if args[3] != nil {
505+ arg3 = args[3].(int64)
506+ }
507+ var arg4 int64
508+ if args[4] != nil {
509+ arg4 = args[4].(int64)
510+ }
511+ var arg5 string
512+ if args[5] != nil {
513+ arg5 = args[5].(string)
514+ }
515+ run(
516+ arg0,
517+ arg1,
518+ arg2,
519+ arg3,
520+ arg4,
521+ arg5,
522+ )
523+ })
524+ return _c
525+}
526+
527+func (_c *MergeRequestService_EditComment_Call) Return(comment domain.Comment, err error) *MergeRequestService_EditComment_Call {
528+ _c.Call.Return(comment, err)
529+ return _c
530+}
531+
532+func (_c *MergeRequestService_EditComment_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, text string) (domain.Comment, error)) *MergeRequestService_EditComment_Call {
533+ _c.Call.Return(run)
534+ return _c
535+}
536+
363537 // Get provides a mock function for the type MergeRequestService
364538 func (_mock *MergeRequestService) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) {
365539 ret := _mock.Called(ctx, p, name, number, opts)
@@ -360,6 +360,180 @@ func (_c *MergeRequestService_Create_Call) RunAndReturn(run func(ctx context.Con
360 return _c360 return _c
361 }361 }
362 362
363+// DeleteComment provides a mock function for the type MergeRequestService
364+func (_mock *MergeRequestService) DeleteComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64) (domain.Comment, error) {
365+ ret := _mock.Called(ctx, p, name, number, commentID)
366+
367+ if len(ret) == 0 {
368+ panic("no return value specified for DeleteComment")
369+ }
370+
371+ var r0 domain.Comment
372+ var r1 error
373+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int64) (domain.Comment, error)); ok {
374+ return returnFunc(ctx, p, name, number, commentID)
375+ }
376+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int64) domain.Comment); ok {
377+ r0 = returnFunc(ctx, p, name, number, commentID)
378+ } else {
379+ r0 = ret.Get(0).(domain.Comment)
380+ }
381+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, int64) error); ok {
382+ r1 = returnFunc(ctx, p, name, number, commentID)
383+ } else {
384+ r1 = ret.Error(1)
385+ }
386+ return r0, r1
387+}
388+
389+// MergeRequestService_DeleteComment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteComment'
390+type MergeRequestService_DeleteComment_Call struct {
391+ *mock.Call
392+}
393+
394+// DeleteComment is a helper method to define mock.On call
395+// - ctx context.Context
396+// - p domain.Principal
397+// - name domain.RepoName
398+// - number int64
399+// - commentID int64
400+func (_e *MergeRequestService_Expecter) DeleteComment(ctx any, p any, name any, number any, commentID any) *MergeRequestService_DeleteComment_Call {
401+ return &MergeRequestService_DeleteComment_Call{Call: _e.mock.On("DeleteComment", ctx, p, name, number, commentID)}
402+}
403+
404+func (_c *MergeRequestService_DeleteComment_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64)) *MergeRequestService_DeleteComment_Call {
405+ _c.Call.Run(func(args mock.Arguments) {
406+ var arg0 context.Context
407+ if args[0] != nil {
408+ arg0 = args[0].(context.Context)
409+ }
410+ var arg1 domain.Principal
411+ if args[1] != nil {
412+ arg1 = args[1].(domain.Principal)
413+ }
414+ var arg2 domain.RepoName
415+ if args[2] != nil {
416+ arg2 = args[2].(domain.RepoName)
417+ }
418+ var arg3 int64
419+ if args[3] != nil {
420+ arg3 = args[3].(int64)
421+ }
422+ var arg4 int64
423+ if args[4] != nil {
424+ arg4 = args[4].(int64)
425+ }
426+ run(
427+ arg0,
428+ arg1,
429+ arg2,
430+ arg3,
431+ arg4,
432+ )
433+ })
434+ return _c
435+}
436+
437+func (_c *MergeRequestService_DeleteComment_Call) Return(comment domain.Comment, err error) *MergeRequestService_DeleteComment_Call {
438+ _c.Call.Return(comment, err)
439+ return _c
440+}
441+
442+func (_c *MergeRequestService_DeleteComment_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64) (domain.Comment, error)) *MergeRequestService_DeleteComment_Call {
443+ _c.Call.Return(run)
444+ return _c
445+}
446+
447+// EditComment provides a mock function for the type MergeRequestService
448+func (_mock *MergeRequestService) EditComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, text string) (domain.Comment, error) {
449+ ret := _mock.Called(ctx, p, name, number, commentID, text)
450+
451+ if len(ret) == 0 {
452+ panic("no return value specified for EditComment")
453+ }
454+
455+ var r0 domain.Comment
456+ var r1 error
457+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int64, string) (domain.Comment, error)); ok {
458+ return returnFunc(ctx, p, name, number, commentID, text)
459+ }
460+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int64, string) domain.Comment); ok {
461+ r0 = returnFunc(ctx, p, name, number, commentID, text)
462+ } else {
463+ r0 = ret.Get(0).(domain.Comment)
464+ }
465+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, int64, string) error); ok {
466+ r1 = returnFunc(ctx, p, name, number, commentID, text)
467+ } else {
468+ r1 = ret.Error(1)
469+ }
470+ return r0, r1
471+}
472+
473+// MergeRequestService_EditComment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EditComment'
474+type MergeRequestService_EditComment_Call struct {
475+ *mock.Call
476+}
477+
478+// EditComment is a helper method to define mock.On call
479+// - ctx context.Context
480+// - p domain.Principal
481+// - name domain.RepoName
482+// - number int64
483+// - commentID int64
484+// - text string
485+func (_e *MergeRequestService_Expecter) EditComment(ctx any, p any, name any, number any, commentID any, text any) *MergeRequestService_EditComment_Call {
486+ return &MergeRequestService_EditComment_Call{Call: _e.mock.On("EditComment", ctx, p, name, number, commentID, text)}
487+}
488+
489+func (_c *MergeRequestService_EditComment_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, text string)) *MergeRequestService_EditComment_Call {
490+ _c.Call.Run(func(args mock.Arguments) {
491+ var arg0 context.Context
492+ if args[0] != nil {
493+ arg0 = args[0].(context.Context)
494+ }
495+ var arg1 domain.Principal
496+ if args[1] != nil {
497+ arg1 = args[1].(domain.Principal)
498+ }
499+ var arg2 domain.RepoName
500+ if args[2] != nil {
501+ arg2 = args[2].(domain.RepoName)
502+ }
503+ var arg3 int64
504+ if args[3] != nil {
505+ arg3 = args[3].(int64)
506+ }
507+ var arg4 int64
508+ if args[4] != nil {
509+ arg4 = args[4].(int64)
510+ }
511+ var arg5 string
512+ if args[5] != nil {
513+ arg5 = args[5].(string)
514+ }
515+ run(
516+ arg0,
517+ arg1,
518+ arg2,
519+ arg3,
520+ arg4,
521+ arg5,
522+ )
523+ })
524+ return _c
525+}
526+
527+func (_c *MergeRequestService_EditComment_Call) Return(comment domain.Comment, err error) *MergeRequestService_EditComment_Call {
528+ _c.Call.Return(comment, err)
529+ return _c
530+}
531+
532+func (_c *MergeRequestService_EditComment_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, text string) (domain.Comment, error)) *MergeRequestService_EditComment_Call {
533+ _c.Call.Return(run)
534+ return _c
535+}
536+
363 // Get provides a mock function for the type MergeRequestService537 // Get provides a mock function for the type MergeRequestService
364 func (_mock *MergeRequestService) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) {538 func (_mock *MergeRequestService) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) {
365 ret := _mock.Called(ctx, p, name, number, opts)539 ret := _mock.Called(ctx, p, name, number, opts)
M internal/ports/services.go +2 -0
@@ -113,6 +113,8 @@ type MergeRequestService interface {
113113 List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error)
114114 Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts GetMergeRequestOptions) (MergeRequestDetail, error)
115115 AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params AddCommentParams) (domain.Comment, error)
116+ EditComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, text string) (domain.Comment, error)
117+ DeleteComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64) (domain.Comment, error)
116118 ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, resolved bool) (domain.Comment, error)
117119 Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)
118120 MarkRead(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, head domain.ObjectID) (domain.ObjectID, error)
@@ -113,6 +113,8 @@ type MergeRequestService interface {
113 List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error)113 List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error)
114 Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts GetMergeRequestOptions) (MergeRequestDetail, error)114 Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts GetMergeRequestOptions) (MergeRequestDetail, error)
115 AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params AddCommentParams) (domain.Comment, error)115 AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params AddCommentParams) (domain.Comment, error)
116+ EditComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, text string) (domain.Comment, error)
117+ DeleteComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64) (domain.Comment, error)
116 ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, resolved bool) (domain.Comment, error)118 ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, resolved bool) (domain.Comment, error)
117 Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)119 Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)
118 MarkRead(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, head domain.ObjectID) (domain.ObjectID, error)120 MarkRead(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, head domain.ObjectID) (domain.ObjectID, error)
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/events.go +12 -0
@@ -178,6 +178,12 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
178178 return repoSnapshot(ev.Visibility, ev.CreatedAt)
179179 case domain.CommentAdded:
180180 return repoSnapshot(ev.Visibility, ev.CreatedAt)
181+ case domain.CommentEdited:
182+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
183+ case domain.CommentDeleted:
184+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
185+ case domain.CommentResolved:
186+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
181187 case domain.RunUpdated:
182188 return repoSnapshot(ev.Visibility, ev.CreatedAt)
183189 case domain.JobUpdated:
@@ -487,6 +493,12 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
487493 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
488494 case domain.CommentAdded:
489495 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
496+ case domain.CommentEdited:
497+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
498+ case domain.CommentDeleted:
499+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
500+ case domain.CommentResolved:
501+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
490502 case domain.RunUpdated:
491503 // Home shows the CI state of each repository.
492504 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)
@@ -178,6 +178,12 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
178 return repoSnapshot(ev.Visibility, ev.CreatedAt)178 return repoSnapshot(ev.Visibility, ev.CreatedAt)
179 case domain.CommentAdded:179 case domain.CommentAdded:
180 return repoSnapshot(ev.Visibility, ev.CreatedAt)180 return repoSnapshot(ev.Visibility, ev.CreatedAt)
181+ case domain.CommentEdited:
182+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
183+ case domain.CommentDeleted:
184+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
185+ case domain.CommentResolved:
186+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
181 case domain.RunUpdated:187 case domain.RunUpdated:
182 return repoSnapshot(ev.Visibility, ev.CreatedAt)188 return repoSnapshot(ev.Visibility, ev.CreatedAt)
183 case domain.JobUpdated:189 case domain.JobUpdated:
@@ -487,6 +493,12 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
487 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)493 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
488 case domain.CommentAdded:494 case domain.CommentAdded:
489 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)495 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
496+ case domain.CommentEdited:
497+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
498+ case domain.CommentDeleted:
499+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
500+ case domain.CommentResolved:
501+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
490 case domain.RunUpdated:502 case domain.RunUpdated:
491 // Home shows the CI state of each repository.503 // Home shows the CI state of each repository.
492 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)504 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)
M internal/usecase/events_test.go +32 -0
@@ -922,3 +922,35 @@ func TestWatchHomeGetsRunUpdates(t *testing.T) {
922922 root.quiet(t, r, pub)
923923 })
924924 }
925+
926+func TestNoteMutationEventVisibility(t *testing.T) {
927+ synctest.Test(t, func(t *testing.T) {
928+ r := newEventRig(t, 0)
929+ pub := r.repo("pub", domain.VisibilityPublic)
930+ priv := r.repo("priv", domain.VisibilityPrivate)
931+ ctx, cancel := context.WithCancel(context.Background())
932+ defer cancel()
933+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{})
934+ owner := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:priv"}})
935+ synctest.Wait()
936+ for _, kind := range []string{"edited", "deleted", "resolved"} {
937+ for _, n := range []domain.RepoName{priv, pub} {
938+ vis := domain.VisibilityPublic
939+ if n == priv {
940+ vis = domain.VisibilityPrivate
941+ }
942+ switch kind {
943+ case "edited":
944+ r.feed <- domain.CommentEdited{Repo: n, Visibility: vis, CreatedAt: now, At: now}
945+ case "deleted":
946+ r.feed <- domain.CommentDeleted{Repo: n, Visibility: vis, CreatedAt: now, At: now}
947+ case "resolved":
948+ r.feed <- domain.CommentResolved{Repo: n, Visibility: vis, CreatedAt: now, At: now}
949+ }
950+ }
951+ assert.Equal(t, "mr.comment_"+kind, anon.next(t).Event.EventKind())
952+ assert.Equal(t, "mr.comment_"+kind, owner.next(t).Event.EventKind())
953+ anon.quiet(t, r, pub)
954+ }
955+ })
956+}
@@ -922,3 +922,35 @@ func TestWatchHomeGetsRunUpdates(t *testing.T) {
922 root.quiet(t, r, pub)922 root.quiet(t, r, pub)
923 })923 })
924 }924 }
925+
926+func TestNoteMutationEventVisibility(t *testing.T) {
927+ synctest.Test(t, func(t *testing.T) {
928+ r := newEventRig(t, 0)
929+ pub := r.repo("pub", domain.VisibilityPublic)
930+ priv := r.repo("priv", domain.VisibilityPrivate)
931+ ctx, cancel := context.WithCancel(context.Background())
932+ defer cancel()
933+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{})
934+ owner := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:priv"}})
935+ synctest.Wait()
936+ for _, kind := range []string{"edited", "deleted", "resolved"} {
937+ for _, n := range []domain.RepoName{priv, pub} {
938+ vis := domain.VisibilityPublic
939+ if n == priv {
940+ vis = domain.VisibilityPrivate
941+ }
942+ switch kind {
943+ case "edited":
944+ r.feed <- domain.CommentEdited{Repo: n, Visibility: vis, CreatedAt: now, At: now}
945+ case "deleted":
946+ r.feed <- domain.CommentDeleted{Repo: n, Visibility: vis, CreatedAt: now, At: now}
947+ case "resolved":
948+ r.feed <- domain.CommentResolved{Repo: n, Visibility: vis, CreatedAt: now, At: now}
949+ }
950+ }
951+ assert.Equal(t, "mr.comment_"+kind, anon.next(t).Event.EventKind())
952+ assert.Equal(t, "mr.comment_"+kind, owner.next(t).Event.EventKind())
953+ anon.quiet(t, r, pub)
954+ }
955+ })
956+}
M internal/usecase/export_test.go +0 -3
@@ -15,9 +15,6 @@ import (
1515 "bnema.dev/ermite/internal/ports"
1616 )
1717
18-// AuthorOf exposes authorOf to the tests.
19-func AuthorOf(p domain.Principal) string { return authorOf(p) }
20-
2118 // Sweep exposes the daily sweep to the tests.
2219 func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }
2320
@@ -15,9 +15,6 @@ import (
15 "bnema.dev/ermite/internal/ports"15 "bnema.dev/ermite/internal/ports"
16 )16 )
17 17
18-// AuthorOf exposes authorOf to the tests.
19-func AuthorOf(p domain.Principal) string { return authorOf(p) }
20-
21 // Sweep exposes the daily sweep to the tests.18 // Sweep exposes the daily sweep to the tests.
22 func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }19 func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }
23 20
M internal/usecase/mergerequests.go +137 -9
@@ -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)
@@ -468,6 +468,7 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
468468 if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil {
469469 return ports.MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err)
470470 }
471+ d.Verdicts = domain.CurrentVerdicts(d.Comments, mr.HeadID)
471472 if d.LastReadID, err = m.store.GetRead(ctx, name, number); err != nil {
472473 return ports.MergeRequestDetail{}, err
473474 }
@@ -547,7 +548,7 @@ func (m *MergeRequests) rangeOf(ctx context.Context, mr domain.MergeRequest) (ex
547548 }
548549
549550 // AddComment adds a comment and publishes CommentAdded. The principal needs
550-// mr:write. The text must follow the rules of ADR 0017 and not be blank; a
551+// mr:write. The text must follow the rules of ADR 0032 and not be blank unless carrying a verdict; a
551552 // comment on a line must name a line of the current diff of the request (see
552553 // domain.Diff.HasLine), else the call fails with domain.ErrInvalid. A request
553554 // that is not open gives domain.ErrPreconditionFailed, and one that already
@@ -566,21 +567,44 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
566567 switch {
567568 case err != nil:
568569 v.AddErr("text", err)
569- case text.IsBlank():
570+ case text.IsBlank() && params.Verdict == domain.VerdictNone:
570571 v.Add("text", "is required")
571572 }
572573 general := params.Path == ""
573574 switch {
574- case general && (params.Line != 0 || params.Side != domain.SideNone):
575+ case general && (params.Line != 0 || params.Side != domain.SideNone || params.StartLine != 0):
575576 v.Add("path", "is required with line and side")
576577 case !general && params.Line < 1:
577578 v.Add("line", "must be 1 or more with a path")
578579 case !general && params.Side != domain.SideOld && params.Side != domain.SideNew:
579580 v.Add("side", "is required with a path")
580581 }
582+ if params.StartLine != 0 && (params.StartLine < 1 || params.StartLine >= params.Line) {
583+ v.Add("start_line", "must be 1 or more and less than line")
584+ }
585+ if params.Verdict > domain.VerdictChangesRequested {
586+ v.Add("verdict", "is unknown")
587+ }
588+ if params.ReplyTo != 0 {
589+ if params.Path != "" || params.Line != 0 || params.StartLine != 0 || params.Side != domain.SideNone || params.Verdict != domain.VerdictNone {
590+ v.Add("reply_to", "a reply cannot specify an anchor or verdict")
591+ }
592+ }
581593 if err := v.Err(); err != nil {
582594 return domain.Comment{}, err
583595 }
596+ if params.ReplyTo != 0 {
597+ root, err := m.replyRoot(ctx, name, number, params.ReplyTo)
598+ if err != nil {
599+ return domain.Comment{}, err
600+ }
601+ params.Path, params.Line, params.StartLine, params.Side = root.Path, root.Line, root.StartLine, root.Side
602+ }
603+ if params.Verdict != domain.VerdictNone {
604+ if err := scopeAccess(p, repo, domain.ScopeMRReview); err != nil {
605+ return domain.Comment{}, err
606+ }
607+ }
584608 if !mr.IsOpen() {
585609 return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
586610 }
@@ -588,7 +612,7 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
588612 if mr, err = m.withHead(ctx, mr); err != nil {
589613 return domain.Comment{}, err
590614 }
591- if !general {
615+ if !general && params.ReplyTo == 0 {
592616 if err := m.checkLine(ctx, mr, params); err != nil {
593617 return domain.Comment{}, err
594618 }
@@ -600,7 +624,8 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
600624 return domain.Comment{}, fmt.Errorf("%w: !%d already has %d comments", domain.ErrResourceExhausted, number, MaxCommentsPerMergeRequest)
601625 }
602626 c = domain.Comment{
603- ID: mr.CommentSeq + 1, Author: authorOf(p), Path: params.Path, Line: params.Line, Side: params.Side,
627+ ID: mr.CommentSeq + 1, Author: domain.AuthorName(p), Path: params.Path, Line: params.Line, Side: params.Side,
628+ ReplyTo: params.ReplyTo, StartLine: params.StartLine, Verdict: params.Verdict, Resolved: params.Verdict == domain.VerdictApprove,
604629 CommitID: mr.HeadID, Text: text, CreatedAt: m.clock.Now().UTC(),
605630 }
606631 mr.CommentSeq, mr.UpdatedAt = c.ID, c.CreatedAt
@@ -616,6 +641,12 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
616641 if !fresh.IsOpen() {
617642 return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, fresh.State)
618643 }
644+ if params.ReplyTo != 0 {
645+ // The root may have been deleted by the write that got in between.
646+ if _, rerr := m.replyRoot(ctx, name, number, params.ReplyTo); rerr != nil {
647+ return domain.Comment{}, rerr
648+ }
649+ }
619650 fresh.HeadID = mr.HeadID
620651 mr = fresh
621652 }
@@ -624,7 +655,7 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
624655 }
625656 log := zerowrap.FromCtx(ctx)
626657 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
627- Bool("on_line", !general).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment added")
658+ Int64("reply_to", c.ReplyTo).Str("verdict", c.Verdict.String()).Bool("on_line", !general).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment added")
628659 m.bus.Publish(ctx, domain.CommentAdded{
629660 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
630661 })
@@ -667,6 +698,9 @@ func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, p
667698 if idx < 0 || !diff.HasLine(params.Path, params.Side, params.Line) {
668699 v.Add("path", fmt.Sprintf("%s:%d on the %s side is not a line of the diff", params.Path, params.Line, params.Side))
669700 }
701+ if params.StartLine != 0 && !diff.HasLine(params.Path, params.Side, params.StartLine) {
702+ v.Add("start_line", "is not a line of the diff")
703+ }
670704 return v.Err()
671705 }
672706
@@ -683,7 +717,8 @@ func diffFileName(f domain.FileDiff) string {
683717 // not open domain.ErrPreconditionFailed. Setting the state the comment already
684718 // has succeeds and writes nothing.
685719 func (m *MergeRequests) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, resolved bool) (domain.Comment, error) {
686- if _, err := m.writable(ctx, p, name, domain.ScopeMRWrite); err != nil {
720+ repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
721+ if err != nil {
687722 return domain.Comment{}, err
688723 }
689724 for range maxWriteRetries {
@@ -698,6 +733,11 @@ func (m *MergeRequests) ResolveComment(ctx context.Context, p domain.Principal,
698733 if !mr.IsOpen() {
699734 return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
700735 }
736+ if c.IsReply() {
737+ var v domain.Violations
738+ v.Add("comment_id", fmt.Sprintf("resolve the thread on its root ^%d", c.ReplyTo))
739+ return domain.Comment{}, v.Err()
740+ }
701741 if c.Resolved == resolved {
702742 return c, nil
703743 }
@@ -710,6 +750,7 @@ func (m *MergeRequests) ResolveComment(ctx context.Context, p domain.Principal,
710750 if err != nil {
711751 return domain.Comment{}, fmt.Errorf("resolve comment %d of !%d in %q: %w", commentID, number, name, err)
712752 }
753+ m.bus.Publish(ctx, domain.CommentResolved{Repo: name, Number: number, Comment: c, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
713754 return c, nil
714755 }
715756 return domain.Comment{}, fmt.Errorf("resolve comment %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)
@@ -1090,7 +1131,7 @@ func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest,
10901131 // does, under the merge lock (domain.CIRefTip). The run shows the owner, as a
10911132 // merge's push does on GitHub.
10921133 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 {
1134+ domain.AuthorName(domain.Owner()), domain.PusherOf(p), domain.TrustedActor(p)); err != nil {
10941135 return domain.ObjectID{}, err
10951136 }
10961137 err := m.git.UpdateBranch(ctx, mr.Repo, mr.Target, tip, newTip)
@@ -1129,3 +1170,90 @@ func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, nu
11291170 Msg("the target branch moved but the merge request could not be marked merged")
11301171 return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, domain.ErrConflict)
11311172 }
1173+
1174+// EditComment changes only the author's text, preserving the immutable verdict.
1175+func (m *MergeRequests) EditComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, text string) (domain.Comment, error) {
1176+ return m.changeComment(ctx, p, name, number, commentID, text, false)
1177+}
1178+
1179+// DeleteComment removes text but retains the anchor, ID and verdict. Repeated deletion is a no-op.
1180+func (m *MergeRequests) DeleteComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64) (domain.Comment, error) {
1181+ return m.changeComment(ctx, p, name, number, commentID, "", true)
1182+}
1183+
1184+func (m *MergeRequests) changeComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, text string, remove bool) (domain.Comment, error) {
1185+ repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
1186+ if err != nil {
1187+ return domain.Comment{}, err
1188+ }
1189+ for range maxWriteRetries {
1190+ mr, err := m.store.Get(ctx, name, number)
1191+ if err != nil {
1192+ return domain.Comment{}, err
1193+ }
1194+ c, err := m.store.GetComment(ctx, name, number, commentID)
1195+ if err != nil {
1196+ return domain.Comment{}, err
1197+ }
1198+ if !mr.IsOpen() {
1199+ return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
1200+ }
1201+ if domain.AuthorName(p) != c.Author {
1202+ return domain.Comment{}, fmt.Errorf("%w: only the author of a note can edit or delete it", domain.ErrForbidden)
1203+ }
1204+ if c.Deleted {
1205+ if remove {
1206+ return c, nil
1207+ }
1208+ return domain.Comment{}, fmt.Errorf("%w: the note was deleted", domain.ErrPreconditionFailed)
1209+ }
1210+ parsed, err := domain.ParsePlainText(text)
1211+ var v domain.Violations
1212+ if err != nil {
1213+ v.AddErr("text", err)
1214+ } else if !remove && parsed.IsBlank() && c.Verdict == domain.VerdictNone {
1215+ v.Add("text", "is required")
1216+ }
1217+ if err := v.Err(); err != nil {
1218+ return domain.Comment{}, err
1219+ }
1220+ if !remove && parsed == c.Text {
1221+ return c, nil
1222+ }
1223+ c.Text, c.Deleted, c.EditedAt = parsed, remove, m.clock.Now().UTC()
1224+ mr.UpdatedAt = c.EditedAt
1225+ if err := m.store.SaveComment(ctx, mr, c); errors.Is(err, domain.ErrConflict) {
1226+ continue
1227+ } else if err != nil {
1228+ return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, err)
1229+ }
1230+ log := zerowrap.FromCtx(ctx)
1231+ log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).Bool("deleted", remove).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment changed")
1232+ if remove {
1233+ m.bus.Publish(ctx, domain.CommentDeleted{Repo: name, Number: number, Comment: c, At: c.EditedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1234+ } else {
1235+ m.bus.Publish(ctx, domain.CommentEdited{Repo: name, Number: number, Comment: c, At: c.EditedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1236+ }
1237+ return c, nil
1238+ }
1239+ return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)
1240+}
1241+
1242+// replyRoot returns the note a reply targets, or a violation when it is
1243+// missing, itself a reply, or deleted.
1244+func (m *MergeRequests) replyRoot(ctx context.Context, name domain.RepoName, number, id int64) (domain.Comment, error) {
1245+ root, err := m.store.GetComment(ctx, name, number, id)
1246+ var v domain.Violations
1247+ switch {
1248+ case errors.Is(err, domain.ErrNotFound):
1249+ v.Add("reply_to", fmt.Sprintf("no note ^%d", id))
1250+ case err != nil:
1251+ return domain.Comment{}, err
1252+ case root.IsReply() || root.Deleted:
1253+ v.Add("reply_to", "must name a non-deleted root note")
1254+ }
1255+ if err := v.Err(); err != nil {
1256+ return domain.Comment{}, err
1257+ }
1258+ return root, nil
1259+}
@@ -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)
@@ -468,6 +468,7 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
468 if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil {468 if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil {
469 return ports.MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err)469 return ports.MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err)
470 }470 }
471+ d.Verdicts = domain.CurrentVerdicts(d.Comments, mr.HeadID)
471 if d.LastReadID, err = m.store.GetRead(ctx, name, number); err != nil {472 if d.LastReadID, err = m.store.GetRead(ctx, name, number); err != nil {
472 return ports.MergeRequestDetail{}, err473 return ports.MergeRequestDetail{}, err
473 }474 }
@@ -547,7 +548,7 @@ func (m *MergeRequests) rangeOf(ctx context.Context, mr domain.MergeRequest) (ex
547 }548 }
548 549
549 // AddComment adds a comment and publishes CommentAdded. The principal needs550 // AddComment adds a comment and publishes CommentAdded. The principal needs
550-// mr:write. The text must follow the rules of ADR 0017 and not be blank; a551+// mr:write. The text must follow the rules of ADR 0032 and not be blank unless carrying a verdict; a
551 // comment on a line must name a line of the current diff of the request (see552 // comment on a line must name a line of the current diff of the request (see
552 // domain.Diff.HasLine), else the call fails with domain.ErrInvalid. A request553 // domain.Diff.HasLine), else the call fails with domain.ErrInvalid. A request
553 // that is not open gives domain.ErrPreconditionFailed, and one that already554 // that is not open gives domain.ErrPreconditionFailed, and one that already
@@ -566,21 +567,44 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
566 switch {567 switch {
567 case err != nil:568 case err != nil:
568 v.AddErr("text", err)569 v.AddErr("text", err)
569- case text.IsBlank():570+ case text.IsBlank() && params.Verdict == domain.VerdictNone:
570 v.Add("text", "is required")571 v.Add("text", "is required")
571 }572 }
572 general := params.Path == ""573 general := params.Path == ""
573 switch {574 switch {
574- case general && (params.Line != 0 || params.Side != domain.SideNone):575+ case general && (params.Line != 0 || params.Side != domain.SideNone || params.StartLine != 0):
575 v.Add("path", "is required with line and side")576 v.Add("path", "is required with line and side")
576 case !general && params.Line < 1:577 case !general && params.Line < 1:
577 v.Add("line", "must be 1 or more with a path")578 v.Add("line", "must be 1 or more with a path")
578 case !general && params.Side != domain.SideOld && params.Side != domain.SideNew:579 case !general && params.Side != domain.SideOld && params.Side != domain.SideNew:
579 v.Add("side", "is required with a path")580 v.Add("side", "is required with a path")
580 }581 }
582+ if params.StartLine != 0 && (params.StartLine < 1 || params.StartLine >= params.Line) {
583+ v.Add("start_line", "must be 1 or more and less than line")
584+ }
585+ if params.Verdict > domain.VerdictChangesRequested {
586+ v.Add("verdict", "is unknown")
587+ }
588+ if params.ReplyTo != 0 {
589+ if params.Path != "" || params.Line != 0 || params.StartLine != 0 || params.Side != domain.SideNone || params.Verdict != domain.VerdictNone {
590+ v.Add("reply_to", "a reply cannot specify an anchor or verdict")
591+ }
592+ }
581 if err := v.Err(); err != nil {593 if err := v.Err(); err != nil {
582 return domain.Comment{}, err594 return domain.Comment{}, err
583 }595 }
596+ if params.ReplyTo != 0 {
597+ root, err := m.replyRoot(ctx, name, number, params.ReplyTo)
598+ if err != nil {
599+ return domain.Comment{}, err
600+ }
601+ params.Path, params.Line, params.StartLine, params.Side = root.Path, root.Line, root.StartLine, root.Side
602+ }
603+ if params.Verdict != domain.VerdictNone {
604+ if err := scopeAccess(p, repo, domain.ScopeMRReview); err != nil {
605+ return domain.Comment{}, err
606+ }
607+ }
584 if !mr.IsOpen() {608 if !mr.IsOpen() {
585 return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)609 return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
586 }610 }
@@ -588,7 +612,7 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
588 if mr, err = m.withHead(ctx, mr); err != nil {612 if mr, err = m.withHead(ctx, mr); err != nil {
589 return domain.Comment{}, err613 return domain.Comment{}, err
590 }614 }
591- if !general {615+ if !general && params.ReplyTo == 0 {
592 if err := m.checkLine(ctx, mr, params); err != nil {616 if err := m.checkLine(ctx, mr, params); err != nil {
593 return domain.Comment{}, err617 return domain.Comment{}, err
594 }618 }
@@ -600,7 +624,8 @@ 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)624 return domain.Comment{}, fmt.Errorf("%w: !%d already has %d comments", domain.ErrResourceExhausted, number, MaxCommentsPerMergeRequest)
601 }625 }
602 c = domain.Comment{626 c = domain.Comment{
603- ID: mr.CommentSeq + 1, Author: authorOf(p), Path: params.Path, Line: params.Line, Side: params.Side,627+ ID: mr.CommentSeq + 1, Author: domain.AuthorName(p), Path: params.Path, Line: params.Line, Side: params.Side,
628+ ReplyTo: params.ReplyTo, StartLine: params.StartLine, Verdict: params.Verdict, Resolved: params.Verdict == domain.VerdictApprove,
604 CommitID: mr.HeadID, Text: text, CreatedAt: m.clock.Now().UTC(),629 CommitID: mr.HeadID, Text: text, CreatedAt: m.clock.Now().UTC(),
605 }630 }
606 mr.CommentSeq, mr.UpdatedAt = c.ID, c.CreatedAt631 mr.CommentSeq, mr.UpdatedAt = c.ID, c.CreatedAt
@@ -616,6 +641,12 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
616 if !fresh.IsOpen() {641 if !fresh.IsOpen() {
617 return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, fresh.State)642 return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, fresh.State)
618 }643 }
644+ if params.ReplyTo != 0 {
645+ // The root may have been deleted by the write that got in between.
646+ if _, rerr := m.replyRoot(ctx, name, number, params.ReplyTo); rerr != nil {
647+ return domain.Comment{}, rerr
648+ }
649+ }
619 fresh.HeadID = mr.HeadID650 fresh.HeadID = mr.HeadID
620 mr = fresh651 mr = fresh
621 }652 }
@@ -624,7 +655,7 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
624 }655 }
625 log := zerowrap.FromCtx(ctx)656 log := zerowrap.FromCtx(ctx)
626 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).657 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
627- Bool("on_line", !general).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment added")658+ Int64("reply_to", c.ReplyTo).Str("verdict", c.Verdict.String()).Bool("on_line", !general).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment added")
628 m.bus.Publish(ctx, domain.CommentAdded{659 m.bus.Publish(ctx, domain.CommentAdded{
629 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,660 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
630 })661 })
@@ -667,6 +698,9 @@ func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, p
667 if idx < 0 || !diff.HasLine(params.Path, params.Side, params.Line) {698 if idx < 0 || !diff.HasLine(params.Path, params.Side, params.Line) {
668 v.Add("path", fmt.Sprintf("%s:%d on the %s side is not a line of the diff", params.Path, params.Line, params.Side))699 v.Add("path", fmt.Sprintf("%s:%d on the %s side is not a line of the diff", params.Path, params.Line, params.Side))
669 }700 }
701+ if params.StartLine != 0 && !diff.HasLine(params.Path, params.Side, params.StartLine) {
702+ v.Add("start_line", "is not a line of the diff")
703+ }
670 return v.Err()704 return v.Err()
671 }705 }
672 706
@@ -683,7 +717,8 @@ func diffFileName(f domain.FileDiff) string {
683 // not open domain.ErrPreconditionFailed. Setting the state the comment already717 // not open domain.ErrPreconditionFailed. Setting the state the comment already
684 // has succeeds and writes nothing.718 // has succeeds and writes nothing.
685 func (m *MergeRequests) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, resolved bool) (domain.Comment, error) {719 func (m *MergeRequests) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, resolved bool) (domain.Comment, error) {
686- if _, err := m.writable(ctx, p, name, domain.ScopeMRWrite); err != nil {720+ repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
721+ if err != nil {
687 return domain.Comment{}, err722 return domain.Comment{}, err
688 }723 }
689 for range maxWriteRetries {724 for range maxWriteRetries {
@@ -698,6 +733,11 @@ func (m *MergeRequests) ResolveComment(ctx context.Context, p domain.Principal,
698 if !mr.IsOpen() {733 if !mr.IsOpen() {
699 return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)734 return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
700 }735 }
736+ if c.IsReply() {
737+ var v domain.Violations
738+ v.Add("comment_id", fmt.Sprintf("resolve the thread on its root ^%d", c.ReplyTo))
739+ return domain.Comment{}, v.Err()
740+ }
701 if c.Resolved == resolved {741 if c.Resolved == resolved {
702 return c, nil742 return c, nil
703 }743 }
@@ -710,6 +750,7 @@ func (m *MergeRequests) ResolveComment(ctx context.Context, p domain.Principal,
710 if err != nil {750 if err != nil {
711 return domain.Comment{}, fmt.Errorf("resolve comment %d of !%d in %q: %w", commentID, number, name, err)751 return domain.Comment{}, fmt.Errorf("resolve comment %d of !%d in %q: %w", commentID, number, name, err)
712 }752 }
753+ m.bus.Publish(ctx, domain.CommentResolved{Repo: name, Number: number, Comment: c, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
713 return c, nil754 return c, nil
714 }755 }
715 return domain.Comment{}, fmt.Errorf("resolve comment %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)756 return domain.Comment{}, fmt.Errorf("resolve comment %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)
@@ -1090,7 +1131,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 a1131 // does, under the merge lock (domain.CIRefTip). The run shows the owner, as a
1091 // merge's push does on GitHub.1132 // 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}},1133 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 {1134+ domain.AuthorName(domain.Owner()), domain.PusherOf(p), domain.TrustedActor(p)); err != nil {
1094 return domain.ObjectID{}, err1135 return domain.ObjectID{}, err
1095 }1136 }
1096 err := m.git.UpdateBranch(ctx, mr.Repo, mr.Target, tip, newTip)1137 err := m.git.UpdateBranch(ctx, mr.Repo, mr.Target, tip, newTip)
@@ -1129,3 +1170,90 @@ func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, nu
1129 Msg("the target branch moved but the merge request could not be marked merged")1170 Msg("the target branch moved but the merge request could not be marked merged")
1130 return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, domain.ErrConflict)1171 return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, domain.ErrConflict)
1131 }1172 }
1173+
1174+// EditComment changes only the author's text, preserving the immutable verdict.
1175+func (m *MergeRequests) EditComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, text string) (domain.Comment, error) {
1176+ return m.changeComment(ctx, p, name, number, commentID, text, false)
1177+}
1178+
1179+// DeleteComment removes text but retains the anchor, ID and verdict. Repeated deletion is a no-op.
1180+func (m *MergeRequests) DeleteComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64) (domain.Comment, error) {
1181+ return m.changeComment(ctx, p, name, number, commentID, "", true)
1182+}
1183+
1184+func (m *MergeRequests) changeComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, text string, remove bool) (domain.Comment, error) {
1185+ repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
1186+ if err != nil {
1187+ return domain.Comment{}, err
1188+ }
1189+ for range maxWriteRetries {
1190+ mr, err := m.store.Get(ctx, name, number)
1191+ if err != nil {
1192+ return domain.Comment{}, err
1193+ }
1194+ c, err := m.store.GetComment(ctx, name, number, commentID)
1195+ if err != nil {
1196+ return domain.Comment{}, err
1197+ }
1198+ if !mr.IsOpen() {
1199+ return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
1200+ }
1201+ if domain.AuthorName(p) != c.Author {
1202+ return domain.Comment{}, fmt.Errorf("%w: only the author of a note can edit or delete it", domain.ErrForbidden)
1203+ }
1204+ if c.Deleted {
1205+ if remove {
1206+ return c, nil
1207+ }
1208+ return domain.Comment{}, fmt.Errorf("%w: the note was deleted", domain.ErrPreconditionFailed)
1209+ }
1210+ parsed, err := domain.ParsePlainText(text)
1211+ var v domain.Violations
1212+ if err != nil {
1213+ v.AddErr("text", err)
1214+ } else if !remove && parsed.IsBlank() && c.Verdict == domain.VerdictNone {
1215+ v.Add("text", "is required")
1216+ }
1217+ if err := v.Err(); err != nil {
1218+ return domain.Comment{}, err
1219+ }
1220+ if !remove && parsed == c.Text {
1221+ return c, nil
1222+ }
1223+ c.Text, c.Deleted, c.EditedAt = parsed, remove, m.clock.Now().UTC()
1224+ mr.UpdatedAt = c.EditedAt
1225+ if err := m.store.SaveComment(ctx, mr, c); errors.Is(err, domain.ErrConflict) {
1226+ continue
1227+ } else if err != nil {
1228+ return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, err)
1229+ }
1230+ log := zerowrap.FromCtx(ctx)
1231+ log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).Bool("deleted", remove).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment changed")
1232+ if remove {
1233+ m.bus.Publish(ctx, domain.CommentDeleted{Repo: name, Number: number, Comment: c, At: c.EditedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1234+ } else {
1235+ m.bus.Publish(ctx, domain.CommentEdited{Repo: name, Number: number, Comment: c, At: c.EditedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1236+ }
1237+ return c, nil
1238+ }
1239+ return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)
1240+}
1241+
1242+// replyRoot returns the note a reply targets, or a violation when it is
1243+// missing, itself a reply, or deleted.
1244+func (m *MergeRequests) replyRoot(ctx context.Context, name domain.RepoName, number, id int64) (domain.Comment, error) {
1245+ root, err := m.store.GetComment(ctx, name, number, id)
1246+ var v domain.Violations
1247+ switch {
1248+ case errors.Is(err, domain.ErrNotFound):
1249+ v.Add("reply_to", fmt.Sprintf("no note ^%d", id))
1250+ case err != nil:
1251+ return domain.Comment{}, err
1252+ case root.IsReply() || root.Deleted:
1253+ v.Add("reply_to", "must name a non-deleted root note")
1254+ }
1255+ if err := v.Err(); err != nil {
1256+ return domain.Comment{}, err
1257+ }
1258+ return root, nil
1259+}
M internal/usecase/mergerequests_integration_test.go +107 -1
@@ -689,7 +689,10 @@ func TestResolveComment(t *testing.T) {
689689 _, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, 0, true)
690690 assert.ErrorIs(t, err, domain.ErrNotFound)
691691
692- assert.Empty(t, r.drain(), "a resolved comment has no event")
692+ events := r.drain()
693+ require.Len(t, events, 2, "only state changes publish resolution events")
694+ assert.True(t, events[0].(domain.CommentResolved).Comment.Resolved)
695+ assert.False(t, events[1].(domain.CommentResolved).Comment.Resolved)
693696 }
694697
695698 func TestEventsOfAMergeRequest(t *testing.T) {
@@ -1342,3 +1345,106 @@ func TestCommentsPerMergeRequestAreBounded(t *testing.T) {
13421345 _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, other.Number, ports.AddCommentParams{Text: "fine"})
13431346 require.NoError(t, err)
13441347 }
1348+
1349+func TestNoteThreadsRangesAndVerdicts(t *testing.T) {
1350+ t.Run("start outside diff", func(t *testing.T) {
1351+ r := newMRRig(t)
1352+ r.seed()
1353+ before := strings.Repeat("unchanged\n", 30)
1354+ r.commit(author, "long.txt", before, "long base")
1355+ r.push("main")
1356+ r.sh("checkout", "-q", "-B", "range", "main")
1357+ r.commit(author, "long.txt", before+"changed\n", "late hunk")
1358+ r.push("range")
1359+ mr := r.open("range")
1360+ _, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Path: "long.txt", Line: 31, Side: domain.SideNew, Text: "valid"})
1361+ require.NoError(t, err)
1362+ _, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Path: "long.txt", StartLine: 1, Line: 31, Side: domain.SideNew, Text: "invalid range"})
1363+ require.ErrorIs(t, err, domain.ErrInvalid)
1364+ assert.Contains(t, fieldsOf(t, err), "start_line")
1365+ })
1366+ r := newMRRig(t)
1367+ r.seed()
1368+ head := r.feature("feat")
1369+ mr := r.open("feat")
1370+ add := func(p domain.Principal, params ports.AddCommentParams) (domain.Comment, error) {
1371+ return r.mrs.AddComment(r.ctx, p, r.repo, mr.Number, params)
1372+ }
1373+ rootNote, err := add(root, ports.AddCommentParams{Path: "a.txt", StartLine: 1, Line: 3, Side: domain.SideNew, Text: "range"})
1374+ require.NoError(t, err)
1375+ reply, err := add(mrW, ports.AddCommentParams{ReplyTo: rootNote.ID, Text: "reply"})
1376+ require.NoError(t, err)
1377+ assert.Equal(t, rootNote.Path, reply.Path)
1378+ assert.Equal(t, rootNote.StartLine, reply.StartLine)
1379+ assert.Equal(t, rootNote.Line, reply.Line)
1380+ assert.Equal(t, rootNote.Side, reply.Side)
1381+ assert.Equal(t, head, reply.CommitID)
1382+ assert.False(t, reply.Resolved)
1383+ for _, params := range []ports.AddCommentParams{
1384+ {ReplyTo: reply.ID, Text: "x"}, {ReplyTo: 999, Text: "x"}, {ReplyTo: rootNote.ID, Path: "a.txt", Text: "x"}, {ReplyTo: rootNote.ID, Line: 1, Text: "x"}, {ReplyTo: rootNote.ID, Side: domain.SideNew, Text: "x"}, {ReplyTo: rootNote.ID, StartLine: 1, Text: "x"}, {ReplyTo: rootNote.ID, Verdict: domain.VerdictApprove},
1385+ {StartLine: 1, Text: "x"}, {Path: "a.txt", StartLine: 3, Line: 3, Side: domain.SideNew, Text: "x"}, {Path: "a.txt", StartLine: -1, Line: 3, Side: domain.SideNew, Text: "x"}, {Path: "a.txt", StartLine: 1, Line: 99, Side: domain.SideNew, Text: "x"},
1386+ } {
1387+ _, err := add(root, params)
1388+ assert.ErrorIs(t, err, domain.ErrInvalid, "%+v", params)
1389+ }
1390+ _, err = r.mrs.ResolveComment(r.ctx, root, r.repo, mr.Number, reply.ID, true)
1391+ assert.ErrorIs(t, err, domain.ErrInvalid)
1392+ assert.ErrorContains(t, err, fmt.Sprintf("root ^%d", rootNote.ID))
1393+ _, err = add(mrW, ports.AddCommentParams{Verdict: domain.VerdictApprove})
1394+ assert.ErrorIs(t, err, domain.ErrForbidden)
1395+ assert.ErrorContains(t, err, "this needs the mr:review scope")
1396+ reviewer := domain.TokenPrincipal(domain.Token{Label: "reviewer", Scopes: []domain.Scope{domain.ScopeMRWrite, domain.ScopeMRReview}})
1397+ approve, err := add(reviewer, ports.AddCommentParams{Verdict: domain.VerdictApprove})
1398+ require.NoError(t, err)
1399+ assert.True(t, approve.Resolved)
1400+ changes, err := add(root, ports.AddCommentParams{Verdict: domain.VerdictChangesRequested})
1401+ require.NoError(t, err)
1402+ assert.False(t, changes.Resolved)
1403+ _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, approve.ID, "no")
1404+ assert.ErrorIs(t, err, domain.ErrForbidden)
1405+ _, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, approve.ID)
1406+ assert.ErrorIs(t, err, domain.ErrForbidden)
1407+ r.drain()
1408+ edited, err := r.mrs.EditComment(r.ctx, reviewer, r.repo, mr.Number, approve.ID, "updated")
1409+ require.NoError(t, err)
1410+ assert.False(t, edited.EditedAt.IsZero())
1411+ assert.Equal(t, approve.Verdict, edited.Verdict)
1412+ _, err = r.mrs.EditComment(r.ctx, reviewer, r.repo, mr.Number, approve.ID, "updated")
1413+ require.NoError(t, err)
1414+ events := r.drain()
1415+ require.Len(t, events, 1)
1416+ assert.IsType(t, domain.CommentEdited{}, events[0])
1417+ deleted, err := r.mrs.DeleteComment(r.ctx, reviewer, r.repo, mr.Number, approve.ID)
1418+ require.NoError(t, err)
1419+ assert.True(t, deleted.Deleted)
1420+ assert.Equal(t, "", deleted.Text.String())
1421+ assert.Equal(t, approve.Verdict, deleted.Verdict)
1422+ again, err := r.mrs.DeleteComment(r.ctx, reviewer, r.repo, mr.Number, approve.ID)
1423+ require.NoError(t, err)
1424+ deleted.Version = again.Version // SaveComment assigns the new version on the next read.
1425+ assert.Equal(t, deleted, again)
1426+ events = r.drain()
1427+ require.Len(t, events, 1)
1428+ assert.IsType(t, domain.CommentDeleted{}, events[0])
1429+ _, err = r.mrs.EditComment(r.ctx, reviewer, r.repo, mr.Number, approve.ID, "x")
1430+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
1431+ _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, rootNote.ID, "")
1432+ assert.ErrorIs(t, err, domain.ErrInvalid)
1433+ _, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, rootNote.ID)
1434+ require.NoError(t, err)
1435+ _, err = add(root, ports.AddCommentParams{ReplyTo: rootNote.ID, Text: "x"})
1436+ assert.ErrorIs(t, err, domain.ErrInvalid)
1437+ r.commit(author, "a.txt", "changed\n2\nthree\n", "advance")
1438+ r.push("feat")
1439+ detail, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{})
1440+ require.NoError(t, err)
1441+ require.Len(t, detail.Verdicts, 2)
1442+ assert.True(t, detail.Verdicts[0].Stale)
1443+ assert.False(t, detail.Verdicts[1].Stale)
1444+ _, err = r.mrs.Close(r.ctx, root, r.repo, mr.Number)
1445+ require.NoError(t, err)
1446+ _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, changes.ID, "x")
1447+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
1448+ _, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, changes.ID)
1449+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
1450+}
@@ -689,7 +689,10 @@ func TestResolveComment(t *testing.T) {
689 _, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, 0, true)689 _, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, 0, true)
690 assert.ErrorIs(t, err, domain.ErrNotFound)690 assert.ErrorIs(t, err, domain.ErrNotFound)
691 691
692- assert.Empty(t, r.drain(), "a resolved comment has no event")692+ events := r.drain()
693+ require.Len(t, events, 2, "only state changes publish resolution events")
694+ assert.True(t, events[0].(domain.CommentResolved).Comment.Resolved)
695+ assert.False(t, events[1].(domain.CommentResolved).Comment.Resolved)
693 }696 }
694 697
695 func TestEventsOfAMergeRequest(t *testing.T) {698 func TestEventsOfAMergeRequest(t *testing.T) {
@@ -1342,3 +1345,106 @@ func TestCommentsPerMergeRequestAreBounded(t *testing.T) {
1342 _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, other.Number, ports.AddCommentParams{Text: "fine"})1345 _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, other.Number, ports.AddCommentParams{Text: "fine"})
1343 require.NoError(t, err)1346 require.NoError(t, err)
1344 }1347 }
1348+
1349+func TestNoteThreadsRangesAndVerdicts(t *testing.T) {
1350+ t.Run("start outside diff", func(t *testing.T) {
1351+ r := newMRRig(t)
1352+ r.seed()
1353+ before := strings.Repeat("unchanged\n", 30)
1354+ r.commit(author, "long.txt", before, "long base")
1355+ r.push("main")
1356+ r.sh("checkout", "-q", "-B", "range", "main")
1357+ r.commit(author, "long.txt", before+"changed\n", "late hunk")
1358+ r.push("range")
1359+ mr := r.open("range")
1360+ _, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Path: "long.txt", Line: 31, Side: domain.SideNew, Text: "valid"})
1361+ require.NoError(t, err)
1362+ _, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Path: "long.txt", StartLine: 1, Line: 31, Side: domain.SideNew, Text: "invalid range"})
1363+ require.ErrorIs(t, err, domain.ErrInvalid)
1364+ assert.Contains(t, fieldsOf(t, err), "start_line")
1365+ })
1366+ r := newMRRig(t)
1367+ r.seed()
1368+ head := r.feature("feat")
1369+ mr := r.open("feat")
1370+ add := func(p domain.Principal, params ports.AddCommentParams) (domain.Comment, error) {
1371+ return r.mrs.AddComment(r.ctx, p, r.repo, mr.Number, params)
1372+ }
1373+ rootNote, err := add(root, ports.AddCommentParams{Path: "a.txt", StartLine: 1, Line: 3, Side: domain.SideNew, Text: "range"})
1374+ require.NoError(t, err)
1375+ reply, err := add(mrW, ports.AddCommentParams{ReplyTo: rootNote.ID, Text: "reply"})
1376+ require.NoError(t, err)
1377+ assert.Equal(t, rootNote.Path, reply.Path)
1378+ assert.Equal(t, rootNote.StartLine, reply.StartLine)
1379+ assert.Equal(t, rootNote.Line, reply.Line)
1380+ assert.Equal(t, rootNote.Side, reply.Side)
1381+ assert.Equal(t, head, reply.CommitID)
1382+ assert.False(t, reply.Resolved)
1383+ for _, params := range []ports.AddCommentParams{
1384+ {ReplyTo: reply.ID, Text: "x"}, {ReplyTo: 999, Text: "x"}, {ReplyTo: rootNote.ID, Path: "a.txt", Text: "x"}, {ReplyTo: rootNote.ID, Line: 1, Text: "x"}, {ReplyTo: rootNote.ID, Side: domain.SideNew, Text: "x"}, {ReplyTo: rootNote.ID, StartLine: 1, Text: "x"}, {ReplyTo: rootNote.ID, Verdict: domain.VerdictApprove},
1385+ {StartLine: 1, Text: "x"}, {Path: "a.txt", StartLine: 3, Line: 3, Side: domain.SideNew, Text: "x"}, {Path: "a.txt", StartLine: -1, Line: 3, Side: domain.SideNew, Text: "x"}, {Path: "a.txt", StartLine: 1, Line: 99, Side: domain.SideNew, Text: "x"},
1386+ } {
1387+ _, err := add(root, params)
1388+ assert.ErrorIs(t, err, domain.ErrInvalid, "%+v", params)
1389+ }
1390+ _, err = r.mrs.ResolveComment(r.ctx, root, r.repo, mr.Number, reply.ID, true)
1391+ assert.ErrorIs(t, err, domain.ErrInvalid)
1392+ assert.ErrorContains(t, err, fmt.Sprintf("root ^%d", rootNote.ID))
1393+ _, err = add(mrW, ports.AddCommentParams{Verdict: domain.VerdictApprove})
1394+ assert.ErrorIs(t, err, domain.ErrForbidden)
1395+ assert.ErrorContains(t, err, "this needs the mr:review scope")
1396+ reviewer := domain.TokenPrincipal(domain.Token{Label: "reviewer", Scopes: []domain.Scope{domain.ScopeMRWrite, domain.ScopeMRReview}})
1397+ approve, err := add(reviewer, ports.AddCommentParams{Verdict: domain.VerdictApprove})
1398+ require.NoError(t, err)
1399+ assert.True(t, approve.Resolved)
1400+ changes, err := add(root, ports.AddCommentParams{Verdict: domain.VerdictChangesRequested})
1401+ require.NoError(t, err)
1402+ assert.False(t, changes.Resolved)
1403+ _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, approve.ID, "no")
1404+ assert.ErrorIs(t, err, domain.ErrForbidden)
1405+ _, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, approve.ID)
1406+ assert.ErrorIs(t, err, domain.ErrForbidden)
1407+ r.drain()
1408+ edited, err := r.mrs.EditComment(r.ctx, reviewer, r.repo, mr.Number, approve.ID, "updated")
1409+ require.NoError(t, err)
1410+ assert.False(t, edited.EditedAt.IsZero())
1411+ assert.Equal(t, approve.Verdict, edited.Verdict)
1412+ _, err = r.mrs.EditComment(r.ctx, reviewer, r.repo, mr.Number, approve.ID, "updated")
1413+ require.NoError(t, err)
1414+ events := r.drain()
1415+ require.Len(t, events, 1)
1416+ assert.IsType(t, domain.CommentEdited{}, events[0])
1417+ deleted, err := r.mrs.DeleteComment(r.ctx, reviewer, r.repo, mr.Number, approve.ID)
1418+ require.NoError(t, err)
1419+ assert.True(t, deleted.Deleted)
1420+ assert.Equal(t, "", deleted.Text.String())
1421+ assert.Equal(t, approve.Verdict, deleted.Verdict)
1422+ again, err := r.mrs.DeleteComment(r.ctx, reviewer, r.repo, mr.Number, approve.ID)
1423+ require.NoError(t, err)
1424+ deleted.Version = again.Version // SaveComment assigns the new version on the next read.
1425+ assert.Equal(t, deleted, again)
1426+ events = r.drain()
1427+ require.Len(t, events, 1)
1428+ assert.IsType(t, domain.CommentDeleted{}, events[0])
1429+ _, err = r.mrs.EditComment(r.ctx, reviewer, r.repo, mr.Number, approve.ID, "x")
1430+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
1431+ _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, rootNote.ID, "")
1432+ assert.ErrorIs(t, err, domain.ErrInvalid)
1433+ _, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, rootNote.ID)
1434+ require.NoError(t, err)
1435+ _, err = add(root, ports.AddCommentParams{ReplyTo: rootNote.ID, Text: "x"})
1436+ assert.ErrorIs(t, err, domain.ErrInvalid)
1437+ r.commit(author, "a.txt", "changed\n2\nthree\n", "advance")
1438+ r.push("feat")
1439+ detail, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{})
1440+ require.NoError(t, err)
1441+ require.Len(t, detail.Verdicts, 2)
1442+ assert.True(t, detail.Verdicts[0].Stale)
1443+ assert.False(t, detail.Verdicts[1].Stale)
1444+ _, err = r.mrs.Close(r.ctx, root, r.repo, mr.Number)
1445+ require.NoError(t, err)
1446+ _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, changes.ID, "x")
1447+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
1448+ _, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, changes.ID)
1449+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
1450+}
M internal/usecase/mergerequests_test.go +116 -0
@@ -6,6 +6,7 @@ import (
66 "context"
77 "errors"
88 "testing"
9+ "time"
910
1011 "github.com/stretchr/testify/assert"
1112 "github.com/stretchr/testify/mock"
@@ -117,3 +118,118 @@ func TestMergeReturnsTheFailureToReadTheSettings(t *testing.T) {
117118 })
118119 }
119120 }
121+
122+func TestNoteMutationRetries(t *testing.T) {
123+ for _, action := range []string{"edit", "delete", "resolve"} {
124+ t.Run(action, func(t *testing.T) {
125+ u := newMRUnit(t)
126+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
127+ mr := domain.MergeRequest{Repo: u.repo.Name, Number: 1, State: domain.MRStateOpen}
128+ text, err := domain.ParsePlainText("before")
129+ require.NoError(t, err)
130+ c := domain.Comment{ID: 1, Author: "root", Text: text}
131+ freshMR := mr
132+ freshMR.Version = 2
133+ fresh := c
134+ fresh.Version = 3
135+ fresh.CreatedAt = now.Add(-time.Hour)
136+ if action != "resolve" {
137+ fresh.Resolved = true
138+ }
139+ want := fresh
140+ switch action {
141+ case "edit":
142+ want.Text, err = domain.ParsePlainText("after")
143+ require.NoError(t, err)
144+ want.EditedAt = now.UTC()
145+ case "delete":
146+ want.Text, err = domain.ParsePlainText("")
147+ require.NoError(t, err)
148+ want.Deleted = true
149+ want.EditedAt = now.UTC()
150+ case "resolve":
151+ want.Resolved = true
152+ }
153+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(mr, nil).Once()
154+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(freshMR, nil).Once()
155+ u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(c, nil).Once()
156+ u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(fresh, nil).Once()
157+ u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
158+ u.store.EXPECT().SaveComment(mock.Anything, mock.MatchedBy(func(saved domain.MergeRequest) bool {
159+ return saved.Version == freshMR.Version
160+ }), mock.MatchedBy(func(saved domain.Comment) bool { return saved == want })).Return(nil).Once()
161+ bus := mocks.NewEventBus(t)
162+ bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(e domain.Event) bool {
163+ switch event := e.(type) {
164+ case domain.CommentEdited:
165+ return action == "edit" && event.Comment == want
166+ case domain.CommentDeleted:
167+ return action == "delete" && event.Comment == want
168+ case domain.CommentResolved:
169+ return action == "resolve" && event.Comment == want
170+ }
171+ return false
172+ })).Return().Once()
173+ clock := mocks.NewClock(t)
174+ clock.EXPECT().Now().Return(now)
175+ svc := usecase.NewMergeRequests(usecase.MergeRequestsConfig{Meta: u.meta, Store: u.store, Bus: bus, Clock: clock})
176+ var got domain.Comment
177+ switch action {
178+ case "edit":
179+ got, err = svc.EditComment(context.Background(), domain.Root(), u.repo.Name, 1, 1, "after")
180+ case "delete":
181+ got, err = svc.DeleteComment(context.Background(), domain.Root(), u.repo.Name, 1, 1)
182+ case "resolve":
183+ got, err = svc.ResolveComment(context.Background(), domain.Root(), u.repo.Name, 1, 1, true)
184+ }
185+ require.NoError(t, err)
186+ assert.Equal(t, want, got)
187+ })
188+ }
189+}
190+
191+// A reply whose root is deleted while the save is retried is rejected.
192+func TestAddNoteRechecksReplyRootOnConflict(t *testing.T) {
193+ u := newMRUnit(t)
194+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
195+ u.git.EXPECT().BranchHead(mock.Anything, mock.Anything, mock.Anything).Return(domain.ObjectID{}, domain.ErrNotFound)
196+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{State: domain.MRStateOpen}, nil).Twice()
197+ u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(domain.Comment{ID: 1}, nil).Once()
198+ u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(domain.Comment{ID: 1, Deleted: true}, nil).Once()
199+ u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
200+ _, err := u.mrs.AddComment(context.Background(), domain.Root(), u.repo.Name, 1, ports.AddCommentParams{ReplyTo: 1, Text: "x"})
201+ require.ErrorIs(t, err, domain.ErrInvalid)
202+}
203+
204+func TestAddNoteValidation(t *testing.T) {
205+ for _, tc := range []struct {
206+ name string
207+ params ports.AddCommentParams
208+ target domain.Comment
209+ missing bool
210+ }{
211+ {name: "range without path", params: ports.AddCommentParams{StartLine: 1, Text: "x"}},
212+ {name: "equal ends", params: ports.AddCommentParams{Path: "a", StartLine: 3, Line: 3, Side: domain.SideNew, Text: "x"}},
213+ {name: "reversed ends", params: ports.AddCommentParams{Path: "a", StartLine: 4, Line: 3, Side: domain.SideNew, Text: "x"}},
214+ {name: "reply anchor", params: ports.AddCommentParams{ReplyTo: 1, Path: "a", Line: 1, Side: domain.SideNew, Text: "x"}},
215+ {name: "reply verdict", params: ports.AddCommentParams{ReplyTo: 1, Verdict: domain.VerdictApprove}},
216+ {name: "reply to reply", params: ports.AddCommentParams{ReplyTo: 1, Text: "x"}, target: domain.Comment{ReplyTo: 2}},
217+ {name: "deleted root", params: ports.AddCommentParams{ReplyTo: 1, Text: "x"}, target: domain.Comment{Deleted: true}},
218+ {name: "missing root", params: ports.AddCommentParams{ReplyTo: 1, Text: "x"}, missing: true},
219+ } {
220+ t.Run(tc.name, func(t *testing.T) {
221+ u := newMRUnit(t)
222+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
223+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{State: domain.MRStateOpen}, nil)
224+ if tc.name == "reply to reply" || tc.name == "deleted root" || tc.missing {
225+ var err error
226+ if tc.missing {
227+ err = domain.ErrNotFound
228+ }
229+ u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(tc.target, err)
230+ }
231+ _, err := u.mrs.AddComment(context.Background(), domain.Root(), u.repo.Name, 1, tc.params)
232+ require.ErrorIs(t, err, domain.ErrInvalid)
233+ })
234+ }
235+}
@@ -6,6 +6,7 @@ import (
6 "context"6 "context"
7 "errors"7 "errors"
8 "testing"8 "testing"
9+ "time"
9 10
10 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/mock"12 "github.com/stretchr/testify/mock"
@@ -117,3 +118,118 @@ func TestMergeReturnsTheFailureToReadTheSettings(t *testing.T) {
117 })118 })
118 }119 }
119 }120 }
121+
122+func TestNoteMutationRetries(t *testing.T) {
123+ for _, action := range []string{"edit", "delete", "resolve"} {
124+ t.Run(action, func(t *testing.T) {
125+ u := newMRUnit(t)
126+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
127+ mr := domain.MergeRequest{Repo: u.repo.Name, Number: 1, State: domain.MRStateOpen}
128+ text, err := domain.ParsePlainText("before")
129+ require.NoError(t, err)
130+ c := domain.Comment{ID: 1, Author: "root", Text: text}
131+ freshMR := mr
132+ freshMR.Version = 2
133+ fresh := c
134+ fresh.Version = 3
135+ fresh.CreatedAt = now.Add(-time.Hour)
136+ if action != "resolve" {
137+ fresh.Resolved = true
138+ }
139+ want := fresh
140+ switch action {
141+ case "edit":
142+ want.Text, err = domain.ParsePlainText("after")
143+ require.NoError(t, err)
144+ want.EditedAt = now.UTC()
145+ case "delete":
146+ want.Text, err = domain.ParsePlainText("")
147+ require.NoError(t, err)
148+ want.Deleted = true
149+ want.EditedAt = now.UTC()
150+ case "resolve":
151+ want.Resolved = true
152+ }
153+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(mr, nil).Once()
154+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(freshMR, nil).Once()
155+ u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(c, nil).Once()
156+ u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(fresh, nil).Once()
157+ u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
158+ u.store.EXPECT().SaveComment(mock.Anything, mock.MatchedBy(func(saved domain.MergeRequest) bool {
159+ return saved.Version == freshMR.Version
160+ }), mock.MatchedBy(func(saved domain.Comment) bool { return saved == want })).Return(nil).Once()
161+ bus := mocks.NewEventBus(t)
162+ bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(e domain.Event) bool {
163+ switch event := e.(type) {
164+ case domain.CommentEdited:
165+ return action == "edit" && event.Comment == want
166+ case domain.CommentDeleted:
167+ return action == "delete" && event.Comment == want
168+ case domain.CommentResolved:
169+ return action == "resolve" && event.Comment == want
170+ }
171+ return false
172+ })).Return().Once()
173+ clock := mocks.NewClock(t)
174+ clock.EXPECT().Now().Return(now)
175+ svc := usecase.NewMergeRequests(usecase.MergeRequestsConfig{Meta: u.meta, Store: u.store, Bus: bus, Clock: clock})
176+ var got domain.Comment
177+ switch action {
178+ case "edit":
179+ got, err = svc.EditComment(context.Background(), domain.Root(), u.repo.Name, 1, 1, "after")
180+ case "delete":
181+ got, err = svc.DeleteComment(context.Background(), domain.Root(), u.repo.Name, 1, 1)
182+ case "resolve":
183+ got, err = svc.ResolveComment(context.Background(), domain.Root(), u.repo.Name, 1, 1, true)
184+ }
185+ require.NoError(t, err)
186+ assert.Equal(t, want, got)
187+ })
188+ }
189+}
190+
191+// A reply whose root is deleted while the save is retried is rejected.
192+func TestAddNoteRechecksReplyRootOnConflict(t *testing.T) {
193+ u := newMRUnit(t)
194+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
195+ u.git.EXPECT().BranchHead(mock.Anything, mock.Anything, mock.Anything).Return(domain.ObjectID{}, domain.ErrNotFound)
196+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{State: domain.MRStateOpen}, nil).Twice()
197+ u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(domain.Comment{ID: 1}, nil).Once()
198+ u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(domain.Comment{ID: 1, Deleted: true}, nil).Once()
199+ u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
200+ _, err := u.mrs.AddComment(context.Background(), domain.Root(), u.repo.Name, 1, ports.AddCommentParams{ReplyTo: 1, Text: "x"})
201+ require.ErrorIs(t, err, domain.ErrInvalid)
202+}
203+
204+func TestAddNoteValidation(t *testing.T) {
205+ for _, tc := range []struct {
206+ name string
207+ params ports.AddCommentParams
208+ target domain.Comment
209+ missing bool
210+ }{
211+ {name: "range without path", params: ports.AddCommentParams{StartLine: 1, Text: "x"}},
212+ {name: "equal ends", params: ports.AddCommentParams{Path: "a", StartLine: 3, Line: 3, Side: domain.SideNew, Text: "x"}},
213+ {name: "reversed ends", params: ports.AddCommentParams{Path: "a", StartLine: 4, Line: 3, Side: domain.SideNew, Text: "x"}},
214+ {name: "reply anchor", params: ports.AddCommentParams{ReplyTo: 1, Path: "a", Line: 1, Side: domain.SideNew, Text: "x"}},
215+ {name: "reply verdict", params: ports.AddCommentParams{ReplyTo: 1, Verdict: domain.VerdictApprove}},
216+ {name: "reply to reply", params: ports.AddCommentParams{ReplyTo: 1, Text: "x"}, target: domain.Comment{ReplyTo: 2}},
217+ {name: "deleted root", params: ports.AddCommentParams{ReplyTo: 1, Text: "x"}, target: domain.Comment{Deleted: true}},
218+ {name: "missing root", params: ports.AddCommentParams{ReplyTo: 1, Text: "x"}, missing: true},
219+ } {
220+ t.Run(tc.name, func(t *testing.T) {
221+ u := newMRUnit(t)
222+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
223+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{State: domain.MRStateOpen}, nil)
224+ if tc.name == "reply to reply" || tc.name == "deleted root" || tc.missing {
225+ var err error
226+ if tc.missing {
227+ err = domain.ErrNotFound
228+ }
229+ u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(tc.target, err)
230+ }
231+ _, err := u.mrs.AddComment(context.Background(), domain.Root(), u.repo.Name, 1, tc.params)
232+ require.ErrorIs(t, err, domain.ErrInvalid)
233+ })
234+ }
235+}
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 internal/usecase/sessions_test.go +0 -9
@@ -334,12 +334,3 @@ func TestSignOutEverywhereEndsSessions(t *testing.T) {
334334 _, err = f.sessions.Verify(ctx, c)
335335 assert.NoError(t, err, "a new session works")
336336 }
337-
338-// The owner signed in on the web signs what it writes as "owner", where root
339-// signs as "root" although both hold every right.
340-func TestAuthorOfOwnerSession(t *testing.T) {
341- t.Parallel()
342- assert.Equal(t, "owner", usecase.AuthorOf(domain.Owner()))
343- assert.Equal(t, "root", usecase.AuthorOf(domain.Root()))
344- assert.Equal(t, "ci", usecase.AuthorOf(domain.TokenPrincipal(domain.Token{Label: "ci", Scopes: domain.AllScopes()})))
345-}
@@ -334,12 +334,3 @@ func TestSignOutEverywhereEndsSessions(t *testing.T) {
334 _, err = f.sessions.Verify(ctx, c)334 _, err = f.sessions.Verify(ctx, c)
335 assert.NoError(t, err, "a new session works")335 assert.NoError(t, err, "a new session works")
336 }336 }
337-
338-// The owner signed in on the web signs what it writes as "owner", where root
339-// signs as "root" although both hold every right.
340-func TestAuthorOfOwnerSession(t *testing.T) {
341- t.Parallel()
342- assert.Equal(t, "owner", usecase.AuthorOf(domain.Owner()))
343- assert.Equal(t, "root", usecase.AuthorOf(domain.Root()))
344- assert.Equal(t, "ci", usecase.AuthorOf(domain.TokenPrincipal(domain.Token{Label: "ci", Scopes: domain.AllScopes()})))
345-}
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
M skills/ermite/CLI.md +11 -6
@@ -28,7 +28,7 @@ Presets add up with `--scope` (repeatable or comma-separated):
2828
2929 | Preset | Scopes |
3030 |---|---|
31-| `cli` | every scope, for the owner's daily use |
31+| `cli` | every scope (including `mr:review`), for the owner's daily use; a token created before `mr:review` existed lacks it and loses owner rights: recreate it with `--preset cli` using the root token or from a signed-in owner web session (an old token lacking `mr:review` cannot grant it), then replace the stored CLI secret and revoke the old token |
3232 | `agent` | `repo:write`, `mr:write` |
3333 | `ci` | `repo:read` |
3434
@@ -39,7 +39,8 @@ Scopes:
3939 | `repo:read` | read private repositories |
4040 | `repo:write` | push, create a repository |
4141 | `repo:admin` | delete a repository, change visibility, description, default branch, merge settings |
42-| `mr:write` | open, comment on, resolve, close, mark read |
42+| `mr:write` | open, comment on, reply, edit and delete own notes, resolve, close, mark read |
43+| `mr:review` | give a verdict (`--approve`, `--request-changes`) |
4344 | `mr:merge` | merge, and delete the merged source branch with `--delete-branch` |
4445 | `token:admin` | create, list and revoke tokens |
4546 | `ci:write`, `secrets:write`, `runner:register` | reserved for CI |
@@ -55,8 +56,10 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
5556 | `mr create <repo> --source B --title T [--target B] [--description TEXT\|-]` | `mr:write` | target defaults to the default branch; title of 120 characters max; `-` reads standard input |
5657 | `mr list <repo> [--state open\|merged\|closed\|all] [--json]` | none | default `open` |
5758 | `mr show <repo> <n> [--diff] [--since-last-read] [--mark-read] [--json]` | `mr:write` for `--mark-read` | commits, changed files and comments; `--diff` adds hunks with line numbers |
58-| `mr comment <repo> <n> -m TEXT\|- [--path P --line N --side old\|new]` | `mr:write` | the line must be in the current diff; a deleted file uses its old path |
59-| `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | doing it twice is not an error |
59+| `mr comment <repo> <n> -m TEXT\|- [--path P --line N\|A-B --side old\|new] [--reply ID] [--approve\|--request-changes]` | `mr:write`; verdicts `mr:review` | Markdown note (8000 characters max); the lines must be in the current diff on one side; a deleted file uses its old path; `--reply` takes a root note (`12`, `#12` or `^12`), inherits its anchor and cannot carry a verdict; a verdict note may have no message |
60+| `mr edit-comment <repo> <n> <comment-id> -m TEXT\|-` | `mr:write` | author only, open merge request; the verdict is unchanged |
61+| `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept |
62+| `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | on the thread root, covers its replies; doing it twice is not an error |
6063 | `mr merge <repo> <n> --head <full-id> [--squash] [--delete-branch]` | `mr:merge` | refused if the branch moved or the target is not an ancestor of the head; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |
6164 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |
6265
@@ -64,7 +67,9 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
6467 merge request web URL on its own line. With `--json`, merge request objects
6568 (create, show, list, close and merge) include `url` alongside the other fields.
6669
67-Merge request JSON: `repo`, `url` (full web URL), `number`, `title`, `description`, `source_branch`, `target_branch`, `state`, `author` (token label), `created_at`, `updated_at`, `head_id`, `merged_commit_id`, `merge_mode`. `mr show --json` adds `commits`, `diff_base_id`, `stats`, `files` (with `hunks` under `--diff`: `kind`, `old_number`, `new_number`, `text`), `comments`, `last_read_id`, `last_read_lost`.
70+Merge request JSON: `repo`, `url` (full web URL), `number`, `title`, `description`, `source_branch`, `target_branch`, `state`, `author` (token label), `created_at`, `updated_at`, `head_id`, `merged_commit_id`, `merge_mode`. `mr show --json` adds `commits`, `diff_base_id`, `stats`, `files` (with `hunks` under `--diff`: `kind`, `old_number`, `new_number`, `text`), `comments`, `verdicts`, `last_read_id`, `last_read_lost`.
71+
72+Comment JSON: `id`, `author`, `path`, `line`, `side`, `commit_id`, `text`, `resolved`, `created_at`, `reply_to` (empty for roots), `start_line` (0 for a single line), `verdict` (`""`, `approve`, `changes_requested`), `edited_at`, `deleted`. Verdict JSON (latest per author): `author`, `verdict`, `comment_id`, `commit_id`, `created_at`, `stale` (an approval given on another head; changes requested are never stale). Verdicts are informative and never block a merge. In notes, `^N` refers to note N.
6873
6974 ## Workflows
7075
@@ -86,7 +91,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the `
8691 | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` |
8792 | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) |
8893 | `merge_request_opened`, `merge_request_merged`, `merge_request_closed` | `repo`, `merge_request` |
89-| `comment_added` | `repo`, `number`, `comment` |
94+| `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) |
9095 | `gap` | none: events were lost, reload the state |
9196
9297 ## Server and web
@@ -28,7 +28,7 @@ Presets add up with `--scope` (repeatable or comma-separated):
28 28
29 | Preset | Scopes |29 | Preset | Scopes |
30 |---|---|30 |---|---|
31-| `cli` | every scope, for the owner's daily use |31+| `cli` | every scope (including `mr:review`), for the owner's daily use; a token created before `mr:review` existed lacks it and loses owner rights: recreate it with `--preset cli` using the root token or from a signed-in owner web session (an old token lacking `mr:review` cannot grant it), then replace the stored CLI secret and revoke the old token |
32 | `agent` | `repo:write`, `mr:write` |32 | `agent` | `repo:write`, `mr:write` |
33 | `ci` | `repo:read` |33 | `ci` | `repo:read` |
34 34
@@ -39,7 +39,8 @@ Scopes:
39 | `repo:read` | read private repositories |39 | `repo:read` | read private repositories |
40 | `repo:write` | push, create a repository |40 | `repo:write` | push, create a repository |
41 | `repo:admin` | delete a repository, change visibility, description, default branch, merge settings |41 | `repo:admin` | delete a repository, change visibility, description, default branch, merge settings |
42-| `mr:write` | open, comment on, resolve, close, mark read |42+| `mr:write` | open, comment on, reply, edit and delete own notes, resolve, close, mark read |
43+| `mr:review` | give a verdict (`--approve`, `--request-changes`) |
43 | `mr:merge` | merge, and delete the merged source branch with `--delete-branch` |44 | `mr:merge` | merge, and delete the merged source branch with `--delete-branch` |
44 | `token:admin` | create, list and revoke tokens |45 | `token:admin` | create, list and revoke tokens |
45 | `ci:write`, `secrets:write`, `runner:register` | reserved for CI |46 | `ci:write`, `secrets:write`, `runner:register` | reserved for CI |
@@ -55,8 +56,10 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
55 | `mr create <repo> --source B --title T [--target B] [--description TEXT\|-]` | `mr:write` | target defaults to the default branch; title of 120 characters max; `-` reads standard input |56 | `mr create <repo> --source B --title T [--target B] [--description TEXT\|-]` | `mr:write` | target defaults to the default branch; title of 120 characters max; `-` reads standard input |
56 | `mr list <repo> [--state open\|merged\|closed\|all] [--json]` | none | default `open` |57 | `mr list <repo> [--state open\|merged\|closed\|all] [--json]` | none | default `open` |
57 | `mr show <repo> <n> [--diff] [--since-last-read] [--mark-read] [--json]` | `mr:write` for `--mark-read` | commits, changed files and comments; `--diff` adds hunks with line numbers |58 | `mr show <repo> <n> [--diff] [--since-last-read] [--mark-read] [--json]` | `mr:write` for `--mark-read` | commits, changed files and comments; `--diff` adds hunks with line numbers |
58-| `mr comment <repo> <n> -m TEXT\|- [--path P --line N --side old\|new]` | `mr:write` | the line must be in the current diff; a deleted file uses its old path |59+| `mr comment <repo> <n> -m TEXT\|- [--path P --line N\|A-B --side old\|new] [--reply ID] [--approve\|--request-changes]` | `mr:write`; verdicts `mr:review` | Markdown note (8000 characters max); the lines must be in the current diff on one side; a deleted file uses its old path; `--reply` takes a root note (`12`, `#12` or `^12`), inherits its anchor and cannot carry a verdict; a verdict note may have no message |
59-| `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | doing it twice is not an error |60+| `mr edit-comment <repo> <n> <comment-id> -m TEXT\|-` | `mr:write` | author only, open merge request; the verdict is unchanged |
61+| `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept |
62+| `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | on the thread root, covers its replies; doing it twice is not an error |
60 | `mr merge <repo> <n> --head <full-id> [--squash] [--delete-branch]` | `mr:merge` | refused if the branch moved or the target is not an ancestor of the head; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |63 | `mr merge <repo> <n> --head <full-id> [--squash] [--delete-branch]` | `mr:merge` | refused if the branch moved or the target is not an ancestor of the head; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |
61 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |64 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |
62 65
@@ -64,7 +67,9 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
64 merge request web URL on its own line. With `--json`, merge request objects67 merge request web URL on its own line. With `--json`, merge request objects
65 (create, show, list, close and merge) include `url` alongside the other fields.68 (create, show, list, close and merge) include `url` alongside the other fields.
66 69
67-Merge request JSON: `repo`, `url` (full web URL), `number`, `title`, `description`, `source_branch`, `target_branch`, `state`, `author` (token label), `created_at`, `updated_at`, `head_id`, `merged_commit_id`, `merge_mode`. `mr show --json` adds `commits`, `diff_base_id`, `stats`, `files` (with `hunks` under `--diff`: `kind`, `old_number`, `new_number`, `text`), `comments`, `last_read_id`, `last_read_lost`.70+Merge request JSON: `repo`, `url` (full web URL), `number`, `title`, `description`, `source_branch`, `target_branch`, `state`, `author` (token label), `created_at`, `updated_at`, `head_id`, `merged_commit_id`, `merge_mode`. `mr show --json` adds `commits`, `diff_base_id`, `stats`, `files` (with `hunks` under `--diff`: `kind`, `old_number`, `new_number`, `text`), `comments`, `verdicts`, `last_read_id`, `last_read_lost`.
71+
72+Comment JSON: `id`, `author`, `path`, `line`, `side`, `commit_id`, `text`, `resolved`, `created_at`, `reply_to` (empty for roots), `start_line` (0 for a single line), `verdict` (`""`, `approve`, `changes_requested`), `edited_at`, `deleted`. Verdict JSON (latest per author): `author`, `verdict`, `comment_id`, `commit_id`, `created_at`, `stale` (an approval given on another head; changes requested are never stale). Verdicts are informative and never block a merge. In notes, `^N` refers to note N.
68 73
69 ## Workflows74 ## Workflows
70 75
@@ -86,7 +91,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the `
86 | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` |91 | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` |
87 | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) |92 | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) |
88 | `merge_request_opened`, `merge_request_merged`, `merge_request_closed` | `repo`, `merge_request` |93 | `merge_request_opened`, `merge_request_merged`, `merge_request_closed` | `repo`, `merge_request` |
89-| `comment_added` | `repo`, `number`, `comment` |94+| `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) |
90 | `gap` | none: events were lost, reload the state |95 | `gap` | none: events were lost, reload the state |
91 96
92 ## Server and web97 ## Server and web
M skills/ermite/SKILL.md +13 -4
@@ -35,11 +35,20 @@ git push https://git.example.com/<repo>.git main # password = token,
3535
3636 1. List: `ermite mr list <repo> --json`.
3737 2. Read: `ermite mr show <repo> <n> --diff --json`. Keep `head_id`; take line numbers from the hunks.
38-3. Comment on a line: `ermite mr comment <repo> <n> -m "text" --path <file> --line <n> --side new`. Without `--path` the comment is general. The line must appear in the diff.
39-4. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--squash` for one commit signed by the server and `--delete-branch` to delete the source branch afterwards.
38+3. Comment on a line: `ermite mr comment <repo> <n> -m "text" --path <file> --line <n> --side new`; `--line 10-14` covers a range on one side. Without `--path` the comment is general. The line must appear in the diff. Notes are Markdown; `^N` refers to note N.
39+4. End a review with a verdict (needs `mr:review`): `--approve` or `--request-changes`, alone or with a message. Verdicts are informative and cannot be edited; a new verdict replaces the previous one.
40+5. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--squash` for one commit signed by the server and `--delete-branch` to delete the source branch afterwards.
4041
4142 The merge is refused when the branch moved since `head_id` (review again) or when the target is not an ancestor of the head (rebase and push again). The repository may allow only one merge mode.
4243
44+### Act on a review
45+
46+Notes are visible as soon as they are written, so a review is not finished until its verdict. Act on a verdict, not on each note (`ermite watch --json` shows `verdict` on the `comment_added` event).
47+
48+On `changes_requested`: run `ermite mr show <repo> <n> --json`, read the open threads (roots with `resolved` false and their replies), fix the code, push, then answer each thread with `ermite mr comment <repo> <n> --reply <id> -m "Fixed in ^N"`. Leave resolving to the reviewer: do not run `mr resolve` on their threads. Edit or delete only your own notes (`mr edit-comment`, `mr delete-comment`).
49+
50+An approval is stale after a push; changes requested stay until the reviewer gives a new verdict.
51+
4352 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
4453 merge request web URL on its own line. With `--json`, merge request objects
4554 (create, show, list, close and merge) include `url` alongside the other fields.
@@ -52,11 +61,11 @@ Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new p
5261 ermite token create my-agent --preset agent --repo <repo> --expires 30d
5362 ```
5463
55-The secret is printed once. Presets: `agent` (push, open and comment on merge requests), `ci` (read), `cli` (everything). Add `--scope mr:merge` to let an agent merge. A token can never grant more than the token that creates it. Full scope list: [CLI.md](CLI.md).
64+The secret is printed once. Presets: `agent` (push, open and comment on merge requests; no verdicts), `ci` (read), `cli` (everything). Add `--scope mr:review` to let an agent give verdicts, `--scope mr:merge` to let it merge. A token can never grant more than the token that creates it. Full scope list: [CLI.md](CLI.md).
5665
5766 ## Follow events
5867
59-`ermite watch [repo] --json` prints one JSON object per event (pushes, repositories, merge requests, comments). It reconnects and resumes on its own; a `gap` event means some events were lost, so reload the state.
68+`ermite watch [repo] --json` prints one JSON object per event (pushes, repositories, merge requests, notes: added, edited, deleted, resolved). It reconnects and resumes on its own; a `gap` event means some events were lost, so reload the state.
6069
6170 ## Run a server
6271
@@ -35,11 +35,20 @@ git push https://git.example.com/<repo>.git main # password = token,
35 35
36 1. List: `ermite mr list <repo> --json`.36 1. List: `ermite mr list <repo> --json`.
37 2. Read: `ermite mr show <repo> <n> --diff --json`. Keep `head_id`; take line numbers from the hunks.37 2. Read: `ermite mr show <repo> <n> --diff --json`. Keep `head_id`; take line numbers from the hunks.
38-3. Comment on a line: `ermite mr comment <repo> <n> -m "text" --path <file> --line <n> --side new`. Without `--path` the comment is general. The line must appear in the diff.38+3. Comment on a line: `ermite mr comment <repo> <n> -m "text" --path <file> --line <n> --side new`; `--line 10-14` covers a range on one side. Without `--path` the comment is general. The line must appear in the diff. Notes are Markdown; `^N` refers to note N.
39-4. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--squash` for one commit signed by the server and `--delete-branch` to delete the source branch afterwards.39+4. End a review with a verdict (needs `mr:review`): `--approve` or `--request-changes`, alone or with a message. Verdicts are informative and cannot be edited; a new verdict replaces the previous one.
40+5. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--squash` for one commit signed by the server and `--delete-branch` to delete the source branch afterwards.
40 41
41 The merge is refused when the branch moved since `head_id` (review again) or when the target is not an ancestor of the head (rebase and push again). The repository may allow only one merge mode.42 The merge is refused when the branch moved since `head_id` (review again) or when the target is not an ancestor of the head (rebase and push again). The repository may allow only one merge mode.
42 43
44+### Act on a review
45+
46+Notes are visible as soon as they are written, so a review is not finished until its verdict. Act on a verdict, not on each note (`ermite watch --json` shows `verdict` on the `comment_added` event).
47+
48+On `changes_requested`: run `ermite mr show <repo> <n> --json`, read the open threads (roots with `resolved` false and their replies), fix the code, push, then answer each thread with `ermite mr comment <repo> <n> --reply <id> -m "Fixed in ^N"`. Leave resolving to the reviewer: do not run `mr resolve` on their threads. Edit or delete only your own notes (`mr edit-comment`, `mr delete-comment`).
49+
50+An approval is stale after a push; changes requested stay until the reviewer gives a new verdict.
51+
43 `ermite mr create` and `ermite mr close` print a confirmation followed by the full52 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
44 merge request web URL on its own line. With `--json`, merge request objects53 merge request web URL on its own line. With `--json`, merge request objects
45 (create, show, list, close and merge) include `url` alongside the other fields.54 (create, show, list, close and merge) include `url` alongside the other fields.
@@ -52,11 +61,11 @@ Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new p
52 ermite token create my-agent --preset agent --repo <repo> --expires 30d61 ermite token create my-agent --preset agent --repo <repo> --expires 30d
53 ```62 ```
54 63
55-The secret is printed once. Presets: `agent` (push, open and comment on merge requests), `ci` (read), `cli` (everything). Add `--scope mr:merge` to let an agent merge. A token can never grant more than the token that creates it. Full scope list: [CLI.md](CLI.md).64+The secret is printed once. Presets: `agent` (push, open and comment on merge requests; no verdicts), `ci` (read), `cli` (everything). Add `--scope mr:review` to let an agent give verdicts, `--scope mr:merge` to let it merge. A token can never grant more than the token that creates it. Full scope list: [CLI.md](CLI.md).
56 65
57 ## Follow events66 ## Follow events
58 67
59-`ermite watch [repo] --json` prints one JSON object per event (pushes, repositories, merge requests, comments). It reconnects and resumes on its own; a `gap` event means some events were lost, so reload the state.68+`ermite watch [repo] --json` prints one JSON object per event (pushes, repositories, merge requests, notes: added, edited, deleted, resolved). It reconnects and resumes on its own; a `gap` event means some events were lost, so reload the state.
60 69
61 ## Run a server70 ## Run a server
62 71

Keyboard shortcuts

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

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

Branches 2

Tags 45

↑↓ moveenter openesc close