detached 86ac875

#1 feat(mr): notes with threads, line ranges, edits and verdicts

state
merged · 86ac875 · fast-forward
branch
feat/mr-notes → main
opened
bnema ·
updated

Merge request notes (ADR 0032): one uniform review object, published at once, no draft review.

  • Threads (one level, resolution on the root), line ranges on one side, author edit and delete.
  • Notes and descriptions rendered as safe Markdown (no raw HTML, link policy), ^N links to note N.
  • Optional verdict on a root note (approve, changes requested), needs the new mr:review scope; informative only; latest per author; approvals stale after a push; changes requested stay until the author's next verdict; immutable.
  • Events comment_edited, comment_deleted, comment_resolved.
  • RPC, CLI (mr comment --reply/--line A-B/--approve/--request-changes, mr edit-comment, mr delete-comment, show and watch), web (threads, quote, Shift+click ranges with a no-JS fallback, verdict list), docs and the ermite skill.

Depends on the ermite-api MR (feat/mr-notes). Until ermite-api v0.8.0 is tagged this branch builds only with a go.work that uses that branch; a last commit will require v0.8.0.

Upgrade: mr:review is a new scope, so a token that held every scope loses the owner's rights until it is recreated (--preset cli) with the root token or from an owner web session.

Checks (with the API branch in a go.work): build, go test -race, go test ./internal/app, golangci-lint --new-from-rev=main, hexcheck, templ drift, buf lint/breaking/generate, mocks drift, node tests (145). Reviewed by three read-only reviewers plus a targeted re-review; all findings fixed. Not verified yet: manual browser check of the merge request page.

CommitSubjectAuthorDateSignatureStatus
86ac875Merge remote-tracking branch 'origin/main' into feat/mr-notesbriceGPG Verified
77b3426build(deps): ermite-api v0.8.0briceGPG Verified
c064166fix(web): split-only note links and disjoint heading ids of split note copiesbriceGPG Verified
06682affix(mr): deleted verdict notes, split-layout note links and verdict labelsbriceGPG Verified
3872b20test(mr): stronger note retry and range testsbriceGPG Verified
31c9416docs(mr): notes, threads, ranges and verdictsbriceGPG Verified
f8d5320merge: web for merge request notesbriceGPG Verified
aec37bffeat(web): threads, line ranges, Markdown notes, edits and verdicts on merge requestsbriceGPG Verified
945925cmerge: RPC and CLI for merge request notesbriceGPG Verified
8def6defeat(cli): replies, ranges, verdicts, edit and delete for merge request notesbriceGPG Verified
aa64b62fix(mr): recheck the reply target when a note save is retriedbriceGPG Verified
b31a354feat(mr): replies, line ranges, edits, deletes and verdicts in the merge request use casebriceGPG Verified
3a784d4feat(mr): note model with threads, ranges, verdicts and the mr:review scopebriceGPG Verified

Checks 8

