detached fa37646

merge: main with merge request notes into feat/issue-form

commit
fa37646691ec7579256a6c4efe51b98a97ef79e1
author
brice · · 2026-10-19 12:47 UTC
merge of
346e37f + 86ac875 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
70 files · +3215 -701
M .github/workflows/ci.yml +8 -0
@@ -1,9 +1,17 @@
11 name: CI
22
3+# A change that only touches these files skips the whole run: none of them
4+# affects the build, the tests or the generated code.
35 on:
46 push:
57 branches: [main]
8+ paths-ignore: &docs-only
9+ - '**.md'
10+ - '**.png'
11+ - 'docs/**'
12+ - 'LICENSE'
613 pull_request:
14+ paths-ignore: *docs-only
715
816 env:
917 # Test the module alone, with the versions in go.mod (never a go.work).
@@ -1,9 +1,17 @@
1 name: CI1 name: CI
2 2
3+# A change that only touches these files skips the whole run: none of them
4+# affects the build, the tests or the generated code.
3 on:5 on:
4 push:6 push:
5 branches: [main]7 branches: [main]
8+ paths-ignore: &docs-only
9+ - '**.md'
10+ - '**.png'
11+ - 'docs/**'
12+ - 'LICENSE'
6 pull_request:13 pull_request:
14+ paths-ignore: *docs-only
7 15
8 env:16 env:
9 # Test the module alone, with the versions in go.mod (never a go.work).17 # Test the module alone, with the versions in go.mod (never a go.work).
M README.md +4 -0
@@ -82,6 +82,10 @@ ermite web login # one-time link to sign in to the web interface
8282
8383 Pushing to a new name also creates a private repository.
8484
85+`ermite mr create` and `ermite mr close` print a confirmation followed by the full
86+merge request web URL on its own line. With `--json`, merge request objects
87+(create, show, list, close and merge) include `url` alongside the other fields.
88+
8589 ## Agent workflow
8690
8791 You and the agent use the same CLI. The difference is the token: the agent's is limited to its own branches. It can never push `main`, tags or settings, and its CI runs get no secrets.
@@ -82,6 +82,10 @@ ermite web login # one-time link to sign in to the web interface
82 82
83 Pushing to a new name also creates a private repository.83 Pushing to a new name also creates a private repository.
84 84
85+`ermite mr create` and `ermite mr close` print a confirmation followed by the full
86+merge request web URL on its own line. With `--json`, merge request objects
87+(create, show, list, close and merge) include `url` alongside the other fields.
88+
85 ## Agent workflow89 ## Agent workflow
86 90
87 You and the agent use the same CLI. The difference is the token: the agent's is limited to its own branches. It can never push `main`, tags or settings, and its CI runs get no secrets.91 You and the agent use the same CLI. The difference is the token: the agent's is limited to its own branches. It can never push `main`, tags or settings, and its CI runs get no secrets.
M docs/cli.md +6 -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 issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]
@@ -92,3 +93,7 @@ Issues need a server with the `issues` feature and `ermite repo edit <repo> --is
9293 - `watch --json` prints one object per line.
9394 - Errors are one line on stderr (`ermite: …`) with exit status 1.
9495 - Shell completion completes repository names from the server.
96+
97+`ermite mr create` and `ermite mr close` print a confirmation followed by the full
98+merge request web URL on its own line. With `--json`, merge request objects
99+(create, show, list, close and merge) include `url` alongside the other fields.
@@ -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 issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]62 ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]
@@ -92,3 +93,7 @@ Issues need a server with the `issues` feature and `ermite repo edit <repo> --is
92 - `watch --json` prints one object per line.93 - `watch --json` prints one object per line.
93 - Errors are one line on stderr (`ermite: …`) with exit status 1.94 - Errors are one line on stderr (`ermite: …`) with exit status 1.
94 - Shell completion completes repository names from the server.95 - Shell completion completes repository names from the server.
96+
97+`ermite mr create` and `ermite mr close` print a confirmation followed by the full
98+merge request web URL on its own line. With `--json`, merge request objects
99+(create, show, list, close and merge) include `url` alongside the other fields.
M docs/merge-requests.md +34 -1
@@ -8,15 +8,48 @@ ermite mr show myrepo 1 --diff
88 ermite mr merge myrepo 1 --head <commit> [--squash] [--delete-branch]
99 ```
1010
11+`ermite mr create` and `ermite mr close` print a confirmation followed by the full
12+merge request web URL on its own line. With `--json`, merge request objects
13+(create, show, list, close and merge) include `url` alongside the other fields.
14+
1115 - **Fast-forward** moves the target. **Squash** writes one commit signed by the server's SSH key.
1216 - The merge is refused when the branch moved since the reviewed `--head`, or when the target is not an ancestor of it.
1317 - With `failing_checks_block` on (default), the merge is refused while a check of the head failed or is still running.
1418 - `--delete-branch` deletes the source branch, unless it is the default branch or another open request uses it.
1519 - `ermite repo edit --merge-modes` limits the modes per repository.
16-- 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.
1721
1822 Verify squash commits with the `allowed_signers` line from `ermite signing-key show`:
1923
2024 ```sh
2125 git verify-commit <commit>
2226 ```
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.
@@ -8,15 +8,48 @@ ermite mr show myrepo 1 --diff
8 ermite mr merge myrepo 1 --head <commit> [--squash] [--delete-branch]8 ermite mr merge myrepo 1 --head <commit> [--squash] [--delete-branch]
9 ```9 ```
10 10
11+`ermite mr create` and `ermite mr close` print a confirmation followed by the full
12+merge request web URL on its own line. With `--json`, merge request objects
13+(create, show, list, close and merge) include `url` alongside the other fields.
14+
11 - **Fast-forward** moves the target. **Squash** writes one commit signed by the server's SSH key.15 - **Fast-forward** moves the target. **Squash** writes one commit signed by the server's SSH key.
12 - The merge is refused when the branch moved since the reviewed `--head`, or when the target is not an ancestor of it.16 - The merge is refused when the branch moved since the reviewed `--head`, or when the target is not an ancestor of it.
13 - 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.
14 - `--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.
15 - `ermite repo edit --merge-modes` limits the modes per repository.19 - `ermite repo edit --merge-modes` limits the modes per repository.
16-- 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.
17 21
18 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`:
19 23
20 ```sh24 ```sh
21 git verify-commit <commit>25 git verify-commit <commit>
22 ```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 (`issue:write` lets a token write issues). 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 (`issue:write` lets a token write issues). 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 `issue:write`, open, comment on, close and reopen issues of the repositories where they are enabled;
3333 - with `mr:merge`, merge into a branch under its prefixes;
3434 - read CI runs, logs and checks; with `ci:write`, cancel and re-run runs of its own branches;
@@ -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 (`issue:write` lets a token write issues). 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 (`issue:write` lets a token write issues). 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 `issue:write`, open, comment on, close and reopen issues of the repositories where they are enabled;32 - with `issue:write`, open, comment on, close and reopen issues of the repositories where they are enabled;
33 - with `mr:merge`, merge into a branch under its prefixes;33 - with `mr:merge`, merge into a branch under its prefixes;
34 - read CI runs, logs and checks; with `ci:write`, cancel and re-run runs of its own branches;34 - read CI runs, logs and checks; with `ci:write`, cancel and re-run runs of its own branches;
M gen/ermite/storage/merge_request.pb.go +127 -23 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 internal/adapters/client/scopes.go +3 -5
@@ -35,14 +35,12 @@ func ScopeName(s ermite.Scope) string {
3535 return strings.Replace(strings.ToLower(strings.TrimPrefix(name, "SCOPE_")), "_", ":", 1)
3636 }
3737
38-// AllScopes lists every scope the contract defines that this server accepts, in
39-// enum order. Deriving it keeps the `cli` preset ("every scope") true when the
40-// contract grows. SCOPE_MR_REVIEW is in the contract but not yet implemented
41-// here, and the server rejects it like any unknown scope, so it is left out.
38+// AllScopes lists every scope the contract defines, in enum order. Deriving it
39+// keeps the `cli` preset ("every scope") true when the contract grows.
4240 func AllScopes() []ermite.Scope {
4341 var scopes []ermite.Scope
4442 for n := range ermite.Scope_name {
45- if s := ermite.Scope(n); s != ermite.Scope_SCOPE_UNSPECIFIED && s != ermite.Scope_SCOPE_MR_REVIEW {
43+ if s := ermite.Scope(n); s != ermite.Scope_SCOPE_UNSPECIFIED {
4644 scopes = append(scopes, s)
4745 }
4846 }
@@ -35,14 +35,12 @@ func ScopeName(s ermite.Scope) string {
35 return strings.Replace(strings.ToLower(strings.TrimPrefix(name, "SCOPE_")), "_", ":", 1)35 return strings.Replace(strings.ToLower(strings.TrimPrefix(name, "SCOPE_")), "_", ":", 1)
36 }36 }
37 37
38-// AllScopes lists every scope the contract defines that this server accepts, in38+// AllScopes lists every scope the contract defines, in enum order. Deriving it
39-// enum order. Deriving it keeps the `cli` preset ("every scope") true when the39+// keeps the `cli` preset ("every scope") true when the contract grows.
40-// contract grows. SCOPE_MR_REVIEW is in the contract but not yet implemented
41-// here, and the server rejects it like any unknown scope, so it is left out.
42 func AllScopes() []ermite.Scope {40 func AllScopes() []ermite.Scope {
43 var scopes []ermite.Scope41 var scopes []ermite.Scope
44 for n := range ermite.Scope_name {42 for n := range ermite.Scope_name {
45- if s := ermite.Scope(n); s != ermite.Scope_SCOPE_UNSPECIFIED && s != ermite.Scope_SCOPE_MR_REVIEW {43+ if s := ermite.Scope(n); s != ermite.Scope_SCOPE_UNSPECIFIED {
46 scopes = append(scopes, s)44 scopes = append(scopes, s)
47 }45 }
48 }46 }
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
@@ -662,6 +662,11 @@ func encodeComment(c domain.Comment) ([]byte, error) {
662662 return nil, err
663663 }
664664 return mustMarshal(&storagepb.Comment{
665+ ReplyTo: c.ReplyTo,
666+ StartLine: int32(c.StartLine), //nolint:gosec // validated against the diff
667+ Verdict: storagepb.Verdict(c.Verdict),
668+ EditedAt: encodeTime(c.EditedAt),
669+ Deleted: c.Deleted,
665670 Id: c.ID,
666671 Author: c.Author,
667672 Path: c.Path,
@@ -685,6 +690,7 @@ func decodeComment(item kv.Item) (domain.Comment, error) {
685690 return domain.Comment{}, fmt.Errorf("decode comment %d: %w", pb.GetId(), err)
686691 }
687692 c := domain.Comment{
693+ ReplyTo: pb.GetReplyTo(), StartLine: int(pb.GetStartLine()), Verdict: domain.Verdict(pb.GetVerdict()), EditedAt: decodeTime(pb.GetEditedAt()), Deleted: pb.GetDeleted(),
688694 ID: pb.GetId(), Author: pb.GetAuthor(), Path: pb.GetPath(), Line: int(pb.GetLine()),
689695 Side: decodeSide(pb.GetSide()), Text: text, Resolved: pb.GetResolved(),
690696 CreatedAt: decodeTime(pb.GetCreatedAt()), Version: item.Version,
@@ -662,6 +662,11 @@ func encodeComment(c domain.Comment) ([]byte, error) {
662 return nil, err662 return nil, err
663 }663 }
664 return mustMarshal(&storagepb.Comment{664 return mustMarshal(&storagepb.Comment{
665+ ReplyTo: c.ReplyTo,
666+ StartLine: int32(c.StartLine), //nolint:gosec // validated against the diff
667+ Verdict: storagepb.Verdict(c.Verdict),
668+ EditedAt: encodeTime(c.EditedAt),
669+ Deleted: c.Deleted,
665 Id: c.ID,670 Id: c.ID,
666 Author: c.Author,671 Author: c.Author,
667 Path: c.Path,672 Path: c.Path,
@@ -685,6 +690,7 @@ func decodeComment(item kv.Item) (domain.Comment, error) {
685 return domain.Comment{}, fmt.Errorf("decode comment %d: %w", pb.GetId(), err)690 return domain.Comment{}, fmt.Errorf("decode comment %d: %w", pb.GetId(), err)
686 }691 }
687 c := domain.Comment{692 c := domain.Comment{
693+ ReplyTo: pb.GetReplyTo(), StartLine: int(pb.GetStartLine()), Verdict: domain.Verdict(pb.GetVerdict()), EditedAt: decodeTime(pb.GetEditedAt()), Deleted: pb.GetDeleted(),
688 ID: pb.GetId(), Author: pb.GetAuthor(), Path: pb.GetPath(), Line: int(pb.GetLine()),694 ID: pb.GetId(), Author: pb.GetAuthor(), Path: pb.GetPath(), Line: int(pb.GetLine()),
689 Side: decodeSide(pb.GetSide()), Text: text, Resolved: pb.GetResolved(),695 Side: decodeSide(pb.GetSide()), Text: text, Resolved: pb.GetResolved(),
690 CreatedAt: decodeTime(pb.GetCreatedAt()), Version: item.Version,696 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,
@@ -487,6 +488,15 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
487488 case domain.IssueSubmitted:
488489 out.Time = timestamp(ev.At)
489490 out.Kind = &pb.Event_IssueSubmitted{IssueSubmitted: &pb.IssueSubmitted{Repo: ev.Repo.String(), Id: ev.ID.String()}}
491+ case domain.CommentEdited:
492+ out.Time = timestamp(ev.At)
493+ out.Kind = &pb.Event_CommentEdited{CommentEdited: &pb.CommentEdited{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
494+ case domain.CommentDeleted:
495+ out.Time = timestamp(ev.At)
496+ out.Kind = &pb.Event_CommentDeleted{CommentDeleted: &pb.CommentDeleted{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
497+ case domain.CommentResolved:
498+ out.Time = timestamp(ev.At)
499+ out.Kind = &pb.Event_CommentResolved{CommentResolved: &pb.CommentResolved{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
490500 case domain.RunUpdated:
491501 out.Time = timestamp(ev.At)
492502 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,
@@ -487,6 +488,15 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
487 case domain.IssueSubmitted:488 case domain.IssueSubmitted:
488 out.Time = timestamp(ev.At)489 out.Time = timestamp(ev.At)
489 out.Kind = &pb.Event_IssueSubmitted{IssueSubmitted: &pb.IssueSubmitted{Repo: ev.Repo.String(), Id: ev.ID.String()}}490 out.Kind = &pb.Event_IssueSubmitted{IssueSubmitted: &pb.IssueSubmitted{Repo: ev.Repo.String(), Id: ev.ID.String()}}
491+ case domain.CommentEdited:
492+ out.Time = timestamp(ev.At)
493+ out.Kind = &pb.Event_CommentEdited{CommentEdited: &pb.CommentEdited{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
494+ case domain.CommentDeleted:
495+ out.Time = timestamp(ev.At)
496+ out.Kind = &pb.Event_CommentDeleted{CommentDeleted: &pb.CommentDeleted{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
497+ case domain.CommentResolved:
498+ out.Time = timestamp(ev.At)
499+ out.Kind = &pb.Event_CommentResolved{CommentResolved: &pb.CommentResolved{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
490 case domain.RunUpdated:500 case domain.RunUpdated:
491 out.Time = timestamp(ev.At)501 out.Time = timestamp(ev.At)
492 out.Kind = runEventToPB(ev)502 out.Kind = runEventToPB(ev)
M internal/adapters/rpc/convert_test.go +1 -6
@@ -50,17 +50,12 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) {
5050 }
5151 _, err := scopeFromPB(pb.Scope_SCOPE_UNSPECIFIED)
5252 assert.ErrorIs(t, err, domain.ErrInvalidScope)
53- // Every scope of the contract is a scope of the domain, except mr:review.
53+ // Every scope of the contract is a scope of the domain.
5454 for v := range pb.Scope_name {
5555 if pb.Scope(v) == pb.Scope_SCOPE_UNSPECIFIED {
5656 continue
5757 }
5858 _, err := scopeFromPB(pb.Scope(v))
59- if pb.Scope(v) == pb.Scope_SCOPE_MR_REVIEW {
60- // In the contract, not implemented here yet: rejected like an unknown scope.
61- assert.ErrorIs(t, err, domain.ErrInvalidScope, pb.Scope(v))
62- continue
63- }
6459 assert.NoError(t, err, pb.Scope(v))
6560 }
6661 })
@@ -50,17 +50,12 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) {
50 }50 }
51 _, err := scopeFromPB(pb.Scope_SCOPE_UNSPECIFIED)51 _, err := scopeFromPB(pb.Scope_SCOPE_UNSPECIFIED)
52 assert.ErrorIs(t, err, domain.ErrInvalidScope)52 assert.ErrorIs(t, err, domain.ErrInvalidScope)
53- // Every scope of the contract is a scope of the domain, except mr:review.53+ // Every scope of the contract is a scope of the domain.
54 for v := range pb.Scope_name {54 for v := range pb.Scope_name {
55 if pb.Scope(v) == pb.Scope_SCOPE_UNSPECIFIED {55 if pb.Scope(v) == pb.Scope_SCOPE_UNSPECIFIED {
56 continue56 continue
57 }57 }
58 _, err := scopeFromPB(pb.Scope(v))58 _, err := scopeFromPB(pb.Scope(v))
59- if pb.Scope(v) == pb.Scope_SCOPE_MR_REVIEW {
60- // In the contract, not implemented here yet: rejected like an unknown scope.
61- assert.ErrorIs(t, err, domain.ErrInvalidScope, pb.Scope(v))
62- continue
63- }
64 assert.NoError(t, err, pb.Scope(v))59 assert.NoError(t, err, pb.Scope(v))
65 }60 }
66 })61 })
M internal/adapters/rpc/mergerequest.go +70 -4
@@ -10,19 +10,61 @@ import (
1010 "connectrpc.com/connect"
1111
1212 pb "bnema.dev/ermite-api/gen/ermite"
13- "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
1413 "bnema.dev/ermite/internal/domain"
1514 "bnema.dev/ermite/internal/ports"
1615 )
1716
1817 // mergeRequestService implements ermiteconnect.MergeRequestServiceHandler.
1918 type mergeRequestService struct {
20- // The note methods (edit, delete, verdicts) are implemented by the merge
21- // request notes work; until then they answer CodeUnimplemented.
22- ermiteconnect.UnimplementedMergeRequestServiceHandler
2319 mrs ports.MergeRequestService
2420 }
2521
22+func (s *mergeRequestService) EditComment(ctx context.Context, req *connect.Request[pb.EditCommentRequest]) (*connect.Response[pb.EditCommentResponse], error) {
23+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
24+ if err != nil {
25+ return nil, connectError(ctx, err)
26+ }
27+ c, err := s.mrs.EditComment(ctx, caller(ctx), name, req.Msg.GetNumber(), commentID(req.Msg.GetCommentId()), req.Msg.GetText())
28+ if err != nil {
29+ return nil, connectError(ctx, err)
30+ }
31+ return connect.NewResponse(&pb.EditCommentResponse{Comment: commentToPB(c)}), nil
32+}
33+
34+func (s *mergeRequestService) DeleteComment(ctx context.Context, req *connect.Request[pb.DeleteCommentRequest]) (*connect.Response[pb.DeleteCommentResponse], error) {
35+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
36+ if err != nil {
37+ return nil, connectError(ctx, err)
38+ }
39+ c, err := s.mrs.DeleteComment(ctx, caller(ctx), name, req.Msg.GetNumber(), commentID(req.Msg.GetCommentId()))
40+ if err != nil {
41+ return nil, connectError(ctx, err)
42+ }
43+ return connect.NewResponse(&pb.DeleteCommentResponse{Comment: commentToPB(c)}), nil
44+}
45+
46+func verdictToPB(v domain.Verdict) pb.Verdict {
47+ switch v {
48+ case domain.VerdictApprove:
49+ return pb.Verdict_VERDICT_APPROVE
50+ case domain.VerdictChangesRequested:
51+ return pb.Verdict_VERDICT_CHANGES_REQUESTED
52+ }
53+ return pb.Verdict_VERDICT_UNSPECIFIED
54+}
55+
56+func verdictFromPB(v pb.Verdict) (domain.Verdict, error) {
57+ switch v {
58+ case pb.Verdict_VERDICT_UNSPECIFIED:
59+ return domain.VerdictNone, nil
60+ case pb.Verdict_VERDICT_APPROVE:
61+ return domain.VerdictApprove, nil
62+ case pb.Verdict_VERDICT_CHANGES_REQUESTED:
63+ return domain.VerdictChangesRequested, nil
64+ }
65+ return domain.VerdictNone, fmt.Errorf("%w: verdict %v", domain.ErrInvalid, v)
66+}
67+
2668 func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState {
2769 switch s {
2870 case domain.MRStateOpen:
@@ -102,9 +144,19 @@ func commentToPB(c domain.Comment) *pb.Comment {
102144 Text: c.Text.String(),
103145 Resolved: c.Resolved,
104146 CreatedAt: timestamp(c.CreatedAt),
147+ ReplyTo: optionalCommentID(c.ReplyTo),
148+ StartLine: int32(c.StartLine), //nolint:gosec // validated diff line
149+ Verdict: verdictToPB(c.Verdict), EditedAt: timestamp(c.EditedAt), Deleted: c.Deleted,
105150 }
106151 }
107152
153+func optionalCommentID(id int64) string {
154+ if id == 0 {
155+ return ""
156+ }
157+ return strconv.FormatInt(id, 10)
158+}
159+
108160 // commentID parses the opaque id of a comment. One that this server never
109161 // issued is 0, which names no comment.
110162 func commentID(s string) int64 {
@@ -191,6 +243,9 @@ func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect.
191243 for _, c := range d.Comments {
192244 out.Comments = append(out.Comments, commentToPB(c))
193245 }
246+ for _, v := range d.Verdicts {
247+ 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})
248+ }
194249 return connect.NewResponse(out), nil
195250 }
196251
@@ -203,7 +258,18 @@ func (s *mergeRequestService) AddComment(ctx context.Context, req *connect.Reque
203258 if err != nil {
204259 return nil, connectError(ctx, err)
205260 }
261+ verdict, err := verdictFromPB(req.Msg.GetVerdict())
262+ if err != nil {
263+ return nil, connectError(ctx, err)
264+ }
265+ replyTo := commentID(req.Msg.GetReplyTo())
266+ if req.Msg.GetReplyTo() != "" && replyTo == 0 {
267+ var v domain.Violations
268+ v.Add("reply_to", "is not a note id")
269+ return nil, connectError(ctx, v.Err())
270+ }
206271 c, err := s.mrs.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.AddCommentParams{
272+ ReplyTo: replyTo, StartLine: int(req.Msg.GetStartLine()), Verdict: verdict,
207273 Path: req.Msg.GetPath(), Line: int(req.Msg.GetLine()), Side: side, Text: req.Msg.GetText(),
208274 })
209275 if err != nil {
@@ -10,19 +10,61 @@ import (
10 "connectrpc.com/connect"10 "connectrpc.com/connect"
11 11
12 pb "bnema.dev/ermite-api/gen/ermite"12 pb "bnema.dev/ermite-api/gen/ermite"
13- "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
14 "bnema.dev/ermite/internal/domain"13 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/ports"14 "bnema.dev/ermite/internal/ports"
16 )15 )
17 16
18 // mergeRequestService implements ermiteconnect.MergeRequestServiceHandler.17 // mergeRequestService implements ermiteconnect.MergeRequestServiceHandler.
19 type mergeRequestService struct {18 type mergeRequestService struct {
20- // The note methods (edit, delete, verdicts) are implemented by the merge
21- // request notes work; until then they answer CodeUnimplemented.
22- ermiteconnect.UnimplementedMergeRequestServiceHandler
23 mrs ports.MergeRequestService19 mrs ports.MergeRequestService
24 }20 }
25 21
22+func (s *mergeRequestService) EditComment(ctx context.Context, req *connect.Request[pb.EditCommentRequest]) (*connect.Response[pb.EditCommentResponse], error) {
23+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
24+ if err != nil {
25+ return nil, connectError(ctx, err)
26+ }
27+ c, err := s.mrs.EditComment(ctx, caller(ctx), name, req.Msg.GetNumber(), commentID(req.Msg.GetCommentId()), req.Msg.GetText())
28+ if err != nil {
29+ return nil, connectError(ctx, err)
30+ }
31+ return connect.NewResponse(&pb.EditCommentResponse{Comment: commentToPB(c)}), nil
32+}
33+
34+func (s *mergeRequestService) DeleteComment(ctx context.Context, req *connect.Request[pb.DeleteCommentRequest]) (*connect.Response[pb.DeleteCommentResponse], error) {
35+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
36+ if err != nil {
37+ return nil, connectError(ctx, err)
38+ }
39+ c, err := s.mrs.DeleteComment(ctx, caller(ctx), name, req.Msg.GetNumber(), commentID(req.Msg.GetCommentId()))
40+ if err != nil {
41+ return nil, connectError(ctx, err)
42+ }
43+ return connect.NewResponse(&pb.DeleteCommentResponse{Comment: commentToPB(c)}), nil
44+}
45+
46+func verdictToPB(v domain.Verdict) pb.Verdict {
47+ switch v {
48+ case domain.VerdictApprove:
49+ return pb.Verdict_VERDICT_APPROVE
50+ case domain.VerdictChangesRequested:
51+ return pb.Verdict_VERDICT_CHANGES_REQUESTED
52+ }
53+ return pb.Verdict_VERDICT_UNSPECIFIED
54+}
55+
56+func verdictFromPB(v pb.Verdict) (domain.Verdict, error) {
57+ switch v {
58+ case pb.Verdict_VERDICT_UNSPECIFIED:
59+ return domain.VerdictNone, nil
60+ case pb.Verdict_VERDICT_APPROVE:
61+ return domain.VerdictApprove, nil
62+ case pb.Verdict_VERDICT_CHANGES_REQUESTED:
63+ return domain.VerdictChangesRequested, nil
64+ }
65+ return domain.VerdictNone, fmt.Errorf("%w: verdict %v", domain.ErrInvalid, v)
66+}
67+
26 func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState {68 func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState {
27 switch s {69 switch s {
28 case domain.MRStateOpen:70 case domain.MRStateOpen:
@@ -102,9 +144,19 @@ func commentToPB(c domain.Comment) *pb.Comment {
102 Text: c.Text.String(),144 Text: c.Text.String(),
103 Resolved: c.Resolved,145 Resolved: c.Resolved,
104 CreatedAt: timestamp(c.CreatedAt),146 CreatedAt: timestamp(c.CreatedAt),
147+ ReplyTo: optionalCommentID(c.ReplyTo),
148+ StartLine: int32(c.StartLine), //nolint:gosec // validated diff line
149+ Verdict: verdictToPB(c.Verdict), EditedAt: timestamp(c.EditedAt), Deleted: c.Deleted,
105 }150 }
106 }151 }
107 152
153+func optionalCommentID(id int64) string {
154+ if id == 0 {
155+ return ""
156+ }
157+ return strconv.FormatInt(id, 10)
158+}
159+
108 // commentID parses the opaque id of a comment. One that this server never160 // commentID parses the opaque id of a comment. One that this server never
109 // issued is 0, which names no comment.161 // issued is 0, which names no comment.
110 func commentID(s string) int64 {162 func commentID(s string) int64 {
@@ -191,6 +243,9 @@ func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect.
191 for _, c := range d.Comments {243 for _, c := range d.Comments {
192 out.Comments = append(out.Comments, commentToPB(c))244 out.Comments = append(out.Comments, commentToPB(c))
193 }245 }
246+ for _, v := range d.Verdicts {
247+ 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})
248+ }
194 return connect.NewResponse(out), nil249 return connect.NewResponse(out), nil
195 }250 }
196 251
@@ -203,7 +258,18 @@ func (s *mergeRequestService) AddComment(ctx context.Context, req *connect.Reque
203 if err != nil {258 if err != nil {
204 return nil, connectError(ctx, err)259 return nil, connectError(ctx, err)
205 }260 }
261+ verdict, err := verdictFromPB(req.Msg.GetVerdict())
262+ if err != nil {
263+ return nil, connectError(ctx, err)
264+ }
265+ replyTo := commentID(req.Msg.GetReplyTo())
266+ if req.Msg.GetReplyTo() != "" && replyTo == 0 {
267+ var v domain.Violations
268+ v.Add("reply_to", "is not a note id")
269+ return nil, connectError(ctx, v.Err())
270+ }
206 c, err := s.mrs.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.AddCommentParams{271 c, err := s.mrs.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.AddCommentParams{
272+ ReplyTo: replyTo, StartLine: int(req.Msg.GetStartLine()), Verdict: verdict,
207 Path: req.Msg.GetPath(), Line: int(req.Msg.GetLine()), Side: side, Text: req.Msg.GetText(),273 Path: req.Msg.GetPath(), Line: int(req.Msg.GetLine()), Side: side, Text: req.Msg.GetText(),
208 })274 })
209 if err != nil {275 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
@@ -60,7 +60,7 @@ func liveEvent(we ports.WatchEvent) bool {
6060 }
6161 switch we.Event.(type) {
6262 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
63- domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded,
63+ domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
6464 domain.RunUpdated, domain.JobUpdated,
6565 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved:
6666 return true
@@ -170,6 +170,12 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
170170 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
171171 case domain.MergeRequestReady:
172172 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")
173+ case domain.CommentEdited:
174+ return l.mrNotice(ctx, ev.Number, "a note was edited — reload")
175+ case domain.CommentDeleted:
176+ return l.mrNotice(ctx, ev.Number, "a note was deleted — reload")
177+ case domain.CommentResolved:
178+ return l.mrNotice(ctx, ev.Number, "a thread was resolved — reload")
173179 case domain.CommentAdded:
174180 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")
175181 case domain.IssueOpened:
@@ -60,7 +60,7 @@ func liveEvent(we ports.WatchEvent) bool {
60 }60 }
61 switch we.Event.(type) {61 switch we.Event.(type) {
62 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,62 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
63- domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded,63+ domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
64 domain.RunUpdated, domain.JobUpdated,64 domain.RunUpdated, domain.JobUpdated,
65 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved:65 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved:
66 return true66 return true
@@ -170,6 +170,12 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
170 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")170 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
171 case domain.MergeRequestReady:171 case domain.MergeRequestReady:
172 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")172 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")
173+ case domain.CommentEdited:
174+ return l.mrNotice(ctx, ev.Number, "a note was edited — reload")
175+ case domain.CommentDeleted:
176+ return l.mrNotice(ctx, ev.Number, "a note was deleted — reload")
177+ case domain.CommentResolved:
178+ return l.mrNotice(ctx, ev.Number, "a thread was resolved — reload")
173 case domain.CommentAdded:179 case domain.CommentAdded:
174 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")180 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")
175 case domain.IssueOpened:181 case domain.IssueOpened:
M internal/adapters/web/inbox.go +1 -1
@@ -264,7 +264,7 @@ func (s *Server) inboxCount(ctx context.Context) (n int, show bool) {
264264 // on the inbox page: the counters, the filter and the two lists.
265265 func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
266266 switch we.Event.(type) {
267- case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded,
267+ case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
268268 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,
269269 domain.RepoUpdated, domain.RepoDeleted:
270270 default:
@@ -264,7 +264,7 @@ func (s *Server) inboxCount(ctx context.Context) (n int, show bool) {
264 // on the inbox page: the counters, the filter and the two lists.264 // on the inbox page: the counters, the filter and the two lists.
265 func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {265 func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
266 switch we.Event.(type) {266 switch we.Event.(type) {
267- case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded,267+ case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
268 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,268 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,
269 domain.RepoUpdated, domain.RepoDeleted:269 domain.RepoUpdated, domain.RepoDeleted:
270 default:270 default:
M internal/adapters/web/issues.go +1 -1
@@ -275,7 +275,7 @@ func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Componen
275275 p.Comments = append(p.Comments, v)
276276 }
277277 if signed {
278- p.Form = &CommentForm{ID: "cf-general", Action: issueHref(d.name(), n, "comments"), Title: "comment on the issue"}
278+ p.Form = &CommentForm{ID: "cf-general", Action: issueHref(d.name(), n, "comments"), Title: "comment on the issue", Plain: true}
279279 switch i.State {
280280 case domain.IssueStateOpen:
281281 p.Transition = &IssueActions{Action: issueHref(d.name(), n, "close"), Label: "Close issue"}
@@ -275,7 +275,7 @@ func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Componen
275 p.Comments = append(p.Comments, v)275 p.Comments = append(p.Comments, v)
276 }276 }
277 if signed {277 if signed {
278- p.Form = &CommentForm{ID: "cf-general", Action: issueHref(d.name(), n, "comments"), Title: "comment on the issue"}278+ p.Form = &CommentForm{ID: "cf-general", Action: issueHref(d.name(), n, "comments"), Title: "comment on the issue", Plain: true}
279 switch i.State {279 switch i.State {
280 case domain.IssueStateOpen:280 case domain.IssueStateOpen:
281 p.Transition = &IssueActions{Action: issueHref(d.name(), n, "close"), Label: "Close issue"}281 p.Transition = &IssueActions{Action: issueHref(d.name(), n, "close"), Label: "Close issue"}
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 +78 -3
@@ -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,7 +64,13 @@ 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) {
65- return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix})
67+ return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: 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.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, notes)
73+ return h, err
6674 }
6775
6876 // renderUntrusted renders Markdown written by an anonymous visitor (an issue
@@ -73,15 +81,35 @@ func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string)
7381 // every other scheme lose their link and keep the text;
7482 // - nothing points into the repository.
7583 func (m *markdown) renderUntrusted(source []byte, idPrefix string) (template.HTML, error) {
76- h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true})
84+ h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}, nil)
7785 return h, err
7886 }
7987
80-func (m *markdown) renderPolicy(source []byte, pol linkPolicy) (template.HTML, []Heading, error) {
88+func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64) bool) (template.HTML, []Heading, error) {
8189 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))
8290 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
8391 pol.source = source
92+ // Collect eligible text before the policy unwraps rejected links: their
93+ // labels must not accidentally become references either.
94+ var refs []*ast.Text
95+ if notes != nil {
96+ _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
97+ if !entering {
98+ return ast.WalkContinue, nil
99+ }
100+ switch n := n.(type) {
101+ case *ast.CodeSpan, *ast.Link, *ast.AutoLink, *ast.Image, *ast.CodeBlock, *ast.FencedCodeBlock:
102+ return ast.WalkSkipChildren, nil
103+ case *ast.Text:
104+ refs = append(refs, n)
105+ }
106+ return ast.WalkContinue, nil
107+ })
108+ }
84109 pol.apply(doc)
110+ for _, t := range refs {
111+ noteReferences(t, source, notes)
112+ }
85113
86114 var buf bytes.Buffer
87115 if err := m.md.Renderer().Render(&buf, source, doc); err != nil {
@@ -361,3 +389,50 @@ func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
361389 return ast.WalkContinue, nil
362390 })
363391 }
392+
393+var noteReferencePattern = regexp.MustCompile(`(^|[^0-9A-Za-z_^])\^([1-9][0-9]{0,5})\b`)
394+
395+// noteReferences changes only text segments; code and link labels never enter
396+// this function. New fragment links deliberately bypass heading-prefix rewriting.
397+func noteReferences(t *ast.Text, source []byte, exists func(int64) bool) {
398+ parent := t.Parent()
399+ if parent == nil {
400+ return
401+ }
402+ value := t.Value(source)
403+ offset := 0
404+ for _, match := range noteReferencePattern.FindAllSubmatchIndex(value, -1) {
405+ id := string(value[match[4]:match[5]])
406+ n, err := strconv.ParseInt(id, 10, 64)
407+ if err != nil || !exists(n) {
408+ continue
409+ }
410+ start := match[4] - 1
411+ // Markdown punctuation may split adjacent text into separate AST nodes.
412+ // Check the original preceding byte as well as the segment-local regex.
413+ if at := t.Segment.Start + start; at > 0 {
414+ prev := source[at-1]
415+ if prev >= '0' && prev <= '9' || prev >= 'A' && prev <= 'Z' || prev >= 'a' && prev <= 'z' || prev == '_' || prev == '^' {
416+ continue
417+ }
418+ }
419+ if start > offset {
420+ parent.InsertBefore(parent, t, ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Start+start)))
421+ }
422+ link := ast.NewLink()
423+ link.Destination = []byte("#c" + id)
424+ link.Title = []byte("note " + id)
425+ link.SetAttributeString("class", []byte("ref"))
426+ link.AppendChild(link, ast.NewString(value[start:match[5]]))
427+ parent.InsertBefore(parent, t, link)
428+ offset = match[5]
429+ }
430+ if offset == 0 {
431+ return
432+ }
433+ tail := ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Stop))
434+ tail.SetSoftLineBreak(t.SoftLineBreak())
435+ tail.SetHardLineBreak(t.HardLineBreak())
436+ parent.InsertBefore(parent, t, tail)
437+ parent.RemoveChild(parent, t)
438+}
@@ -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,7 +64,13 @@ 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) {
65- return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix})67+ return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: 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.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, notes)
73+ return h, err
66 }74 }
67 75
68 // renderUntrusted renders Markdown written by an anonymous visitor (an issue76 // renderUntrusted renders Markdown written by an anonymous visitor (an issue
@@ -73,15 +81,35 @@ func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string)
73 // every other scheme lose their link and keep the text;81 // every other scheme lose their link and keep the text;
74 // - nothing points into the repository.82 // - nothing points into the repository.
75 func (m *markdown) renderUntrusted(source []byte, idPrefix string) (template.HTML, error) {83 func (m *markdown) renderUntrusted(source []byte, idPrefix string) (template.HTML, error) {
76- h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true})84+ h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}, nil)
77 return h, err85 return h, err
78 }86 }
79 87
80-func (m *markdown) renderPolicy(source []byte, pol linkPolicy) (template.HTML, []Heading, error) {88+func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64) bool) (template.HTML, []Heading, error) {
81 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))89 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))
82 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))90 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
83 pol.source = source91 pol.source = source
92+ // Collect eligible text before the policy unwraps rejected links: their
93+ // labels must not accidentally become references either.
94+ var refs []*ast.Text
95+ if notes != nil {
96+ _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
97+ if !entering {
98+ return ast.WalkContinue, nil
99+ }
100+ switch n := n.(type) {
101+ case *ast.CodeSpan, *ast.Link, *ast.AutoLink, *ast.Image, *ast.CodeBlock, *ast.FencedCodeBlock:
102+ return ast.WalkSkipChildren, nil
103+ case *ast.Text:
104+ refs = append(refs, n)
105+ }
106+ return ast.WalkContinue, nil
107+ })
108+ }
84 pol.apply(doc)109 pol.apply(doc)
110+ for _, t := range refs {
111+ noteReferences(t, source, notes)
112+ }
85 113
86 var buf bytes.Buffer114 var buf bytes.Buffer
87 if err := m.md.Renderer().Render(&buf, source, doc); err != nil {115 if err := m.md.Renderer().Render(&buf, source, doc); err != nil {
@@ -361,3 +389,50 @@ func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
361 return ast.WalkContinue, nil389 return ast.WalkContinue, nil
362 })390 })
363 }391 }
392+
393+var noteReferencePattern = regexp.MustCompile(`(^|[^0-9A-Za-z_^])\^([1-9][0-9]{0,5})\b`)
394+
395+// noteReferences changes only text segments; code and link labels never enter
396+// this function. New fragment links deliberately bypass heading-prefix rewriting.
397+func noteReferences(t *ast.Text, source []byte, exists func(int64) bool) {
398+ parent := t.Parent()
399+ if parent == nil {
400+ return
401+ }
402+ value := t.Value(source)
403+ offset := 0
404+ for _, match := range noteReferencePattern.FindAllSubmatchIndex(value, -1) {
405+ id := string(value[match[4]:match[5]])
406+ n, err := strconv.ParseInt(id, 10, 64)
407+ if err != nil || !exists(n) {
408+ continue
409+ }
410+ start := match[4] - 1
411+ // Markdown punctuation may split adjacent text into separate AST nodes.
412+ // Check the original preceding byte as well as the segment-local regex.
413+ if at := t.Segment.Start + start; at > 0 {
414+ prev := source[at-1]
415+ if prev >= '0' && prev <= '9' || prev >= 'A' && prev <= 'Z' || prev >= 'a' && prev <= 'z' || prev == '_' || prev == '^' {
416+ continue
417+ }
418+ }
419+ if start > offset {
420+ parent.InsertBefore(parent, t, ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Start+start)))
421+ }
422+ link := ast.NewLink()
423+ link.Destination = []byte("#c" + id)
424+ link.Title = []byte("note " + id)
425+ link.SetAttributeString("class", []byte("ref"))
426+ link.AppendChild(link, ast.NewString(value[start:match[5]]))
427+ parent.InsertBefore(parent, t, link)
428+ offset = match[5]
429+ }
430+ if offset == 0 {
431+ return
432+ }
433+ tail := ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Stop))
434+ tail.SetSoftLineBreak(t.SoftLineBreak())
435+ tail.SetHardLineBreak(t.HardLineBreak())
436+ parent.InsertBefore(parent, t, tail)
437+ parent.RemoveChild(parent, t)
438+}
M internal/adapters/web/markdown_test.go +29 -0
@@ -203,3 +203,32 @@ func TestMarkdownUntrustedBigInputs(t *testing.T) {
203203
204204 assert.NotContains(t, renderUntrustedMD(t, "[x](https://a.test/\"onmouseover=\"alert(1))\n"), "onmouseover=\"")
205205 }
206+
207+func TestNoteMarkdown(t *testing.T) {
208+ t.Parallel()
209+ tests := []struct {
210+ source string
211+ links int
212+ }{
213+ {"^2 ^99 a^2 _^2 ^^2 1^2", 1},
214+ {"`^2` [^2](https://example.org) [^2](javascript:alert(1))", 0},
215+ {"```\n^2\n```", 0},
216+ {"^2, ^2\n^2", 3},
217+ }
218+ for _, tt := range tests {
219+ t.Run(tt.source, func(t *testing.T) {
220+ t.Parallel()
221+ h, err := newMarkdown().renderNote([]byte(tt.source), "demo", "main", "c1-", func(n int64) bool { return n == 2 })
222+ require.NoError(t, err)
223+ assert.Equal(t, tt.links, strings.Count(string(h), `href="#c2"`), string(h))
224+ assert.NotContains(t, string(h), `href="#c99"`)
225+ assert.NotContains(t, string(h), "javascript:")
226+ })
227+ }
228+ 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 })
229+ require.NoError(t, err)
230+ assert.Contains(t, string(h), `id="c1-heading"`)
231+ assert.Contains(t, string(h), "<strong>bold</strong>")
232+ assert.NotContains(t, string(h), "<script")
233+ assert.Contains(t, string(h), `title="note 2"`)
234+}
@@ -203,3 +203,32 @@ func TestMarkdownUntrustedBigInputs(t *testing.T) {
203 203
204 assert.NotContains(t, renderUntrustedMD(t, "[x](https://a.test/\"onmouseover=\"alert(1))\n"), "onmouseover=\"")204 assert.NotContains(t, renderUntrustedMD(t, "[x](https://a.test/\"onmouseover=\"alert(1))\n"), "onmouseover=\"")
205 }205 }
206+
207+func TestNoteMarkdown(t *testing.T) {
208+ t.Parallel()
209+ tests := []struct {
210+ source string
211+ links int
212+ }{
213+ {"^2 ^99 a^2 _^2 ^^2 1^2", 1},
214+ {"`^2` [^2](https://example.org) [^2](javascript:alert(1))", 0},
215+ {"```\n^2\n```", 0},
216+ {"^2, ^2\n^2", 3},
217+ }
218+ for _, tt := range tests {
219+ t.Run(tt.source, func(t *testing.T) {
220+ t.Parallel()
221+ h, err := newMarkdown().renderNote([]byte(tt.source), "demo", "main", "c1-", func(n int64) bool { return n == 2 })
222+ require.NoError(t, err)
223+ assert.Equal(t, tt.links, strings.Count(string(h), `href="#c2"`), string(h))
224+ assert.NotContains(t, string(h), `href="#c99"`)
225+ assert.NotContains(t, string(h), "javascript:")
226+ })
227+ }
228+ 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 })
229+ require.NoError(t, err)
230+ assert.Contains(t, string(h), `id="c1-heading"`)
231+ assert.Contains(t, string(h), "<strong>bold</strong>")
232+ assert.NotContains(t, string(h), "<script")
233+ assert.Contains(t, string(h), `title="note 2"`)
234+}
M internal/adapters/web/mr.go +134 -25
@@ -101,13 +101,22 @@ 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+ // Parts is a plain-text body (an issue comment); Body is the Markdown of
111+ // a merge request note.
112+ Parts []TextPart
113+ Replies []CommentView
114+ Edited, Deleted bool
115+ Verdict, Range, Quote string
116+ Edit *EditForm
117+ Delete *DeleteForm
118+ Reply *CommentForm
119+ Resolved bool
111120 // Outdated is true when the head moved since the comment was written, so
112121 // its line may have moved too.
113122 Outdated bool
@@ -119,6 +128,19 @@ type CommentView struct {
119128 Resolve *ResolveForm
120129 }
121130
131+type EditForm struct {
132+ Action, Text string
133+ From int
134+}
135+type DeleteForm struct {
136+ Action string
137+ From int
138+}
139+type VerdictView struct {
140+ Author, Label, Href, Short string
141+ Stale bool
142+}
143+
122144 // ResolveForm is the POST that changes the resolved state of a comment.
123145 type ResolveForm struct {
124146 Action string
@@ -135,9 +157,12 @@ type CommentForm struct {
135157 Action string
136158 Title string
137159 // Path, Line and Side designate the line; empty for a general comment.
138- Path, Line, Side string
139- Cancel string
140- Focus bool
160+ Path, Line, Side string
161+ StartLine, ReplyTo string
162+ // Plain is a form of an issue comment: plain text, no verdict.
163+ Plain bool
164+ Cancel string
165+ Focus bool
141166 // From is the window of the page the form is on (see mrWindow), sent
142167 // back so that the answer returns to it; 0 for the first.
143168 From int
@@ -192,6 +217,7 @@ type MRPage struct {
192217 State string
193218 Open bool
194219 Signed bool
220+ NoteAuthor string
195221 Source, Target string
196222 SourceHref string
197223 TargetHref string
@@ -226,6 +252,7 @@ type MRPage struct {
226252 NComments int
227253 NUnresolved int
228254 GeneralForm *CommentForm
255+ Verdicts []VerdictView
229256
230257 Commits []MRCommit
231258 CommitsNote string
@@ -471,7 +498,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
471498
472499 p := MRPage{
473500 Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()),
474- State: mrStateName(m), Open: open, Signed: signed,
501+ State: mrStateName(m), Open: open, Signed: signed, NoteAuthor: domain.AuthorName(principal(ctx)),
475502 Source: visibleText(m.Source), Target: visibleText(m.Target),
476503 Author: s.who(m.Author),
477504 Head: head, HeadShort: short(head),
@@ -485,12 +512,15 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
485512 // Relative links point into the source branch as it is now (its head
486513 // commit); the heading ids carry their own prefix, apart from the
487514 // ids of the diff.
488- h, _, err := s.md.renderWithIDs([]byte(desc), d.name(), head, "", "mr-desc-")
515+ h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))
489516 if err != nil {
490517 return nil, 0, err
491518 }
492519 p.Desc = h
493520 }
521+ for _, v := range res.Verdicts {
522+ 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())})
523+ }
494524 p.Opened.Name = p.Author
495525 p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)
496526 if !m.CreatedAt.IsZero() {
@@ -684,9 +714,10 @@ func shellWord(s string) string {
684714 // lineRef designates a line of the diff in the query of the comment form:
685715 // "?on=new:12:path/to/file".
686716 type lineRef struct {
687- side string
688- line int
689- path string
717+ side string
718+ line int
719+ start int
720+ path string
690721 }
691722
692723 func parseOn(s string) *lineRef {
@@ -695,11 +726,21 @@ func parseOn(s string) *lineRef {
695726 return nil
696727 }
697728 num, path, ok := strings.Cut(rest, ":")
729+ start := 0
730+ if a, b, rangeOK := strings.Cut(num, "-"); rangeOK {
731+ var e error
732+ start, e = strconv.Atoi(a)
733+ end, e2 := strconv.Atoi(b)
734+ if e != nil || e2 != nil || start < 1 || start >= end {
735+ return nil
736+ }
737+ num = b
738+ }
698739 line, err := strconv.Atoi(num)
699740 if !ok || err != nil || line < 1 || path == "" {
700741 return nil
701742 }
702- return &lineRef{side: side, line: line, path: path}
743+ return &lineRef{side: side, line: line, start: start, path: path}
703744 }
704745
705746 // placeComments puts every comment under its line when the diff shown has it,
@@ -757,21 +798,66 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
757798 return index[lineKey{path, side, line}]
758799 }
759800
801+ exists := noteIDs(comments)
802+ children := map[int64][]domain.Comment{}
760803 for _, c := range comments {
804+ if c.ReplyTo != 0 && !c.Deleted {
805+ children[c.ReplyTo] = append(children[c.ReplyTo], c)
806+ }
807+ if !c.Deleted {
808+ p.NComments++
809+ }
810+ if c.ReplyTo == 0 && !c.Deleted && !c.Resolved {
811+ p.NUnresolved++
812+ }
813+ }
814+ var view func(domain.Comment, bool) CommentView
815+ view = func(c domain.Comment, replyable bool) CommentView {
761816 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,
817+ body, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"-", exists)
818+ if err != nil {
819+ body = ""
766820 }
821+ bodySplit, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"s-", exists)
822+ if err != nil {
823+ bodySplit = ""
824+ }
825+ 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}
767826 v.When, v.WhenAt = whenOf(now, c.CreatedAt)
768827 if !c.CreatedAt.IsZero() {
769828 v.Abs = absolute(c.CreatedAt)
770829 }
771- p.NComments++
772- if !c.Resolved {
773- p.NUnresolved++
830+ if c.StartLine > 0 {
831+ v.Range = "lines " + itoa(c.StartLine) + "–" + itoa(c.Line)
774832 }
833+ if canWrite && !c.Deleted {
834+ from := windowStart(fileAt[c.Path])
835+ root := cid
836+ if c.ReplyTo != 0 {
837+ root = strconv.FormatInt(c.ReplyTo, 10)
838+ }
839+ if replyable {
840+ v.Quote = "> " + strings.ReplaceAll(c.Text.String(), "\n", "\n> ") + "\n\n^" + cid + " "
841+ }
842+ if c.ReplyTo == 0 {
843+ v.Reply = &CommentForm{ID: "reply-" + cid, Action: mrHref(repo, n, "comments"), Title: "Reply", ReplyTo: root, From: from}
844+ }
845+ if c.Author == p.NoteAuthor {
846+ v.Edit = &EditForm{Action: mrHref(repo, n, "comments", cid, "edit"), Text: c.Text.String(), From: from}
847+ v.Delete = &DeleteForm{Action: mrHref(repo, n, "comments", cid, "delete"), From: from}
848+ }
849+ }
850+ for _, reply := range children[c.ID] {
851+ v.Replies = append(v.Replies, view(reply, replyable))
852+ }
853+ return v
854+ }
855+ for _, c := range comments {
856+ if c.ReplyTo != 0 || c.Deleted && c.Verdict == domain.VerdictNone && len(children[c.ID]) == 0 {
857+ continue
858+ }
859+ cid := strconv.FormatInt(c.ID, 10)
860+ v := view(c, canWrite && !c.Deleted)
775861 general := c.Path == ""
776862 fi, listed := fileAt[c.Path]
777863 if !general && listed && p.Files[fi].Lazy {
@@ -789,7 +875,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
789875 v.Where = visibleText(c.Path) + ":" + itoa(c.Line) + " (" + side + ")"
790876 }
791877 }
792- if canWrite {
878+ if canWrite && !c.Deleted {
793879 v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"}
794880 if !general && listed {
795881 v.Resolve.From = windowStart(fi)
@@ -815,9 +901,18 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
815901 if on.side == "old" {
816902 side = domain.SideOld
817903 }
904+ start := on.start
905+ if start == 0 {
906+ start = on.line
907+ }
908+ for key, ln := range index {
909+ if key.path == on.path && key.side == side && key.line >= start && key.line <= on.line {
910+ ln.Selected = true
911+ }
912+ }
818913 if ln := find(on.path, side, on.line); ln != nil {
819914 ln.Form = &CommentForm{
820- ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
915+ StartLine: optionalLine(on.start), ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
821916 Title: "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",
822917 Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true,
823918 From: windowStart(fileAt[on.path]),
@@ -842,3 +937,17 @@ func userText(parts []TextPart) templ.Component {
842937 return err
843938 })
844939 }
940+
941+func noteIDs(comments []domain.Comment) func(int64) bool {
942+ ids := make(map[int64]bool, len(comments))
943+ for _, c := range comments {
944+ ids[c.ID] = true
945+ }
946+ return func(n int64) bool { return ids[n] }
947+}
948+func optionalLine(n int) string {
949+ if n == 0 {
950+ return ""
951+ }
952+ return itoa(n)
953+}
@@ -101,13 +101,22 @@ 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+ // Parts is a plain-text body (an issue comment); Body is the Markdown of
111+ // a merge request note.
112+ Parts []TextPart
113+ Replies []CommentView
114+ Edited, Deleted bool
115+ Verdict, Range, Quote string
116+ Edit *EditForm
117+ Delete *DeleteForm
118+ Reply *CommentForm
119+ Resolved bool
111 // Outdated is true when the head moved since the comment was written, so120 // Outdated is true when the head moved since the comment was written, so
112 // its line may have moved too.121 // its line may have moved too.
113 Outdated bool122 Outdated bool
@@ -119,6 +128,19 @@ type CommentView struct {
119 Resolve *ResolveForm128 Resolve *ResolveForm
120 }129 }
121 130
131+type EditForm struct {
132+ Action, Text string
133+ From int
134+}
135+type DeleteForm struct {
136+ Action string
137+ From int
138+}
139+type VerdictView struct {
140+ Author, Label, Href, Short string
141+ Stale bool
142+}
143+
122 // ResolveForm is the POST that changes the resolved state of a comment.144 // ResolveForm is the POST that changes the resolved state of a comment.
123 type ResolveForm struct {145 type ResolveForm struct {
124 Action string146 Action string
@@ -135,9 +157,12 @@ type CommentForm struct {
135 Action string157 Action string
136 Title string158 Title string
137 // Path, Line and Side designate the line; empty for a general comment.159 // Path, Line and Side designate the line; empty for a general comment.
138- Path, Line, Side string160+ Path, Line, Side string
139- Cancel string161+ StartLine, ReplyTo string
140- Focus bool162+ // Plain is a form of an issue comment: plain text, no verdict.
163+ Plain bool
164+ Cancel string
165+ Focus bool
141 // From is the window of the page the form is on (see mrWindow), sent166 // 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.167 // back so that the answer returns to it; 0 for the first.
143 From int168 From int
@@ -192,6 +217,7 @@ type MRPage struct {
192 State string217 State string
193 Open bool218 Open bool
194 Signed bool219 Signed bool
220+ NoteAuthor string
195 Source, Target string221 Source, Target string
196 SourceHref string222 SourceHref string
197 TargetHref string223 TargetHref string
@@ -226,6 +252,7 @@ type MRPage struct {
226 NComments int252 NComments int
227 NUnresolved int253 NUnresolved int
228 GeneralForm *CommentForm254 GeneralForm *CommentForm
255+ Verdicts []VerdictView
229 256
230 Commits []MRCommit257 Commits []MRCommit
231 CommitsNote string258 CommitsNote string
@@ -471,7 +498,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
471 498
472 p := MRPage{499 p := MRPage{
473 Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()),500 Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()),
474- State: mrStateName(m), Open: open, Signed: signed,501+ State: mrStateName(m), Open: open, Signed: signed, NoteAuthor: domain.AuthorName(principal(ctx)),
475 Source: visibleText(m.Source), Target: visibleText(m.Target),502 Source: visibleText(m.Source), Target: visibleText(m.Target),
476 Author: s.who(m.Author),503 Author: s.who(m.Author),
477 Head: head, HeadShort: short(head),504 Head: head, HeadShort: short(head),
@@ -485,12 +512,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 head512 // Relative links point into the source branch as it is now (its head
486 // commit); the heading ids carry their own prefix, apart from the513 // commit); the heading ids carry their own prefix, apart from the
487 // ids of the diff.514 // ids of the diff.
488- h, _, err := s.md.renderWithIDs([]byte(desc), d.name(), head, "", "mr-desc-")515+ h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))
489 if err != nil {516 if err != nil {
490 return nil, 0, err517 return nil, 0, err
491 }518 }
492 p.Desc = h519 p.Desc = h
493 }520 }
521+ for _, v := range res.Verdicts {
522+ 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())})
523+ }
494 p.Opened.Name = p.Author524 p.Opened.Name = p.Author
495 p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)525 p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)
496 if !m.CreatedAt.IsZero() {526 if !m.CreatedAt.IsZero() {
@@ -684,9 +714,10 @@ func shellWord(s string) string {
684 // lineRef designates a line of the diff in the query of the comment form:714 // lineRef designates a line of the diff in the query of the comment form:
685 // "?on=new:12:path/to/file".715 // "?on=new:12:path/to/file".
686 type lineRef struct {716 type lineRef struct {
687- side string717+ side string
688- line int718+ line int
689- path string719+ start int
720+ path string
690 }721 }
691 722
692 func parseOn(s string) *lineRef {723 func parseOn(s string) *lineRef {
@@ -695,11 +726,21 @@ func parseOn(s string) *lineRef {
695 return nil726 return nil
696 }727 }
697 num, path, ok := strings.Cut(rest, ":")728 num, path, ok := strings.Cut(rest, ":")
729+ start := 0
730+ if a, b, rangeOK := strings.Cut(num, "-"); rangeOK {
731+ var e error
732+ start, e = strconv.Atoi(a)
733+ end, e2 := strconv.Atoi(b)
734+ if e != nil || e2 != nil || start < 1 || start >= end {
735+ return nil
736+ }
737+ num = b
738+ }
698 line, err := strconv.Atoi(num)739 line, err := strconv.Atoi(num)
699 if !ok || err != nil || line < 1 || path == "" {740 if !ok || err != nil || line < 1 || path == "" {
700 return nil741 return nil
701 }742 }
702- return &lineRef{side: side, line: line, path: path}743+ return &lineRef{side: side, line: line, start: start, path: path}
703 }744 }
704 745
705 // placeComments puts every comment under its line when the diff shown has it,746 // placeComments puts every comment under its line when the diff shown has it,
@@ -757,21 +798,66 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
757 return index[lineKey{path, side, line}]798 return index[lineKey{path, side, line}]
758 }799 }
759 800
801+ exists := noteIDs(comments)
802+ children := map[int64][]domain.Comment{}
760 for _, c := range comments {803 for _, c := range comments {
804+ if c.ReplyTo != 0 && !c.Deleted {
805+ children[c.ReplyTo] = append(children[c.ReplyTo], c)
806+ }
807+ if !c.Deleted {
808+ p.NComments++
809+ }
810+ if c.ReplyTo == 0 && !c.Deleted && !c.Resolved {
811+ p.NUnresolved++
812+ }
813+ }
814+ var view func(domain.Comment, bool) CommentView
815+ view = func(c domain.Comment, replyable bool) CommentView {
761 cid := strconv.FormatInt(c.ID, 10)816 cid := strconv.FormatInt(c.ID, 10)
762- v := CommentView{817+ body, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"-", exists)
763- ID: cid, Author: s.who(c.Author),818+ if err != nil {
764- Parts: plainParts(c.Text.String()), Resolved: c.Resolved,819+ body = ""
765- Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head,
766 }820 }
821+ bodySplit, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"s-", exists)
822+ if err != nil {
823+ bodySplit = ""
824+ }
825+ 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)826 v.When, v.WhenAt = whenOf(now, c.CreatedAt)
768 if !c.CreatedAt.IsZero() {827 if !c.CreatedAt.IsZero() {
769 v.Abs = absolute(c.CreatedAt)828 v.Abs = absolute(c.CreatedAt)
770 }829 }
771- p.NComments++830+ if c.StartLine > 0 {
772- if !c.Resolved {831+ v.Range = "lines " + itoa(c.StartLine) + "–" + itoa(c.Line)
773- p.NUnresolved++
774 }832 }
833+ if canWrite && !c.Deleted {
834+ from := windowStart(fileAt[c.Path])
835+ root := cid
836+ if c.ReplyTo != 0 {
837+ root = strconv.FormatInt(c.ReplyTo, 10)
838+ }
839+ if replyable {
840+ v.Quote = "> " + strings.ReplaceAll(c.Text.String(), "\n", "\n> ") + "\n\n^" + cid + " "
841+ }
842+ if c.ReplyTo == 0 {
843+ v.Reply = &CommentForm{ID: "reply-" + cid, Action: mrHref(repo, n, "comments"), Title: "Reply", ReplyTo: root, From: from}
844+ }
845+ if c.Author == p.NoteAuthor {
846+ v.Edit = &EditForm{Action: mrHref(repo, n, "comments", cid, "edit"), Text: c.Text.String(), From: from}
847+ v.Delete = &DeleteForm{Action: mrHref(repo, n, "comments", cid, "delete"), From: from}
848+ }
849+ }
850+ for _, reply := range children[c.ID] {
851+ v.Replies = append(v.Replies, view(reply, replyable))
852+ }
853+ return v
854+ }
855+ for _, c := range comments {
856+ if c.ReplyTo != 0 || c.Deleted && c.Verdict == domain.VerdictNone && len(children[c.ID]) == 0 {
857+ continue
858+ }
859+ cid := strconv.FormatInt(c.ID, 10)
860+ v := view(c, canWrite && !c.Deleted)
775 general := c.Path == ""861 general := c.Path == ""
776 fi, listed := fileAt[c.Path]862 fi, listed := fileAt[c.Path]
777 if !general && listed && p.Files[fi].Lazy {863 if !general && listed && p.Files[fi].Lazy {
@@ -789,7 +875,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
789 v.Where = visibleText(c.Path) + ":" + itoa(c.Line) + " (" + side + ")"875 v.Where = visibleText(c.Path) + ":" + itoa(c.Line) + " (" + side + ")"
790 }876 }
791 }877 }
792- if canWrite {878+ if canWrite && !c.Deleted {
793 v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"}879 v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"}
794 if !general && listed {880 if !general && listed {
795 v.Resolve.From = windowStart(fi)881 v.Resolve.From = windowStart(fi)
@@ -815,9 +901,18 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
815 if on.side == "old" {901 if on.side == "old" {
816 side = domain.SideOld902 side = domain.SideOld
817 }903 }
904+ start := on.start
905+ if start == 0 {
906+ start = on.line
907+ }
908+ for key, ln := range index {
909+ if key.path == on.path && key.side == side && key.line >= start && key.line <= on.line {
910+ ln.Selected = true
911+ }
912+ }
818 if ln := find(on.path, side, on.line); ln != nil {913 if ln := find(on.path, side, on.line); ln != nil {
819 ln.Form = &CommentForm{914 ln.Form = &CommentForm{
820- ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,915+ 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 + ")",916 Title: "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",
822 Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true,917 Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true,
823 From: windowStart(fileAt[on.path]),918 From: windowStart(fileAt[on.path]),
@@ -842,3 +937,17 @@ func userText(parts []TextPart) templ.Component {
842 return err937 return err
843 })938 })
844 }939 }
940+
941+func noteIDs(comments []domain.Comment) func(int64) bool {
942+ ids := make(map[int64]bool, len(comments))
943+ for _, c := range comments {
944+ ids[c.ID] = true
945+ }
946+ return func(n int64) bool { return ids[n] }
947+}
948+func optionalLine(n int) string {
949+ if n == 0 {
950+ return ""
951+ }
952+ return itoa(n)
953+}
M internal/adapters/web/mr.templ +73 -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,49 @@ 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 len(c.Parts) > 0 {
251+ <p class="c-text">@userText(c.Parts)</p>
252+ } else if c.Deleted {
253+ if c.Verdict != "" {
254+ <p class="mute">text deleted</p>
255+ } else {
256+ <p class="mute">deleted note</p>
257+ }
258+ } else {
259+ <div class="c-body prose">
260+ if suffix == "-s" {
261+ @templ.Raw(string(c.BodySplit))
262+ } else {
263+ @templ.Raw(string(c.Body))
264+ }
265+ </div>
266+ }
267+ if c.Edited {
268+ <span class="mute">edited</span>
269+ }
270+ if c.Verdict != "" {
271+ <span class="c-state">{ c.Verdict }</span>
272+ }
273+ if c.Range != "" {
274+ <span class="mute">{ c.Range }</span>
275+ }
276+ if c.Quote != "" {
277+ <button type="button" class="btn" data-js data-quote={ c.Quote }>Quote</button>
278+ }
279+ if c.Edit != nil {
280+ <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>
281+ }
282+ if c.Delete != nil {
283+ <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>
284+ }
285+ for _, reply := range c.Replies {
286+ @commentView(reply, suffix)
287+ }
288+ if c.Reply != nil {
289+ <details data-reply>
290+ <summary>Reply</summary>@commentForm(*c.Reply, suffix)
291+ </details>
292+ }
252293 if c.Resolve != nil {
253294 <form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>
254295 <input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/>
@@ -277,8 +318,12 @@ templ commentForm(f CommentForm, suffix string) {
277318 name="text"
278319 rows="3"
279320 maxlength="8000"
280- required
281- placeholder="plain text, 8000 characters at most"
321+ if f.Plain {
322+ required
323+ placeholder="plain text, 8000 characters at most"
324+ } else {
325+ placeholder="Markdown, 8000 characters at most"
326+ }
282327 if f.Focus {
283328 autofocus
284329 }
@@ -287,6 +332,13 @@ templ commentForm(f CommentForm, suffix string) {
287332 <input type="hidden" name="path" value={ f.Path }/>
288333 <input type="hidden" name="line" value={ f.Line }/>
289334 <input type="hidden" name="side" value={ f.Side }/>
335+ <label>from line <input type="number" name="start_line" min="1" value={ f.StartLine }/></label>
336+ }
337+ if f.ReplyTo != "" {
338+ <input type="hidden" name="reply_to" value={ f.ReplyTo }/>
339+ }
340+ if f.Path == "" && f.ReplyTo == "" && !f.Plain {
341+ <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>
290342 }
291343 if f.From > 0 {
292344 <input type="hidden" name="from" value={ itoa(f.From) }/>
@@ -310,6 +362,21 @@ templ mrRail(p MRPage) {
310362 </h2>
311363 @FileList(p.Files)
312364 </section>
365+ if len(p.Verdicts) > 0 {
366+ <section>
367+ <h2>Verdicts</h2>
368+ <ul>
369+ for _, v := range p.Verdicts {
370+ <li>
371+ <a href={ templ.SafeURL(v.Href) }>{ v.Author }: { v.Label }</a>
372+ if v.Stale {
373+ <span class="mute">(stale)</span>
374+ }
375+ </li>
376+ }
377+ </ul>
378+ </section>
379+ }
313380 @MRChecks(p.Checks)
314381 <section aria-labelledby="r-commits">
315382 <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,49 @@ 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 len(c.Parts) > 0 {
251+ <p class="c-text">@userText(c.Parts)</p>
252+ } else if c.Deleted {
253+ if c.Verdict != "" {
254+ <p class="mute">text deleted</p>
255+ } else {
256+ <p class="mute">deleted note</p>
257+ }
258+ } else {
259+ <div class="c-body prose">
260+ if suffix == "-s" {
261+ @templ.Raw(string(c.BodySplit))
262+ } else {
263+ @templ.Raw(string(c.Body))
264+ }
265+ </div>
266+ }
267+ if c.Edited {
268+ <span class="mute">edited</span>
269+ }
270+ if c.Verdict != "" {
271+ <span class="c-state">{ c.Verdict }</span>
272+ }
273+ if c.Range != "" {
274+ <span class="mute">{ c.Range }</span>
275+ }
276+ if c.Quote != "" {
277+ <button type="button" class="btn" data-js data-quote={ c.Quote }>Quote</button>
278+ }
279+ if c.Edit != nil {
280+ <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>
281+ }
282+ if c.Delete != nil {
283+ <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>
284+ }
285+ for _, reply := range c.Replies {
286+ @commentView(reply, suffix)
287+ }
288+ if c.Reply != nil {
289+ <details data-reply>
290+ <summary>Reply</summary>@commentForm(*c.Reply, suffix)
291+ </details>
292+ }
252 if c.Resolve != nil {293 if c.Resolve != nil {
253 <form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>294 <form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>
254 <input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/>295 <input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/>
@@ -277,8 +318,12 @@ templ commentForm(f CommentForm, suffix string) {
277 name="text"318 name="text"
278 rows="3"319 rows="3"
279 maxlength="8000"320 maxlength="8000"
280- required321+ if f.Plain {
281- placeholder="plain text, 8000 characters at most"322+ required
323+ placeholder="plain text, 8000 characters at most"
324+ } else {
325+ placeholder="Markdown, 8000 characters at most"
326+ }
282 if f.Focus {327 if f.Focus {
283 autofocus328 autofocus
284 }329 }
@@ -287,6 +332,13 @@ templ commentForm(f CommentForm, suffix string) {
287 <input type="hidden" name="path" value={ f.Path }/>332 <input type="hidden" name="path" value={ f.Path }/>
288 <input type="hidden" name="line" value={ f.Line }/>333 <input type="hidden" name="line" value={ f.Line }/>
289 <input type="hidden" name="side" value={ f.Side }/>334 <input type="hidden" name="side" value={ f.Side }/>
335+ <label>from line <input type="number" name="start_line" min="1" value={ f.StartLine }/></label>
336+ }
337+ if f.ReplyTo != "" {
338+ <input type="hidden" name="reply_to" value={ f.ReplyTo }/>
339+ }
340+ if f.Path == "" && f.ReplyTo == "" && !f.Plain {
341+ <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 }342 }
291 if f.From > 0 {343 if f.From > 0 {
292 <input type="hidden" name="from" value={ itoa(f.From) }/>344 <input type="hidden" name="from" value={ itoa(f.From) }/>
@@ -310,6 +362,21 @@ templ mrRail(p MRPage) {
310 </h2>362 </h2>
311 @FileList(p.Files)363 @FileList(p.Files)
312 </section>364 </section>
365+ if len(p.Verdicts) > 0 {
366+ <section>
367+ <h2>Verdicts</h2>
368+ <ul>
369+ for _, v := range p.Verdicts {
370+ <li>
371+ <a href={ templ.SafeURL(v.Href) }>{ v.Author }: { v.Label }</a>
372+ if v.Stale {
373+ <span class="mute">(stale)</span>
374+ }
375+ </li>
376+ }
377+ </ul>
378+ </section>
379+ }
313 @MRChecks(p.Checks)380 @MRChecks(p.Checks)
314 <section aria-labelledby="r-commits">381 <section aria-labelledby="r-commits">
315 <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2>382 <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
@@ -184,6 +184,23 @@ func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string
184184 return "", back, formError("side must be new or old")
185185 }
186186 }
187+ if raw := r.PostFormValue("reply_to"); raw != "" {
188+ params.ReplyTo = commentID(raw)
189+ if params.ReplyTo == 0 {
190+ return "", back, formError("reply_to is not a note id")
191+ }
192+ }
193+ if raw := r.PostFormValue("start_line"); raw != "" {
194+ v, e := strconv.ParseInt(raw, 10, 32)
195+ if e != nil {
196+ return "", back, formError("start_line is not a number")
197+ }
198+ params.StartLine = int(v)
199+ }
200+ params.Verdict, err = domain.ParseVerdict(r.PostFormValue("verdict"))
201+ if err != nil {
202+ return "", back, err
203+ }
187204 name, err := domain.ParseRepoName(repo)
188205 if err != nil {
189206 return "", back, err
@@ -363,3 +380,33 @@ func formBool(s string) (bool, error) {
363380 func normalizeNewlines(s string) string {
364381 return strings.ReplaceAll(s, "\r\n", "\n")
365382 }
383+
384+// mrEditComment passes source text to the author-checked use case.
385+func (s *Server) mrEditComment(ctx context.Context, r *http.Request) (string, string, error) {
386+ n, err := number(r)
387+ if err != nil {
388+ return "", "", err
389+ }
390+ back := withFrom(mrHref(r.PathValue("repo"), n), formFrom(r))
391+ name, err := domain.ParseRepoName(r.PathValue("repo"))
392+ if err != nil {
393+ return "", back, err
394+ }
395+ c, err := s.cfg.MergeRequests.EditComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), normalizeNewlines(r.PostFormValue("text")))
396+ return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
397+}
398+
399+// mrDeleteComment passes the note id to the author-checked use case.
400+func (s *Server) mrDeleteComment(ctx context.Context, r *http.Request) (string, string, error) {
401+ n, err := number(r)
402+ if err != nil {
403+ return "", "", err
404+ }
405+ back := withFrom(mrHref(r.PathValue("repo"), n), formFrom(r))
406+ name, err := domain.ParseRepoName(r.PathValue("repo"))
407+ if err != nil {
408+ return "", back, err
409+ }
410+ c, err := s.cfg.MergeRequests.DeleteComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")))
411+ return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
412+}
@@ -184,6 +184,23 @@ func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string
184 return "", back, formError("side must be new or old")184 return "", back, formError("side must be new or old")
185 }185 }
186 }186 }
187+ if raw := r.PostFormValue("reply_to"); raw != "" {
188+ params.ReplyTo = commentID(raw)
189+ if params.ReplyTo == 0 {
190+ return "", back, formError("reply_to is not a note id")
191+ }
192+ }
193+ if raw := r.PostFormValue("start_line"); raw != "" {
194+ v, e := strconv.ParseInt(raw, 10, 32)
195+ if e != nil {
196+ return "", back, formError("start_line is not a number")
197+ }
198+ params.StartLine = int(v)
199+ }
200+ params.Verdict, err = domain.ParseVerdict(r.PostFormValue("verdict"))
201+ if err != nil {
202+ return "", back, err
203+ }
187 name, err := domain.ParseRepoName(repo)204 name, err := domain.ParseRepoName(repo)
188 if err != nil {205 if err != nil {
189 return "", back, err206 return "", back, err
@@ -363,3 +380,33 @@ func formBool(s string) (bool, error) {
363 func normalizeNewlines(s string) string {380 func normalizeNewlines(s string) string {
364 return strings.ReplaceAll(s, "\r\n", "\n")381 return strings.ReplaceAll(s, "\r\n", "\n")
365 }382 }
383+
384+// mrEditComment passes source text to the author-checked use case.
385+func (s *Server) mrEditComment(ctx context.Context, r *http.Request) (string, string, error) {
386+ n, err := number(r)
387+ if err != nil {
388+ return "", "", err
389+ }
390+ back := withFrom(mrHref(r.PathValue("repo"), n), formFrom(r))
391+ name, err := domain.ParseRepoName(r.PathValue("repo"))
392+ if err != nil {
393+ return "", back, err
394+ }
395+ c, err := s.cfg.MergeRequests.EditComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), normalizeNewlines(r.PostFormValue("text")))
396+ return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
397+}
398+
399+// mrDeleteComment passes the note id to the author-checked use case.
400+func (s *Server) mrDeleteComment(ctx context.Context, r *http.Request) (string, string, error) {
401+ n, err := number(r)
402+ if err != nil {
403+ return "", "", err
404+ }
405+ back := withFrom(mrHref(r.PathValue("repo"), n), formFrom(r))
406+ name, err := domain.ParseRepoName(r.PathValue("repo"))
407+ if err != nil {
408+ return "", back, err
409+ }
410+ c, err := s.cfg.MergeRequests.DeleteComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")))
411+ return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
412+}
M internal/adapters/web/mr_templ.go +801 -480 generated file: collapsed
M internal/adapters/web/static/app/apple-touch-icon.png +0 -0 binary file: no text diff

binary file: no text diff

M internal/adapters/web/static/app/icon-maskable-512.png +0 -0 binary file: no text diff

binary file: no text diff

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; }
@@ -215,3 +213,10 @@
215213 .list.mrs .c-who { display: none; }
216214 }
217215 }
216+
217+@layer components {
218+ .c-body { white-space: normal; overflow-wrap: anywhere; }
219+ .c-body.prose > * { margin-block: .5em; }
220+ .c-body .ref { font-variant-numeric: tabular-nums; text-decoration: underline; }
221+ .cmt > .cmt { margin-inline-start: 2ch; }
222+}
@@ -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; }
@@ -215,3 +213,10 @@
215 .list.mrs .c-who { display: none; }213 .list.mrs .c-who { display: none; }
216 }214 }
217 }215 }
216+
217+@layer components {
218+ .c-body { white-space: normal; overflow-wrap: anywhere; }
219+ .c-body.prose > * { margin-block: .5em; }
220+ .c-body .ref { font-variant-numeric: tabular-nums; text-decoration: underline; }
221+ .cmt > .cmt { margin-inline-start: 2ch; }
222+}
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
@@ -220,6 +220,8 @@ func (s *Server) routes() *http.ServeMux {
220220 mux.HandleFunc("POST /{repo}/merge-requests", s.mrAction(s.mrCreate))
221221 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments", s.mrAction(s.mrComment))
222222 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/resolve", s.mrAction(s.mrResolve))
223+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/edit", s.mrAction(s.mrEditComment))
224+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/delete", s.mrAction(s.mrDeleteComment))
223225 mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))
224226 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
225227 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
@@ -220,6 +220,8 @@ func (s *Server) routes() *http.ServeMux {
220 mux.HandleFunc("POST /{repo}/merge-requests", s.mrAction(s.mrCreate))220 mux.HandleFunc("POST /{repo}/merge-requests", s.mrAction(s.mrCreate))
221 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments", s.mrAction(s.mrComment))221 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments", s.mrAction(s.mrComment))
222 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/resolve", s.mrAction(s.mrResolve))222 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/resolve", s.mrAction(s.mrResolve))
223+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/edit", s.mrAction(s.mrEditComment))
224+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/delete", s.mrAction(s.mrDeleteComment))
223 mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))225 mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))
224 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))226 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
225 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))227 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
M internal/app/cli_mr.go +252 -41
@@ -7,6 +7,7 @@ import (
77 "errors"
88 "fmt"
99 "io"
10+ "net/url"
1011 "strconv"
1112 "strings"
1213
@@ -101,6 +102,7 @@ func parseSide(s string) (ermite.DiffSide, error) {
101102 // jsonMergeRequest is the stable JSON shape of a merge request.
102103 type jsonMergeRequest struct {
103104 Repo string `json:"repo"`
105+ URL string `json:"url"`
104106 Number int64 `json:"number"`
105107 Title string `json:"title"`
106108 Description string `json:"description"`
@@ -121,8 +123,9 @@ type jsonMerged struct {
121123 SourceBranchDeleted bool `json:"source_branch_deleted"`
122124 }
123125
124-func toJSONMergeRequest(m *ermite.MergeRequest) jsonMergeRequest {
126+func toJSONMergeRequest(m *ermite.MergeRequest, baseURL string) jsonMergeRequest {
125127 return jsonMergeRequest{
128+ URL: mrWebURL(baseURL, m.GetRepo(), m.GetNumber()),
126129 Repo: m.GetRepo(), Number: m.GetNumber(), Title: m.GetTitle(), Description: m.GetDescription(),
127130 SourceBranch: m.GetSourceBranch(), TargetBranch: m.GetTargetBranch(), State: stateName(m.GetState()),
128131 Author: m.GetAuthor(), CreatedAt: jsonTime(m.GetCreatedAt()), UpdatedAt: jsonTime(m.GetUpdatedAt()),
@@ -141,17 +144,33 @@ type jsonComment struct {
141144 Text string `json:"text"`
142145 Resolved bool `json:"resolved"`
143146 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 ""
144162 }
145163
146164 func toJSONComment(c *ermite.Comment) jsonComment {
147165 return jsonComment{
148166 ID: c.GetId(), Author: c.GetAuthor(), Path: c.GetPath(), Line: c.GetLine(), Side: sideName(c.GetSide()),
149167 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(),
150169 }
151170 }
152171
153172 const jsonMRHelp = `
154-JSON fields of a merge request (--json): repo, number, title, description, source_branch,
173+JSON fields of a merge request (--json): repo, url (full web URL), number, title, description, source_branch,
155174 target_branch, state ("open", "merged" or "closed"), author (token label), created_at and
156175 updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the
157176 branch is gone), merged_commit_id and merge_mode ("fast-forward" or "squash"; both empty
@@ -160,7 +179,8 @@ unless merged).`
160179 const jsonCommentHelp = `
161180 JSON fields of a comment: id, author, path, line and side ("old" or "new"; empty path, line 0
162181 and empty side for a general comment), commit_id (the head when it was written), text,
163-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.`
164184
165185 func newMRCmd(env *cliEnv) *cobra.Command {
166186 mr := &cobra.Command{
@@ -172,7 +192,7 @@ func newMRCmd(env *cliEnv) *cobra.Command {
172192 }
173193 mr.AddCommand(
174194 newMRCreateCmd(env), newMRListCmd(env), newMRShowCmd(env), newMRCommentCmd(env),
175- newMRResolveCmd(env), newMRMergeCmd(env), newMRCloseCmd(env),
195+ newMRResolveCmd(env), newMREditCommentCmd(env), newMRDeleteCommentCmd(env), newMRMergeCmd(env), newMRCloseCmd(env),
176196 )
177197 return mr
178198 }
@@ -245,7 +265,8 @@ func newMRCreateCmd(env *cliEnv) *cobra.Command {
245265 "The title is one line of at most 120 characters, the description plain text of at most 8000;\n" +
246266 "neither may hold control characters (a description may hold line breaks) or bidirectional\n" +
247267 "override characters. --description - reads it from standard input. Fails if an open merge request\n" +
248- "already has the same source and target. Needs the mr:write scope." + jsonMRHelp,
268+ "already has the same source and target. Prints a confirmation, then the full web URL on its own line.\n" +
269+ "Needs the mr:write scope." + jsonMRHelp,
249270 Args: cobra.ExactArgs(1),
250271 ValidArgsFunction: completeRepoNames(env),
251272 }
@@ -273,20 +294,26 @@ func newMRCreateCmd(env *cliEnv) *cobra.Command {
273294 if err != nil {
274295 return c.Describe(err)
275296 }
276- return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "opened")
297+ return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "opened", c.URL())
277298 }
278299 return cmd
279300 }
280301
281302 func trimBranch(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "refs/heads/") }
282303
283-// outputMR prints a merge request as JSON, or as one confirmation line.
284-func outputMR(cmd *cobra.Command, m *ermite.MergeRequest, asJSON bool, verb string) error {
304+// mrWebURL builds the web route with the repository as a single path segment.
305+// Repository names currently forbid slashes, but escape all path delimiters defensively.
306+func mrWebURL(baseURL, repo string, number int64) string {
307+ return strings.TrimRight(baseURL, "/") + "/" + url.PathEscape(repo) + "/merge-requests/" + strconv.FormatInt(number, 10)
308+}
309+
310+// outputMR prints a merge request as JSON, or a confirmation followed by its URL.
311+func outputMR(cmd *cobra.Command, m *ermite.MergeRequest, asJSON bool, verb, baseURL string) error {
285312 if asJSON {
286- return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m))
313+ return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, baseURL))
287314 }
288- _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s!%d (%s into %s)\n", verb, clean(m.GetRepo()), m.GetNumber(),
289- clean(m.GetSourceBranch()), clean(m.GetTargetBranch()))
315+ _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s!%d (%s into %s)\n%s\n", verb, clean(m.GetRepo()), m.GetNumber(),
316+ clean(m.GetSourceBranch()), clean(m.GetTargetBranch()), mrWebURL(baseURL, m.GetRepo(), m.GetNumber()))
290317 return err
291318 }
292319
@@ -321,7 +348,7 @@ func newMRListCmd(env *cliEnv) *cobra.Command {
321348 if *asJSON {
322349 out := make([]jsonMergeRequest, 0, len(mrs))
323350 for _, m := range mrs {
324- out = append(out, toJSONMergeRequest(m))
351+ out = append(out, toJSONMergeRequest(m, c.URL()))
325352 }
326353 return writeJSON(cmd.OutOrStdout(), out)
327354 }
@@ -385,15 +412,25 @@ type jsonCommit struct {
385412 // jsonMRDetail is the stable JSON shape of `mr show --json`.
386413 type jsonMRDetail struct {
387414 jsonMergeRequest
388- Commits []jsonCommit `json:"commits"`
389- CommitsTruncated bool `json:"commits_truncated"`
390- DiffBaseID string `json:"diff_base_id"`
391- Stats jsonStats `json:"stats"`
392- DiffTruncated bool `json:"diff_truncated"`
393- Files []jsonFile `json:"files"`
394- Comments []jsonComment `json:"comments"`
395- LastReadID string `json:"last_read_id"`
396- LastReadLost bool `json:"last_read_lost"`
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"`
397434 }
398435
399436 func fileStatusName(s ermite.FileStatus) string {
@@ -404,16 +441,16 @@ func lineKindName(k ermite.LineKind) string {
404441 return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_"))
405442 }
406443
407-func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool) jsonMRDetail {
444+func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail {
408445 out := jsonMRDetail{
409- jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest()),
446+ jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL),
410447 Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(),
411448 DiffBaseID: d.GetDiffBaseId(),
412449 Stats: jsonStats{
413450 FilesChanged: d.GetStats().GetFilesChanged(), Additions: d.GetStats().GetAdditions(), Deletions: d.GetStats().GetDeletions(),
414451 },
415452 DiffTruncated: d.GetDiff().GetTruncated(),
416- Files: []jsonFile{}, Comments: []jsonComment{},
453+ Files: []jsonFile{}, Comments: []jsonComment{}, Verdicts: []jsonAuthorVerdict{},
417454 LastReadID: d.GetLastReadId(), LastReadLost: d.GetLastReadLost(),
418455 }
419456 for _, c := range d.GetCommits() {
@@ -443,6 +480,9 @@ func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool) jsonMRDetail
443480 for _, c := range d.GetComments() {
444481 out.Comments = append(out.Comments, toJSONComment(c))
445482 }
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+ }
446486 return out
447487 }
448488
@@ -461,7 +501,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
461501 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +
462502 "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" +
463503 "with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" +
464- "and last_read_lost.",
504+ "and last_read_lost; verdicts (author, verdict, comment_id, commit_id, created_at, stale).",
465505 Args: cobra.ExactArgs(2),
466506 ValidArgsFunction: completeMRRepoThenNumber(env),
467507 }
@@ -487,7 +527,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
487527 }
488528 d := res.Msg
489529 if *asJSON {
490- err = writeJSON(cmd.OutOrStdout(), toJSONDetail(d, withDiff))
530+ err = writeJSON(cmd.OutOrStdout(), toJSONDetail(d, withDiff, c.URL()))
491531 } else {
492532 err = printMRDetail(cmd.OutOrStdout(), d, withDiff, sinceLastRead)
493533 }
@@ -529,6 +569,25 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
529569 if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED {
530570 pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"})
531571 }
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+ }
532591 if err := fields(w, pairs); err != nil {
533592 return err
534593 }
@@ -561,16 +620,46 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
561620 }
562621
563622 _, _ = fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments()))
564- 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 {
565636 where := "general"
566637 if c.GetPath() != "" {
567638 where = fmt.Sprintf("%s:%d (%s)", clean(c.GetPath()), c.GetLine(), sideName(c.GetSide()))
568639 }
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+ }
569643 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+ }
570653 if c.GetResolved() {
571- mark = " [resolved]"
654+ mark += " [resolved]"
655+ }
656+ prefix := " "
657+ body := indentText(c.GetText())
658+ if c.GetReplyTo() != "" {
659+ prefix += " "
660+ body = " " + strings.ReplaceAll(body, "\n", "\n ")
572661 }
573- _, _ = fmt.Fprintf(w, " #%s %s, %s, %s%s\n%s\n", c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), where, mark, indentText(c.GetText()))
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)
574663 }
575664 if withDiff {
576665 printDiff(w, d.GetDiff())
@@ -643,12 +732,14 @@ func printDiff(w io.Writer, d *ermite.Diff) {
643732 }
644733
645734 func newMRCommentCmd(env *cliEnv) *cobra.Command {
646- var message, path, side string
647- var line int
735+ var message, path, side, line, reply string
736+ var approve, requestChanges bool
648737 cmd := &cobra.Command{
649738 Use: "comment <repo> <number>",
650739 Short: "Comment on a merge request",
651- 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" +
652743 "-m - reads it from standard input. Without --path it is a general comment. With --path, --line and\n" +
653744 "--side (default new) it is attached to a line of the current diff: the path as in `mr show --diff`\n" +
654745 "(the new path, the old one for a deleted file), the line number on the old or new side; a line\n" +
@@ -656,11 +747,18 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command {
656747 Args: cobra.ExactArgs(2),
657748 ValidArgsFunction: completeMRRepoThenNumber(env),
658749 }
659- 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)")
660751 cmd.Flags().StringVar(&path, "path", "", "file the comment is attached to")
661- 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)")
662756 cmd.Flags().StringVar(&side, "side", sideNew, "side the line counts on: old or new")
663- _ = 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.
664762 _ = cmd.RegisterFlagCompletionFunc("side", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
665763 return []string{sideOld, sideNew}, cobra.ShellCompDirectiveNoFileComp
666764 })
@@ -674,17 +772,36 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command {
674772 if err != nil {
675773 return err
676774 }
775+ if !cmd.Flags().Changed("message") && !approve && !requestChanges {
776+ return errors.New("--message is required unless --approve or --request-changes is set")
777+ }
677778 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+ }
678791 switch {
679792 case path != "":
680- if line < 1 {
793+ if line == "" {
681794 return errors.New("--line is required with --path: give the line number")
682795 }
796+ req.StartLine, req.Line, err = parseLineRange(line)
797+ if err != nil {
798+ return err
799+ }
683800 if req.Side, err = parseSide(side); err != nil {
684801 return err
685802 }
686- req.Path, req.Line = path, int32(line) //nolint:gosec // a line number typed by a person
687- case line != 0 || cmd.Flags().Changed("side"):
803+ req.Path = path
804+ case cmd.Flags().Changed("line") || cmd.Flags().Changed("side"):
688805 return errors.New("--line and --side need --path")
689806 }
690807 c, err := env.client(cmd)
@@ -704,6 +821,100 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command {
704821 return cmd
705822 }
706823
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+
707918 func newMRResolveCmd(env *cliEnv) *cobra.Command {
708919 var undo bool
709920 cmd := &cobra.Command{
@@ -803,7 +1014,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
8031014 clean(m.GetSourceBranch()))
8041015 }
8051016 if *asJSON {
806- return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m), SourceBranchDeleted: deleted})
1017+ return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m, c.URL()), SourceBranchDeleted: deleted})
8071018 }
8081019 _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(),
8091020 clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId()))
@@ -820,7 +1031,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command {
8201031 Use: "close <repo> <number>",
8211032 Short: "Close a merge request without merging it",
8221033 Long: "Close an open merge request without merging it. A closed merge request cannot be reopened: open a\n" +
823- "new one. Needs the mr:write scope." + jsonMRHelp,
1034+ "new one. Prints a confirmation, then the full web URL on its own line. Needs the mr:write scope." + jsonMRHelp,
8241035 Args: cobra.ExactArgs(2),
8251036 ValidArgsFunction: completeMRRepoThenNumber(env),
8261037 }
@@ -838,7 +1049,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command {
8381049 if err != nil {
8391050 return c.Describe(err)
8401051 }
841- return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "closed")
1052+ return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "closed", c.URL())
8421053 }
8431054 return cmd
8441055 }
@@ -7,6 +7,7 @@ import (
7 "errors"7 "errors"
8 "fmt"8 "fmt"
9 "io"9 "io"
10+ "net/url"
10 "strconv"11 "strconv"
11 "strings"12 "strings"
12 13
@@ -101,6 +102,7 @@ func parseSide(s string) (ermite.DiffSide, error) {
101 // jsonMergeRequest is the stable JSON shape of a merge request.102 // jsonMergeRequest is the stable JSON shape of a merge request.
102 type jsonMergeRequest struct {103 type jsonMergeRequest struct {
103 Repo string `json:"repo"`104 Repo string `json:"repo"`
105+ URL string `json:"url"`
104 Number int64 `json:"number"`106 Number int64 `json:"number"`
105 Title string `json:"title"`107 Title string `json:"title"`
106 Description string `json:"description"`108 Description string `json:"description"`
@@ -121,8 +123,9 @@ type jsonMerged struct {
121 SourceBranchDeleted bool `json:"source_branch_deleted"`123 SourceBranchDeleted bool `json:"source_branch_deleted"`
122 }124 }
123 125
124-func toJSONMergeRequest(m *ermite.MergeRequest) jsonMergeRequest {126+func toJSONMergeRequest(m *ermite.MergeRequest, baseURL string) jsonMergeRequest {
125 return jsonMergeRequest{127 return jsonMergeRequest{
128+ URL: mrWebURL(baseURL, m.GetRepo(), m.GetNumber()),
126 Repo: m.GetRepo(), Number: m.GetNumber(), Title: m.GetTitle(), Description: m.GetDescription(),129 Repo: m.GetRepo(), Number: m.GetNumber(), Title: m.GetTitle(), Description: m.GetDescription(),
127 SourceBranch: m.GetSourceBranch(), TargetBranch: m.GetTargetBranch(), State: stateName(m.GetState()),130 SourceBranch: m.GetSourceBranch(), TargetBranch: m.GetTargetBranch(), State: stateName(m.GetState()),
128 Author: m.GetAuthor(), CreatedAt: jsonTime(m.GetCreatedAt()), UpdatedAt: jsonTime(m.GetUpdatedAt()),131 Author: m.GetAuthor(), CreatedAt: jsonTime(m.GetCreatedAt()), UpdatedAt: jsonTime(m.GetUpdatedAt()),
@@ -141,17 +144,33 @@ type jsonComment struct {
141 Text string `json:"text"`144 Text string `json:"text"`
142 Resolved bool `json:"resolved"`145 Resolved bool `json:"resolved"`
143 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 ""
144 }162 }
145 163
146 func toJSONComment(c *ermite.Comment) jsonComment {164 func toJSONComment(c *ermite.Comment) jsonComment {
147 return jsonComment{165 return jsonComment{
148 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()),
149 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(),
150 }169 }
151 }170 }
152 171
153 const jsonMRHelp = `172 const jsonMRHelp = `
154-JSON fields of a merge request (--json): repo, number, title, description, source_branch,173+JSON fields of a merge request (--json): repo, url (full web URL), number, title, description, source_branch,
155 target_branch, state ("open", "merged" or "closed"), author (token label), created_at and174 target_branch, state ("open", "merged" or "closed"), author (token label), created_at and
156 updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the175 updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the
157 branch is gone), merged_commit_id and merge_mode ("fast-forward" or "squash"; both empty176 branch is gone), merged_commit_id and merge_mode ("fast-forward" or "squash"; both empty
@@ -160,7 +179,8 @@ unless merged).`
160 const jsonCommentHelp = `179 const jsonCommentHelp = `
161 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
162 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,
163-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.`
164 184
165 func newMRCmd(env *cliEnv) *cobra.Command {185 func newMRCmd(env *cliEnv) *cobra.Command {
166 mr := &cobra.Command{186 mr := &cobra.Command{
@@ -172,7 +192,7 @@ func newMRCmd(env *cliEnv) *cobra.Command {
172 }192 }
173 mr.AddCommand(193 mr.AddCommand(
174 newMRCreateCmd(env), newMRListCmd(env), newMRShowCmd(env), newMRCommentCmd(env),194 newMRCreateCmd(env), newMRListCmd(env), newMRShowCmd(env), newMRCommentCmd(env),
175- newMRResolveCmd(env), newMRMergeCmd(env), newMRCloseCmd(env),195+ newMRResolveCmd(env), newMREditCommentCmd(env), newMRDeleteCommentCmd(env), newMRMergeCmd(env), newMRCloseCmd(env),
176 )196 )
177 return mr197 return mr
178 }198 }
@@ -245,7 +265,8 @@ func newMRCreateCmd(env *cliEnv) *cobra.Command {
245 "The title is one line of at most 120 characters, the description plain text of at most 8000;\n" +265 "The title is one line of at most 120 characters, the description plain text of at most 8000;\n" +
246 "neither may hold control characters (a description may hold line breaks) or bidirectional\n" +266 "neither may hold control characters (a description may hold line breaks) or bidirectional\n" +
247 "override characters. --description - reads it from standard input. Fails if an open merge request\n" +267 "override characters. --description - reads it from standard input. Fails if an open merge request\n" +
248- "already has the same source and target. Needs the mr:write scope." + jsonMRHelp,268+ "already has the same source and target. Prints a confirmation, then the full web URL on its own line.\n" +
269+ "Needs the mr:write scope." + jsonMRHelp,
249 Args: cobra.ExactArgs(1),270 Args: cobra.ExactArgs(1),
250 ValidArgsFunction: completeRepoNames(env),271 ValidArgsFunction: completeRepoNames(env),
251 }272 }
@@ -273,20 +294,26 @@ func newMRCreateCmd(env *cliEnv) *cobra.Command {
273 if err != nil {294 if err != nil {
274 return c.Describe(err)295 return c.Describe(err)
275 }296 }
276- return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "opened")297+ return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "opened", c.URL())
277 }298 }
278 return cmd299 return cmd
279 }300 }
280 301
281 func trimBranch(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "refs/heads/") }302 func trimBranch(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "refs/heads/") }
282 303
283-// outputMR prints a merge request as JSON, or as one confirmation line.304+// mrWebURL builds the web route with the repository as a single path segment.
284-func outputMR(cmd *cobra.Command, m *ermite.MergeRequest, asJSON bool, verb string) error {305+// Repository names currently forbid slashes, but escape all path delimiters defensively.
306+func mrWebURL(baseURL, repo string, number int64) string {
307+ return strings.TrimRight(baseURL, "/") + "/" + url.PathEscape(repo) + "/merge-requests/" + strconv.FormatInt(number, 10)
308+}
309+
310+// outputMR prints a merge request as JSON, or a confirmation followed by its URL.
311+func outputMR(cmd *cobra.Command, m *ermite.MergeRequest, asJSON bool, verb, baseURL string) error {
285 if asJSON {312 if asJSON {
286- return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m))313+ return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, baseURL))
287 }314 }
288- _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s!%d (%s into %s)\n", verb, clean(m.GetRepo()), m.GetNumber(),315+ _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s!%d (%s into %s)\n%s\n", verb, clean(m.GetRepo()), m.GetNumber(),
289- clean(m.GetSourceBranch()), clean(m.GetTargetBranch()))316+ clean(m.GetSourceBranch()), clean(m.GetTargetBranch()), mrWebURL(baseURL, m.GetRepo(), m.GetNumber()))
290 return err317 return err
291 }318 }
292 319
@@ -321,7 +348,7 @@ func newMRListCmd(env *cliEnv) *cobra.Command {
321 if *asJSON {348 if *asJSON {
322 out := make([]jsonMergeRequest, 0, len(mrs))349 out := make([]jsonMergeRequest, 0, len(mrs))
323 for _, m := range mrs {350 for _, m := range mrs {
324- out = append(out, toJSONMergeRequest(m))351+ out = append(out, toJSONMergeRequest(m, c.URL()))
325 }352 }
326 return writeJSON(cmd.OutOrStdout(), out)353 return writeJSON(cmd.OutOrStdout(), out)
327 }354 }
@@ -385,15 +412,25 @@ type jsonCommit struct {
385 // jsonMRDetail is the stable JSON shape of `mr show --json`.412 // jsonMRDetail is the stable JSON shape of `mr show --json`.
386 type jsonMRDetail struct {413 type jsonMRDetail struct {
387 jsonMergeRequest414 jsonMergeRequest
388- Commits []jsonCommit `json:"commits"`415+ Commits []jsonCommit `json:"commits"`
389- CommitsTruncated bool `json:"commits_truncated"`416+ CommitsTruncated bool `json:"commits_truncated"`
390- DiffBaseID string `json:"diff_base_id"`417+ DiffBaseID string `json:"diff_base_id"`
391- Stats jsonStats `json:"stats"`418+ Stats jsonStats `json:"stats"`
392- DiffTruncated bool `json:"diff_truncated"`419+ DiffTruncated bool `json:"diff_truncated"`
393- Files []jsonFile `json:"files"`420+ Files []jsonFile `json:"files"`
394- Comments []jsonComment `json:"comments"`421+ Comments []jsonComment `json:"comments"`
395- LastReadID string `json:"last_read_id"`422+ Verdicts []jsonAuthorVerdict `json:"verdicts"`
396- 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"`
397 }434 }
398 435
399 func fileStatusName(s ermite.FileStatus) string {436 func fileStatusName(s ermite.FileStatus) string {
@@ -404,16 +441,16 @@ func lineKindName(k ermite.LineKind) string {
404 return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_"))441 return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_"))
405 }442 }
406 443
407-func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool) jsonMRDetail {444+func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail {
408 out := jsonMRDetail{445 out := jsonMRDetail{
409- jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest()),446+ jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL),
410 Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(),447 Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(),
411 DiffBaseID: d.GetDiffBaseId(),448 DiffBaseID: d.GetDiffBaseId(),
412 Stats: jsonStats{449 Stats: jsonStats{
413 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(),
414 },451 },
415 DiffTruncated: d.GetDiff().GetTruncated(),452 DiffTruncated: d.GetDiff().GetTruncated(),
416- Files: []jsonFile{}, Comments: []jsonComment{},453+ Files: []jsonFile{}, Comments: []jsonComment{}, Verdicts: []jsonAuthorVerdict{},
417 LastReadID: d.GetLastReadId(), LastReadLost: d.GetLastReadLost(),454 LastReadID: d.GetLastReadId(), LastReadLost: d.GetLastReadLost(),
418 }455 }
419 for _, c := range d.GetCommits() {456 for _, c := range d.GetCommits() {
@@ -443,6 +480,9 @@ func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool) jsonMRDetail
443 for _, c := range d.GetComments() {480 for _, c := range d.GetComments() {
444 out.Comments = append(out.Comments, toJSONComment(c))481 out.Comments = append(out.Comments, toJSONComment(c))
445 }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+ }
446 return out486 return out
447 }487 }
448 488
@@ -461,7 +501,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
461 "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" +
462 "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" +
463 "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" +
464- "and last_read_lost.",504+ "and last_read_lost; verdicts (author, verdict, comment_id, commit_id, created_at, stale).",
465 Args: cobra.ExactArgs(2),505 Args: cobra.ExactArgs(2),
466 ValidArgsFunction: completeMRRepoThenNumber(env),506 ValidArgsFunction: completeMRRepoThenNumber(env),
467 }507 }
@@ -487,7 +527,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
487 }527 }
488 d := res.Msg528 d := res.Msg
489 if *asJSON {529 if *asJSON {
490- err = writeJSON(cmd.OutOrStdout(), toJSONDetail(d, withDiff))530+ err = writeJSON(cmd.OutOrStdout(), toJSONDetail(d, withDiff, c.URL()))
491 } else {531 } else {
492 err = printMRDetail(cmd.OutOrStdout(), d, withDiff, sinceLastRead)532 err = printMRDetail(cmd.OutOrStdout(), d, withDiff, sinceLastRead)
493 }533 }
@@ -529,6 +569,25 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
529 if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED {569 if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED {
530 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()) + ")"})
531 }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+ }
532 if err := fields(w, pairs); err != nil {591 if err := fields(w, pairs); err != nil {
533 return err592 return err
534 }593 }
@@ -561,16 +620,46 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
561 }620 }
562 621
563 _, _ = fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments()))622 _, _ = fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments()))
564- 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 {
565 where := "general"636 where := "general"
566 if c.GetPath() != "" {637 if c.GetPath() != "" {
567 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()))
568 }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+ }
569 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+ }
570 if c.GetResolved() {653 if c.GetResolved() {
571- mark = " [resolved]"654+ mark += " [resolved]"
655+ }
656+ prefix := " "
657+ body := indentText(c.GetText())
658+ if c.GetReplyTo() != "" {
659+ prefix += " "
660+ body = " " + strings.ReplaceAll(body, "\n", "\n ")
572 }661 }
573- _, _ = fmt.Fprintf(w, " #%s %s, %s, %s%s\n%s\n", c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), where, mark, indentText(c.GetText()))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)
574 }663 }
575 if withDiff {664 if withDiff {
576 printDiff(w, d.GetDiff())665 printDiff(w, d.GetDiff())
@@ -643,12 +732,14 @@ func printDiff(w io.Writer, d *ermite.Diff) {
643 }732 }
644 733
645 func newMRCommentCmd(env *cliEnv) *cobra.Command {734 func newMRCommentCmd(env *cliEnv) *cobra.Command {
646- var message, path, side string735+ var message, path, side, line, reply string
647- var line int736+ var approve, requestChanges bool
648 cmd := &cobra.Command{737 cmd := &cobra.Command{
649 Use: "comment <repo> <number>",738 Use: "comment <repo> <number>",
650 Short: "Comment on a merge request",739 Short: "Comment on a merge request",
651- 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" +
652 "-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" +
653 "--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" +
654 "(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" +
@@ -656,11 +747,18 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command {
656 Args: cobra.ExactArgs(2),747 Args: cobra.ExactArgs(2),
657 ValidArgsFunction: completeMRRepoThenNumber(env),748 ValidArgsFunction: completeMRRepoThenNumber(env),
658 }749 }
659- 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)")
660 cmd.Flags().StringVar(&path, "path", "", "file the comment is attached to")751 cmd.Flags().StringVar(&path, "path", "", "file the comment is attached to")
661- 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)")
662 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")
663- _ = 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.
664 _ = cmd.RegisterFlagCompletionFunc("side", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {762 _ = cmd.RegisterFlagCompletionFunc("side", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
665 return []string{sideOld, sideNew}, cobra.ShellCompDirectiveNoFileComp763 return []string{sideOld, sideNew}, cobra.ShellCompDirectiveNoFileComp
666 })764 })
@@ -674,17 +772,36 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command {
674 if err != nil {772 if err != nil {
675 return err773 return err
676 }774 }
775+ if !cmd.Flags().Changed("message") && !approve && !requestChanges {
776+ return errors.New("--message is required unless --approve or --request-changes is set")
777+ }
677 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+ }
678 switch {791 switch {
679 case path != "":792 case path != "":
680- if line < 1 {793+ if line == "" {
681 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")
682 }795 }
796+ req.StartLine, req.Line, err = parseLineRange(line)
797+ if err != nil {
798+ return err
799+ }
683 if req.Side, err = parseSide(side); err != nil {800 if req.Side, err = parseSide(side); err != nil {
684 return err801 return err
685 }802 }
686- req.Path, req.Line = path, int32(line) //nolint:gosec // a line number typed by a person803+ req.Path = path
687- case line != 0 || cmd.Flags().Changed("side"):804+ case cmd.Flags().Changed("line") || cmd.Flags().Changed("side"):
688 return errors.New("--line and --side need --path")805 return errors.New("--line and --side need --path")
689 }806 }
690 c, err := env.client(cmd)807 c, err := env.client(cmd)
@@ -704,6 +821,100 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command {
704 return cmd821 return cmd
705 }822 }
706 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+
707 func newMRResolveCmd(env *cliEnv) *cobra.Command {918 func newMRResolveCmd(env *cliEnv) *cobra.Command {
708 var undo bool919 var undo bool
709 cmd := &cobra.Command{920 cmd := &cobra.Command{
@@ -803,7 +1014,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
803 clean(m.GetSourceBranch()))1014 clean(m.GetSourceBranch()))
804 }1015 }
805 if *asJSON {1016 if *asJSON {
806- return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m), SourceBranchDeleted: deleted})1017+ return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m, c.URL()), SourceBranchDeleted: deleted})
807 }1018 }
808 _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(),1019 _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(),
809 clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId()))1020 clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId()))
@@ -820,7 +1031,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command {
820 Use: "close <repo> <number>",1031 Use: "close <repo> <number>",
821 Short: "Close a merge request without merging it",1032 Short: "Close a merge request without merging it",
822 Long: "Close an open merge request without merging it. A closed merge request cannot be reopened: open a\n" +1033 Long: "Close an open merge request without merging it. A closed merge request cannot be reopened: open a\n" +
823- "new one. Needs the mr:write scope." + jsonMRHelp,1034+ "new one. Prints a confirmation, then the full web URL on its own line. Needs the mr:write scope." + jsonMRHelp,
824 Args: cobra.ExactArgs(2),1035 Args: cobra.ExactArgs(2),
825 ValidArgsFunction: completeMRRepoThenNumber(env),1036 ValidArgsFunction: completeMRRepoThenNumber(env),
826 }1037 }
@@ -838,7 +1049,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command {
838 if err != nil {1049 if err != nil {
839 return c.Describe(err)1050 return c.Describe(err)
840 }1051 }
841- return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "closed")1052+ return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "closed", c.URL())
842 }1053 }
843 return cmd1054 return cmd
844 }1055 }
M internal/app/cli_mr_e2e_test.go +74 -1
@@ -18,6 +18,7 @@ import (
1818
1919 type mrOut struct {
2020 Repo string `json:"repo"`
21+ URL string `json:"url"`
2122 Number int64 `json:"number"`
2223 Title string `json:"title"`
2324 Description string `json:"description"`
@@ -32,7 +33,7 @@ type mrOut struct {
3233 MergeMode string `json:"merge_mode"`
3334 }
3435
35-var mrKeys = []string{"author", "created_at", "description", "head_id", "merge_mode", "merged_commit_id", "number", "repo", "source_branch", "state", "target_branch", "title", "updated_at"}
36+var mrKeys = []string{"author", "created_at", "description", "head_id", "merge_mode", "merged_commit_id", "number", "repo", "source_branch", "state", "target_branch", "title", "updated_at", "url"}
3637
3738 type commentOut struct {
3839 ID string `json:"id"`
@@ -420,3 +421,75 @@ func (w *watchProc) nextOfType(t *testing.T, typ string) eventOut {
420421 t.Fatalf("no %s event", typ)
421422 return eventOut{}
422423 }
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+}
@@ -18,6 +18,7 @@ import (
18 18
19 type mrOut struct {19 type mrOut struct {
20 Repo string `json:"repo"`20 Repo string `json:"repo"`
21+ URL string `json:"url"`
21 Number int64 `json:"number"`22 Number int64 `json:"number"`
22 Title string `json:"title"`23 Title string `json:"title"`
23 Description string `json:"description"`24 Description string `json:"description"`
@@ -32,7 +33,7 @@ type mrOut struct {
32 MergeMode string `json:"merge_mode"`33 MergeMode string `json:"merge_mode"`
33 }34 }
34 35
35-var mrKeys = []string{"author", "created_at", "description", "head_id", "merge_mode", "merged_commit_id", "number", "repo", "source_branch", "state", "target_branch", "title", "updated_at"}36+var mrKeys = []string{"author", "created_at", "description", "head_id", "merge_mode", "merged_commit_id", "number", "repo", "source_branch", "state", "target_branch", "title", "updated_at", "url"}
36 37
37 type commentOut struct {38 type commentOut struct {
38 ID string `json:"id"`39 ID string `json:"id"`
@@ -420,3 +421,75 @@ func (w *watchProc) nextOfType(t *testing.T, typ string) eventOut {
420 t.Fatalf("no %s event", typ)421 t.Fatalf("no %s event", typ)
421 return eventOut{}422 return eventOut{}
422 }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 +109 -3
@@ -30,7 +30,7 @@ func TestMRCreateMapsFlags(t *testing.T) {
3030 url := f.serve(t)
3131 res := runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "Add search", "--description", "Finds things.",
3232 "--source", "refs/heads/feat/x", "--target", " main ").ok(t)
33- assert.Equal(t, "opened alpha!1 (feat/x into main)\n", res.stdout)
33+ assert.Equal(t, "opened alpha!1 (feat/x into main)\n"+url+"/alpha/merge-requests/1\n", res.stdout)
3434 _, reqs := f.snapshot()
3535 got := reqs[0].(*ermite.CreateMergeRequestRequest)
3636 assert.Equal(t, "alpha", got.GetRepo())
@@ -49,7 +49,7 @@ func TestMRCreateMapsFlags(t *testing.T) {
4949 _ = res
5050
5151 res = runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "t", "--source", "x", "--json").ok(t)
52- assert.JSONEq(t, `{"repo":"alpha","number":1,"title":"Add search","description":"Finds things.","source_branch":"feat/x","target_branch":"main","state":"open","author":"agent-7","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","head_id":"`+headA+`","merged_commit_id":"","merge_mode":""}`, res.stdout)
52+ assert.JSONEq(t, `{"url":"`+url+`/alpha/merge-requests/1","repo":"alpha","number":1,"title":"Add search","description":"Finds things.","source_branch":"feat/x","target_branch":"main","state":"open","author":"agent-7","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","head_id":"`+headA+`","merged_commit_id":"","merge_mode":""}`, res.stdout)
5353
5454 for _, args := range [][]string{{"mr", "create", "alpha", "--source", "x"}, {"mr", "create", "alpha", "--title", "t"}, {"mr", "create", "--title", "t", "--source", "x"}} {
5555 require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args)
@@ -85,6 +85,9 @@ func TestMRListTableJSONAndStates(t *testing.T) {
8585 res = runCLI(t, envFor(url), "", "mr", "list", "alpha", "--json").ok(t)
8686 assert.Equal(t, []string{"alpha", "alpha"}, []string{decode[[]mrOut](t, res.stdout)[0].Repo, decode[[]mrOut](t, res.stdout)[1].Repo})
8787 assert.Equal(t, "squash", decode[[]mrOut](t, res.stdout)[0].MergeMode)
88+ for i, m := range decode[[]mrOut](t, res.stdout) {
89+ assert.Equal(t, mrWebURL(url, "alpha", int64(2-i)), m.URL)
90+ }
8891 }
8992
9093 func TestMRListFollowsPages(t *testing.T) {
@@ -153,6 +156,7 @@ func TestMRShow(t *testing.T) {
153156 assert.NotContains(t, js.stdout, `"hunks"`)
154157 js = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json", "--diff").ok(t)
155158 show := decode[showOut](t, js.stdout)
159+ assert.Equal(t, url+"/alpha/merge-requests/1", decode[mrOut](t, js.stdout).URL)
156160 require.Len(t, show.Files, 1)
157161 require.Len(t, show.Files[0].Hunks, 1)
158162 assert.Equal(t, "addition", show.Files[0].Hunks[0].Lines[2].Kind)
@@ -274,6 +278,7 @@ func TestMRResolveMergeAndClose(t *testing.T) {
274278 // The default mode is a fast-forward.
275279 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t)
276280 assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State)
281+ assert.Equal(t, url+"/alpha/merge-requests/1", decode[mrOut](t, res.stdout).URL)
277282 calls, reqs = f.snapshot()
278283 assert.Equal(t, "MergeMergeRequest", calls[len(calls)-1])
279284 m = reqs[len(reqs)-1].(*ermite.MergeMergeRequestRequest)
@@ -281,9 +286,10 @@ func TestMRResolveMergeAndClose(t *testing.T) {
281286 assert.Equal(t, headA, m.GetHeadId())
282287
283288 res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1").ok(t)
284- assert.Equal(t, "closed alpha!1 (feat/x into main)\n", res.stdout)
289+ assert.Equal(t, "closed alpha!1 (feat/x into main)\n"+url+"/alpha/merge-requests/1\n", res.stdout)
285290 res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1", "--json").ok(t)
286291 assert.Equal(t, "closed", decode[mrOut](t, res.stdout).State)
292+ assert.Equal(t, url+"/alpha/merge-requests/1", decode[mrOut](t, res.stdout).URL)
287293 }
288294
289295 // ADR 0017: the merge is of the commit that was reviewed, so --head is
@@ -381,3 +387,103 @@ func TestSigningKeyShowOutput(t *testing.T) {
381387 res = runCLI(t, envFor(url), "", "signing-key", "show", "--json").ok(t)
382388 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)
383389 }
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+
480+func TestMRWebURL(t *testing.T) {
481+ for _, tt := range []struct{ name, base, repo, want string }{
482+ {"plain", "https://git.example.org", "my-repo", "https://git.example.org/my-repo/merge-requests/42"},
483+ {"trailing slash", "https://git.example.org/", "alpha", "https://git.example.org/alpha/merge-requests/42"},
484+ {"base path", "https://git.example.org/forge///", "alpha", "https://git.example.org/forge/alpha/merge-requests/42"},
485+ {"escaped segment", "https://git.example.org", "team/repo #?%", "https://git.example.org/team%2Frepo%20%23%3F%25/merge-requests/42"},
486+ } {
487+ t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, mrWebURL(tt.base, tt.repo, 42)) })
488+ }
489+}
@@ -30,7 +30,7 @@ func TestMRCreateMapsFlags(t *testing.T) {
30 url := f.serve(t)30 url := f.serve(t)
31 res := runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "Add search", "--description", "Finds things.",31 res := runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "Add search", "--description", "Finds things.",
32 "--source", "refs/heads/feat/x", "--target", " main ").ok(t)32 "--source", "refs/heads/feat/x", "--target", " main ").ok(t)
33- assert.Equal(t, "opened alpha!1 (feat/x into main)\n", res.stdout)33+ assert.Equal(t, "opened alpha!1 (feat/x into main)\n"+url+"/alpha/merge-requests/1\n", res.stdout)
34 _, reqs := f.snapshot()34 _, reqs := f.snapshot()
35 got := reqs[0].(*ermite.CreateMergeRequestRequest)35 got := reqs[0].(*ermite.CreateMergeRequestRequest)
36 assert.Equal(t, "alpha", got.GetRepo())36 assert.Equal(t, "alpha", got.GetRepo())
@@ -49,7 +49,7 @@ func TestMRCreateMapsFlags(t *testing.T) {
49 _ = res49 _ = res
50 50
51 res = runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "t", "--source", "x", "--json").ok(t)51 res = runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "t", "--source", "x", "--json").ok(t)
52- assert.JSONEq(t, `{"repo":"alpha","number":1,"title":"Add search","description":"Finds things.","source_branch":"feat/x","target_branch":"main","state":"open","author":"agent-7","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","head_id":"`+headA+`","merged_commit_id":"","merge_mode":""}`, res.stdout)52+ assert.JSONEq(t, `{"url":"`+url+`/alpha/merge-requests/1","repo":"alpha","number":1,"title":"Add search","description":"Finds things.","source_branch":"feat/x","target_branch":"main","state":"open","author":"agent-7","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","head_id":"`+headA+`","merged_commit_id":"","merge_mode":""}`, res.stdout)
53 53
54 for _, args := range [][]string{{"mr", "create", "alpha", "--source", "x"}, {"mr", "create", "alpha", "--title", "t"}, {"mr", "create", "--title", "t", "--source", "x"}} {54 for _, args := range [][]string{{"mr", "create", "alpha", "--source", "x"}, {"mr", "create", "alpha", "--title", "t"}, {"mr", "create", "--title", "t", "--source", "x"}} {
55 require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args)55 require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args)
@@ -85,6 +85,9 @@ func TestMRListTableJSONAndStates(t *testing.T) {
85 res = runCLI(t, envFor(url), "", "mr", "list", "alpha", "--json").ok(t)85 res = runCLI(t, envFor(url), "", "mr", "list", "alpha", "--json").ok(t)
86 assert.Equal(t, []string{"alpha", "alpha"}, []string{decode[[]mrOut](t, res.stdout)[0].Repo, decode[[]mrOut](t, res.stdout)[1].Repo})86 assert.Equal(t, []string{"alpha", "alpha"}, []string{decode[[]mrOut](t, res.stdout)[0].Repo, decode[[]mrOut](t, res.stdout)[1].Repo})
87 assert.Equal(t, "squash", decode[[]mrOut](t, res.stdout)[0].MergeMode)87 assert.Equal(t, "squash", decode[[]mrOut](t, res.stdout)[0].MergeMode)
88+ for i, m := range decode[[]mrOut](t, res.stdout) {
89+ assert.Equal(t, mrWebURL(url, "alpha", int64(2-i)), m.URL)
90+ }
88 }91 }
89 92
90 func TestMRListFollowsPages(t *testing.T) {93 func TestMRListFollowsPages(t *testing.T) {
@@ -153,6 +156,7 @@ func TestMRShow(t *testing.T) {
153 assert.NotContains(t, js.stdout, `"hunks"`)156 assert.NotContains(t, js.stdout, `"hunks"`)
154 js = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json", "--diff").ok(t)157 js = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json", "--diff").ok(t)
155 show := decode[showOut](t, js.stdout)158 show := decode[showOut](t, js.stdout)
159+ assert.Equal(t, url+"/alpha/merge-requests/1", decode[mrOut](t, js.stdout).URL)
156 require.Len(t, show.Files, 1)160 require.Len(t, show.Files, 1)
157 require.Len(t, show.Files[0].Hunks, 1)161 require.Len(t, show.Files[0].Hunks, 1)
158 assert.Equal(t, "addition", show.Files[0].Hunks[0].Lines[2].Kind)162 assert.Equal(t, "addition", show.Files[0].Hunks[0].Lines[2].Kind)
@@ -274,6 +278,7 @@ func TestMRResolveMergeAndClose(t *testing.T) {
274 // The default mode is a fast-forward.278 // The default mode is a fast-forward.
275 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t)279 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t)
276 assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State)280 assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State)
281+ assert.Equal(t, url+"/alpha/merge-requests/1", decode[mrOut](t, res.stdout).URL)
277 calls, reqs = f.snapshot()282 calls, reqs = f.snapshot()
278 assert.Equal(t, "MergeMergeRequest", calls[len(calls)-1])283 assert.Equal(t, "MergeMergeRequest", calls[len(calls)-1])
279 m = reqs[len(reqs)-1].(*ermite.MergeMergeRequestRequest)284 m = reqs[len(reqs)-1].(*ermite.MergeMergeRequestRequest)
@@ -281,9 +286,10 @@ func TestMRResolveMergeAndClose(t *testing.T) {
281 assert.Equal(t, headA, m.GetHeadId())286 assert.Equal(t, headA, m.GetHeadId())
282 287
283 res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1").ok(t)288 res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1").ok(t)
284- assert.Equal(t, "closed alpha!1 (feat/x into main)\n", res.stdout)289+ assert.Equal(t, "closed alpha!1 (feat/x into main)\n"+url+"/alpha/merge-requests/1\n", res.stdout)
285 res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1", "--json").ok(t)290 res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1", "--json").ok(t)
286 assert.Equal(t, "closed", decode[mrOut](t, res.stdout).State)291 assert.Equal(t, "closed", decode[mrOut](t, res.stdout).State)
292+ assert.Equal(t, url+"/alpha/merge-requests/1", decode[mrOut](t, res.stdout).URL)
287 }293 }
288 294
289 // ADR 0017: the merge is of the commit that was reviewed, so --head is295 // ADR 0017: the merge is of the commit that was reviewed, so --head is
@@ -381,3 +387,103 @@ func TestSigningKeyShowOutput(t *testing.T) {
381 res = runCLI(t, envFor(url), "", "signing-key", "show", "--json").ok(t)387 res = runCLI(t, envFor(url), "", "signing-key", "show", "--json").ok(t)
382 assert.JSONEq(t, `{"public_key":"ssh-ed25519 AAAAC3Nz","fingerprint":"SHA256:abc","committer_name":"ermite","committer_email":"ermite@git.example.org","allowed_signers":"ermite@git.example.org ssh-ed25519 AAAAC3Nz"}`, res.stdout)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)
383 }389 }
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+
480+func TestMRWebURL(t *testing.T) {
481+ for _, tt := range []struct{ name, base, repo, want string }{
482+ {"plain", "https://git.example.org", "my-repo", "https://git.example.org/my-repo/merge-requests/42"},
483+ {"trailing slash", "https://git.example.org/", "alpha", "https://git.example.org/alpha/merge-requests/42"},
484+ {"base path", "https://git.example.org/forge///", "alpha", "https://git.example.org/forge/alpha/merge-requests/42"},
485+ {"escaped segment", "https://git.example.org", "team/repo #?%", "https://git.example.org/team%2Frepo%20%23%3F%25/merge-requests/42"},
486+ } {
487+ t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, mrWebURL(tt.base, tt.repo, 42)) })
488+ }
489+}
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 +26 -13
@@ -45,15 +45,18 @@ 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,
5558 // which the contract says to ignore.
56-func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) {
59+func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) {
5760 out := jsonEvent{ID: ev.GetId(), Time: jsonTime(ev.GetTime())}
5861 switch k := ev.GetKind().(type) {
5962 case *ermite.Event_RepoCreated:
@@ -71,17 +74,26 @@ func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) {
7174 out.RefUpdates = append(out.RefUpdates, jsonRefUpdate{Ref: u.GetRef(), OldID: u.GetOldId(), NewID: u.GetNewId()})
7275 }
7376 case *ermite.Event_MergeRequestOpened:
74- m := toJSONMergeRequest(k.MergeRequestOpened.GetMergeRequest())
77+ m := toJSONMergeRequest(k.MergeRequestOpened.GetMergeRequest(), baseURL)
7578 out.Type, out.Repo, out.MergeRequest = eventMROpened, m.Repo, &m
7679 case *ermite.Event_MergeRequestMerged:
77- m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest())
80+ m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest(), baseURL)
7881 out.Type, out.Repo, out.MergeRequest = eventMRMerged, m.Repo, &m
7982 case *ermite.Event_MergeRequestClosed:
80- m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest())
83+ m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest(), baseURL)
8184 out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m
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),
@@ -177,7 +190,7 @@ func newWatchCmd(env *cliEnv) *cobra.Command {
177190 OnNotice: func(msg string) { noticef(cmd, "%s", clean(msg)) },
178191 }
179192 return c.Watch(ctx, opts, func(ev *ermite.Event) error {
180- e, ok := toJSONEvent(ev)
193+ e, ok := toJSONEvent(ev, c.URL())
181194 if !ok {
182195 return nil
183196 }
@@ -45,15 +45,18 @@ 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,
55 // which the contract says to ignore.58 // which the contract says to ignore.
56-func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) {59+func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) {
57 out := jsonEvent{ID: ev.GetId(), Time: jsonTime(ev.GetTime())}60 out := jsonEvent{ID: ev.GetId(), Time: jsonTime(ev.GetTime())}
58 switch k := ev.GetKind().(type) {61 switch k := ev.GetKind().(type) {
59 case *ermite.Event_RepoCreated:62 case *ermite.Event_RepoCreated:
@@ -71,17 +74,26 @@ func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) {
71 out.RefUpdates = append(out.RefUpdates, jsonRefUpdate{Ref: u.GetRef(), OldID: u.GetOldId(), NewID: u.GetNewId()})74 out.RefUpdates = append(out.RefUpdates, jsonRefUpdate{Ref: u.GetRef(), OldID: u.GetOldId(), NewID: u.GetNewId()})
72 }75 }
73 case *ermite.Event_MergeRequestOpened:76 case *ermite.Event_MergeRequestOpened:
74- m := toJSONMergeRequest(k.MergeRequestOpened.GetMergeRequest())77+ m := toJSONMergeRequest(k.MergeRequestOpened.GetMergeRequest(), baseURL)
75 out.Type, out.Repo, out.MergeRequest = eventMROpened, m.Repo, &m78 out.Type, out.Repo, out.MergeRequest = eventMROpened, m.Repo, &m
76 case *ermite.Event_MergeRequestMerged:79 case *ermite.Event_MergeRequestMerged:
77- m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest())80+ m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest(), baseURL)
78 out.Type, out.Repo, out.MergeRequest = eventMRMerged, m.Repo, &m81 out.Type, out.Repo, out.MergeRequest = eventMRMerged, m.Repo, &m
79 case *ermite.Event_MergeRequestClosed:82 case *ermite.Event_MergeRequestClosed:
80- m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest())83+ m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest(), baseURL)
81 out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m84 out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m
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),
@@ -177,7 +190,7 @@ func newWatchCmd(env *cliEnv) *cobra.Command {
177 OnNotice: func(msg string) { noticef(cmd, "%s", clean(msg)) },190 OnNotice: func(msg string) { noticef(cmd, "%s", clean(msg)) },
178 }191 }
179 return c.Watch(ctx, opts, func(ev *ermite.Event) error {192 return c.Watch(ctx, opts, func(ev *ermite.Event) error {
180- e, ok := toJSONEvent(ev)193+ e, ok := toJSONEvent(ev, c.URL())
181 if !ok {194 if !ok {
182 return nil195 return nil
183 }196 }
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
@@ -245,6 +245,45 @@ type IssuePendingRemoved struct {
245245 // EventKind implements Event.
246246 func (IssuePendingRemoved) EventKind() string { return "issue.pending_removed" }
247247
248+// CommentEdited is published when the text of a note was edited.
249+type CommentEdited struct {
250+ Repo RepoName
251+ Number int64
252+ Comment Comment
253+ At time.Time
254+ Visibility Visibility
255+ CreatedAt time.Time
256+}
257+
258+// EventKind implements Event.
259+func (CommentEdited) EventKind() string { return "mr.comment_edited" }
260+
261+// CommentDeleted is published when the text of a note was deleted.
262+type CommentDeleted struct {
263+ Repo RepoName
264+ Number int64
265+ Comment Comment
266+ At time.Time
267+ Visibility Visibility
268+ CreatedAt time.Time
269+}
270+
271+// EventKind implements Event.
272+func (CommentDeleted) EventKind() string { return "mr.comment_deleted" }
273+
274+// CommentResolved is published when a thread was resolved or reopened.
275+type CommentResolved struct {
276+ Repo RepoName
277+ Number int64
278+ Comment Comment
279+ At time.Time
280+ Visibility Visibility
281+ CreatedAt time.Time
282+}
283+
284+// EventKind implements Event.
285+func (CommentResolved) EventKind() string { return "mr.comment_resolved" }
286+
248287 // EventGap tells a subscriber that it missed events because it was too slow;
249288 // it must refresh its whole view.
250289 type EventGap struct{}
@@ -245,6 +245,45 @@ type IssuePendingRemoved struct {
245 // EventKind implements Event.245 // EventKind implements Event.
246 func (IssuePendingRemoved) EventKind() string { return "issue.pending_removed" }246 func (IssuePendingRemoved) EventKind() string { return "issue.pending_removed" }
247 247
248+// CommentEdited is published when the text of a note was edited.
249+type CommentEdited struct {
250+ Repo RepoName
251+ Number int64
252+ Comment Comment
253+ At time.Time
254+ Visibility Visibility
255+ CreatedAt time.Time
256+}
257+
258+// EventKind implements Event.
259+func (CommentEdited) EventKind() string { return "mr.comment_edited" }
260+
261+// CommentDeleted is published when the text of a note was deleted.
262+type CommentDeleted struct {
263+ Repo RepoName
264+ Number int64
265+ Comment Comment
266+ At time.Time
267+ Visibility Visibility
268+ CreatedAt time.Time
269+}
270+
271+// EventKind implements Event.
272+func (CommentDeleted) EventKind() string { return "mr.comment_deleted" }
273+
274+// CommentResolved is published when a thread was resolved or reopened.
275+type CommentResolved struct {
276+ Repo RepoName
277+ Number int64
278+ Comment Comment
279+ At time.Time
280+ Visibility Visibility
281+ CreatedAt time.Time
282+}
283+
284+// EventKind implements Event.
285+func (CommentResolved) EventKind() string { return "mr.comment_resolved" }
286+
248 // EventGap tells a subscriber that it missed events because it was too slow;287 // EventGap tells a subscriber that it missed events because it was too slow;
249 // it must refresh its whole view.288 // it must refresh its whole view.
250 type EventGap struct{}289 type EventGap struct{}
M internal/domain/mergerequest.go +80 -0
@@ -188,6 +188,11 @@ func (m MergeRequest) IsOpen() bool { return m.State == MRStateOpen }
188188
189189 // Comment is a note on a merge request, general or on a line of its diff.
190190 type Comment struct {
191+ ReplyTo int64
192+ StartLine int
193+ Verdict Verdict
194+ EditedAt time.Time
195+ Deleted bool
191196 // ID is unique within the merge request, in order of creation.
192197 ID int64
193198 Author string
@@ -350,3 +355,78 @@ func singleLine(s string) string {
350355 return r
351356 }, s)
352357 }
358+
359+// Verdict is an immutable review decision on a root note.
360+type Verdict uint8
361+
362+// Review verdicts.
363+const (
364+ VerdictNone Verdict = iota
365+ VerdictApprove
366+ VerdictChangesRequested
367+)
368+
369+// String returns the wire name of a verdict.
370+func (v Verdict) String() string {
371+ switch v {
372+ case VerdictApprove:
373+ return "approve"
374+ case VerdictChangesRequested:
375+ return "changes_requested"
376+ }
377+ return ""
378+}
379+
380+// ParseVerdict parses a review decision.
381+func ParseVerdict(s string) (Verdict, error) {
382+ switch s {
383+ case "":
384+ return VerdictNone, nil
385+ case "approve":
386+ return VerdictApprove, nil
387+ case "changes_requested":
388+ return VerdictChangesRequested, nil
389+ }
390+ return VerdictNone, fmt.Errorf("%w: verdict %q", ErrInvalid, s)
391+}
392+
393+// IsReply reports whether this note replies to a root.
394+func (c Comment) IsReply() bool { return c.ReplyTo != 0 }
395+
396+// IsRange reports whether this note anchors a range.
397+func (c Comment) IsRange() bool { return c.StartLine != 0 }
398+
399+// AuthorVerdict is an author's latest review decision.
400+type AuthorVerdict struct {
401+ Author string
402+ Verdict Verdict
403+ CommentID int64
404+ CommitID ObjectID
405+ At time.Time
406+ Stale bool
407+}
408+
409+// CurrentVerdicts returns the latest verdict per author, including deleted notes,
410+// in note ID order. Only approvals become stale when the head changes.
411+func CurrentVerdicts(comments []Comment, head ObjectID) []AuthorVerdict {
412+ latest := make(map[string]Comment)
413+ for _, c := range comments {
414+ if c.Verdict != VerdictNone && c.ID > latest[c.Author].ID {
415+ latest[c.Author] = c
416+ }
417+ }
418+ out := make([]AuthorVerdict, 0, len(latest))
419+ for _, c := range latest {
420+ 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)})
421+ }
422+ slices.SortFunc(out, func(a, b AuthorVerdict) int {
423+ if a.CommentID < b.CommentID {
424+ return -1
425+ }
426+ if a.CommentID > b.CommentID {
427+ return 1
428+ }
429+ return 0
430+ })
431+ return out
432+}
@@ -188,6 +188,11 @@ func (m MergeRequest) IsOpen() bool { return m.State == MRStateOpen }
188 188
189 // Comment is a note on a merge request, general or on a line of its diff.189 // Comment is a note on a merge request, general or on a line of its diff.
190 type Comment struct {190 type Comment struct {
191+ ReplyTo int64
192+ StartLine int
193+ Verdict Verdict
194+ EditedAt time.Time
195+ Deleted bool
191 // ID is unique within the merge request, in order of creation.196 // ID is unique within the merge request, in order of creation.
192 ID int64197 ID int64
193 Author string198 Author string
@@ -350,3 +355,78 @@ func singleLine(s string) string {
350 return r355 return r
351 }, s)356 }, s)
352 }357 }
358+
359+// Verdict is an immutable review decision on a root note.
360+type Verdict uint8
361+
362+// Review verdicts.
363+const (
364+ VerdictNone Verdict = iota
365+ VerdictApprove
366+ VerdictChangesRequested
367+)
368+
369+// String returns the wire name of a verdict.
370+func (v Verdict) String() string {
371+ switch v {
372+ case VerdictApprove:
373+ return "approve"
374+ case VerdictChangesRequested:
375+ return "changes_requested"
376+ }
377+ return ""
378+}
379+
380+// ParseVerdict parses a review decision.
381+func ParseVerdict(s string) (Verdict, error) {
382+ switch s {
383+ case "":
384+ return VerdictNone, nil
385+ case "approve":
386+ return VerdictApprove, nil
387+ case "changes_requested":
388+ return VerdictChangesRequested, nil
389+ }
390+ return VerdictNone, fmt.Errorf("%w: verdict %q", ErrInvalid, s)
391+}
392+
393+// IsReply reports whether this note replies to a root.
394+func (c Comment) IsReply() bool { return c.ReplyTo != 0 }
395+
396+// IsRange reports whether this note anchors a range.
397+func (c Comment) IsRange() bool { return c.StartLine != 0 }
398+
399+// AuthorVerdict is an author's latest review decision.
400+type AuthorVerdict struct {
401+ Author string
402+ Verdict Verdict
403+ CommentID int64
404+ CommitID ObjectID
405+ At time.Time
406+ Stale bool
407+}
408+
409+// CurrentVerdicts returns the latest verdict per author, including deleted notes,
410+// in note ID order. Only approvals become stale when the head changes.
411+func CurrentVerdicts(comments []Comment, head ObjectID) []AuthorVerdict {
412+ latest := make(map[string]Comment)
413+ for _, c := range comments {
414+ if c.Verdict != VerdictNone && c.ID > latest[c.Author].ID {
415+ latest[c.Author] = c
416+ }
417+ }
418+ out := make([]AuthorVerdict, 0, len(latest))
419+ for _, c := range latest {
420+ 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)})
421+ }
422+ slices.SortFunc(out, func(a, b AuthorVerdict) int {
423+ if a.CommentID < b.CommentID {
424+ return -1
425+ }
426+ if a.CommentID > b.CommentID {
427+ return 1
428+ }
429+ return 0
430+ })
431+ return out
432+}
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.
@@ -43,11 +45,11 @@ const (
4345 ScopeIssueWrite Scope = "issue:write"
4446 )
4547
46-// AllScopes returns the ten scopes in a stable order.
48+// AllScopes returns the eleven scopes in a stable order.
4749 func AllScopes() []Scope {
4850 return []Scope{
4951 ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,
50- ScopeMRWrite, ScopeMRMerge,
52+ ScopeMRWrite, ScopeMRMerge, ScopeMRReview,
5153 ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,
5254 ScopeTokenAdmin,
5355 ScopeIssueWrite,
@@ -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.
@@ -43,11 +45,11 @@ const (
43 ScopeIssueWrite Scope = "issue:write"45 ScopeIssueWrite Scope = "issue:write"
44 )46 )
45 47
46-// AllScopes returns the ten scopes in a stable order.48+// AllScopes returns the eleven scopes in a stable order.
47 func AllScopes() []Scope {49 func AllScopes() []Scope {
48 return []Scope{50 return []Scope{
49 ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,51 ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,
50- ScopeMRWrite, ScopeMRMerge,52+ ScopeMRWrite, ScopeMRMerge, ScopeMRReview,
51 ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,53 ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,
52 ScopeTokenAdmin,54 ScopeTokenAdmin,
53 ScopeIssueWrite,55 ScopeIssueWrite,
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, 10)
19+ assert.Len(t, all, 11)
2020 assert.Contains(t, all, domain.ScopeIssueWrite)
2121 got, err := domain.ParseScope("issue:write")
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, 10)19+ assert.Len(t, all, 11)
20 assert.Contains(t, all, domain.ScopeIssueWrite)20 assert.Contains(t, all, domain.ScopeIssueWrite)
21 got, err := domain.ParseScope("issue:write")21 got, err := domain.ParseScope("issue:write")
22 require.NoError(t, err)22 require.NoError(t, err)
M internal/ports/dto.go +8 -0
@@ -260,6 +260,8 @@ type MergeRequestDetail struct {
260260 Diff domain.Diff
261261 // Comments are the comments, oldest first.
262262 Comments []domain.Comment
263+ // Verdicts are the latest verdict per author in note ID order.
264+ Verdicts []domain.AuthorVerdict
263265 // LastReadID is the head recorded by MarkRead; zero when none.
264266 LastReadID domain.ObjectID
265267 // LastReadLost tells that since-last-read was asked for, a head was
@@ -289,6 +291,12 @@ type FileWindow struct{ From, To int }
289291
290292 // AddCommentParams describes the comment to add.
291293 type AddCommentParams struct {
294+ // ReplyTo names a non-deleted root; replies cannot specify an anchor or verdict.
295+ ReplyTo int64
296+ // StartLine starts an inclusive range, requiring a path and 1 <= StartLine < Line.
297+ StartLine int
298+ // Verdict requires mr:review and is only permitted on roots.
299+ Verdict domain.Verdict
292300 // Path, Line and Side designate a line of the diff; all empty for a
293301 // general comment.
294302 Path string
@@ -260,6 +260,8 @@ type MergeRequestDetail struct {
260 Diff domain.Diff260 Diff domain.Diff
261 // Comments are the comments, oldest first.261 // Comments are the comments, oldest first.
262 Comments []domain.Comment262 Comments []domain.Comment
263+ // Verdicts are the latest verdict per author in note ID order.
264+ Verdicts []domain.AuthorVerdict
263 // LastReadID is the head recorded by MarkRead; zero when none.265 // LastReadID is the head recorded by MarkRead; zero when none.
264 LastReadID domain.ObjectID266 LastReadID domain.ObjectID
265 // LastReadLost tells that since-last-read was asked for, a head was267 // LastReadLost tells that since-last-read was asked for, a head was
@@ -289,6 +291,12 @@ type FileWindow struct{ From, To int }
289 291
290 // AddCommentParams describes the comment to add.292 // AddCommentParams describes the comment to add.
291 type AddCommentParams struct {293 type AddCommentParams struct {
294+ // ReplyTo names a non-deleted root; replies cannot specify an anchor or verdict.
295+ ReplyTo int64
296+ // StartLine starts an inclusive range, requiring a path and 1 <= StartLine < Line.
297+ StartLine int
298+ // Verdict requires mr:review and is only permitted on roots.
299+ Verdict domain.Verdict
292 // Path, Line and Side designate a line of the diff; all empty for a300 // Path, Line and Side designate a line of the diff; all empty for a
293 // general comment.301 // general comment.
294 Path string302 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 +14 -1
@@ -192,6 +192,12 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
192192 return repoSnapshot(ev.Visibility, ev.CreatedAt)
193193 case domain.IssueSubmitted, domain.IssuePendingRemoved:
194194 return bufferedEvent{known: true} // owner topic only: no repository snapshot
195+ case domain.CommentEdited:
196+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
197+ case domain.CommentDeleted:
198+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
199+ case domain.CommentResolved:
200+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
195201 case domain.RunUpdated:
196202 return repoSnapshot(ev.Visibility, ev.CreatedAt)
197203 case domain.JobUpdated:
@@ -529,6 +535,12 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
529535 return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
530536 case domain.IssueCommented:
531537 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
538+ case domain.CommentEdited:
539+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
540+ case domain.CommentDeleted:
541+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
542+ case domain.CommentResolved:
543+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
532544 case domain.RunUpdated:
533545 // Home shows the CI state of each repository.
534546 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)
@@ -633,7 +645,8 @@ func isRepoLifecycleEvent(ev domain.Event) bool {
633645 func isItemEvent(ev domain.Event) bool {
634646 switch ev.(type) {
635647 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
636- domain.MergeRequestReady, domain.CommentAdded:
648+ domain.MergeRequestReady, domain.CommentAdded,
649+ domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
637650 return true
638651 }
639652 return isIssueEvent(ev)
@@ -192,6 +192,12 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
192 return repoSnapshot(ev.Visibility, ev.CreatedAt)192 return repoSnapshot(ev.Visibility, ev.CreatedAt)
193 case domain.IssueSubmitted, domain.IssuePendingRemoved:193 case domain.IssueSubmitted, domain.IssuePendingRemoved:
194 return bufferedEvent{known: true} // owner topic only: no repository snapshot194 return bufferedEvent{known: true} // owner topic only: no repository snapshot
195+ case domain.CommentEdited:
196+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
197+ case domain.CommentDeleted:
198+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
199+ case domain.CommentResolved:
200+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
195 case domain.RunUpdated:201 case domain.RunUpdated:
196 return repoSnapshot(ev.Visibility, ev.CreatedAt)202 return repoSnapshot(ev.Visibility, ev.CreatedAt)
197 case domain.JobUpdated:203 case domain.JobUpdated:
@@ -529,6 +535,12 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
529 return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)535 return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
530 case domain.IssueCommented:536 case domain.IssueCommented:
531 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)537 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
538+ case domain.CommentEdited:
539+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
540+ case domain.CommentDeleted:
541+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
542+ case domain.CommentResolved:
543+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
532 case domain.RunUpdated:544 case domain.RunUpdated:
533 // Home shows the CI state of each repository.545 // Home shows the CI state of each repository.
534 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)546 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)
@@ -633,7 +645,8 @@ func isRepoLifecycleEvent(ev domain.Event) bool {
633 func isItemEvent(ev domain.Event) bool {645 func isItemEvent(ev domain.Event) bool {
634 switch ev.(type) {646 switch ev.(type) {
635 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,647 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
636- domain.MergeRequestReady, domain.CommentAdded:648+ domain.MergeRequestReady, domain.CommentAdded,
649+ domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
637 return true650 return true
638 }651 }
639 return isIssueEvent(ev)652 return isIssueEvent(ev)
M internal/usecase/events_test.go +32 -0
@@ -965,3 +965,35 @@ func TestWatchHomeGetsRunUpdates(t *testing.T) {
965965 root.quiet(t, r, pub)
966966 })
967967 }
968+
969+func TestNoteMutationEventVisibility(t *testing.T) {
970+ synctest.Test(t, func(t *testing.T) {
971+ r := newEventRig(t, 0)
972+ pub := r.repo("pub", domain.VisibilityPublic)
973+ priv := r.repo("priv", domain.VisibilityPrivate)
974+ ctx, cancel := context.WithCancel(context.Background())
975+ defer cancel()
976+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{})
977+ owner := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:priv"}})
978+ synctest.Wait()
979+ for _, kind := range []string{"edited", "deleted", "resolved"} {
980+ for _, n := range []domain.RepoName{priv, pub} {
981+ vis := domain.VisibilityPublic
982+ if n == priv {
983+ vis = domain.VisibilityPrivate
984+ }
985+ switch kind {
986+ case "edited":
987+ r.feed <- domain.CommentEdited{Repo: n, Visibility: vis, CreatedAt: now, At: now}
988+ case "deleted":
989+ r.feed <- domain.CommentDeleted{Repo: n, Visibility: vis, CreatedAt: now, At: now}
990+ case "resolved":
991+ r.feed <- domain.CommentResolved{Repo: n, Visibility: vis, CreatedAt: now, At: now}
992+ }
993+ }
994+ assert.Equal(t, "mr.comment_"+kind, anon.next(t).Event.EventKind())
995+ assert.Equal(t, "mr.comment_"+kind, owner.next(t).Event.EventKind())
996+ anon.quiet(t, r, pub)
997+ }
998+ })
999+}
@@ -965,3 +965,35 @@ func TestWatchHomeGetsRunUpdates(t *testing.T) {
965 root.quiet(t, r, pub)965 root.quiet(t, r, pub)
966 })966 })
967 }967 }
968+
969+func TestNoteMutationEventVisibility(t *testing.T) {
970+ synctest.Test(t, func(t *testing.T) {
971+ r := newEventRig(t, 0)
972+ pub := r.repo("pub", domain.VisibilityPublic)
973+ priv := r.repo("priv", domain.VisibilityPrivate)
974+ ctx, cancel := context.WithCancel(context.Background())
975+ defer cancel()
976+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{})
977+ owner := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:priv"}})
978+ synctest.Wait()
979+ for _, kind := range []string{"edited", "deleted", "resolved"} {
980+ for _, n := range []domain.RepoName{priv, pub} {
981+ vis := domain.VisibilityPublic
982+ if n == priv {
983+ vis = domain.VisibilityPrivate
984+ }
985+ switch kind {
986+ case "edited":
987+ r.feed <- domain.CommentEdited{Repo: n, Visibility: vis, CreatedAt: now, At: now}
988+ case "deleted":
989+ r.feed <- domain.CommentDeleted{Repo: n, Visibility: vis, CreatedAt: now, At: now}
990+ case "resolved":
991+ r.feed <- domain.CommentResolved{Repo: n, Visibility: vis, CreatedAt: now, At: now}
992+ }
993+ }
994+ assert.Equal(t, "mr.comment_"+kind, anon.next(t).Event.EventKind())
995+ assert.Equal(t, "mr.comment_"+kind, owner.next(t).Event.EventKind())
996+ anon.quiet(t, r, pub)
997+ }
998+ })
999+}
M internal/usecase/export_test.go +0 -3
@@ -16,9 +16,6 @@ import (
1616 "bnema.dev/ermite/internal/ports"
1717 )
1818
19-// AuthorOf exposes authorOf to the tests.
20-func AuthorOf(p domain.Principal) string { return authorOf(p) }
21-
2219 // Sweep exposes the daily sweep to the tests.
2320 func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }
2421
@@ -16,9 +16,6 @@ import (
16 "bnema.dev/ermite/internal/ports"16 "bnema.dev/ermite/internal/ports"
17 )17 )
18 18
19-// AuthorOf exposes authorOf to the tests.
20-func AuthorOf(p domain.Principal) string { return authorOf(p) }
21-
22 // Sweep exposes the daily sweep to the tests.19 // Sweep exposes the daily sweep to the tests.
23 func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }20 func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }
24 21
M internal/usecase/issues.go +2 -2
@@ -116,7 +116,7 @@ func (i *Issues) Create(ctx context.Context, p domain.Principal, name domain.Rep
116116 now := i.clock.Now().UTC()
117117 issue, err := i.store.Create(ctx, domain.Issue{
118118 Repo: name, Title: title, Body: body, State: domain.IssueStateOpen,
119- Author: authorOf(p), CreatedAt: now, UpdatedAt: now,
119+ Author: domain.AuthorName(p), CreatedAt: now, UpdatedAt: now,
120120 })
121121 if err != nil {
122122 return domain.Issue{}, fmt.Errorf("create issue of %q: %w", name, err)
@@ -191,7 +191,7 @@ func (i *Issues) AddComment(ctx context.Context, p domain.Principal, name domain
191191 if issue.CommentSeq >= MaxCommentsPerIssue {
192192 return domain.IssueComment{}, fmt.Errorf("%w: #%d already has %d comments", domain.ErrResourceExhausted, number, MaxCommentsPerIssue)
193193 }
194- c = domain.IssueComment{ID: issue.CommentSeq + 1, Author: authorOf(p), Text: body, CreatedAt: i.clock.Now().UTC()}
194+ c = domain.IssueComment{ID: issue.CommentSeq + 1, Author: domain.AuthorName(p), Text: body, CreatedAt: i.clock.Now().UTC()}
195195 issue.CommentSeq, issue.UpdatedAt = c.ID, c.CreatedAt
196196 err = i.store.SaveComment(ctx, issue, c)
197197 if err == nil {
@@ -116,7 +116,7 @@ func (i *Issues) Create(ctx context.Context, p domain.Principal, name domain.Rep
116 now := i.clock.Now().UTC()116 now := i.clock.Now().UTC()
117 issue, err := i.store.Create(ctx, domain.Issue{117 issue, err := i.store.Create(ctx, domain.Issue{
118 Repo: name, Title: title, Body: body, State: domain.IssueStateOpen,118 Repo: name, Title: title, Body: body, State: domain.IssueStateOpen,
119- Author: authorOf(p), CreatedAt: now, UpdatedAt: now,119+ Author: domain.AuthorName(p), CreatedAt: now, UpdatedAt: now,
120 })120 })
121 if err != nil {121 if err != nil {
122 return domain.Issue{}, fmt.Errorf("create issue of %q: %w", name, err)122 return domain.Issue{}, fmt.Errorf("create issue of %q: %w", name, err)
@@ -191,7 +191,7 @@ func (i *Issues) AddComment(ctx context.Context, p domain.Principal, name domain
191 if issue.CommentSeq >= MaxCommentsPerIssue {191 if issue.CommentSeq >= MaxCommentsPerIssue {
192 return domain.IssueComment{}, fmt.Errorf("%w: #%d already has %d comments", domain.ErrResourceExhausted, number, MaxCommentsPerIssue)192 return domain.IssueComment{}, fmt.Errorf("%w: #%d already has %d comments", domain.ErrResourceExhausted, number, MaxCommentsPerIssue)
193 }193 }
194- c = domain.IssueComment{ID: issue.CommentSeq + 1, Author: authorOf(p), Text: body, CreatedAt: i.clock.Now().UTC()}194+ c = domain.IssueComment{ID: issue.CommentSeq + 1, Author: domain.AuthorName(p), Text: body, CreatedAt: i.clock.Now().UTC()}
195 issue.CommentSeq, issue.UpdatedAt = c.ID, c.CreatedAt195 issue.CommentSeq, issue.UpdatedAt = c.ID, c.CreatedAt
196 err = i.store.SaveComment(ctx, issue, c)196 err = i.store.SaveComment(ctx, issue, c)
197 if err == nil {197 if err == nil {
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
@@ -74,6 +74,13 @@ message MergeRequest {
7474 int64 comment_seq = 14;
7575 }
7676
77+// Verdict is the immutable review decision of a note.
78+enum Verdict {
79+ VERDICT_UNSPECIFIED = 0;
80+ VERDICT_APPROVE = 1;
81+ VERDICT_CHANGES_REQUESTED = 2;
82+}
83+
7784 // Comment is stored under the key "mrc/<repo>/<number:010d>/<id:010d>".
7885 message Comment {
7986 int64 id = 1;
@@ -85,6 +92,11 @@ message Comment {
8592 string text = 7;
8693 bool resolved = 8;
8794 google.protobuf.Timestamp created_at = 9;
95+ int64 reply_to = 10;
96+ int32 start_line = 11;
97+ Verdict verdict = 12;
98+ google.protobuf.Timestamp edited_at = 13;
99+ bool deleted = 14;
88100 }
89101
90102 // Sequence is stored under the key "mrseq/<repo>": the last merge request
@@ -74,6 +74,13 @@ message MergeRequest {
74 int64 comment_seq = 14;74 int64 comment_seq = 14;
75 }75 }
76 76
77+// Verdict is the immutable review decision of a note.
78+enum Verdict {
79+ VERDICT_UNSPECIFIED = 0;
80+ VERDICT_APPROVE = 1;
81+ VERDICT_CHANGES_REQUESTED = 2;
82+}
83+
77 // Comment is stored under the key "mrc/<repo>/<number:010d>/<id:010d>".84 // Comment is stored under the key "mrc/<repo>/<number:010d>/<id:010d>".
78 message Comment {85 message Comment {
79 int64 id = 1;86 int64 id = 1;
@@ -85,6 +92,11 @@ message Comment {
85 string text = 7;92 string text = 7;
86 bool resolved = 8;93 bool resolved = 8;
87 google.protobuf.Timestamp created_at = 9;94 google.protobuf.Timestamp created_at = 9;
95+ int64 reply_to = 10;
96+ int32 start_line = 11;
97+ Verdict verdict = 12;
98+ google.protobuf.Timestamp edited_at = 13;
99+ bool deleted = 14;
88 }100 }
89 101
90 // Sequence is stored under the key "mrseq/<repo>": the last merge request102 // Sequence is stored under the key "mrseq/<repo>": the last merge request
M skills/ermite/CLI.md +15 -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 | `issue:write` | open, comment on, close and reopen issues |
4546 | `token:admin` | create, list and revoke tokens |
@@ -56,12 +57,20 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
5657 | `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 |
5758 | `mr list <repo> [--state open\|merged\|closed\|all] [--json]` | none | default `open` |
5859 | `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 |
59-| `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 |
60-| `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | doing it twice is not an error |
60+| `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 |
61+| `mr edit-comment <repo> <n> <comment-id> -m TEXT\|-` | `mr:write` | author only, open merge request; the verdict is unchanged |
62+| `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept |
63+| `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | on the thread root, covers its replies; doing it twice is not an error |
6164 | `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`) |
6265 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |
6366
64-Merge request JSON: `repo`, `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`.
67+`ermite mr create` and `ermite mr close` print a confirmation followed by the full
68+merge request web URL on its own line. With `--json`, merge request objects
69+(create, show, list, close and merge) include `url` alongside the other fields.
70+
71+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`.
72+
73+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.
6574
6675 ## Issues
6776
@@ -97,7 +106,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the `
97106 | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` |
98107 | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) |
99108 | `merge_request_opened`, `merge_request_merged`, `merge_request_closed` | `repo`, `merge_request` |
100-| `comment_added` | `repo`, `number`, `comment` |
109+| `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) |
101110 | `gap` | none: events were lost, reload the state |
102111
103112 ## 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 | `issue:write` | open, comment on, close and reopen issues |45 | `issue:write` | open, comment on, close and reopen issues |
45 | `token:admin` | create, list and revoke tokens |46 | `token:admin` | create, list and revoke tokens |
@@ -56,12 +57,20 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
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 |57 | `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 |
57 | `mr list <repo> [--state open\|merged\|closed\|all] [--json]` | none | default `open` |58 | `mr list <repo> [--state open\|merged\|closed\|all] [--json]` | none | default `open` |
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 |59 | `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 |
59-| `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 |60+| `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 resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | doing it twice is not an error |61+| `mr edit-comment <repo> <n> <comment-id> -m TEXT\|-` | `mr:write` | author only, open merge request; the verdict is unchanged |
62+| `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept |
63+| `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | on the thread root, covers its replies; doing it twice is not an error |
61 | `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`) |64 | `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`) |
62 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |65 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |
63 66
64-Merge request JSON: `repo`, `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`.67+`ermite mr create` and `ermite mr close` print a confirmation followed by the full
68+merge request web URL on its own line. With `--json`, merge request objects
69+(create, show, list, close and merge) include `url` alongside the other fields.
70+
71+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`.
72+
73+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.
65 74
66 ## Issues75 ## Issues
67 76
@@ -97,7 +106,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the `
97 | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` |106 | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` |
98 | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) |107 | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) |
99 | `merge_request_opened`, `merge_request_merged`, `merge_request_closed` | `repo`, `merge_request` |108 | `merge_request_opened`, `merge_request_merged`, `merge_request_closed` | `repo`, `merge_request` |
100-| `comment_added` | `repo`, `number`, `comment` |109+| `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) |
101 | `gap` | none: events were lost, reload the state |110 | `gap` | none: events were lost, reload the state |
102 111
103 ## Server and web112 ## Server and web
M skills/ermite/SKILL.md +17 -4
@@ -35,11 +35,24 @@ 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+
52+`ermite mr create` and `ermite mr close` print a confirmation followed by the full
53+merge request web URL on its own line. With `--json`, merge request objects
54+(create, show, list, close and merge) include `url` alongside the other fields.
55+
4356 Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new pushes, `mr show --since-last-read` shows only what changed since `--mark-read`.
4457
4558 ## Tokens
@@ -48,11 +61,11 @@ Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new p
4861 ermite token create my-agent --preset agent --repo <repo> --expires 30d
4962 ```
5063
51-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).
5265
5366 ## Follow events
5467
55-`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.
5669
5770 ## Run a server
5871
@@ -35,11 +35,24 @@ 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+
52+`ermite mr create` and `ermite mr close` print a confirmation followed by the full
53+merge request web URL on its own line. With `--json`, merge request objects
54+(create, show, list, close and merge) include `url` alongside the other fields.
55+
43 Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new pushes, `mr show --since-last-read` shows only what changed since `--mark-read`.56 Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new pushes, `mr show --since-last-read` shows only what changed since `--mark-read`.
44 57
45 ## Tokens58 ## Tokens
@@ -48,11 +61,11 @@ Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new p
48 ermite token create my-agent --preset agent --repo <repo> --expires 30d61 ermite token create my-agent --preset agent --repo <repo> --expires 30d
49 ```62 ```
50 63
51-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).
52 65
53 ## Follow events66 ## Follow events
54 67
55-`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.
56 69
57 ## Run a server70 ## Run a server
58 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 41

↑↓ moveenter openesc close