62 files · +3086 -650
7/8
M docs/cli.md +2 -1
@@ -55,7 +55,8 @@ ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo
5555 ermite token list | revoke <id>
5656
5757 ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff]
58-ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment>
58+ermite mr comment <repo> <n> -m … [--path … --line N|A-B --side old|new] [--reply <id>] [--approve|--request-changes]
59+ermite mr resolve <repo> <n> <comment> | edit-comment <repo> <n> <comment> -m … | delete-comment <repo> <n> <comment>
5960 ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>
6061
6162 ermite run list <repo> [--workflow …] [--branch …] [--status …]
@@ -55,7 +55,8 @@ ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo
55 ermite token list | revoke <id>55 ermite token list | revoke <id>
56 56
57 ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff]57 ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff]
58-ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment>58+ermite mr comment <repo> <n> -m … [--path … --line N|A-B --side old|new] [--reply <id>] [--approve|--request-changes]
59+ermite mr resolve <repo> <n> <comment> | edit-comment <repo> <n> <comment> -m … | delete-comment <repo> <n> <comment>
59 ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>60 ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>
60 61
61 ermite run list <repo> [--workflow …] [--branch …] [--status …]62 ermite run list <repo> [--workflow …] [--branch …] [--status …]
M docs/merge-requests.md +30 -1
@@ -17,10 +17,39 @@ merge request web URL on its own line. With `--json`, merge request objects
1717 - With `failing_checks_block` on (default), the merge is refused while a check of the head failed or is still running.
1818 - `--delete-branch` deletes the source branch, unless it is the default branch or another open request uses it.
1919 - `ermite repo edit --merge-modes` limits the modes per repository.
20-- Scopes: `mr:write` to open and comment, `mr:merge` to merge.
20+- Scopes: `mr:write` to open and comment, `mr:review` to give a verdict, `mr:merge` to merge.
2121
2222 Verify squash commits with the `allowed_signers` line from `ermite signing-key show`:
2323
2424 ```sh
2525 git verify-commit <commit>
2626 ```
27+
28+## Notes
29+
30+A note is the only review object. It is published as soon as it is written: there is no draft and no submit step.
31+
32+```sh
33+ermite mr comment myrepo 1 -m "General remark"
34+ermite mr comment myrepo 1 -m "Why?" --path main.go --line 10-14 --side new
35+ermite mr comment myrepo 1 -m "Fixed in ^3" --reply 2
36+ermite mr resolve myrepo 1 2
37+ermite mr edit-comment myrepo 1 2 -m "New text"
38+ermite mr delete-comment myrepo 1 2
39+```
40+
41+- Notes are Markdown, up to 8000 characters, rendered without raw HTML. Quote with `> `.
42+- `^N` in a note or a description links to note N of the same merge request. A reference to a missing note stays plain text.
43+- A note is general, on one line, or on a range `A-B` of lines on one side (`old` or `new`) of the diff. The lines must be in the diff.
44+- Threads have one level: a reply (`--reply <id>`) takes the anchor of its root and cannot carry a verdict. Resolving or reopening is done on the root and covers its replies.
45+- Only the author edits or deletes a note. An edited note shows "edited". A deleted note loses its text; a deleted root with replies stays as "deleted note".
46+
47+## Verdicts
48+
49+A root note can carry a verdict: `--approve` or `--request-changes`. The text may then be empty. A verdict needs the `mr:review` scope (or the owner); `mr:write` comments but cannot give one.
50+
51+- A verdict ends a review: agents and `ermite watch` act on it, not on each note.
52+- Verdicts are informative: none blocks or allows a merge.
53+- `mr show` lists the latest verdict of each author (`verdicts` in `--json`).
54+- An approval is stale once the branch head differs from the head it was given on. Changes requested stay current until their author gives a new verdict, whatever is pushed.
55+- Verdicts are immutable: editing or deleting the note keeps its verdict. Only a new verdict of the same author replaces it.
@@ -17,10 +17,39 @@ merge request web URL on its own line. With `--json`, merge request objects
17 - With `failing_checks_block` on (default), the merge is refused while a check of the head failed or is still running.17 - With `failing_checks_block` on (default), the merge is refused while a check of the head failed or is still running.
18 - `--delete-branch` deletes the source branch, unless it is the default branch or another open request uses it.18 - `--delete-branch` deletes the source branch, unless it is the default branch or another open request uses it.
19 - `ermite repo edit --merge-modes` limits the modes per repository.19 - `ermite repo edit --merge-modes` limits the modes per repository.
20-- Scopes: `mr:write` to open and comment, `mr:merge` to merge.20+- Scopes: `mr:write` to open and comment, `mr:review` to give a verdict, `mr:merge` to merge.
21 21
22 Verify squash commits with the `allowed_signers` line from `ermite signing-key show`:22 Verify squash commits with the `allowed_signers` line from `ermite signing-key show`:
23 23
24 ```sh24 ```sh
25 git verify-commit <commit>25 git verify-commit <commit>
26 ```26 ```
27+
28+## Notes
29+
30+A note is the only review object. It is published as soon as it is written: there is no draft and no submit step.
31+
32+```sh
33+ermite mr comment myrepo 1 -m "General remark"
34+ermite mr comment myrepo 1 -m "Why?" --path main.go --line 10-14 --side new
35+ermite mr comment myrepo 1 -m "Fixed in ^3" --reply 2
36+ermite mr resolve myrepo 1 2
37+ermite mr edit-comment myrepo 1 2 -m "New text"
38+ermite mr delete-comment myrepo 1 2
39+```
40+
41+- Notes are Markdown, up to 8000 characters, rendered without raw HTML. Quote with `> `.
42+- `^N` in a note or a description links to note N of the same merge request. A reference to a missing note stays plain text.
43+- A note is general, on one line, or on a range `A-B` of lines on one side (`old` or `new`) of the diff. The lines must be in the diff.
44+- Threads have one level: a reply (`--reply <id>`) takes the anchor of its root and cannot carry a verdict. Resolving or reopening is done on the root and covers its replies.
45+- Only the author edits or deletes a note. An edited note shows "edited". A deleted note loses its text; a deleted root with replies stays as "deleted note".
46+
47+## Verdicts
48+
49+A root note can carry a verdict: `--approve` or `--request-changes`. The text may then be empty. A verdict needs the `mr:review` scope (or the owner); `mr:write` comments but cannot give one.
50+
51+- A verdict ends a review: agents and `ermite watch` act on it, not on each note.
52+- Verdicts are informative: none blocks or allows a merge.
53+- `mr show` lists the latest verdict of each author (`verdicts` in `--json`).
54+- An approval is stale once the branch head differs from the head it was given on. Changes requested stay current until their author gives a new verdict, whatever is pushed.
55+- Verdicts are immutable: editing or deleting the note keeps its verdict. Only a new verdict of the same author replaces it.
M docs/tokens.md +2 -2
@@ -6,7 +6,7 @@ ermite token list
66 ermite token revoke <id>
77 ```
88
9-The secret is printed once. Presets and `--scope` add up. The full scope list is in [`skills/ermite/CLI.md`](../skills/ermite/CLI.md).
9+The secret is printed once. Presets and `--scope` add up. The full scope list is in [`skills/ermite/CLI.md`](../skills/ermite/CLI.md). `mr:review` lets a token give a merge request verdict (`--approve`, `--request-changes`); the `cli` preset includes it, the `agent` preset does not. A token created before `mr:review` existed lacks it: recreate it with `--preset cli` using the root token or from a signed-in owner web session (an old token lacking `mr:review` cannot grant it), then replace the stored CLI secret and revoke the old token to keep the owner's rights.
1010
1111 | Preset | For |
1212 |---|---|
@@ -28,7 +28,7 @@ ermite token create claude --preset agent --scope repo:read --repo myrepo --bran
2828 The token **can**:
2929
3030 - push, force-push and delete branches under its prefixes;
31-- open, comment on and close merge requests;
31+- open, comment on and close merge requests; with `mr:review`, give verdicts;
3232 - with `mr:merge`, merge into a branch under its prefixes;
3333 - read CI runs, logs and checks; with `ci:write`, cancel and re-run runs of its own branches;
3434 - create tokens whose prefixes lie within its own.
@@ -6,7 +6,7 @@ ermite token list
6 ermite token revoke <id>6 ermite token revoke <id>
7 ```7 ```
8 8
9-The secret is printed once. Presets and `--scope` add up. The full scope list is in [`skills/ermite/CLI.md`](../skills/ermite/CLI.md).9+The secret is printed once. Presets and `--scope` add up. The full scope list is in [`skills/ermite/CLI.md`](../skills/ermite/CLI.md). `mr:review` lets a token give a merge request verdict (`--approve`, `--request-changes`); the `cli` preset includes it, the `agent` preset does not. A token created before `mr:review` existed lacks it: recreate it with `--preset cli` using the root token or from a signed-in owner web session (an old token lacking `mr:review` cannot grant it), then replace the stored CLI secret and revoke the old token to keep the owner's rights.
10 10
11 | Preset | For |11 | Preset | For |
12 |---|---|12 |---|---|
@@ -28,7 +28,7 @@ ermite token create claude --preset agent --scope repo:read --repo myrepo --bran
28 The token **can**:28 The token **can**:
29 29
30 - push, force-push and delete branches under its prefixes;30 - push, force-push and delete branches under its prefixes;
31-- open, comment on and close merge requests;31+- open, comment on and close merge requests; with `mr:review`, give verdicts;
32 - with `mr:merge`, merge into a branch under its prefixes;32 - with `mr:merge`, merge into a branch under its prefixes;
33 - read CI runs, logs and checks; with `ci:write`, cancel and re-run runs of its own branches;33 - read CI runs, logs and checks; with `ci:write`, cancel and re-run runs of its own branches;
34 - create tokens whose prefixes lie within its own.34 - create tokens whose prefixes lie within its own.
M gen/ermite/storage/merge_request.pb.go +122 -18 generated file: collapsed
M go.mod +1 -1
@@ -13,7 +13,7 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.7.0
16+ bnema.dev/ermite-api v0.8.0
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.1.0
1919 connectrpc.com/connect v1.21.0
@@ -13,7 +13,7 @@ tool (
13 13
14 require (14 require (
15 bnema.dev/ermite-actions v0.2.015 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.7.016+ bnema.dev/ermite-api v0.8.0
17 bnema.dev/ermite-git v0.1.017 bnema.dev/ermite-git v0.1.0
18 bnema.dev/ermite-kv v0.1.018 bnema.dev/ermite-kv v0.1.0
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
M go.sum +2 -2
@@ -1,7 +1,7 @@
11 bnema.dev/ermite-actions v0.2.0 h1:238UBhSZcm8RxqqjZvX3RLGKQNyKtxoXKnKIkYt4P/M=
22 bnema.dev/ermite-actions v0.2.0/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4=
3-bnema.dev/ermite-api v0.7.0 h1:lJtja1PWpbNnnJSh7NnlYbEi8dDhwxOC9oSTUXQ6Igk=
4-bnema.dev/ermite-api v0.7.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=
3+bnema.dev/ermite-api v0.8.0 h1:GNd7Sx+SoRLfzEy4sBC0HB12LyBJduR9XiXWCVja+nw=
4+bnema.dev/ermite-api v0.8.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=
55 bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI=
66 bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg=
77 bnema.dev/ermite-kv v0.1.0 h1:NbD76jgqMq08AVTe/l0EEqDTHjywaYNoqC5c/WgYRcE=
@@ -1,7 +1,7 @@
1 bnema.dev/ermite-actions v0.2.0 h1:238UBhSZcm8RxqqjZvX3RLGKQNyKtxoXKnKIkYt4P/M=1 bnema.dev/ermite-actions v0.2.0 h1:238UBhSZcm8RxqqjZvX3RLGKQNyKtxoXKnKIkYt4P/M=
2 bnema.dev/ermite-actions v0.2.0/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4=2 bnema.dev/ermite-actions v0.2.0/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4=
3-bnema.dev/ermite-api v0.7.0 h1:lJtja1PWpbNnnJSh7NnlYbEi8dDhwxOC9oSTUXQ6Igk=3+bnema.dev/ermite-api v0.8.0 h1:GNd7Sx+SoRLfzEy4sBC0HB12LyBJduR9XiXWCVja+nw=
4-bnema.dev/ermite-api v0.7.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=4+bnema.dev/ermite-api v0.8.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=
5 bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI=5 bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI=
6 bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg=6 bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg=
7 bnema.dev/ermite-kv v0.1.0 h1:NbD76jgqMq08AVTe/l0EEqDTHjywaYNoqC5c/WgYRcE=7 bnema.dev/ermite-kv v0.1.0 h1:NbD76jgqMq08AVTe/l0EEqDTHjywaYNoqC5c/WgYRcE=
M internal/adapters/client/scopes_test.go +1 -0
@@ -16,6 +16,7 @@ import (
1616
1717 func TestScopeWireNames(t *testing.T) {
1818 want := map[ermite.Scope]string{
19+ ermite.Scope_SCOPE_MR_REVIEW: "mr:review",
1920 ermite.Scope_SCOPE_REPO_READ: "repo:read",
2021 ermite.Scope_SCOPE_REPO_WRITE: "repo:write",
2122 ermite.Scope_SCOPE_REPO_ADMIN: "repo:admin",
@@ -16,6 +16,7 @@ import (
16 16
17 func TestScopeWireNames(t *testing.T) {17 func TestScopeWireNames(t *testing.T) {
18 want := map[ermite.Scope]string{18 want := map[ermite.Scope]string{
19+ ermite.Scope_SCOPE_MR_REVIEW: "mr:review",
19 ermite.Scope_SCOPE_REPO_READ: "repo:read",20 ermite.Scope_SCOPE_REPO_READ: "repo:read",
20 ermite.Scope_SCOPE_REPO_WRITE: "repo:write",21 ermite.Scope_SCOPE_REPO_WRITE: "repo:write",
21 ermite.Scope_SCOPE_REPO_ADMIN: "repo:admin",22 ermite.Scope_SCOPE_REPO_ADMIN: "repo:admin",
M internal/adapters/kvstore/mergerequests.go +6 -0
@@ -570,6 +570,11 @@ func encodeComment(c domain.Comment) ([]byte, error) {
570570 return nil, err
571571 }
572572 return mustMarshal(&storagepb.Comment{
573+ ReplyTo: c.ReplyTo,
574+ StartLine: int32(c.StartLine), //nolint:gosec // validated against the diff
575+ Verdict: storagepb.Verdict(c.Verdict),
576+ EditedAt: encodeTime(c.EditedAt),
577+ Deleted: c.Deleted,
573578 Id: c.ID,
574579 Author: c.Author,
575580 Path: c.Path,
@@ -593,6 +598,7 @@ func decodeComment(item kv.Item) (domain.Comment, error) {
593598 return domain.Comment{}, fmt.Errorf("decode comment %d: %w", pb.GetId(), err)
594599 }
595600 c := domain.Comment{
601+ ReplyTo: pb.GetReplyTo(), StartLine: int(pb.GetStartLine()), Verdict: domain.Verdict(pb.GetVerdict()), EditedAt: decodeTime(pb.GetEditedAt()), Deleted: pb.GetDeleted(),
596602 ID: pb.GetId(), Author: pb.GetAuthor(), Path: pb.GetPath(), Line: int(pb.GetLine()),
597603 Side: decodeSide(pb.GetSide()), Text: text, Resolved: pb.GetResolved(),
598604 CreatedAt: decodeTime(pb.GetCreatedAt()), Version: item.Version,
@@ -570,6 +570,11 @@ func encodeComment(c domain.Comment) ([]byte, error) {
570 return nil, err570 return nil, err
571 }571 }
572 return mustMarshal(&storagepb.Comment{572 return mustMarshal(&storagepb.Comment{
573+ ReplyTo: c.ReplyTo,
574+ StartLine: int32(c.StartLine), //nolint:gosec // validated against the diff
575+ Verdict: storagepb.Verdict(c.Verdict),
576+ EditedAt: encodeTime(c.EditedAt),
577+ Deleted: c.Deleted,
573 Id: c.ID,578 Id: c.ID,
574 Author: c.Author,579 Author: c.Author,
575 Path: c.Path,580 Path: c.Path,
@@ -593,6 +598,7 @@ func decodeComment(item kv.Item) (domain.Comment, error) {
593 return domain.Comment{}, fmt.Errorf("decode comment %d: %w", pb.GetId(), err)598 return domain.Comment{}, fmt.Errorf("decode comment %d: %w", pb.GetId(), err)
594 }599 }
595 c := domain.Comment{600 c := domain.Comment{
601+ ReplyTo: pb.GetReplyTo(), StartLine: int(pb.GetStartLine()), Verdict: domain.Verdict(pb.GetVerdict()), EditedAt: decodeTime(pb.GetEditedAt()), Deleted: pb.GetDeleted(),
596 ID: pb.GetId(), Author: pb.GetAuthor(), Path: pb.GetPath(), Line: int(pb.GetLine()),602 ID: pb.GetId(), Author: pb.GetAuthor(), Path: pb.GetPath(), Line: int(pb.GetLine()),
597 Side: decodeSide(pb.GetSide()), Text: text, Resolved: pb.GetResolved(),603 Side: decodeSide(pb.GetSide()), Text: text, Resolved: pb.GetResolved(),
598 CreatedAt: decodeTime(pb.GetCreatedAt()), Version: item.Version,604 CreatedAt: decodeTime(pb.GetCreatedAt()), Version: item.Version,
M internal/adapters/kvstore/mergerequests_test.go +25 -0
@@ -547,3 +547,28 @@ func TestSigningSeedRejectsBadGenerators(t *testing.T) {
547547 _, err = s.SigningSeed(ctx, func() ([]byte, error) { return nil, assert.AnError }, created)
548548 require.ErrorIs(t, err, assert.AnError)
549549 }
550+
551+func TestNoteFieldsRoundTrip(t *testing.T) {
552+ ctx := context.Background()
553+ _, store, name := repoWithMRs(t)
554+ mr, err := store.Create(ctx, newMR(t, name, "notes"))
555+ require.NoError(t, err)
556+ for id := int64(1); id <= 2; id++ {
557+ mr, err = store.Get(ctx, name, mr.Number)
558+ require.NoError(t, err)
559+ c := newComment(t, id, "note")
560+ if id == 2 {
561+ c.ReplyTo = 1
562+ c.StartLine = 1
563+ c.Verdict = domain.VerdictApprove
564+ c.EditedAt = created
565+ c.Deleted = true
566+ }
567+ mr.CommentSeq = id
568+ require.NoError(t, store.SaveComment(ctx, mr, c))
569+ got, err := store.GetComment(ctx, name, mr.Number, id)
570+ require.NoError(t, err)
571+ c.Version = got.Version
572+ assert.Equal(t, c, got)
573+ }
574+}
@@ -547,3 +547,28 @@ func TestSigningSeedRejectsBadGenerators(t *testing.T) {
547 _, err = s.SigningSeed(ctx, func() ([]byte, error) { return nil, assert.AnError }, created)547 _, err = s.SigningSeed(ctx, func() ([]byte, error) { return nil, assert.AnError }, created)
548 require.ErrorIs(t, err, assert.AnError)548 require.ErrorIs(t, err, assert.AnError)
549 }549 }
550+
551+func TestNoteFieldsRoundTrip(t *testing.T) {
552+ ctx := context.Background()
553+ _, store, name := repoWithMRs(t)
554+ mr, err := store.Create(ctx, newMR(t, name, "notes"))
555+ require.NoError(t, err)
556+ for id := int64(1); id <= 2; id++ {
557+ mr, err = store.Get(ctx, name, mr.Number)
558+ require.NoError(t, err)
559+ c := newComment(t, id, "note")
560+ if id == 2 {
561+ c.ReplyTo = 1
562+ c.StartLine = 1
563+ c.Verdict = domain.VerdictApprove
564+ c.EditedAt = created
565+ c.Deleted = true
566+ }
567+ mr.CommentSeq = id
568+ require.NoError(t, store.SaveComment(ctx, mr, c))
569+ got, err := store.GetComment(ctx, name, mr.Number, id)
570+ require.NoError(t, err)
571+ c.Version = got.Version
572+ assert.Equal(t, c, got)
573+ }
574+}
M internal/adapters/rpc/convert.go +10 -0
@@ -124,6 +124,7 @@ func mergeModesFromPB(in []pb.MergeMode) ([]domain.MergeMode, error) {
124124 }
125125
126126 var scopeToPB = map[domain.Scope]pb.Scope{
127+ domain.ScopeMRReview: pb.Scope_SCOPE_MR_REVIEW,
127128 domain.ScopeRepoRead: pb.Scope_SCOPE_REPO_READ,
128129 domain.ScopeRepoWrite: pb.Scope_SCOPE_REPO_WRITE,
129130 domain.ScopeRepoAdmin: pb.Scope_SCOPE_REPO_ADMIN,
@@ -471,6 +472,15 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
471472 case domain.CommentAdded:
472473 out.Time = timestamp(ev.At)
473474 out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
475+ case domain.CommentEdited:
476+ out.Time = timestamp(ev.At)
477+ out.Kind = &pb.Event_CommentEdited{CommentEdited: &pb.CommentEdited{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
478+ case domain.CommentDeleted:
479+ out.Time = timestamp(ev.At)
480+ out.Kind = &pb.Event_CommentDeleted{CommentDeleted: &pb.CommentDeleted{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
481+ case domain.CommentResolved:
482+ out.Time = timestamp(ev.At)
483+ out.Kind = &pb.Event_CommentResolved{CommentResolved: &pb.CommentResolved{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
474484 case domain.RunUpdated:
475485 out.Time = timestamp(ev.At)
476486 out.Kind = runEventToPB(ev)
@@ -124,6 +124,7 @@ func mergeModesFromPB(in []pb.MergeMode) ([]domain.MergeMode, error) {
124 }124 }
125 125
126 var scopeToPB = map[domain.Scope]pb.Scope{126 var scopeToPB = map[domain.Scope]pb.Scope{
127+ domain.ScopeMRReview: pb.Scope_SCOPE_MR_REVIEW,
127 domain.ScopeRepoRead: pb.Scope_SCOPE_REPO_READ,128 domain.ScopeRepoRead: pb.Scope_SCOPE_REPO_READ,
128 domain.ScopeRepoWrite: pb.Scope_SCOPE_REPO_WRITE,129 domain.ScopeRepoWrite: pb.Scope_SCOPE_REPO_WRITE,
129 domain.ScopeRepoAdmin: pb.Scope_SCOPE_REPO_ADMIN,130 domain.ScopeRepoAdmin: pb.Scope_SCOPE_REPO_ADMIN,
@@ -471,6 +472,15 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
471 case domain.CommentAdded:472 case domain.CommentAdded:
472 out.Time = timestamp(ev.At)473 out.Time = timestamp(ev.At)
473 out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}474 out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
475+ case domain.CommentEdited:
476+ out.Time = timestamp(ev.At)
477+ out.Kind = &pb.Event_CommentEdited{CommentEdited: &pb.CommentEdited{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
478+ case domain.CommentDeleted:
479+ out.Time = timestamp(ev.At)
480+ out.Kind = &pb.Event_CommentDeleted{CommentDeleted: &pb.CommentDeleted{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
481+ case domain.CommentResolved:
482+ out.Time = timestamp(ev.At)
483+ out.Kind = &pb.Event_CommentResolved{CommentResolved: &pb.CommentResolved{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
474 case domain.RunUpdated:484 case domain.RunUpdated:
475 out.Time = timestamp(ev.At)485 out.Time = timestamp(ev.At)
476 out.Kind = runEventToPB(ev)486 out.Kind = runEventToPB(ev)
M internal/adapters/rpc/mergerequest.go +70 -0
@@ -17,6 +17,52 @@ import (
1717 // mergeRequestService implements ermiteconnect.MergeRequestServiceHandler.
1818 type mergeRequestService struct{ mrs ports.MergeRequestService }
1919
20+func (s *mergeRequestService) EditComment(ctx context.Context, req *connect.Request[pb.EditCommentRequest]) (*connect.Response[pb.EditCommentResponse], error) {
21+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
22+ if err != nil {
23+ return nil, connectError(ctx, err)
24+ }
25+ c, err := s.mrs.EditComment(ctx, caller(ctx), name, req.Msg.GetNumber(), commentID(req.Msg.GetCommentId()), req.Msg.GetText())
26+ if err != nil {
27+ return nil, connectError(ctx, err)
28+ }
29+ return connect.NewResponse(&pb.EditCommentResponse{Comment: commentToPB(c)}), nil
30+}
31+
32+func (s *mergeRequestService) DeleteComment(ctx context.Context, req *connect.Request[pb.DeleteCommentRequest]) (*connect.Response[pb.DeleteCommentResponse], error) {
33+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
34+ if err != nil {
35+ return nil, connectError(ctx, err)
36+ }
37+ c, err := s.mrs.DeleteComment(ctx, caller(ctx), name, req.Msg.GetNumber(), commentID(req.Msg.GetCommentId()))
38+ if err != nil {
39+ return nil, connectError(ctx, err)
40+ }
41+ return connect.NewResponse(&pb.DeleteCommentResponse{Comment: commentToPB(c)}), nil
42+}
43+
44+func verdictToPB(v domain.Verdict) pb.Verdict {
45+ switch v {
46+ case domain.VerdictApprove:
47+ return pb.Verdict_VERDICT_APPROVE
48+ case domain.VerdictChangesRequested:
49+ return pb.Verdict_VERDICT_CHANGES_REQUESTED
50+ }
51+ return pb.Verdict_VERDICT_UNSPECIFIED
52+}
53+
54+func verdictFromPB(v pb.Verdict) (domain.Verdict, error) {
55+ switch v {
56+ case pb.Verdict_VERDICT_UNSPECIFIED:
57+ return domain.VerdictNone, nil
58+ case pb.Verdict_VERDICT_APPROVE:
59+ return domain.VerdictApprove, nil
60+ case pb.Verdict_VERDICT_CHANGES_REQUESTED:
61+ return domain.VerdictChangesRequested, nil
62+ }
63+ return domain.VerdictNone, fmt.Errorf("%w: verdict %v", domain.ErrInvalid, v)
64+}
65+
2066 func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState {
2167 switch s {
2268 case domain.MRStateOpen:
@@ -96,9 +142,19 @@ func commentToPB(c domain.Comment) *pb.Comment {
96142 Text: c.Text.String(),
97143 Resolved: c.Resolved,
98144 CreatedAt: timestamp(c.CreatedAt),
145+ ReplyTo: optionalCommentID(c.ReplyTo),
146+ StartLine: int32(c.StartLine), //nolint:gosec // validated diff line
147+ Verdict: verdictToPB(c.Verdict), EditedAt: timestamp(c.EditedAt), Deleted: c.Deleted,
99148 }
100149 }
101150
151+func optionalCommentID(id int64) string {
152+ if id == 0 {
153+ return ""
154+ }
155+ return strconv.FormatInt(id, 10)
156+}
157+
102158 // commentID parses the opaque id of a comment. One that this server never
103159 // issued is 0, which names no comment.
104160 func commentID(s string) int64 {
@@ -185,6 +241,9 @@ func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect.
185241 for _, c := range d.Comments {
186242 out.Comments = append(out.Comments, commentToPB(c))
187243 }
244+ for _, v := range d.Verdicts {
245+ out.Verdicts = append(out.Verdicts, &pb.AuthorVerdict{Author: v.Author, Verdict: verdictToPB(v.Verdict), CommentId: strconv.FormatInt(v.CommentID, 10), CommitId: v.CommitID.String(), CreatedAt: timestamp(v.At), Stale: v.Stale})
246+ }
188247 return connect.NewResponse(out), nil
189248 }
190249
@@ -197,7 +256,18 @@ func (s *mergeRequestService) AddComment(ctx context.Context, req *connect.Reque
197256 if err != nil {
198257 return nil, connectError(ctx, err)
199258 }
259+ verdict, err := verdictFromPB(req.Msg.GetVerdict())
260+ if err != nil {
261+ return nil, connectError(ctx, err)
262+ }
263+ replyTo := commentID(req.Msg.GetReplyTo())
264+ if req.Msg.GetReplyTo() != "" && replyTo == 0 {
265+ var v domain.Violations
266+ v.Add("reply_to", "is not a note id")
267+ return nil, connectError(ctx, v.Err())
268+ }
200269 c, err := s.mrs.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.AddCommentParams{
270+ ReplyTo: replyTo, StartLine: int(req.Msg.GetStartLine()), Verdict: verdict,
201271 Path: req.Msg.GetPath(), Line: int(req.Msg.GetLine()), Side: side, Text: req.Msg.GetText(),
202272 })
203273 if err != nil {
@@ -17,6 +17,52 @@ import (
17 // mergeRequestService implements ermiteconnect.MergeRequestServiceHandler.17 // mergeRequestService implements ermiteconnect.MergeRequestServiceHandler.
18 type mergeRequestService struct{ mrs ports.MergeRequestService }18 type mergeRequestService struct{ mrs ports.MergeRequestService }
19 19
20+func (s *mergeRequestService) EditComment(ctx context.Context, req *connect.Request[pb.EditCommentRequest]) (*connect.Response[pb.EditCommentResponse], error) {
21+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
22+ if err != nil {
23+ return nil, connectError(ctx, err)
24+ }
25+ c, err := s.mrs.EditComment(ctx, caller(ctx), name, req.Msg.GetNumber(), commentID(req.Msg.GetCommentId()), req.Msg.GetText())
26+ if err != nil {
27+ return nil, connectError(ctx, err)
28+ }
29+ return connect.NewResponse(&pb.EditCommentResponse{Comment: commentToPB(c)}), nil
30+}
31+
32+func (s *mergeRequestService) DeleteComment(ctx context.Context, req *connect.Request[pb.DeleteCommentRequest]) (*connect.Response[pb.DeleteCommentResponse], error) {
33+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
34+ if err != nil {
35+ return nil, connectError(ctx, err)
36+ }
37+ c, err := s.mrs.DeleteComment(ctx, caller(ctx), name, req.Msg.GetNumber(), commentID(req.Msg.GetCommentId()))
38+ if err != nil {
39+ return nil, connectError(ctx, err)
40+ }
41+ return connect.NewResponse(&pb.DeleteCommentResponse{Comment: commentToPB(c)}), nil
42+}
43+
44+func verdictToPB(v domain.Verdict) pb.Verdict {
45+ switch v {
46+ case domain.VerdictApprove:
47+ return pb.Verdict_VERDICT_APPROVE
48+ case domain.VerdictChangesRequested:
49+ return pb.Verdict_VERDICT_CHANGES_REQUESTED
50+ }
51+ return pb.Verdict_VERDICT_UNSPECIFIED
52+}
53+
54+func verdictFromPB(v pb.Verdict) (domain.Verdict, error) {
55+ switch v {
56+ case pb.Verdict_VERDICT_UNSPECIFIED:
57+ return domain.VerdictNone, nil
58+ case pb.Verdict_VERDICT_APPROVE:
59+ return domain.VerdictApprove, nil
60+ case pb.Verdict_VERDICT_CHANGES_REQUESTED:
61+ return domain.VerdictChangesRequested, nil
62+ }
63+ return domain.VerdictNone, fmt.Errorf("%w: verdict %v", domain.ErrInvalid, v)
64+}
65+
20 func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState {66 func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState {
21 switch s {67 switch s {
22 case domain.MRStateOpen:68 case domain.MRStateOpen:
@@ -96,9 +142,19 @@ func commentToPB(c domain.Comment) *pb.Comment {
96 Text: c.Text.String(),142 Text: c.Text.String(),
97 Resolved: c.Resolved,143 Resolved: c.Resolved,
98 CreatedAt: timestamp(c.CreatedAt),144 CreatedAt: timestamp(c.CreatedAt),
145+ ReplyTo: optionalCommentID(c.ReplyTo),
146+ StartLine: int32(c.StartLine), //nolint:gosec // validated diff line
147+ Verdict: verdictToPB(c.Verdict), EditedAt: timestamp(c.EditedAt), Deleted: c.Deleted,
99 }148 }
100 }149 }
101 150
151+func optionalCommentID(id int64) string {
152+ if id == 0 {
153+ return ""
154+ }
155+ return strconv.FormatInt(id, 10)
156+}
157+
102 // commentID parses the opaque id of a comment. One that this server never158 // commentID parses the opaque id of a comment. One that this server never
103 // issued is 0, which names no comment.159 // issued is 0, which names no comment.
104 func commentID(s string) int64 {160 func commentID(s string) int64 {
@@ -185,6 +241,9 @@ func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect.
185 for _, c := range d.Comments {241 for _, c := range d.Comments {
186 out.Comments = append(out.Comments, commentToPB(c))242 out.Comments = append(out.Comments, commentToPB(c))
187 }243 }
244+ for _, v := range d.Verdicts {
245+ out.Verdicts = append(out.Verdicts, &pb.AuthorVerdict{Author: v.Author, Verdict: verdictToPB(v.Verdict), CommentId: strconv.FormatInt(v.CommentID, 10), CommitId: v.CommitID.String(), CreatedAt: timestamp(v.At), Stale: v.Stale})
246+ }
188 return connect.NewResponse(out), nil247 return connect.NewResponse(out), nil
189 }248 }
190 249
@@ -197,7 +256,18 @@ func (s *mergeRequestService) AddComment(ctx context.Context, req *connect.Reque
197 if err != nil {256 if err != nil {
198 return nil, connectError(ctx, err)257 return nil, connectError(ctx, err)
199 }258 }
259+ verdict, err := verdictFromPB(req.Msg.GetVerdict())
260+ if err != nil {
261+ return nil, connectError(ctx, err)
262+ }
263+ replyTo := commentID(req.Msg.GetReplyTo())
264+ if req.Msg.GetReplyTo() != "" && replyTo == 0 {
265+ var v domain.Violations
266+ v.Add("reply_to", "is not a note id")
267+ return nil, connectError(ctx, v.Err())
268+ }
200 c, err := s.mrs.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.AddCommentParams{269 c, err := s.mrs.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.AddCommentParams{
270+ ReplyTo: replyTo, StartLine: int(req.Msg.GetStartLine()), Verdict: verdict,
201 Path: req.Msg.GetPath(), Line: int(req.Msg.GetLine()), Side: side, Text: req.Msg.GetText(),271 Path: req.Msg.GetPath(), Line: int(req.Msg.GetLine()), Side: side, Text: req.Msg.GetText(),
202 })272 })
203 if err != nil {273 if err != nil {
A internal/adapters/rpc/mr_api_test.go +124 -0
@@ -0,0 +1,124 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "testing"
7+ "time"
8+
9+ pb "bnema.dev/ermite-api/gen/ermite"
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ "bnema.dev/ermite/internal/ports/mocks"
13+ "connectrpc.com/connect"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func TestNoteRPCFields(t *testing.T) {
20+ now := time.Now().UTC()
21+ note := domain.Comment{ID: 4, ReplyTo: 2, StartLine: 10, Line: 24, Verdict: domain.VerdictApprove, EditedAt: now, Deleted: true}
22+ got := commentToPB(note)
23+ assert.Equal(t, "2", got.GetReplyTo())
24+ assert.EqualValues(t, 10, got.GetStartLine())
25+ assert.Equal(t, pb.Verdict_VERDICT_APPROVE, got.GetVerdict())
26+ assert.Equal(t, now, got.GetEditedAt().AsTime())
27+ assert.True(t, got.GetDeleted())
28+ assert.Empty(t, commentToPB(domain.Comment{}).GetReplyTo())
29+ assert.Nil(t, commentToPB(domain.Comment{}).GetEditedAt())
30+ for _, v := range []domain.Verdict{domain.VerdictNone, domain.VerdictApprove, domain.VerdictChangesRequested} {
31+ back, err := verdictFromPB(verdictToPB(v))
32+ require.NoError(t, err)
33+ assert.Equal(t, v, back)
34+ }
35+ m := mocks.NewMergeRequestService(t)
36+ m.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), ports.AddCommentParams{Text: "note", StartLine: 10, Line: 24, Verdict: domain.VerdictApprove}).Return(note, nil).Once()
37+ res, err := (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, Text: "note", StartLine: 10, Line: 24, Verdict: pb.Verdict_VERDICT_APPROVE}))
38+ require.NoError(t, err)
39+ assert.Equal(t, got, res.Msg.GetComment())
40+ _, err = (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Verdict: pb.Verdict(99)}))
41+ requireCode(t, connect.CodeInvalidArgument, err)
42+}
43+
44+func TestNoteMutationRPCCodes(t *testing.T) {
45+ for _, edit := range []bool{false, true} {
46+ for _, failure := range []error{nil, domain.ErrForbidden, domain.ErrPreconditionFailed, domain.ErrNotFound} {
47+ m := mocks.NewMergeRequestService(t)
48+ note := domain.Comment{ID: 2, Deleted: !edit}
49+ var err error
50+ if edit {
51+ m.EXPECT().EditComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), int64(2), "new").Return(note, failure).Once()
52+ _, err = (&mergeRequestService{m}).EditComment(ownerCtx(), connect.NewRequest(&pb.EditCommentRequest{Repo: "demo", Number: 1, CommentId: "2", Text: "new"}))
53+ } else {
54+ m.EXPECT().DeleteComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), int64(2)).Return(note, failure).Once()
55+ _, err = (&mergeRequestService{m}).DeleteComment(ownerCtx(), connect.NewRequest(&pb.DeleteCommentRequest{Repo: "demo", Number: 1, CommentId: "2"}))
56+ }
57+ switch failure {
58+ case nil:
59+ require.NoError(t, err)
60+ case domain.ErrForbidden:
61+ requireCode(t, connect.CodePermissionDenied, err)
62+ case domain.ErrPreconditionFailed:
63+ requireCode(t, connect.CodeFailedPrecondition, err)
64+ case domain.ErrNotFound:
65+ requireCode(t, connect.CodeNotFound, err)
66+ }
67+ }
68+ }
69+}
70+
71+func TestNoteEventsRPC(t *testing.T) {
72+ repo := mustRepoName(t, "demo")
73+ note := domain.Comment{ID: 3, Verdict: domain.VerdictApprove, Deleted: true}
74+ at := time.Now().UTC()
75+ events := []domain.Event{
76+ domain.CommentEdited{Repo: repo, Number: 1, Comment: note, At: at},
77+ domain.CommentDeleted{Repo: repo, Number: 1, Comment: note, At: at},
78+ domain.CommentResolved{Repo: repo, Number: 1, Comment: note, At: at},
79+ }
80+ for i, ev := range events {
81+ got := watchEventToPB(ports.WatchEvent{ID: "e", Event: ev})
82+ require.NotNil(t, got)
83+ assert.Equal(t, at, got.GetTime().AsTime())
84+ var c *pb.Comment
85+ switch i {
86+ case 0:
87+ c = got.GetCommentEdited().GetComment()
88+ case 1:
89+ c = got.GetCommentDeleted().GetComment()
90+ case 2:
91+ c = got.GetCommentResolved().GetComment()
92+ }
93+ assert.Equal(t, "3", c.GetId())
94+ assert.True(t, c.GetDeleted())
95+ assert.Equal(t, pb.Verdict_VERDICT_APPROVE, c.GetVerdict())
96+ }
97+}
98+
99+func TestNoteReplyIDValidation(t *testing.T) {
100+ for _, id := range []string{"abc", "0", "-1", "01", "9223372036854775808"} {
101+ _, err := (&mergeRequestService{mocks.NewMergeRequestService(t)}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, ReplyTo: id, Text: "reply"}))
102+ requireCode(t, connect.CodeInvalidArgument, err)
103+ assert.Contains(t, err.Error(), "reply_to")
104+ assert.Contains(t, err.Error(), "is not a note id")
105+ }
106+ m := mocks.NewMergeRequestService(t)
107+ m.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), ports.AddCommentParams{ReplyTo: 2, Text: "reply"}).Return(domain.Comment{ID: 3, ReplyTo: 2}, nil).Once()
108+ res, err := (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, ReplyTo: "2", Text: "reply"}))
109+ require.NoError(t, err)
110+ assert.Equal(t, "2", res.Msg.GetComment().GetReplyTo())
111+}
112+
113+func TestNoteVerdictsDetailAndScope(t *testing.T) {
114+ m := mocks.NewMergeRequestService(t)
115+ m.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), ports.GetMergeRequestOptions{}).Return(ports.MergeRequestDetail{Verdicts: []domain.AuthorVerdict{{Author: "owner", Verdict: domain.VerdictApprove, CommentID: 4, Stale: true}}}, nil).Once()
116+ res, err := (&mergeRequestService{m}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 1}))
117+ require.NoError(t, err)
118+ require.Len(t, res.Msg.GetVerdicts(), 1)
119+ assert.Equal(t, "4", res.Msg.GetVerdicts()[0].GetCommentId())
120+ assert.True(t, res.Msg.GetVerdicts()[0].GetStale())
121+ m.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), ports.AddCommentParams{Verdict: domain.VerdictApprove}).Return(domain.Comment{}, domain.ErrForbidden).Once()
122+ _, err = (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, Verdict: pb.Verdict_VERDICT_APPROVE}))
123+ requireCode(t, connect.CodePermissionDenied, err)
124+}
@@ -0,0 +1,124 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "testing"
7+ "time"
8+
9+ pb "bnema.dev/ermite-api/gen/ermite"
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ "bnema.dev/ermite/internal/ports/mocks"
13+ "connectrpc.com/connect"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func TestNoteRPCFields(t *testing.T) {
20+ now := time.Now().UTC()
21+ note := domain.Comment{ID: 4, ReplyTo: 2, StartLine: 10, Line: 24, Verdict: domain.VerdictApprove, EditedAt: now, Deleted: true}
22+ got := commentToPB(note)
23+ assert.Equal(t, "2", got.GetReplyTo())
24+ assert.EqualValues(t, 10, got.GetStartLine())
25+ assert.Equal(t, pb.Verdict_VERDICT_APPROVE, got.GetVerdict())
26+ assert.Equal(t, now, got.GetEditedAt().AsTime())
27+ assert.True(t, got.GetDeleted())
28+ assert.Empty(t, commentToPB(domain.Comment{}).GetReplyTo())
29+ assert.Nil(t, commentToPB(domain.Comment{}).GetEditedAt())
30+ for _, v := range []domain.Verdict{domain.VerdictNone, domain.VerdictApprove, domain.VerdictChangesRequested} {
31+ back, err := verdictFromPB(verdictToPB(v))
32+ require.NoError(t, err)
33+ assert.Equal(t, v, back)
34+ }
35+ m := mocks.NewMergeRequestService(t)
36+ m.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), ports.AddCommentParams{Text: "note", StartLine: 10, Line: 24, Verdict: domain.VerdictApprove}).Return(note, nil).Once()
37+ res, err := (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, Text: "note", StartLine: 10, Line: 24, Verdict: pb.Verdict_VERDICT_APPROVE}))
38+ require.NoError(t, err)
39+ assert.Equal(t, got, res.Msg.GetComment())
40+ _, err = (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Verdict: pb.Verdict(99)}))
41+ requireCode(t, connect.CodeInvalidArgument, err)
42+}
43+
44+func TestNoteMutationRPCCodes(t *testing.T) {
45+ for _, edit := range []bool{false, true} {
46+ for _, failure := range []error{nil, domain.ErrForbidden, domain.ErrPreconditionFailed, domain.ErrNotFound} {
47+ m := mocks.NewMergeRequestService(t)
48+ note := domain.Comment{ID: 2, Deleted: !edit}
49+ var err error
50+ if edit {
51+ m.EXPECT().EditComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), int64(2), "new").Return(note, failure).Once()
52+ _, err = (&mergeRequestService{m}).EditComment(ownerCtx(), connect.NewRequest(&pb.EditCommentRequest{Repo: "demo", Number: 1, CommentId: "2", Text: "new"}))
53+ } else {
54+ m.EXPECT().DeleteComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), int64(2)).Return(note, failure).Once()
55+ _, err = (&mergeRequestService{m}).DeleteComment(ownerCtx(), connect.NewRequest(&pb.DeleteCommentRequest{Repo: "demo", Number: 1, CommentId: "2"}))
56+ }
57+ switch failure {
58+ case nil:
59+ require.NoError(t, err)
60+ case domain.ErrForbidden:
61+ requireCode(t, connect.CodePermissionDenied, err)
62+ case domain.ErrPreconditionFailed:
63+ requireCode(t, connect.CodeFailedPrecondition, err)
64+ case domain.ErrNotFound:
65+ requireCode(t, connect.CodeNotFound, err)
66+ }
67+ }
68+ }
69+}
70+
71+func TestNoteEventsRPC(t *testing.T) {
72+ repo := mustRepoName(t, "demo")
73+ note := domain.Comment{ID: 3, Verdict: domain.VerdictApprove, Deleted: true}
74+ at := time.Now().UTC()
75+ events := []domain.Event{
76+ domain.CommentEdited{Repo: repo, Number: 1, Comment: note, At: at},
77+ domain.CommentDeleted{Repo: repo, Number: 1, Comment: note, At: at},
78+ domain.CommentResolved{Repo: repo, Number: 1, Comment: note, At: at},
79+ }
80+ for i, ev := range events {
81+ got := watchEventToPB(ports.WatchEvent{ID: "e", Event: ev})
82+ require.NotNil(t, got)
83+ assert.Equal(t, at, got.GetTime().AsTime())
84+ var c *pb.Comment
85+ switch i {
86+ case 0:
87+ c = got.GetCommentEdited().GetComment()
88+ case 1:
89+ c = got.GetCommentDeleted().GetComment()
90+ case 2:
91+ c = got.GetCommentResolved().GetComment()
92+ }
93+ assert.Equal(t, "3", c.GetId())
94+ assert.True(t, c.GetDeleted())
95+ assert.Equal(t, pb.Verdict_VERDICT_APPROVE, c.GetVerdict())
96+ }
97+}
98+
99+func TestNoteReplyIDValidation(t *testing.T) {
100+ for _, id := range []string{"abc", "0", "-1", "01", "9223372036854775808"} {
101+ _, err := (&mergeRequestService{mocks.NewMergeRequestService(t)}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, ReplyTo: id, Text: "reply"}))
102+ requireCode(t, connect.CodeInvalidArgument, err)
103+ assert.Contains(t, err.Error(), "reply_to")
104+ assert.Contains(t, err.Error(), "is not a note id")
105+ }
106+ m := mocks.NewMergeRequestService(t)
107+ m.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), ports.AddCommentParams{ReplyTo: 2, Text: "reply"}).Return(domain.Comment{ID: 3, ReplyTo: 2}, nil).Once()
108+ res, err := (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, ReplyTo: "2", Text: "reply"}))
109+ require.NoError(t, err)
110+ assert.Equal(t, "2", res.Msg.GetComment().GetReplyTo())
111+}
112+
113+func TestNoteVerdictsDetailAndScope(t *testing.T) {
114+ m := mocks.NewMergeRequestService(t)
115+ m.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), ports.GetMergeRequestOptions{}).Return(ports.MergeRequestDetail{Verdicts: []domain.AuthorVerdict{{Author: "owner", Verdict: domain.VerdictApprove, CommentID: 4, Stale: true}}}, nil).Once()
116+ res, err := (&mergeRequestService{m}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 1}))
117+ require.NoError(t, err)
118+ require.Len(t, res.Msg.GetVerdicts(), 1)
119+ assert.Equal(t, "4", res.Msg.GetVerdicts()[0].GetCommentId())
120+ assert.True(t, res.Msg.GetVerdicts()[0].GetStale())
121+ m.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), ports.AddCommentParams{Verdict: domain.VerdictApprove}).Return(domain.Comment{}, domain.ErrForbidden).Once()
122+ _, err = (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, Verdict: pb.Verdict_VERDICT_APPROVE}))
123+ requireCode(t, connect.CodePermissionDenied, err)
124+}
M internal/adapters/web/diff.go +1 -0
@@ -30,6 +30,7 @@ type Seg struct {
3030 // DiffLine is one line of a hunk, in the unified view and, paired, in the
3131 // split view.
3232 type DiffLine struct {
33+ Selected bool
3334 Kind string // "ctx", "add", "del"
3435 Mark string // " ", "+", "-": every change keeps its marker, not only a colour
3536 Old, New string // line numbers, "" when the side has none
@@ -30,6 +30,7 @@ type Seg struct {
30 // DiffLine is one line of a hunk, in the unified view and, paired, in the30 // DiffLine is one line of a hunk, in the unified view and, paired, in the
31 // split view.31 // split view.
32 type DiffLine struct {32 type DiffLine struct {
33+ Selected bool
33 Kind string // "ctx", "add", "del"34 Kind string // "ctx", "add", "del"
34 Mark string // " ", "+", "-": every change keeps its marker, not only a colour35 Mark string // " ", "+", "-": every change keeps its marker, not only a colour
35 Old, New string // line numbers, "" when the side has none36 Old, New string // line numbers, "" when the side has none
M internal/adapters/web/diff.templ +4 -4
@@ -232,7 +232,7 @@ templ DiffFileView(f DiffFile, p DiffView) {
232232 }
233233 <tr class="hunk"><td colspan="4">{ h.Header }</td></tr>
234234 for _, ln := range h.Lines {
235- <tr class={ ln.Kind }>
235+ <tr class={ ln.Kind, templ.KV("sel", ln.Selected) }>
236236 <td class="ln">@gutter(ln.Old, ln.OldHref)</td>
237237 <td class="ln">@gutter(ln.New, ln.NewHref)</td>
238238 <td class="m">{ ln.Mark }</td>
@@ -369,15 +369,15 @@ templ splitSide(ln *DiffLine, old bool) {
369369 <td class="m none"></td>
370370 <td class="tx none"></td>
371371 } else {
372- <td class={ "ln", ln.Kind }>
372+ <td class={ "ln", ln.Kind, templ.KV("sel", ln.Selected) }>
373373 if old {
374374 @gutter(ln.Old, ln.OldHref)
375375 } else {
376376 @gutter(ln.New, ln.NewHref)
377377 }
378378 </td>
379- <td class={ "m", ln.Kind }>{ ln.Mark }</td>
380- <td class={ "tx", "chroma", ln.Kind }>@codeLine(*ln)</td>
379+ <td class={ "m", ln.Kind, templ.KV("sel", ln.Selected) }>{ ln.Mark }</td>
380+ <td class={ "tx", "chroma", ln.Kind, templ.KV("sel", ln.Selected) }>@codeLine(*ln)</td>
381381 }
382382 }
383383
@@ -232,7 +232,7 @@ templ DiffFileView(f DiffFile, p DiffView) {
232 }232 }
233 <tr class="hunk"><td colspan="4">{ h.Header }</td></tr>233 <tr class="hunk"><td colspan="4">{ h.Header }</td></tr>
234 for _, ln := range h.Lines {234 for _, ln := range h.Lines {
235- <tr class={ ln.Kind }>235+ <tr class={ ln.Kind, templ.KV("sel", ln.Selected) }>
236 <td class="ln">@gutter(ln.Old, ln.OldHref)</td>236 <td class="ln">@gutter(ln.Old, ln.OldHref)</td>
237 <td class="ln">@gutter(ln.New, ln.NewHref)</td>237 <td class="ln">@gutter(ln.New, ln.NewHref)</td>
238 <td class="m">{ ln.Mark }</td>238 <td class="m">{ ln.Mark }</td>
@@ -369,15 +369,15 @@ templ splitSide(ln *DiffLine, old bool) {
369 <td class="m none"></td>369 <td class="m none"></td>
370 <td class="tx none"></td>370 <td class="tx none"></td>
371 } else {371 } else {
372- <td class={ "ln", ln.Kind }>372+ <td class={ "ln", ln.Kind, templ.KV("sel", ln.Selected) }>
373 if old {373 if old {
374 @gutter(ln.Old, ln.OldHref)374 @gutter(ln.Old, ln.OldHref)
375 } else {375 } else {
376 @gutter(ln.New, ln.NewHref)376 @gutter(ln.New, ln.NewHref)
377 }377 }
378 </td>378 </td>
379- <td class={ "m", ln.Kind }>{ ln.Mark }</td>379+ <td class={ "m", ln.Kind, templ.KV("sel", ln.Selected) }>{ ln.Mark }</td>
380- <td class={ "tx", "chroma", ln.Kind }>@codeLine(*ln)</td>380+ <td class={ "tx", "chroma", ln.Kind, templ.KV("sel", ln.Selected) }>@codeLine(*ln)</td>
381 }381 }
382 }382 }
383 383
M internal/adapters/web/diff_templ.go +5 -5 generated file: collapsed
M internal/adapters/web/fragments.go +7 -1
@@ -58,7 +58,7 @@ func liveEvent(we ports.WatchEvent) bool {
5858 }
5959 switch we.Event.(type) {
6060 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
61- domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded,
61+ domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
6262 domain.RunUpdated, domain.JobUpdated:
6363 return true
6464 }
@@ -164,6 +164,12 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
164164 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
165165 case domain.MergeRequestReady:
166166 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")
167+ case domain.CommentEdited:
168+ return l.mrNotice(ctx, ev.Number, "a note was edited — reload")
169+ case domain.CommentDeleted:
170+ return l.mrNotice(ctx, ev.Number, "a note was deleted — reload")
171+ case domain.CommentResolved:
172+ return l.mrNotice(ctx, ev.Number, "a thread was resolved — reload")
167173 case domain.CommentAdded:
168174 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")
169175 case domain.PushEvent:
@@ -58,7 +58,7 @@ func liveEvent(we ports.WatchEvent) bool {
58 }58 }
59 switch we.Event.(type) {59 switch we.Event.(type) {
60 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,60 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
61- domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded,61+ domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
62 domain.RunUpdated, domain.JobUpdated:62 domain.RunUpdated, domain.JobUpdated:
63 return true63 return true
64 }64 }
@@ -164,6 +164,12 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
164 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")164 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
165 case domain.MergeRequestReady:165 case domain.MergeRequestReady:
166 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")166 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")
167+ case domain.CommentEdited:
168+ return l.mrNotice(ctx, ev.Number, "a note was edited — reload")
169+ case domain.CommentDeleted:
170+ return l.mrNotice(ctx, ev.Number, "a note was deleted — reload")
171+ case domain.CommentResolved:
172+ return l.mrNotice(ctx, ev.Number, "a thread was resolved — reload")
167 case domain.CommentAdded:173 case domain.CommentAdded:
168 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")174 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")
169 case domain.PushEvent:175 case domain.PushEvent:
M internal/adapters/web/jstest/mr.test.mjs +56 -0
@@ -36,3 +36,59 @@ test('step: stays in the list', () => {
3636 assert.equal(C.step(-1, -1, 3), 2);
3737 assert.equal(C.step(0, 1, 0), -1);
3838 });
39+
40+test('rangeURL requires the same file, side and MR and sorts endpoints', () => {
41+ const base = 'https://forge.test/demo/merge-requests/1';
42+ const a = base+'?on=new:24:dir%2Fa.go#f1';
43+ const b = base+'?from=25&on=new:10:dir%2Fa.go#f1';
44+ const u = new URL(C.rangeURL(a,b,base));
45+ assert.equal(u.searchParams.get('on'),'new:10-24:dir/a.go');
46+ assert.equal(u.searchParams.get('from'),'25');
47+ assert.equal(u.hash,'#f1');
48+ assert.equal(C.rangeURL(a,a,base),null);
49+ assert.equal(C.rangeURL(a,b.replace('new:','old:'),base),null);
50+ assert.equal(C.rangeURL(a,b.replace('a.go','b.go'),base),null);
51+ assert.equal(C.rangeURL(a,b.replace('/1?','/2?'),base),null);
52+});
53+
54+test('quote opens the thread reply and prefills and focuses its textarea', () => {
55+ let focused=false;
56+ const field={value:'',focus(){focused=true;}};
57+ const details={open:false,querySelector(){return field;}};
58+ const thread={querySelector(){return details;}};
59+ const button={dataset:{quote:'> source\n\n^2 '},closest(){return {closest(){return thread;}};}};
60+ assert.equal(C.quote(button),true);
61+ assert.equal(details.open,true);
62+ assert.equal(field.value,'> source\n\n^2 ');
63+ assert.equal(focused,true);
64+});
65+
66+for (const dataset of [{diff:'split'}, {view:'split'}]) test(`reveal selects the split note (${JSON.stringify(dataset)}), including after lazy loading`, async () => {
67+ const saved = globalThis.document;
68+ const savedRAF = globalThis.requestAnimationFrame;
69+ let scrolled = '';
70+ const details = {open:false};
71+ const node = id => ({id, querySelector(){return null;}, closest(){return details;}, scrollIntoView(){scrolled=id;}});
72+ const nodes = new Map([['c12',node('c12')],['c12-s',node('c12-s')]]);
73+ const ph = {dataset:{notes:'c12'}};
74+ globalThis.document = {
75+ querySelector: sel => sel === '#diffs' ? {dataset} : null,
76+ querySelectorAll: () => [ph],
77+ getElementById: id => nodes.get(id),
78+ };
79+ globalThis.requestAnimationFrame = fn => fn();
80+ try {
81+ assert.equal(window.ermiteMR.reveal('c12'),false);
82+ assert.equal(scrolled,'c12-s');
83+ assert.equal(details.open,true);
84+ nodes.clear(); scrolled=''; details.open=false;
85+ assert.equal(window.ermiteMR.reveal('c12'),true);
86+ nodes.set('c12',node('c12')); nodes.set('c12-s',node('c12-s'));
87+ await Promise.resolve();
88+ assert.equal(scrolled,'c12-s');
89+ assert.equal(details.open,true);
90+ } finally {
91+ globalThis.document=saved;
92+ globalThis.requestAnimationFrame=savedRAF;
93+ }
94+});
@@ -36,3 +36,59 @@ test('step: stays in the list', () => {
36 assert.equal(C.step(-1, -1, 3), 2);36 assert.equal(C.step(-1, -1, 3), 2);
37 assert.equal(C.step(0, 1, 0), -1);37 assert.equal(C.step(0, 1, 0), -1);
38 });38 });
39+
40+test('rangeURL requires the same file, side and MR and sorts endpoints', () => {
41+ const base = 'https://forge.test/demo/merge-requests/1';
42+ const a = base+'?on=new:24:dir%2Fa.go#f1';
43+ const b = base+'?from=25&on=new:10:dir%2Fa.go#f1';
44+ const u = new URL(C.rangeURL(a,b,base));
45+ assert.equal(u.searchParams.get('on'),'new:10-24:dir/a.go');
46+ assert.equal(u.searchParams.get('from'),'25');
47+ assert.equal(u.hash,'#f1');
48+ assert.equal(C.rangeURL(a,a,base),null);
49+ assert.equal(C.rangeURL(a,b.replace('new:','old:'),base),null);
50+ assert.equal(C.rangeURL(a,b.replace('a.go','b.go'),base),null);
51+ assert.equal(C.rangeURL(a,b.replace('/1?','/2?'),base),null);
52+});
53+
54+test('quote opens the thread reply and prefills and focuses its textarea', () => {
55+ let focused=false;
56+ const field={value:'',focus(){focused=true;}};
57+ const details={open:false,querySelector(){return field;}};
58+ const thread={querySelector(){return details;}};
59+ const button={dataset:{quote:'> source\n\n^2 '},closest(){return {closest(){return thread;}};}};
60+ assert.equal(C.quote(button),true);
61+ assert.equal(details.open,true);
62+ assert.equal(field.value,'> source\n\n^2 ');
63+ assert.equal(focused,true);
64+});
65+
66+for (const dataset of [{diff:'split'}, {view:'split'}]) test(`reveal selects the split note (${JSON.stringify(dataset)}), including after lazy loading`, async () => {
67+ const saved = globalThis.document;
68+ const savedRAF = globalThis.requestAnimationFrame;
69+ let scrolled = '';
70+ const details = {open:false};
71+ const node = id => ({id, querySelector(){return null;}, closest(){return details;}, scrollIntoView(){scrolled=id;}});
72+ const nodes = new Map([['c12',node('c12')],['c12-s',node('c12-s')]]);
73+ const ph = {dataset:{notes:'c12'}};
74+ globalThis.document = {
75+ querySelector: sel => sel === '#diffs' ? {dataset} : null,
76+ querySelectorAll: () => [ph],
77+ getElementById: id => nodes.get(id),
78+ };
79+ globalThis.requestAnimationFrame = fn => fn();
80+ try {
81+ assert.equal(window.ermiteMR.reveal('c12'),false);
82+ assert.equal(scrolled,'c12-s');
83+ assert.equal(details.open,true);
84+ nodes.clear(); scrolled=''; details.open=false;
85+ assert.equal(window.ermiteMR.reveal('c12'),true);
86+ nodes.set('c12',node('c12')); nodes.set('c12-s',node('c12-s'));
87+ await Promise.resolve();
88+ assert.equal(scrolled,'c12-s');
89+ assert.equal(details.open,true);
90+ } finally {
91+ globalThis.document=saved;
92+ globalThis.requestAnimationFrame=savedRAF;
93+ }
94+});
M internal/adapters/web/markdown.go +79 -0
@@ -7,6 +7,8 @@ import (
77 "html/template"
88 "net/url"
99 "path"
10+ "regexp"
11+ "strconv"
1012 "strings"
1113
1214 "github.com/yuin/goldmark"
@@ -62,10 +64,40 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML,
6264 // #fragment links chosen, so that several documents on one page (the summaries
6365 // of a run) do not share ids.
6466 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {
67+ return m.renderDocument(source, repo, ref, dir, idPrefix, nil)
68+}
69+
70+// renderNote uses the repository Markdown policy, then adds local note links.
71+func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes func(int64) bool) (template.HTML, error) {
72+ h, _, err := m.renderDocument(source, repo, ref, "", idPrefix, notes)
73+ return h, err
74+}
75+
76+func (m *markdown) renderDocument(source []byte, repo, ref, dir, idPrefix string, notes func(int64) bool) (template.HTML, []Heading, error) {
6577 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: idPrefix, inner: parser.NewContext().IDs()}))
6678 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
6779 pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source, idPrefix: idPrefix}
80+ // Collect eligible text before the policy unwraps rejected links: their
81+ // labels must not accidentally become references either.
82+ var refs []*ast.Text
83+ if notes != nil {
84+ _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
85+ if !entering {
86+ return ast.WalkContinue, nil
87+ }
88+ switch n := n.(type) {
89+ case *ast.CodeSpan, *ast.Link, *ast.AutoLink, *ast.Image, *ast.CodeBlock, *ast.FencedCodeBlock:
90+ return ast.WalkSkipChildren, nil
91+ case *ast.Text:
92+ refs = append(refs, n)
93+ }
94+ return ast.WalkContinue, nil
95+ })
96+ }
6897 pol.apply(doc)
98+ for _, t := range refs {
99+ noteReferences(t, source, notes)
100+ }
69101
70102 var buf bytes.Buffer
71103 if err := m.md.Renderer().Render(&buf, source, doc); err != nil {
@@ -291,3 +323,50 @@ func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
291323 return ast.WalkContinue, nil
292324 })
293325 }
326+
327+var noteReferencePattern = regexp.MustCompile(`(^|[^0-9A-Za-z_^])\^([1-9][0-9]{0,5})\b`)
328+
329+// noteReferences changes only text segments; code and link labels never enter
330+// this function. New fragment links deliberately bypass heading-prefix rewriting.
331+func noteReferences(t *ast.Text, source []byte, exists func(int64) bool) {
332+ parent := t.Parent()
333+ if parent == nil {
334+ return
335+ }
336+ value := t.Value(source)
337+ offset := 0
338+ for _, match := range noteReferencePattern.FindAllSubmatchIndex(value, -1) {
339+ id := string(value[match[4]:match[5]])
340+ n, err := strconv.ParseInt(id, 10, 64)
341+ if err != nil || !exists(n) {
342+ continue
343+ }
344+ start := match[4] - 1
345+ // Markdown punctuation may split adjacent text into separate AST nodes.
346+ // Check the original preceding byte as well as the segment-local regex.
347+ if at := t.Segment.Start + start; at > 0 {
348+ prev := source[at-1]
349+ if prev >= '0' && prev <= '9' || prev >= 'A' && prev <= 'Z' || prev >= 'a' && prev <= 'z' || prev == '_' || prev == '^' {
350+ continue
351+ }
352+ }
353+ if start > offset {
354+ parent.InsertBefore(parent, t, ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Start+start)))
355+ }
356+ link := ast.NewLink()
357+ link.Destination = []byte("#c" + id)
358+ link.Title = []byte("note " + id)
359+ link.SetAttributeString("class", []byte("ref"))
360+ link.AppendChild(link, ast.NewString(value[start:match[5]]))
361+ parent.InsertBefore(parent, t, link)
362+ offset = match[5]
363+ }
364+ if offset == 0 {
365+ return
366+ }
367+ tail := ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Stop))
368+ tail.SetSoftLineBreak(t.SoftLineBreak())
369+ tail.SetHardLineBreak(t.HardLineBreak())
370+ parent.InsertBefore(parent, t, tail)
371+ parent.RemoveChild(parent, t)
372+}
@@ -7,6 +7,8 @@ import (
7 "html/template"7 "html/template"
8 "net/url"8 "net/url"
9 "path"9 "path"
10+ "regexp"
11+ "strconv"
10 "strings"12 "strings"
11 13
12 "github.com/yuin/goldmark"14 "github.com/yuin/goldmark"
@@ -62,10 +64,40 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML,
62 // #fragment links chosen, so that several documents on one page (the summaries64 // #fragment links chosen, so that several documents on one page (the summaries
63 // of a run) do not share ids.65 // of a run) do not share ids.
64 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {66 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {
67+ return m.renderDocument(source, repo, ref, dir, idPrefix, nil)
68+}
69+
70+// renderNote uses the repository Markdown policy, then adds local note links.
71+func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes func(int64) bool) (template.HTML, error) {
72+ h, _, err := m.renderDocument(source, repo, ref, "", idPrefix, notes)
73+ return h, err
74+}
75+
76+func (m *markdown) renderDocument(source []byte, repo, ref, dir, idPrefix string, notes func(int64) bool) (template.HTML, []Heading, error) {
65 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: idPrefix, inner: parser.NewContext().IDs()}))77 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: idPrefix, inner: parser.NewContext().IDs()}))
66 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))78 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
67 pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source, idPrefix: idPrefix}79 pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source, idPrefix: idPrefix}
80+ // Collect eligible text before the policy unwraps rejected links: their
81+ // labels must not accidentally become references either.
82+ var refs []*ast.Text
83+ if notes != nil {
84+ _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
85+ if !entering {
86+ return ast.WalkContinue, nil
87+ }
88+ switch n := n.(type) {
89+ case *ast.CodeSpan, *ast.Link, *ast.AutoLink, *ast.Image, *ast.CodeBlock, *ast.FencedCodeBlock:
90+ return ast.WalkSkipChildren, nil
91+ case *ast.Text:
92+ refs = append(refs, n)
93+ }
94+ return ast.WalkContinue, nil
95+ })
96+ }
68 pol.apply(doc)97 pol.apply(doc)
98+ for _, t := range refs {
99+ noteReferences(t, source, notes)
100+ }
69 101
70 var buf bytes.Buffer102 var buf bytes.Buffer
71 if err := m.md.Renderer().Render(&buf, source, doc); err != nil {103 if err := m.md.Renderer().Render(&buf, source, doc); err != nil {
@@ -291,3 +323,50 @@ func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
291 return ast.WalkContinue, nil323 return ast.WalkContinue, nil
292 })324 })
293 }325 }
326+
327+var noteReferencePattern = regexp.MustCompile(`(^|[^0-9A-Za-z_^])\^([1-9][0-9]{0,5})\b`)
328+
329+// noteReferences changes only text segments; code and link labels never enter
330+// this function. New fragment links deliberately bypass heading-prefix rewriting.
331+func noteReferences(t *ast.Text, source []byte, exists func(int64) bool) {
332+ parent := t.Parent()
333+ if parent == nil {
334+ return
335+ }
336+ value := t.Value(source)
337+ offset := 0
338+ for _, match := range noteReferencePattern.FindAllSubmatchIndex(value, -1) {
339+ id := string(value[match[4]:match[5]])
340+ n, err := strconv.ParseInt(id, 10, 64)
341+ if err != nil || !exists(n) {
342+ continue
343+ }
344+ start := match[4] - 1
345+ // Markdown punctuation may split adjacent text into separate AST nodes.
346+ // Check the original preceding byte as well as the segment-local regex.
347+ if at := t.Segment.Start + start; at > 0 {
348+ prev := source[at-1]
349+ if prev >= '0' && prev <= '9' || prev >= 'A' && prev <= 'Z' || prev >= 'a' && prev <= 'z' || prev == '_' || prev == '^' {
350+ continue
351+ }
352+ }
353+ if start > offset {
354+ parent.InsertBefore(parent, t, ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Start+start)))
355+ }
356+ link := ast.NewLink()
357+ link.Destination = []byte("#c" + id)
358+ link.Title = []byte("note " + id)
359+ link.SetAttributeString("class", []byte("ref"))
360+ link.AppendChild(link, ast.NewString(value[start:match[5]]))
361+ parent.InsertBefore(parent, t, link)
362+ offset = match[5]
363+ }
364+ if offset == 0 {
365+ return
366+ }
367+ tail := ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Stop))
368+ tail.SetSoftLineBreak(t.SoftLineBreak())
369+ tail.SetHardLineBreak(t.HardLineBreak())
370+ parent.InsertBefore(parent, t, tail)
371+ parent.RemoveChild(parent, t)
372+}
M internal/adapters/web/markdown_test.go +29 -0
@@ -136,3 +136,32 @@ func TestMarkdownGFM(t *testing.T) {
136136 assert.Contains(t, out, "<del>gone</del>")
137137 assert.Contains(t, out, `type="checkbox"`)
138138 }
139+
140+func TestNoteMarkdown(t *testing.T) {
141+ t.Parallel()
142+ tests := []struct {
143+ source string
144+ links int
145+ }{
146+ {"^2 ^99 a^2 _^2 ^^2 1^2", 1},
147+ {"`^2` [^2](https://example.org) [^2](javascript:alert(1))", 0},
148+ {"```\n^2\n```", 0},
149+ {"^2, ^2\n^2", 3},
150+ }
151+ for _, tt := range tests {
152+ t.Run(tt.source, func(t *testing.T) {
153+ t.Parallel()
154+ h, err := newMarkdown().renderNote([]byte(tt.source), "demo", "main", "c1-", func(n int64) bool { return n == 2 })
155+ require.NoError(t, err)
156+ assert.Equal(t, tt.links, strings.Count(string(h), `href="#c2"`), string(h))
157+ assert.NotContains(t, string(h), `href="#c99"`)
158+ assert.NotContains(t, string(h), "javascript:")
159+ })
160+ }
161+ h, err := newMarkdown().renderNote([]byte("# Heading\n\n<script>alert(1)</script>\n\n**bold** ^2"), "demo", "main", "c1-", func(n int64) bool { return n == 2 })
162+ require.NoError(t, err)
163+ assert.Contains(t, string(h), `id="c1-heading"`)
164+ assert.Contains(t, string(h), "<strong>bold</strong>")
165+ assert.NotContains(t, string(h), "<script")
166+ assert.Contains(t, string(h), `title="note 2"`)
167+}
@@ -136,3 +136,32 @@ func TestMarkdownGFM(t *testing.T) {
136 assert.Contains(t, out, "<del>gone</del>")136 assert.Contains(t, out, "<del>gone</del>")
137 assert.Contains(t, out, `type="checkbox"`)137 assert.Contains(t, out, `type="checkbox"`)
138 }138 }
139+
140+func TestNoteMarkdown(t *testing.T) {
141+ t.Parallel()
142+ tests := []struct {
143+ source string
144+ links int
145+ }{
146+ {"^2 ^99 a^2 _^2 ^^2 1^2", 1},
147+ {"`^2` [^2](https://example.org) [^2](javascript:alert(1))", 0},
148+ {"```\n^2\n```", 0},
149+ {"^2, ^2\n^2", 3},
150+ }
151+ for _, tt := range tests {
152+ t.Run(tt.source, func(t *testing.T) {
153+ t.Parallel()
154+ h, err := newMarkdown().renderNote([]byte(tt.source), "demo", "main", "c1-", func(n int64) bool { return n == 2 })
155+ require.NoError(t, err)
156+ assert.Equal(t, tt.links, strings.Count(string(h), `href="#c2"`), string(h))
157+ assert.NotContains(t, string(h), `href="#c99"`)
158+ assert.NotContains(t, string(h), "javascript:")
159+ })
160+ }
161+ h, err := newMarkdown().renderNote([]byte("# Heading\n\n<script>alert(1)</script>\n\n**bold** ^2"), "demo", "main", "c1-", func(n int64) bool { return n == 2 })
162+ require.NoError(t, err)
163+ assert.Contains(t, string(h), `id="c1-heading"`)
164+ assert.Contains(t, string(h), "<strong>bold</strong>")
165+ assert.NotContains(t, string(h), "<script")
166+ assert.Contains(t, string(h), `title="note 2"`)
167+}
M internal/adapters/web/mr.go +129 -25
@@ -101,13 +101,19 @@ type MRListPage struct {
101101
102102 // CommentView is a comment of a merge request, ready to show.
103103 type CommentView struct {
104- ID string
105- Author string
106- When string
107- WhenAt string
108- Abs string
109- Parts []TextPart
110- Resolved bool
104+ ID string
105+ Author string
106+ When string
107+ WhenAt string
108+ Abs string
109+ Body, BodySplit template.HTML
110+ Replies []CommentView
111+ Edited, Deleted bool
112+ Verdict, Range, Quote string
113+ Edit *EditForm
114+ Delete *DeleteForm
115+ Reply *CommentForm
116+ Resolved bool
111117 // Outdated is true when the head moved since the comment was written, so
112118 // its line may have moved too.
113119 Outdated bool
@@ -119,6 +125,19 @@ type CommentView struct {
119125 Resolve *ResolveForm
120126 }
121127
128+type EditForm struct {
129+ Action, Text string
130+ From int
131+}
132+type DeleteForm struct {
133+ Action string
134+ From int
135+}
136+type VerdictView struct {
137+ Author, Label, Href, Short string
138+ Stale bool
139+}
140+
122141 // ResolveForm is the POST that changes the resolved state of a comment.
123142 type ResolveForm struct {
124143 Action string
@@ -135,9 +154,10 @@ type CommentForm struct {
135154 Action string
136155 Title string
137156 // Path, Line and Side designate the line; empty for a general comment.
138- Path, Line, Side string
139- Cancel string
140- Focus bool
157+ Path, Line, Side string
158+ StartLine, ReplyTo string
159+ Cancel string
160+ Focus bool
141161 // From is the window of the page the form is on (see mrWindow), sent
142162 // back so that the answer returns to it; 0 for the first.
143163 From int
@@ -192,6 +212,7 @@ type MRPage struct {
192212 State string
193213 Open bool
194214 Signed bool
215+ NoteAuthor string
195216 Source, Target string
196217 SourceHref string
197218 TargetHref string
@@ -226,6 +247,7 @@ type MRPage struct {
226247 NComments int
227248 NUnresolved int
228249 GeneralForm *CommentForm
250+ Verdicts []VerdictView
229251
230252 Commits []MRCommit
231253 CommitsNote string
@@ -471,7 +493,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
471493
472494 p := MRPage{
473495 Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()),
474- State: mrStateName(m), Open: open, Signed: signed,
496+ State: mrStateName(m), Open: open, Signed: signed, NoteAuthor: domain.AuthorName(principal(ctx)),
475497 Source: visibleText(m.Source), Target: visibleText(m.Target),
476498 Author: s.who(m.Author),
477499 Head: head, HeadShort: short(head),
@@ -485,12 +507,15 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
485507 // Relative links point into the source branch as it is now (its head
486508 // commit); the heading ids carry their own prefix, apart from the
487509 // ids of the diff.
488- h, _, err := s.md.renderWithIDs([]byte(desc), d.name(), head, "", "mr-desc-")
510+ h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))
489511 if err != nil {
490512 return nil, 0, err
491513 }
492514 p.Desc = h
493515 }
516+ for _, v := range res.Verdicts {
517+ p.Verdicts = append(p.Verdicts, VerdictView{Author: s.who(v.Author), Label: v.Verdict.String(), Stale: v.Stale, Href: "#c" + strconv.FormatInt(v.CommentID, 10), Short: short(v.CommitID.String())})
518+ }
494519 p.Opened.Name = p.Author
495520 p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)
496521 if !m.CreatedAt.IsZero() {
@@ -684,9 +709,10 @@ func shellWord(s string) string {
684709 // lineRef designates a line of the diff in the query of the comment form:
685710 // "?on=new:12:path/to/file".
686711 type lineRef struct {
687- side string
688- line int
689- path string
712+ side string
713+ line int
714+ start int
715+ path string
690716 }
691717
692718 func parseOn(s string) *lineRef {
@@ -695,11 +721,21 @@ func parseOn(s string) *lineRef {
695721 return nil
696722 }
697723 num, path, ok := strings.Cut(rest, ":")
724+ start := 0
725+ if a, b, rangeOK := strings.Cut(num, "-"); rangeOK {
726+ var e error
727+ start, e = strconv.Atoi(a)
728+ end, e2 := strconv.Atoi(b)
729+ if e != nil || e2 != nil || start < 1 || start >= end {
730+ return nil
731+ }
732+ num = b
733+ }
698734 line, err := strconv.Atoi(num)
699735 if !ok || err != nil || line < 1 || path == "" {
700736 return nil
701737 }
702- return &lineRef{side: side, line: line, path: path}
738+ return &lineRef{side: side, line: line, start: start, path: path}
703739 }
704740
705741 // placeComments puts every comment under its line when the diff shown has it,
@@ -757,21 +793,66 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
757793 return index[lineKey{path, side, line}]
758794 }
759795
796+ exists := noteIDs(comments)
797+ children := map[int64][]domain.Comment{}
760798 for _, c := range comments {
799+ if c.ReplyTo != 0 && !c.Deleted {
800+ children[c.ReplyTo] = append(children[c.ReplyTo], c)
801+ }
802+ if !c.Deleted {
803+ p.NComments++
804+ }
805+ if c.ReplyTo == 0 && !c.Deleted && !c.Resolved {
806+ p.NUnresolved++
807+ }
808+ }
809+ var view func(domain.Comment, bool) CommentView
810+ view = func(c domain.Comment, replyable bool) CommentView {
761811 cid := strconv.FormatInt(c.ID, 10)
762- v := CommentView{
763- ID: cid, Author: s.who(c.Author),
764- Parts: plainParts(c.Text.String()), Resolved: c.Resolved,
765- Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head,
812+ body, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"-", exists)
813+ if err != nil {
814+ body = ""
766815 }
816+ bodySplit, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"s-", exists)
817+ if err != nil {
818+ bodySplit = ""
819+ }
820+ v := CommentView{BodySplit: bodySplit, ID: cid, Author: s.who(c.Author), Body: body, Resolved: c.Resolved, Deleted: c.Deleted, Edited: !c.EditedAt.IsZero(), Verdict: c.Verdict.String(), Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head}
767821 v.When, v.WhenAt = whenOf(now, c.CreatedAt)
768822 if !c.CreatedAt.IsZero() {
769823 v.Abs = absolute(c.CreatedAt)
770824 }
771- p.NComments++
772- if !c.Resolved {
773- p.NUnresolved++
825+ if c.StartLine > 0 {
826+ v.Range = "lines " + itoa(c.StartLine) + "–" + itoa(c.Line)
774827 }
828+ if canWrite && !c.Deleted {
829+ from := windowStart(fileAt[c.Path])
830+ root := cid
831+ if c.ReplyTo != 0 {
832+ root = strconv.FormatInt(c.ReplyTo, 10)
833+ }
834+ if replyable {
835+ v.Quote = "> " + strings.ReplaceAll(c.Text.String(), "\n", "\n> ") + "\n\n^" + cid + " "
836+ }
837+ if c.ReplyTo == 0 {
838+ v.Reply = &CommentForm{ID: "reply-" + cid, Action: mrHref(repo, n, "comments"), Title: "Reply", ReplyTo: root, From: from}
839+ }
840+ if c.Author == p.NoteAuthor {
841+ v.Edit = &EditForm{Action: mrHref(repo, n, "comments", cid, "edit"), Text: c.Text.String(), From: from}
842+ v.Delete = &DeleteForm{Action: mrHref(repo, n, "comments", cid, "delete"), From: from}
843+ }
844+ }
845+ for _, reply := range children[c.ID] {
846+ v.Replies = append(v.Replies, view(reply, replyable))
847+ }
848+ return v
849+ }
850+ for _, c := range comments {
851+ if c.ReplyTo != 0 || c.Deleted && c.Verdict == domain.VerdictNone && len(children[c.ID]) == 0 {
852+ continue
853+ }
854+ cid := strconv.FormatInt(c.ID, 10)
855+ v := view(c, canWrite && !c.Deleted)
775856 general := c.Path == ""
776857 fi, listed := fileAt[c.Path]
777858 if !general && listed && p.Files[fi].Lazy {
@@ -789,7 +870,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
789870 v.Where = visibleText(c.Path) + ":" + itoa(c.Line) + " (" + side + ")"
790871 }
791872 }
792- if canWrite {
873+ if canWrite && !c.Deleted {
793874 v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"}
794875 if !general && listed {
795876 v.Resolve.From = windowStart(fi)
@@ -815,9 +896,18 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
815896 if on.side == "old" {
816897 side = domain.SideOld
817898 }
899+ start := on.start
900+ if start == 0 {
901+ start = on.line
902+ }
903+ for key, ln := range index {
904+ if key.path == on.path && key.side == side && key.line >= start && key.line <= on.line {
905+ ln.Selected = true
906+ }
907+ }
818908 if ln := find(on.path, side, on.line); ln != nil {
819909 ln.Form = &CommentForm{
820- ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
910+ StartLine: optionalLine(on.start), ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
821911 Title: "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",
822912 Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true,
823913 From: windowStart(fileAt[on.path]),
@@ -842,3 +932,17 @@ func userText(parts []TextPart) templ.Component {
842932 return err
843933 })
844934 }
935+
936+func noteIDs(comments []domain.Comment) func(int64) bool {
937+ ids := make(map[int64]bool, len(comments))
938+ for _, c := range comments {
939+ ids[c.ID] = true
940+ }
941+ return func(n int64) bool { return ids[n] }
942+}
943+func optionalLine(n int) string {
944+ if n == 0 {
945+ return ""
946+ }
947+ return itoa(n)
948+}
@@ -101,13 +101,19 @@ type MRListPage struct {
101 101
102 // CommentView is a comment of a merge request, ready to show.102 // CommentView is a comment of a merge request, ready to show.
103 type CommentView struct {103 type CommentView struct {
104- ID string104+ ID string
105- Author string105+ Author string
106- When string106+ When string
107- WhenAt string107+ WhenAt string
108- Abs string108+ Abs string
109- Parts []TextPart109+ Body, BodySplit template.HTML
110- Resolved bool110+ Replies []CommentView
111+ Edited, Deleted bool
112+ Verdict, Range, Quote string
113+ Edit *EditForm
114+ Delete *DeleteForm
115+ Reply *CommentForm
116+ Resolved bool
111 // Outdated is true when the head moved since the comment was written, so117 // Outdated is true when the head moved since the comment was written, so
112 // its line may have moved too.118 // its line may have moved too.
113 Outdated bool119 Outdated bool
@@ -119,6 +125,19 @@ type CommentView struct {
119 Resolve *ResolveForm125 Resolve *ResolveForm
120 }126 }
121 127
128+type EditForm struct {
129+ Action, Text string
130+ From int
131+}
132+type DeleteForm struct {
133+ Action string
134+ From int
135+}
136+type VerdictView struct {
137+ Author, Label, Href, Short string
138+ Stale bool
139+}
140+
122 // ResolveForm is the POST that changes the resolved state of a comment.141 // ResolveForm is the POST that changes the resolved state of a comment.
123 type ResolveForm struct {142 type ResolveForm struct {
124 Action string143 Action string
@@ -135,9 +154,10 @@ type CommentForm struct {
135 Action string154 Action string
136 Title string155 Title string
137 // Path, Line and Side designate the line; empty for a general comment.156 // Path, Line and Side designate the line; empty for a general comment.
138- Path, Line, Side string157+ Path, Line, Side string
139- Cancel string158+ StartLine, ReplyTo string
140- Focus bool159+ Cancel string
160+ Focus bool
141 // From is the window of the page the form is on (see mrWindow), sent161 // From is the window of the page the form is on (see mrWindow), sent
142 // back so that the answer returns to it; 0 for the first.162 // back so that the answer returns to it; 0 for the first.
143 From int163 From int
@@ -192,6 +212,7 @@ type MRPage struct {
192 State string212 State string
193 Open bool213 Open bool
194 Signed bool214 Signed bool
215+ NoteAuthor string
195 Source, Target string216 Source, Target string
196 SourceHref string217 SourceHref string
197 TargetHref string218 TargetHref string
@@ -226,6 +247,7 @@ type MRPage struct {
226 NComments int247 NComments int
227 NUnresolved int248 NUnresolved int
228 GeneralForm *CommentForm249 GeneralForm *CommentForm
250+ Verdicts []VerdictView
229 251
230 Commits []MRCommit252 Commits []MRCommit
231 CommitsNote string253 CommitsNote string
@@ -471,7 +493,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
471 493
472 p := MRPage{494 p := MRPage{
473 Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()),495 Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()),
474- State: mrStateName(m), Open: open, Signed: signed,496+ State: mrStateName(m), Open: open, Signed: signed, NoteAuthor: domain.AuthorName(principal(ctx)),
475 Source: visibleText(m.Source), Target: visibleText(m.Target),497 Source: visibleText(m.Source), Target: visibleText(m.Target),
476 Author: s.who(m.Author),498 Author: s.who(m.Author),
477 Head: head, HeadShort: short(head),499 Head: head, HeadShort: short(head),
@@ -485,12 +507,15 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
485 // Relative links point into the source branch as it is now (its head507 // Relative links point into the source branch as it is now (its head
486 // commit); the heading ids carry their own prefix, apart from the508 // commit); the heading ids carry their own prefix, apart from the
487 // ids of the diff.509 // ids of the diff.
488- h, _, err := s.md.renderWithIDs([]byte(desc), d.name(), head, "", "mr-desc-")510+ h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))
489 if err != nil {511 if err != nil {
490 return nil, 0, err512 return nil, 0, err
491 }513 }
492 p.Desc = h514 p.Desc = h
493 }515 }
516+ for _, v := range res.Verdicts {
517+ p.Verdicts = append(p.Verdicts, VerdictView{Author: s.who(v.Author), Label: v.Verdict.String(), Stale: v.Stale, Href: "#c" + strconv.FormatInt(v.CommentID, 10), Short: short(v.CommitID.String())})
518+ }
494 p.Opened.Name = p.Author519 p.Opened.Name = p.Author
495 p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)520 p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)
496 if !m.CreatedAt.IsZero() {521 if !m.CreatedAt.IsZero() {
@@ -684,9 +709,10 @@ func shellWord(s string) string {
684 // lineRef designates a line of the diff in the query of the comment form:709 // lineRef designates a line of the diff in the query of the comment form:
685 // "?on=new:12:path/to/file".710 // "?on=new:12:path/to/file".
686 type lineRef struct {711 type lineRef struct {
687- side string712+ side string
688- line int713+ line int
689- path string714+ start int
715+ path string
690 }716 }
691 717
692 func parseOn(s string) *lineRef {718 func parseOn(s string) *lineRef {
@@ -695,11 +721,21 @@ func parseOn(s string) *lineRef {
695 return nil721 return nil
696 }722 }
697 num, path, ok := strings.Cut(rest, ":")723 num, path, ok := strings.Cut(rest, ":")
724+ start := 0
725+ if a, b, rangeOK := strings.Cut(num, "-"); rangeOK {
726+ var e error
727+ start, e = strconv.Atoi(a)
728+ end, e2 := strconv.Atoi(b)
729+ if e != nil || e2 != nil || start < 1 || start >= end {
730+ return nil
731+ }
732+ num = b
733+ }
698 line, err := strconv.Atoi(num)734 line, err := strconv.Atoi(num)
699 if !ok || err != nil || line < 1 || path == "" {735 if !ok || err != nil || line < 1 || path == "" {
700 return nil736 return nil
701 }737 }
702- return &lineRef{side: side, line: line, path: path}738+ return &lineRef{side: side, line: line, start: start, path: path}
703 }739 }
704 740
705 // placeComments puts every comment under its line when the diff shown has it,741 // placeComments puts every comment under its line when the diff shown has it,
@@ -757,21 +793,66 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
757 return index[lineKey{path, side, line}]793 return index[lineKey{path, side, line}]
758 }794 }
759 795
796+ exists := noteIDs(comments)
797+ children := map[int64][]domain.Comment{}
760 for _, c := range comments {798 for _, c := range comments {
799+ if c.ReplyTo != 0 && !c.Deleted {
800+ children[c.ReplyTo] = append(children[c.ReplyTo], c)
801+ }
802+ if !c.Deleted {
803+ p.NComments++
804+ }
805+ if c.ReplyTo == 0 && !c.Deleted && !c.Resolved {
806+ p.NUnresolved++
807+ }
808+ }
809+ var view func(domain.Comment, bool) CommentView
810+ view = func(c domain.Comment, replyable bool) CommentView {
761 cid := strconv.FormatInt(c.ID, 10)811 cid := strconv.FormatInt(c.ID, 10)
762- v := CommentView{812+ body, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"-", exists)
763- ID: cid, Author: s.who(c.Author),813+ if err != nil {
764- Parts: plainParts(c.Text.String()), Resolved: c.Resolved,814+ body = ""
765- Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head,
766 }815 }
816+ bodySplit, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"s-", exists)
817+ if err != nil {
818+ bodySplit = ""
819+ }
820+ v := CommentView{BodySplit: bodySplit, ID: cid, Author: s.who(c.Author), Body: body, Resolved: c.Resolved, Deleted: c.Deleted, Edited: !c.EditedAt.IsZero(), Verdict: c.Verdict.String(), Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head}
767 v.When, v.WhenAt = whenOf(now, c.CreatedAt)821 v.When, v.WhenAt = whenOf(now, c.CreatedAt)
768 if !c.CreatedAt.IsZero() {822 if !c.CreatedAt.IsZero() {
769 v.Abs = absolute(c.CreatedAt)823 v.Abs = absolute(c.CreatedAt)
770 }824 }
771- p.NComments++825+ if c.StartLine > 0 {
772- if !c.Resolved {826+ v.Range = "lines " + itoa(c.StartLine) + "–" + itoa(c.Line)
773- p.NUnresolved++
774 }827 }
828+ if canWrite && !c.Deleted {
829+ from := windowStart(fileAt[c.Path])
830+ root := cid
831+ if c.ReplyTo != 0 {
832+ root = strconv.FormatInt(c.ReplyTo, 10)
833+ }
834+ if replyable {
835+ v.Quote = "> " + strings.ReplaceAll(c.Text.String(), "\n", "\n> ") + "\n\n^" + cid + " "
836+ }
837+ if c.ReplyTo == 0 {
838+ v.Reply = &CommentForm{ID: "reply-" + cid, Action: mrHref(repo, n, "comments"), Title: "Reply", ReplyTo: root, From: from}
839+ }
840+ if c.Author == p.NoteAuthor {
841+ v.Edit = &EditForm{Action: mrHref(repo, n, "comments", cid, "edit"), Text: c.Text.String(), From: from}
842+ v.Delete = &DeleteForm{Action: mrHref(repo, n, "comments", cid, "delete"), From: from}
843+ }
844+ }
845+ for _, reply := range children[c.ID] {
846+ v.Replies = append(v.Replies, view(reply, replyable))
847+ }
848+ return v
849+ }
850+ for _, c := range comments {
851+ if c.ReplyTo != 0 || c.Deleted && c.Verdict == domain.VerdictNone && len(children[c.ID]) == 0 {
852+ continue
853+ }
854+ cid := strconv.FormatInt(c.ID, 10)
855+ v := view(c, canWrite && !c.Deleted)
775 general := c.Path == ""856 general := c.Path == ""
776 fi, listed := fileAt[c.Path]857 fi, listed := fileAt[c.Path]
777 if !general && listed && p.Files[fi].Lazy {858 if !general && listed && p.Files[fi].Lazy {
@@ -789,7 +870,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
789 v.Where = visibleText(c.Path) + ":" + itoa(c.Line) + " (" + side + ")"870 v.Where = visibleText(c.Path) + ":" + itoa(c.Line) + " (" + side + ")"
790 }871 }
791 }872 }
792- if canWrite {873+ if canWrite && !c.Deleted {
793 v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"}874 v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"}
794 if !general && listed {875 if !general && listed {
795 v.Resolve.From = windowStart(fi)876 v.Resolve.From = windowStart(fi)
@@ -815,9 +896,18 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
815 if on.side == "old" {896 if on.side == "old" {
816 side = domain.SideOld897 side = domain.SideOld
817 }898 }
899+ start := on.start
900+ if start == 0 {
901+ start = on.line
902+ }
903+ for key, ln := range index {
904+ if key.path == on.path && key.side == side && key.line >= start && key.line <= on.line {
905+ ln.Selected = true
906+ }
907+ }
818 if ln := find(on.path, side, on.line); ln != nil {908 if ln := find(on.path, side, on.line); ln != nil {
819 ln.Form = &CommentForm{909 ln.Form = &CommentForm{
820- ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,910+ StartLine: optionalLine(on.start), ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
821 Title: "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",911 Title: "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",
822 Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true,912 Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true,
823 From: windowStart(fileAt[on.path]),913 From: windowStart(fileAt[on.path]),
@@ -842,3 +932,17 @@ func userText(parts []TextPart) templ.Component {
842 return err932 return err
843 })933 })
844 }934 }
935+
936+func noteIDs(comments []domain.Comment) func(int64) bool {
937+ ids := make(map[int64]bool, len(comments))
938+ for _, c := range comments {
939+ ids[c.ID] = true
940+ }
941+ return func(n int64) bool { return ids[n] }
942+}
943+func optionalLine(n int) string {
944+ if n == 0 {
945+ return ""
946+ }
947+ return itoa(n)
948+}
M internal/adapters/web/mr.templ +66 -6
@@ -229,10 +229,9 @@ templ lineNotes(notes []CommentView, form *CommentForm, suffix string) {
229229 }
230230
231231 // commentView is a note: a ruled block, under its line in the diff or in the
232-// list of comments. The text is plain: escaped, its line breaks kept, its
233-// http(s) addresses linked.
232+// list of comments. Body comes only from the safe Markdown renderer.
234233 templ commentView(c CommentView, suffix string) {
235- <div class="cmt" id={ "c" + c.ID + suffix } data-resolved={ boolText(c.Resolved) }>
234+ <div class="cmt" id={ "c" + c.ID + suffix } data-resolved={ boolText(c.Resolved) } data-thread?={ c.Reply != nil }>
236235 <p class="c-head">
237236 <span class="c-who">{ c.Author }</span>
238237 if c.WhenAt != "" {
@@ -248,7 +247,47 @@ templ commentView(c CommentView, suffix string) {
248247 { " " }<span class="c-where mute">{ c.Where }</span>
249248 }
250249 </p>
251- <p class="c-text">@userText(c.Parts)</p>
250+ if c.Deleted {
251+ if c.Verdict != "" {
252+ <p class="mute">text deleted</p>
253+ } else {
254+ <p class="mute">deleted note</p>
255+ }
256+ } else {
257+ <div class="c-body prose">
258+ if suffix == "-s" {
259+ @templ.Raw(string(c.BodySplit))
260+ } else {
261+ @templ.Raw(string(c.Body))
262+ }
263+ </div>
264+ }
265+ if c.Edited {
266+ <span class="mute">edited</span>
267+ }
268+ if c.Verdict != "" {
269+ <span class="c-state">{ c.Verdict }</span>
270+ }
271+ if c.Range != "" {
272+ <span class="mute">{ c.Range }</span>
273+ }
274+ if c.Quote != "" {
275+ <button type="button" class="btn" data-js data-quote={ c.Quote }>Quote</button>
276+ }
277+ if c.Edit != nil {
278+ <details><summary>Edit</summary><form method="post" action={ templ.SafeURL(c.Edit.Action) }><input type="hidden" name="from" value={ itoa(c.Edit.From) }/><textarea name="text" maxlength="8000">{ c.Edit.Text }</textarea><button class="btn" type="submit">Save</button></form></details>
279+ }
280+ if c.Delete != nil {
281+ <details><summary>Delete</summary><form method="post" action={ templ.SafeURL(c.Delete.Action) }><input type="hidden" name="from" value={ itoa(c.Delete.From) }/><p>Delete this note?</p><button class="btn" type="submit">Confirm delete</button></form></details>
282+ }
283+ for _, reply := range c.Replies {
284+ @commentView(reply, suffix)
285+ }
286+ if c.Reply != nil {
287+ <details data-reply>
288+ <summary>Reply</summary>@commentForm(*c.Reply, suffix)
289+ </details>
290+ }
252291 if c.Resolve != nil {
253292 <form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>
254293 <input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/>
@@ -277,8 +316,7 @@ templ commentForm(f CommentForm, suffix string) {
277316 name="text"
278317 rows="3"
279318 maxlength="8000"
280- required
281- placeholder="plain text, 8000 characters at most"
319+ placeholder="Markdown, 8000 characters at most"
282320 if f.Focus {
283321 autofocus
284322 }
@@ -287,6 +325,13 @@ templ commentForm(f CommentForm, suffix string) {
287325 <input type="hidden" name="path" value={ f.Path }/>
288326 <input type="hidden" name="line" value={ f.Line }/>
289327 <input type="hidden" name="side" value={ f.Side }/>
328+ <label>from line <input type="number" name="start_line" min="1" value={ f.StartLine }/></label>
329+ }
330+ if f.ReplyTo != "" {
331+ <input type="hidden" name="reply_to" value={ f.ReplyTo }/>
332+ }
333+ if f.Path == "" && f.ReplyTo == "" {
334+ <fieldset><legend>Verdict</legend><label><input type="radio" name="verdict" value="" checked/>Note</label><label><input type="radio" name="verdict" value="approve"/>Approve</label><label><input type="radio" name="verdict" value="changes_requested"/>Request changes</label></fieldset>
290335 }
291336 if f.From > 0 {
292337 <input type="hidden" name="from" value={ itoa(f.From) }/>
@@ -310,6 +355,21 @@ templ mrRail(p MRPage) {
310355 </h2>
311356 @FileList(p.Files)
312357 </section>
358+ if len(p.Verdicts) > 0 {
359+ <section>
360+ <h2>Verdicts</h2>
361+ <ul>
362+ for _, v := range p.Verdicts {
363+ <li>
364+ <a href={ templ.SafeURL(v.Href) }>{ v.Author }: { v.Label }</a>
365+ if v.Stale {
366+ <span class="mute">(stale)</span>
367+ }
368+ </li>
369+ }
370+ </ul>
371+ </section>
372+ }
313373 @MRChecks(p.Checks)
314374 <section aria-labelledby="r-commits">
315375 <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2>
@@ -229,10 +229,9 @@ templ lineNotes(notes []CommentView, form *CommentForm, suffix string) {
229 }229 }
230 230
231 // commentView is a note: a ruled block, under its line in the diff or in the231 // commentView is a note: a ruled block, under its line in the diff or in the
232-// list of comments. The text is plain: escaped, its line breaks kept, its232+// list of comments. Body comes only from the safe Markdown renderer.
233-// http(s) addresses linked.
234 templ commentView(c CommentView, suffix string) {233 templ commentView(c CommentView, suffix string) {
235- <div class="cmt" id={ "c" + c.ID + suffix } data-resolved={ boolText(c.Resolved) }>234+ <div class="cmt" id={ "c" + c.ID + suffix } data-resolved={ boolText(c.Resolved) } data-thread?={ c.Reply != nil }>
236 <p class="c-head">235 <p class="c-head">
237 <span class="c-who">{ c.Author }</span>236 <span class="c-who">{ c.Author }</span>
238 if c.WhenAt != "" {237 if c.WhenAt != "" {
@@ -248,7 +247,47 @@ templ commentView(c CommentView, suffix string) {
248 { " " }<span class="c-where mute">{ c.Where }</span>247 { " " }<span class="c-where mute">{ c.Where }</span>
249 }248 }
250 </p>249 </p>
251- <p class="c-text">@userText(c.Parts)</p>250+ if c.Deleted {
251+ if c.Verdict != "" {
252+ <p class="mute">text deleted</p>
253+ } else {
254+ <p class="mute">deleted note</p>
255+ }
256+ } else {
257+ <div class="c-body prose">
258+ if suffix == "-s" {
259+ @templ.Raw(string(c.BodySplit))
260+ } else {
261+ @templ.Raw(string(c.Body))
262+ }
263+ </div>
264+ }
265+ if c.Edited {
266+ <span class="mute">edited</span>
267+ }
268+ if c.Verdict != "" {
269+ <span class="c-state">{ c.Verdict }</span>
270+ }
271+ if c.Range != "" {
272+ <span class="mute">{ c.Range }</span>
273+ }
274+ if c.Quote != "" {
275+ <button type="button" class="btn" data-js data-quote={ c.Quote }>Quote</button>
276+ }
277+ if c.Edit != nil {
278+ <details><summary>Edit</summary><form method="post" action={ templ.SafeURL(c.Edit.Action) }><input type="hidden" name="from" value={ itoa(c.Edit.From) }/><textarea name="text" maxlength="8000">{ c.Edit.Text }</textarea><button class="btn" type="submit">Save</button></form></details>
279+ }
280+ if c.Delete != nil {
281+ <details><summary>Delete</summary><form method="post" action={ templ.SafeURL(c.Delete.Action) }><input type="hidden" name="from" value={ itoa(c.Delete.From) }/><p>Delete this note?</p><button class="btn" type="submit">Confirm delete</button></form></details>
282+ }
283+ for _, reply := range c.Replies {
284+ @commentView(reply, suffix)
285+ }
286+ if c.Reply != nil {
287+ <details data-reply>
288+ <summary>Reply</summary>@commentForm(*c.Reply, suffix)
289+ </details>
290+ }
252 if c.Resolve != nil {291 if c.Resolve != nil {
253 <form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>292 <form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>
254 <input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/>293 <input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/>
@@ -277,8 +316,7 @@ templ commentForm(f CommentForm, suffix string) {
277 name="text"316 name="text"
278 rows="3"317 rows="3"
279 maxlength="8000"318 maxlength="8000"
280- required319+ placeholder="Markdown, 8000 characters at most"
281- placeholder="plain text, 8000 characters at most"
282 if f.Focus {320 if f.Focus {
283 autofocus321 autofocus
284 }322 }
@@ -287,6 +325,13 @@ templ commentForm(f CommentForm, suffix string) {
287 <input type="hidden" name="path" value={ f.Path }/>325 <input type="hidden" name="path" value={ f.Path }/>
288 <input type="hidden" name="line" value={ f.Line }/>326 <input type="hidden" name="line" value={ f.Line }/>
289 <input type="hidden" name="side" value={ f.Side }/>327 <input type="hidden" name="side" value={ f.Side }/>
328+ <label>from line <input type="number" name="start_line" min="1" value={ f.StartLine }/></label>
329+ }
330+ if f.ReplyTo != "" {
331+ <input type="hidden" name="reply_to" value={ f.ReplyTo }/>
332+ }
333+ if f.Path == "" && f.ReplyTo == "" {
334+ <fieldset><legend>Verdict</legend><label><input type="radio" name="verdict" value="" checked/>Note</label><label><input type="radio" name="verdict" value="approve"/>Approve</label><label><input type="radio" name="verdict" value="changes_requested"/>Request changes</label></fieldset>
290 }335 }
291 if f.From > 0 {336 if f.From > 0 {
292 <input type="hidden" name="from" value={ itoa(f.From) }/>337 <input type="hidden" name="from" value={ itoa(f.From) }/>
@@ -310,6 +355,21 @@ templ mrRail(p MRPage) {
310 </h2>355 </h2>
311 @FileList(p.Files)356 @FileList(p.Files)
312 </section>357 </section>
358+ if len(p.Verdicts) > 0 {
359+ <section>
360+ <h2>Verdicts</h2>
361+ <ul>
362+ for _, v := range p.Verdicts {
363+ <li>
364+ <a href={ templ.SafeURL(v.Href) }>{ v.Author }: { v.Label }</a>
365+ if v.Stale {
366+ <span class="mute">(stale)</span>
367+ }
368+ </li>
369+ }
370+ </ul>
371+ </section>
372+ }
313 @MRChecks(p.Checks)373 @MRChecks(p.Checks)
314 <section aria-labelledby="r-commits">374 <section aria-labelledby="r-commits">
315 <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2>375 <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2>
M internal/adapters/web/mr_files.go +5 -3
@@ -131,8 +131,10 @@ func countNotes(files []DiffFile, comments []domain.Comment) {
131131 if c.Path == "" || !ok {
132132 continue
133133 }
134- files[i].Notes++
135- if !c.Resolved {
134+ if c.ReplyTo == 0 && !c.Deleted {
135+ files[i].Notes++
136+ }
137+ if c.ReplyTo == 0 && !c.Deleted && !c.Resolved {
136138 files[i].OpenNotes++
137139 }
138140 ids[i] = append(ids[i], "c"+strconv.FormatInt(c.ID, 10))
@@ -196,7 +198,7 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
196198 m := res.MergeRequest
197199 signed := SignedIn(ctx)
198200 canWrite := signed && m.State == domain.MRStateOpen && !since
199- p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true}
201+ p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true, NoteAuthor: domain.AuthorName(principal(ctx))}
200202 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))
201203 s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
202204 window := p.Files[from:min(from+mrWindow, len(p.Files))]
@@ -131,8 +131,10 @@ func countNotes(files []DiffFile, comments []domain.Comment) {
131 if c.Path == "" || !ok {131 if c.Path == "" || !ok {
132 continue132 continue
133 }133 }
134- files[i].Notes++134+ if c.ReplyTo == 0 && !c.Deleted {
135- if !c.Resolved {135+ files[i].Notes++
136+ }
137+ if c.ReplyTo == 0 && !c.Deleted && !c.Resolved {
136 files[i].OpenNotes++138 files[i].OpenNotes++
137 }139 }
138 ids[i] = append(ids[i], "c"+strconv.FormatInt(c.ID, 10))140 ids[i] = append(ids[i], "c"+strconv.FormatInt(c.ID, 10))
@@ -196,7 +198,7 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component,
196 m := res.MergeRequest198 m := res.MergeRequest
197 signed := SignedIn(ctx)199 signed := SignedIn(ctx)
198 canWrite := signed && m.State == domain.MRStateOpen && !since200 canWrite := signed && m.State == domain.MRStateOpen && !since
199- p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true}201+ p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true, NoteAuthor: domain.AuthorName(principal(ctx))}
200 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))202 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))
201 s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)203 s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
202 window := p.Files[from:min(from+mrWindow, len(p.Files))]204 window := p.Files[from:min(from+mrWindow, len(p.Files))]
A internal/adapters/web/mr_notes_test.go +162 -0
@@ -0,0 +1,162 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bnema.dev/ermite/internal/ports/mocks"
7+ "bytes"
8+ "context"
9+ "github.com/stretchr/testify/mock"
10+ "net/http"
11+ "net/http/httptest"
12+ "net/url"
13+ "strings"
14+ "testing"
15+ "time"
16+
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "github.com/stretchr/testify/assert"
20+ "github.com/stretchr/testify/require"
21+)
22+
23+func TestParseOnRange(t *testing.T) {
24+ t.Parallel()
25+ for _, raw := range []string{"new:0-2:a", "new:2-2:a", "new:3-2:a", "new:1-x:a", "new:1-2:", "other:1-2:a"} {
26+ assert.Nil(t, parseOn(raw), raw)
27+ }
28+ assert.Equal(t, &lineRef{side: "new", start: 10, line: 24, path: "dir:a.go"}, parseOn("new:10-24:dir:a.go"))
29+ assert.Equal(t, &lineRef{side: "old", line: 10, path: "a"}, parseOn("old:10:a"))
30+}
31+
32+func TestNoteThreads(t *testing.T) {
33+ t.Parallel()
34+ text, err := domain.ParsePlainText("**hello** ^2")
35+ require.NoError(t, err)
36+ s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
37+ comments := []domain.Comment{
38+ {ID: 1, Author: "alice", Text: text},
39+ {ID: 2, Author: "bob", ReplyTo: 1, Text: text},
40+ {ID: 3, Author: "alice", Deleted: true},
41+ {ID: 4, Author: "alice", Deleted: true},
42+ {ID: 5, Author: "bob", ReplyTo: 4, Text: text},
43+ {ID: 6, Author: "alice", Text: text, Verdict: domain.VerdictApprove, Resolved: true},
44+ }
45+ p := MRPage{NoteAuthor: "alice"}
46+ s.placeComments(&p, "demo", 1, comments, "", true, true, nil)
47+ require.Len(t, p.General, 3)
48+ assert.Equal(t, 4, p.NComments)
49+ assert.Equal(t, 1, p.NUnresolved)
50+ require.Len(t, p.General[0].Replies, 1)
51+ assert.NotNil(t, p.General[0].Edit)
52+ assert.Nil(t, p.General[0].Replies[0].Edit)
53+ assert.Nil(t, p.General[0].Replies[0].Resolve)
54+ assert.Contains(t, string(p.General[0].Body), "<strong>hello</strong>")
55+ assert.Contains(t, string(p.General[0].Body), `href="#c2"`)
56+ assert.True(t, p.General[1].Deleted)
57+ var b bytes.Buffer
58+ require.NoError(t, commentView(p.General[0], "").Render(context.Background(), &b))
59+ assert.Contains(t, b.String(), `name="reply_to" value="1"`)
60+ assert.Contains(t, b.String(), `data-js data-quote=`)
61+ assert.Contains(t, b.String(), `/comments/1/edit`)
62+ assert.NotContains(t, b.String(), `/comments/2/edit`)
63+}
64+
65+func TestNoteLiveEvents(t *testing.T) {
66+ t.Parallel()
67+ l := liveStream{view: "mr", n: "1"}
68+ events := []domain.Event{domain.CommentEdited{Number: 1}, domain.CommentDeleted{Number: 1}, domain.CommentResolved{Number: 1}}
69+ for _, ev := range events {
70+ we := ports.WatchEvent{Event: ev}
71+ assert.True(t, liveEvent(we))
72+ fs, err := l.repoPage(context.Background(), we)
73+ require.NoError(t, err)
74+ require.Len(t, fs, 1)
75+ assert.Contains(t, fs[0].html, "reload")
76+ }
77+}
78+
79+func TestNotePosts(t *testing.T) {
80+ t.Parallel()
81+ svc := mocks.NewMergeRequestService(t)
82+ repo := repoName(t, "demo")
83+ s := &Server{cfg: Config{MergeRequests: svc}}
84+ ctx := domain.ContextWithPrincipal(context.Background(), domain.Owner())
85+ request := func(values url.Values) *http.Request {
86+ r := httptest.NewRequest("POST", "/demo/merge-requests/1/comments", strings.NewReader(values.Encode())).WithContext(ctx)
87+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
88+ r.SetPathValue("repo", "demo")
89+ r.SetPathValue("n", "1")
90+ r.SetPathValue("id", "2")
91+ require.NoError(t, r.ParseForm())
92+ return r
93+ }
94+ svc.EXPECT().AddComment(mock.Anything, mock.Anything, repo, int64(1), mock.MatchedBy(func(p ports.AddCommentParams) bool { return p.ReplyTo == 2 && p.Text == "reply" })).Return(domain.Comment{ID: 7}, nil).Once()
95+ to, _, err := s.mrComment(ctx, request(url.Values{"reply_to": {"2"}, "text": {"reply"}}))
96+ require.NoError(t, err)
97+ assert.Equal(t, "/demo/merge-requests/1#c7", to)
98+ svc.EXPECT().AddComment(mock.Anything, mock.Anything, repo, int64(1), mock.MatchedBy(func(p ports.AddCommentParams) bool {
99+ return p.StartLine == 10 && p.Line == 24 && p.Path == "a.go" && p.Verdict == domain.VerdictApprove
100+ })).Return(domain.Comment{ID: 8}, nil).Once()
101+ to, _, err = s.mrComment(ctx, request(url.Values{"path": {"a.go"}, "line": {"24"}, "start_line": {"10"}, "side": {"new"}, "verdict": {"approve"}}))
102+ require.NoError(t, err)
103+ assert.Equal(t, "/demo/merge-requests/1#c8", to)
104+ svc.EXPECT().EditComment(mock.Anything, mock.Anything, repo, int64(1), int64(2), "new\ntext").Return(domain.Comment{ID: 2}, nil).Once()
105+ to, _, err = s.mrEditComment(ctx, request(url.Values{"text": {"new\r\ntext"}}))
106+ require.NoError(t, err)
107+ assert.Equal(t, "/demo/merge-requests/1#c2", to)
108+ svc.EXPECT().DeleteComment(mock.Anything, mock.Anything, repo, int64(1), int64(2)).Return(domain.Comment{ID: 2}, nil).Once()
109+ to, _, err = s.mrDeleteComment(ctx, request(nil))
110+ require.NoError(t, err)
111+ assert.Equal(t, "/demo/merge-requests/1#c2", to)
112+}
113+
114+func TestDeletedNoteRendering(t *testing.T) {
115+ t.Parallel()
116+ text, err := domain.ParsePlainText("surviving reply")
117+ require.NoError(t, err)
118+ s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
119+ p := MRPage{}
120+ s.placeComments(&p, "demo", 1, []domain.Comment{
121+ {ID: 1, Author: "reviewer", Deleted: true, Verdict: domain.VerdictApprove},
122+ {ID: 2, Deleted: true},
123+ {ID: 3, ReplyTo: 2, Deleted: true},
124+ {ID: 4, Deleted: true},
125+ {ID: 5, ReplyTo: 4, Text: text},
126+ }, "", true, true, nil)
127+ require.Len(t, p.General, 2)
128+ assert.Equal(t, "1", p.General[0].ID)
129+ assert.Equal(t, "4", p.General[1].ID)
130+ var b bytes.Buffer
131+ require.NoError(t, commentView(p.General[0], "").Render(context.Background(), &b))
132+ assert.Contains(t, b.String(), `<span class="c-who">owner</span>`)
133+ assert.Contains(t, b.String(), "approve")
134+ assert.Contains(t, b.String(), "text deleted")
135+ b.Reset()
136+ require.NoError(t, commentView(p.General[1], "").Render(context.Background(), &b))
137+ assert.Contains(t, b.String(), "surviving reply")
138+ assert.NotContains(t, b.String(), "Quote")
139+ assert.NotContains(t, b.String(), "data-quote")
140+}
141+
142+func TestInlineNoteHeadingIDs(t *testing.T) {
143+ t.Parallel()
144+ // "S H" would get the id the split copy of "H" gets if the prefixes of the
145+ // two copies overlapped.
146+ text, err := domain.ParsePlainText("# H\n\n# S H\n\n[jump](#h)")
147+ require.NoError(t, err)
148+ s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
149+ p := MRPage{Files: []DiffFile{{Key: "a", ID: "f1", Hunks: []DiffHunk{{Lines: []DiffLine{{Kind: "add", newN: 1}}}}}}}
150+ s.placeComments(&p, "demo", 1, []domain.Comment{{ID: 12, Path: "a", Side: domain.SideNew, Line: 1, Text: text}}, "", true, true, nil)
151+ notes := p.Files[0].Hunks[0].Lines[0].Notes
152+ require.Len(t, notes, 1)
153+ var b bytes.Buffer
154+ require.NoError(t, commentView(notes[0], "").Render(context.Background(), &b))
155+ require.NoError(t, commentView(notes[0], "-s").Render(context.Background(), &b))
156+ html := b.String()
157+ for _, id := range []string{"c12-h", "c12-s-h", "c12s-h", "c12s-s-h"} {
158+ assert.Equal(t, 1, strings.Count(html, `id="`+id+`"`), id)
159+ }
160+ assert.Contains(t, html, `href="#c12-h"`)
161+ assert.Contains(t, html, `href="#c12s-h"`)
162+}
@@ -0,0 +1,162 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bnema.dev/ermite/internal/ports/mocks"
7+ "bytes"
8+ "context"
9+ "github.com/stretchr/testify/mock"
10+ "net/http"
11+ "net/http/httptest"
12+ "net/url"
13+ "strings"
14+ "testing"
15+ "time"
16+
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "github.com/stretchr/testify/assert"
20+ "github.com/stretchr/testify/require"
21+)
22+
23+func TestParseOnRange(t *testing.T) {
24+ t.Parallel()
25+ for _, raw := range []string{"new:0-2:a", "new:2-2:a", "new:3-2:a", "new:1-x:a", "new:1-2:", "other:1-2:a"} {
26+ assert.Nil(t, parseOn(raw), raw)
27+ }
28+ assert.Equal(t, &lineRef{side: "new", start: 10, line: 24, path: "dir:a.go"}, parseOn("new:10-24:dir:a.go"))
29+ assert.Equal(t, &lineRef{side: "old", line: 10, path: "a"}, parseOn("old:10:a"))
30+}
31+
32+func TestNoteThreads(t *testing.T) {
33+ t.Parallel()
34+ text, err := domain.ParsePlainText("**hello** ^2")
35+ require.NoError(t, err)
36+ s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
37+ comments := []domain.Comment{
38+ {ID: 1, Author: "alice", Text: text},
39+ {ID: 2, Author: "bob", ReplyTo: 1, Text: text},
40+ {ID: 3, Author: "alice", Deleted: true},
41+ {ID: 4, Author: "alice", Deleted: true},
42+ {ID: 5, Author: "bob", ReplyTo: 4, Text: text},
43+ {ID: 6, Author: "alice", Text: text, Verdict: domain.VerdictApprove, Resolved: true},
44+ }
45+ p := MRPage{NoteAuthor: "alice"}
46+ s.placeComments(&p, "demo", 1, comments, "", true, true, nil)
47+ require.Len(t, p.General, 3)
48+ assert.Equal(t, 4, p.NComments)
49+ assert.Equal(t, 1, p.NUnresolved)
50+ require.Len(t, p.General[0].Replies, 1)
51+ assert.NotNil(t, p.General[0].Edit)
52+ assert.Nil(t, p.General[0].Replies[0].Edit)
53+ assert.Nil(t, p.General[0].Replies[0].Resolve)
54+ assert.Contains(t, string(p.General[0].Body), "<strong>hello</strong>")
55+ assert.Contains(t, string(p.General[0].Body), `href="#c2"`)
56+ assert.True(t, p.General[1].Deleted)
57+ var b bytes.Buffer
58+ require.NoError(t, commentView(p.General[0], "").Render(context.Background(), &b))
59+ assert.Contains(t, b.String(), `name="reply_to" value="1"`)
60+ assert.Contains(t, b.String(), `data-js data-quote=`)
61+ assert.Contains(t, b.String(), `/comments/1/edit`)
62+ assert.NotContains(t, b.String(), `/comments/2/edit`)
63+}
64+
65+func TestNoteLiveEvents(t *testing.T) {
66+ t.Parallel()
67+ l := liveStream{view: "mr", n: "1"}
68+ events := []domain.Event{domain.CommentEdited{Number: 1}, domain.CommentDeleted{Number: 1}, domain.CommentResolved{Number: 1}}
69+ for _, ev := range events {
70+ we := ports.WatchEvent{Event: ev}
71+ assert.True(t, liveEvent(we))
72+ fs, err := l.repoPage(context.Background(), we)
73+ require.NoError(t, err)
74+ require.Len(t, fs, 1)
75+ assert.Contains(t, fs[0].html, "reload")
76+ }
77+}
78+
79+func TestNotePosts(t *testing.T) {
80+ t.Parallel()
81+ svc := mocks.NewMergeRequestService(t)
82+ repo := repoName(t, "demo")
83+ s := &Server{cfg: Config{MergeRequests: svc}}
84+ ctx := domain.ContextWithPrincipal(context.Background(), domain.Owner())
85+ request := func(values url.Values) *http.Request {
86+ r := httptest.NewRequest("POST", "/demo/merge-requests/1/comments", strings.NewReader(values.Encode())).WithContext(ctx)
87+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
88+ r.SetPathValue("repo", "demo")
89+ r.SetPathValue("n", "1")
90+ r.SetPathValue("id", "2")
91+ require.NoError(t, r.ParseForm())
92+ return r
93+ }
94+ svc.EXPECT().AddComment(mock.Anything, mock.Anything, repo, int64(1), mock.MatchedBy(func(p ports.AddCommentParams) bool { return p.ReplyTo == 2 && p.Text == "reply" })).Return(domain.Comment{ID: 7}, nil).Once()
95+ to, _, err := s.mrComment(ctx, request(url.Values{"reply_to": {"2"}, "text": {"reply"}}))
96+ require.NoError(t, err)
97+ assert.Equal(t, "/demo/merge-requests/1#c7", to)
98+ svc.EXPECT().AddComment(mock.Anything, mock.Anything, repo, int64(1), mock.MatchedBy(func(p ports.AddCommentParams) bool {
99+ return p.StartLine == 10 && p.Line == 24 && p.Path == "a.go" && p.Verdict == domain.VerdictApprove
100+ })).Return(domain.Comment{ID: 8}, nil).Once()
101+ to, _, err = s.mrComment(ctx, request(url.Values{"path": {"a.go"}, "line": {"24"}, "start_line": {"10"}, "side": {"new"}, "verdict": {"approve"}}))
102+ require.NoError(t, err)
103+ assert.Equal(t, "/demo/merge-requests/1#c8", to)
104+ svc.EXPECT().EditComment(mock.Anything, mock.Anything, repo, int64(1), int64(2), "new\ntext").Return(domain.Comment{ID: 2}, nil).Once()
105+ to, _, err = s.mrEditComment(ctx, request(url.Values{"text": {"new\r\ntext"}}))
106+ require.NoError(t, err)
107+ assert.Equal(t, "/demo/merge-requests/1#c2", to)
108+ svc.EXPECT().DeleteComment(mock.Anything, mock.Anything, repo, int64(1), int64(2)).Return(domain.Comment{ID: 2}, nil).Once()
109+ to, _, err = s.mrDeleteComment(ctx, request(nil))
110+ require.NoError(t, err)
111+ assert.Equal(t, "/demo/merge-requests/1#c2", to)
112+}
113+
114+func TestDeletedNoteRendering(t *testing.T) {
115+ t.Parallel()
116+ text, err := domain.ParsePlainText("surviving reply")
117+ require.NoError(t, err)
118+ s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
119+ p := MRPage{}
120+ s.placeComments(&p, "demo", 1, []domain.Comment{
121+ {ID: 1, Author: "reviewer", Deleted: true, Verdict: domain.VerdictApprove},
122+ {ID: 2, Deleted: true},
123+ {ID: 3, ReplyTo: 2, Deleted: true},
124+ {ID: 4, Deleted: true},
125+ {ID: 5, ReplyTo: 4, Text: text},
126+ }, "", true, true, nil)
127+ require.Len(t, p.General, 2)
128+ assert.Equal(t, "1", p.General[0].ID)
129+ assert.Equal(t, "4", p.General[1].ID)
130+ var b bytes.Buffer
131+ require.NoError(t, commentView(p.General[0], "").Render(context.Background(), &b))
132+ assert.Contains(t, b.String(), `<span class="c-who">owner</span>`)
133+ assert.Contains(t, b.String(), "approve")
134+ assert.Contains(t, b.String(), "text deleted")
135+ b.Reset()
136+ require.NoError(t, commentView(p.General[1], "").Render(context.Background(), &b))
137+ assert.Contains(t, b.String(), "surviving reply")
138+ assert.NotContains(t, b.String(), "Quote")
139+ assert.NotContains(t, b.String(), "data-quote")
140+}
141+
142+func TestInlineNoteHeadingIDs(t *testing.T) {
143+ t.Parallel()
144+ // "S H" would get the id the split copy of "H" gets if the prefixes of the
145+ // two copies overlapped.
146+ text, err := domain.ParsePlainText("# H\n\n# S H\n\n[jump](#h)")
147+ require.NoError(t, err)
148+ s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
149+ p := MRPage{Files: []DiffFile{{Key: "a", ID: "f1", Hunks: []DiffHunk{{Lines: []DiffLine{{Kind: "add", newN: 1}}}}}}}
150+ s.placeComments(&p, "demo", 1, []domain.Comment{{ID: 12, Path: "a", Side: domain.SideNew, Line: 1, Text: text}}, "", true, true, nil)
151+ notes := p.Files[0].Hunks[0].Lines[0].Notes
152+ require.Len(t, notes, 1)
153+ var b bytes.Buffer
154+ require.NoError(t, commentView(notes[0], "").Render(context.Background(), &b))
155+ require.NoError(t, commentView(notes[0], "-s").Render(context.Background(), &b))
156+ html := b.String()
157+ for _, id := range []string{"c12-h", "c12-s-h", "c12s-h", "c12s-s-h"} {
158+ assert.Equal(t, 1, strings.Count(html, `id="`+id+`"`), id)
159+ }
160+ assert.Contains(t, html, `href="#c12-h"`)
161+ assert.Contains(t, html, `href="#c12s-h"`)
162+}
M internal/adapters/web/mr_post.go +47 -0
@@ -177,6 +177,23 @@ func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string
177177 return "", back, formError("side must be new or old")
178178 }
179179 }
180+ if raw := r.PostFormValue("reply_to"); raw != "" {
181+ params.ReplyTo = commentID(raw)
182+ if params.ReplyTo == 0 {
183+ return "", back, formError("reply_to is not a note id")
184+ }
185+ }
186+ if raw := r.PostFormValue("start_line"); raw != "" {
187+ v, e := strconv.ParseInt(raw, 10, 32)
188+ if e != nil {
189+ return "", back, formError("start_line is not a number")
190+ }
191+ params.StartLine = int(v)
192+ }
193+ params.Verdict, err = domain.ParseVerdict(r.PostFormValue("verdict"))
194+ if err != nil {
195+ return "", back, err
196+ }
180197 name, err := domain.ParseRepoName(repo)
181198 if err != nil {
182199 return "", back, err
@@ -356,3 +373,33 @@ func formBool(s string) (bool, error) {
356373 func normalizeNewlines(s string) string {
357374 return strings.ReplaceAll(s, "\r\n", "\n")
358375 }
376+
377+// mrEditComment passes source text to the author-checked use case.
378+func (s *Server) mrEditComment(ctx context.Context, r *http.Request) (string, string, error) {
379+ n, err := number(r)
380+ if err != nil {
381+ return "", "", err
382+ }
383+ back := withFrom(mrHref(r.PathValue("repo"), n), formFrom(r))
384+ name, err := domain.ParseRepoName(r.PathValue("repo"))
385+ if err != nil {
386+ return "", back, err
387+ }
388+ c, err := s.cfg.MergeRequests.EditComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), normalizeNewlines(r.PostFormValue("text")))
389+ return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
390+}
391+
392+// mrDeleteComment passes the note id to the author-checked use case.
393+func (s *Server) mrDeleteComment(ctx context.Context, r *http.Request) (string, string, error) {
394+ n, err := number(r)
395+ if err != nil {
396+ return "", "", err
397+ }
398+ back := withFrom(mrHref(r.PathValue("repo"), n), formFrom(r))
399+ name, err := domain.ParseRepoName(r.PathValue("repo"))
400+ if err != nil {
401+ return "", back, err
402+ }
403+ c, err := s.cfg.MergeRequests.DeleteComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")))
404+ return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
405+}
@@ -177,6 +177,23 @@ func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string
177 return "", back, formError("side must be new or old")177 return "", back, formError("side must be new or old")
178 }178 }
179 }179 }
180+ if raw := r.PostFormValue("reply_to"); raw != "" {
181+ params.ReplyTo = commentID(raw)
182+ if params.ReplyTo == 0 {
183+ return "", back, formError("reply_to is not a note id")
184+ }
185+ }
186+ if raw := r.PostFormValue("start_line"); raw != "" {
187+ v, e := strconv.ParseInt(raw, 10, 32)
188+ if e != nil {
189+ return "", back, formError("start_line is not a number")
190+ }
191+ params.StartLine = int(v)
192+ }
193+ params.Verdict, err = domain.ParseVerdict(r.PostFormValue("verdict"))
194+ if err != nil {
195+ return "", back, err
196+ }
180 name, err := domain.ParseRepoName(repo)197 name, err := domain.ParseRepoName(repo)
181 if err != nil {198 if err != nil {
182 return "", back, err199 return "", back, err
@@ -356,3 +373,33 @@ func formBool(s string) (bool, error) {
356 func normalizeNewlines(s string) string {373 func normalizeNewlines(s string) string {
357 return strings.ReplaceAll(s, "\r\n", "\n")374 return strings.ReplaceAll(s, "\r\n", "\n")
358 }375 }
376+
377+// mrEditComment passes source text to the author-checked use case.
378+func (s *Server) mrEditComment(ctx context.Context, r *http.Request) (string, string, error) {
379+ n, err := number(r)
380+ if err != nil {
381+ return "", "", err
382+ }
383+ back := withFrom(mrHref(r.PathValue("repo"), n), formFrom(r))
384+ name, err := domain.ParseRepoName(r.PathValue("repo"))
385+ if err != nil {
386+ return "", back, err
387+ }
388+ c, err := s.cfg.MergeRequests.EditComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), normalizeNewlines(r.PostFormValue("text")))
389+ return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
390+}
391+
392+// mrDeleteComment passes the note id to the author-checked use case.
393+func (s *Server) mrDeleteComment(ctx context.Context, r *http.Request) (string, string, error) {
394+ n, err := number(r)
395+ if err != nil {
396+ return "", "", err
397+ }
398+ back := withFrom(mrHref(r.PathValue("repo"), n), formFrom(r))
399+ name, err := domain.ParseRepoName(r.PathValue("repo"))
400+ if err != nil {
401+ return "", back, err
402+ }
403+ c, err := s.cfg.MergeRequests.DeleteComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")))
404+ return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
405+}
M internal/adapters/web/mr_templ.go +777 -480 generated file: collapsed
M internal/adapters/web/static/css/55-diff.css +4 -0
M internal/adapters/web/static/css/60-mr.css +8 -3
M internal/adapters/web/static/js/mr.js +51 -2
M internal/adapters/web/web.go +2 -0
M internal/app/cli_mr.go +226 -25
M internal/app/cli_mr_e2e_test.go +72 -0
M internal/app/cli_mr_test.go +89 -0
M internal/app/cli_token.go +1 -1
M internal/app/cli_watch.go +21 -8
M internal/app/cli_watch_test.go +24 -0
M internal/app/web_mr_test.go +6 -3
M internal/domain/event.go +39 -0
M internal/domain/mergerequest.go +80 -0
M internal/domain/mergerequest_test.go +48 -0
M internal/domain/principal.go +15 -0
M internal/domain/token.go +4 -2
M internal/domain/token_test.go +1 -1
M internal/ports/dto.go +8 -0
M internal/ports/mocks/MergeRequestService.go +174 -0
M internal/ports/services.go +2 -0
M internal/usecase/access.go +0 -15
M internal/usecase/ci_dispatch.go +1 -1
M internal/usecase/ci_pull.go +1 -1
M internal/usecase/ci_runs.go +1 -1
M internal/usecase/ci_tips.go +1 -1
M internal/usecase/ci_trigger.go +4 -4
M internal/usecase/events.go +12 -0
M internal/usecase/events_test.go +32 -0
M internal/usecase/export_test.go +0 -3
M internal/usecase/mergerequests.go +137 -9
M internal/usecase/mergerequests_integration_test.go +107 -1
M internal/usecase/mergerequests_test.go +116 -0
M internal/usecase/pushes.go +1 -1
M internal/usecase/sessions_test.go +0 -9
M proto/ermite/storage/merge_request.proto +12 -0
M skills/ermite/CLI.md +11 -6
M skills/ermite/SKILL.md +13 -4

Comments0 open / 0

no comments yet.

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
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 2

Tags 42

↑↓ moveenter openesc close