detached 0e6522c

Merge c06416608f94ed9c0eb6c312204173b5bac6d8d3 into 00396122d39c11f2d7ecd211a94f823937ff76c0

commit
0e6522cd358c4a4b499c88d1802da3155efdd1a6
author
ermite · · 2026-10-19 06:00 UTC
merge of
0039612 + c064166 · diff against the first parent
signature
unsigned
60 files · +3083 -647
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 …]
M docs/merge-requests.md +30 -1
@@ -13,10 +13,39 @@ ermite mr merge myrepo 1 --head <commit> [--squash] [--delete-branch]
1313 - With `failing_checks_block` on (default), the merge is refused while a check of the head failed or is still running.
1414 - `--delete-branch` deletes the source branch, unless it is the default branch or another open request uses it.
1515 - `ermite repo edit --merge-modes` limits the modes per repository.
16-- Scopes: `mr:write` to open and comment, `mr:merge` to merge.
16+- Scopes: `mr:write` to open and comment, `mr:review` to give a verdict, `mr:merge` to merge.
1717
1818 Verify squash commits with the `allowed_signers` line from `ermite signing-key show`:
1919
2020 ```sh
2121 git verify-commit <commit>
2222 ```
23+
24+## Notes
25+
26+A note is the only review object. It is published as soon as it is written: there is no draft and no submit step.
27+
28+```sh
29+ermite mr comment myrepo 1 -m "General remark"
30+ermite mr comment myrepo 1 -m "Why?" --path main.go --line 10-14 --side new
31+ermite mr comment myrepo 1 -m "Fixed in ^3" --reply 2
32+ermite mr resolve myrepo 1 2
33+ermite mr edit-comment myrepo 1 2 -m "New text"
34+ermite mr delete-comment myrepo 1 2
35+```
36+
37+- Notes are Markdown, up to 8000 characters, rendered without raw HTML. Quote with `> `.
38+- `^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.
39+- 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.
40+- 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.
41+- 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".
42+
43+## Verdicts
44+
45+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.
46+
47+- A verdict ends a review: agents and `ermite watch` act on it, not on each note.
48+- Verdicts are informative: none blocks or allows a merge.
49+- `mr show` lists the latest verdict of each author (`verdicts` in `--json`).
50+- 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.
51+- 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.
M gen/ermite/storage/merge_request.pb.go +122 -18 generated file: collapsed
M internal/adapters/client/scopes_test.go +1 -0
@@ -16,6 +16,7 @@ import (
1616
1717 func TestScopeWireNames(t *testing.T) {
1818 want := map[ermite.Scope]string{
19+ ermite.Scope_SCOPE_MR_REVIEW: "mr:review",
1920 ermite.Scope_SCOPE_REPO_READ: "repo:read",
2021 ermite.Scope_SCOPE_REPO_WRITE: "repo:write",
2122 ermite.Scope_SCOPE_REPO_ADMIN: "repo:admin",
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,
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+}
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)
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 {
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+}
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
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
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:
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+});
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+}
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+}
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+}
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>
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))]
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+}
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+}
M internal/adapters/web/mr_templ.go +777 -480
@@ -1215,8 +1215,7 @@ func lineNotes(notes []CommentView, form *CommentForm, suffix string) templ.Comp
12151215 }
12161216
12171217 // commentView is a note: a ruled block, under its line in the diff or in the
1218-// list of comments. The text is plain: escaped, its line breaks kept, its
1219-// http(s) addresses linked.
1218+// list of comments. Body comes only from the safe Markdown renderer.
12201219 func commentView(c CommentView, suffix string) templ.Component {
12211220 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
12221221 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -1245,7 +1244,7 @@ func commentView(c CommentView, suffix string) templ.Component {
12451244 var templ_7745c5c3_Var59 string
12461245 templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.ResolveAttributeValue("c" + c.ID + suffix)
12471246 if templ_7745c5c3_Err != nil {
1248- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 235, Col: 42}
1247+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 234, Col: 42}
12491248 }
12501249 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var59)
12511250 if templ_7745c5c3_Err != nil {
@@ -1258,26 +1257,36 @@ func commentView(c CommentView, suffix string) templ.Component {
12581257 var templ_7745c5c3_Var60 string
12591258 templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolText(c.Resolved))
12601259 if templ_7745c5c3_Err != nil {
1261- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 235, Col: 81}
1260+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 234, Col: 81}
12621261 }
12631262 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60)
12641263 if templ_7745c5c3_Err != nil {
12651264 return templ_7745c5c3_Err
12661265 }
1267- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "\"><p class=\"c-head\"><span class=\"c-who\">")
1266+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "\"")
1267+ if templ_7745c5c3_Err != nil {
1268+ return templ_7745c5c3_Err
1269+ }
1270+ if c.Reply != nil {
1271+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, " data-thread")
1272+ if templ_7745c5c3_Err != nil {
1273+ return templ_7745c5c3_Err
1274+ }
1275+ }
1276+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "><p class=\"c-head\"><span class=\"c-who\">")
12681277 if templ_7745c5c3_Err != nil {
12691278 return templ_7745c5c3_Err
12701279 }
12711280 var templ_7745c5c3_Var61 string
12721281 templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
12731282 if templ_7745c5c3_Err != nil {
1274- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 237, Col: 33}
1283+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 236, Col: 33}
12751284 }
12761285 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
12771286 if templ_7745c5c3_Err != nil {
12781287 return templ_7745c5c3_Err
12791288 }
1280- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "</span> ")
1289+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "</span> ")
12811290 if templ_7745c5c3_Err != nil {
12821291 return templ_7745c5c3_Err
12831292 }
@@ -1285,52 +1294,52 @@ func commentView(c CommentView, suffix string) templ.Component {
12851294 var templ_7745c5c3_Var62 string
12861295 templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
12871296 if templ_7745c5c3_Err != nil {
1288- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 239, Col: 9}
1297+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 238, Col: 9}
12891298 }
12901299 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
12911300 if templ_7745c5c3_Err != nil {
12921301 return templ_7745c5c3_Err
12931302 }
1294- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "<time class=\"mute\" datetime=\"")
1303+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "<time class=\"mute\" datetime=\"")
12951304 if templ_7745c5c3_Err != nil {
12961305 return templ_7745c5c3_Err
12971306 }
12981307 var templ_7745c5c3_Var63 string
12991308 templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
13001309 if templ_7745c5c3_Err != nil {
1301- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 239, Col: 49}
1310+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 238, Col: 49}
13021311 }
13031312 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)
13041313 if templ_7745c5c3_Err != nil {
13051314 return templ_7745c5c3_Err
13061315 }
1307- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "\" title=\"")
1316+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "\" title=\"")
13081317 if templ_7745c5c3_Err != nil {
13091318 return templ_7745c5c3_Err
13101319 }
13111320 var templ_7745c5c3_Var64 string
13121321 templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Abs)
13131322 if templ_7745c5c3_Err != nil {
1314- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 239, Col: 65}
1323+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 238, Col: 65}
13151324 }
13161325 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
13171326 if templ_7745c5c3_Err != nil {
13181327 return templ_7745c5c3_Err
13191328 }
1320- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "\">")
1329+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "\">")
13211330 if templ_7745c5c3_Err != nil {
13221331 return templ_7745c5c3_Err
13231332 }
13241333 var templ_7745c5c3_Var65 string
13251334 templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
13261335 if templ_7745c5c3_Err != nil {
1327- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 239, Col: 76}
1336+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 238, Col: 76}
13281337 }
13291338 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
13301339 if templ_7745c5c3_Err != nil {
13311340 return templ_7745c5c3_Err
13321341 }
1333- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</time> ")
1342+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "</time> ")
13341343 if templ_7745c5c3_Err != nil {
13351344 return templ_7745c5c3_Err
13361345 }
@@ -1339,13 +1348,13 @@ func commentView(c CommentView, suffix string) templ.Component {
13391348 var templ_7745c5c3_Var66 string
13401349 templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
13411350 if templ_7745c5c3_Err != nil {
1342- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 242, Col: 9}
1351+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 241, Col: 9}
13431352 }
13441353 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))
13451354 if templ_7745c5c3_Err != nil {
13461355 return templ_7745c5c3_Err
13471356 }
1348- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "<span class=\"c-state\">resolved</span> ")
1357+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "<span class=\"c-state\">resolved</span> ")
13491358 if templ_7745c5c3_Err != nil {
13501359 return templ_7745c5c3_Err
13511360 }
@@ -1354,13 +1363,13 @@ func commentView(c CommentView, suffix string) templ.Component {
13541363 var templ_7745c5c3_Var67 string
13551364 templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
13561365 if templ_7745c5c3_Err != nil {
1357- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 245, Col: 9}
1366+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 244, Col: 9}
13581367 }
13591368 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67))
13601369 if templ_7745c5c3_Err != nil {
13611370 return templ_7745c5c3_Err
13621371 }
1363- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "<span class=\"c-state\" title=\"written on an earlier head; the line may have moved\">outdated</span> ")
1372+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "<span class=\"c-state\" title=\"written on an earlier head; the line may have moved\">outdated</span> ")
13641373 if templ_7745c5c3_Err != nil {
13651374 return templ_7745c5c3_Err
13661375 }
@@ -1369,111 +1378,296 @@ func commentView(c CommentView, suffix string) templ.Component {
13691378 var templ_7745c5c3_Var68 string
13701379 templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
13711380 if templ_7745c5c3_Err != nil {
1372- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 248, Col: 9}
1381+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 247, Col: 9}
13731382 }
13741383 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
13751384 if templ_7745c5c3_Err != nil {
13761385 return templ_7745c5c3_Err
13771386 }
1378- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "<span class=\"c-where mute\">")
1387+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "<span class=\"c-where mute\">")
13791388 if templ_7745c5c3_Err != nil {
13801389 return templ_7745c5c3_Err
13811390 }
13821391 var templ_7745c5c3_Var69 string
13831392 templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(c.Where)
13841393 if templ_7745c5c3_Err != nil {
1385- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 248, Col: 47}
1394+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 247, Col: 47}
13861395 }
13871396 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))
13881397 if templ_7745c5c3_Err != nil {
13891398 return templ_7745c5c3_Err
13901399 }
1391- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "</span>")
1400+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "</span>")
13921401 if templ_7745c5c3_Err != nil {
13931402 return templ_7745c5c3_Err
13941403 }
13951404 }
1396- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "</p><p class=\"c-text\">")
1405+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "</p>")
13971406 if templ_7745c5c3_Err != nil {
13981407 return templ_7745c5c3_Err
13991408 }
1400- templ_7745c5c3_Err = userText(c.Parts).Render(ctx, templ_7745c5c3_Buffer)
1401- if templ_7745c5c3_Err != nil {
1402- return templ_7745c5c3_Err
1409+ if c.Deleted {
1410+ if c.Verdict != "" {
1411+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "<p class=\"mute\">text deleted</p>")
1412+ if templ_7745c5c3_Err != nil {
1413+ return templ_7745c5c3_Err
1414+ }
1415+ } else {
1416+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<p class=\"mute\">deleted note</p>")
1417+ if templ_7745c5c3_Err != nil {
1418+ return templ_7745c5c3_Err
1419+ }
1420+ }
1421+ } else {
1422+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "<div class=\"c-body prose\">")
1423+ if templ_7745c5c3_Err != nil {
1424+ return templ_7745c5c3_Err
1425+ }
1426+ if suffix == "-s" {
1427+ templ_7745c5c3_Err = templ.Raw(string(c.BodySplit)).Render(ctx, templ_7745c5c3_Buffer)
1428+ if templ_7745c5c3_Err != nil {
1429+ return templ_7745c5c3_Err
1430+ }
1431+ } else {
1432+ templ_7745c5c3_Err = templ.Raw(string(c.Body)).Render(ctx, templ_7745c5c3_Buffer)
1433+ if templ_7745c5c3_Err != nil {
1434+ return templ_7745c5c3_Err
1435+ }
1436+ }
1437+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "</div>")
1438+ if templ_7745c5c3_Err != nil {
1439+ return templ_7745c5c3_Err
1440+ }
14031441 }
1404- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "</p>")
1405- if templ_7745c5c3_Err != nil {
1406- return templ_7745c5c3_Err
1442+ if c.Edited {
1443+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<span class=\"mute\">edited</span> ")
1444+ if templ_7745c5c3_Err != nil {
1445+ return templ_7745c5c3_Err
1446+ }
14071447 }
1408- if c.Resolve != nil {
1409- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "<form class=\"c-act\" method=\"post\" action=\"")
1448+ if c.Verdict != "" {
1449+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "<span class=\"c-state\">")
14101450 if templ_7745c5c3_Err != nil {
14111451 return templ_7745c5c3_Err
14121452 }
1413- var templ_7745c5c3_Var70 templ.SafeURL
1414- templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Resolve.Action))
1453+ var templ_7745c5c3_Var70 string
1454+ templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(c.Verdict)
14151455 if templ_7745c5c3_Err != nil {
1416- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 253, Col: 77}
1456+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 269, Col: 36}
14171457 }
14181458 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
14191459 if templ_7745c5c3_Err != nil {
14201460 return templ_7745c5c3_Err
14211461 }
1422- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\"><input type=\"hidden\" name=\"resolved\" value=\"")
1462+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "</span> ")
1463+ if templ_7745c5c3_Err != nil {
1464+ return templ_7745c5c3_Err
1465+ }
1466+ }
1467+ if c.Range != "" {
1468+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<span class=\"mute\">")
14231469 if templ_7745c5c3_Err != nil {
14241470 return templ_7745c5c3_Err
14251471 }
14261472 var templ_7745c5c3_Var71 string
1427- templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolText(c.Resolve.Value))
1473+ templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(c.Range)
1474+ if templ_7745c5c3_Err != nil {
1475+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 272, Col: 31}
1476+ }
1477+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))
1478+ if templ_7745c5c3_Err != nil {
1479+ return templ_7745c5c3_Err
1480+ }
1481+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</span> ")
1482+ if templ_7745c5c3_Err != nil {
1483+ return templ_7745c5c3_Err
1484+ }
1485+ }
1486+ if c.Quote != "" {
1487+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "<button type=\"button\" class=\"btn\" data-js data-quote=\"")
1488+ if templ_7745c5c3_Err != nil {
1489+ return templ_7745c5c3_Err
1490+ }
1491+ var templ_7745c5c3_Var72 string
1492+ templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Quote)
1493+ if templ_7745c5c3_Err != nil {
1494+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 275, Col: 65}
1495+ }
1496+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)
14281497 if templ_7745c5c3_Err != nil {
1429- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 254, Col: 74}
1498+ return templ_7745c5c3_Err
1499+ }
1500+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "\">Quote</button> ")
1501+ if templ_7745c5c3_Err != nil {
1502+ return templ_7745c5c3_Err
14301503 }
1431- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var71)
1504+ }
1505+ if c.Edit != nil {
1506+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "<details><summary>Edit</summary><form method=\"post\" action=\"")
14321507 if templ_7745c5c3_Err != nil {
14331508 return templ_7745c5c3_Err
14341509 }
1435- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\"> ")
1510+ var templ_7745c5c3_Var73 templ.SafeURL
1511+ templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Edit.Action))
1512+ if templ_7745c5c3_Err != nil {
1513+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 278, Col: 92}
1514+ }
1515+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73))
1516+ if templ_7745c5c3_Err != nil {
1517+ return templ_7745c5c3_Err
1518+ }
1519+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "\"><input type=\"hidden\" name=\"from\" value=\"")
1520+ if templ_7745c5c3_Err != nil {
1521+ return templ_7745c5c3_Err
1522+ }
1523+ var templ_7745c5c3_Var74 string
1524+ templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(c.Edit.From))
1525+ if templ_7745c5c3_Err != nil {
1526+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 278, Col: 153}
1527+ }
1528+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var74)
1529+ if templ_7745c5c3_Err != nil {
1530+ return templ_7745c5c3_Err
1531+ }
1532+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "\"><textarea name=\"text\" maxlength=\"8000\">")
1533+ if templ_7745c5c3_Err != nil {
1534+ return templ_7745c5c3_Err
1535+ }
1536+ var templ_7745c5c3_Var75 string
1537+ templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(c.Edit.Text)
1538+ if templ_7745c5c3_Err != nil {
1539+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 278, Col: 209}
1540+ }
1541+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))
1542+ if templ_7745c5c3_Err != nil {
1543+ return templ_7745c5c3_Err
1544+ }
1545+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</textarea><button class=\"btn\" type=\"submit\">Save</button></form></details> ")
1546+ if templ_7745c5c3_Err != nil {
1547+ return templ_7745c5c3_Err
1548+ }
1549+ }
1550+ if c.Delete != nil {
1551+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<details><summary>Delete</summary><form method=\"post\" action=\"")
1552+ if templ_7745c5c3_Err != nil {
1553+ return templ_7745c5c3_Err
1554+ }
1555+ var templ_7745c5c3_Var76 templ.SafeURL
1556+ templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Delete.Action))
1557+ if templ_7745c5c3_Err != nil {
1558+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 281, Col: 96}
1559+ }
1560+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
1561+ if templ_7745c5c3_Err != nil {
1562+ return templ_7745c5c3_Err
1563+ }
1564+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "\"><input type=\"hidden\" name=\"from\" value=\"")
1565+ if templ_7745c5c3_Err != nil {
1566+ return templ_7745c5c3_Err
1567+ }
1568+ var templ_7745c5c3_Var77 string
1569+ templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(c.Delete.From))
1570+ if templ_7745c5c3_Err != nil {
1571+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 281, Col: 159}
1572+ }
1573+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var77)
1574+ if templ_7745c5c3_Err != nil {
1575+ return templ_7745c5c3_Err
1576+ }
1577+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "\"><p>Delete this note?</p><button class=\"btn\" type=\"submit\">Confirm delete</button></form></details> ")
1578+ if templ_7745c5c3_Err != nil {
1579+ return templ_7745c5c3_Err
1580+ }
1581+ }
1582+ for _, reply := range c.Replies {
1583+ templ_7745c5c3_Err = commentView(reply, suffix).Render(ctx, templ_7745c5c3_Buffer)
1584+ if templ_7745c5c3_Err != nil {
1585+ return templ_7745c5c3_Err
1586+ }
1587+ }
1588+ if c.Reply != nil {
1589+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<details data-reply><summary>Reply</summary>")
1590+ if templ_7745c5c3_Err != nil {
1591+ return templ_7745c5c3_Err
1592+ }
1593+ templ_7745c5c3_Err = commentForm(*c.Reply, suffix).Render(ctx, templ_7745c5c3_Buffer)
1594+ if templ_7745c5c3_Err != nil {
1595+ return templ_7745c5c3_Err
1596+ }
1597+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "</details> ")
1598+ if templ_7745c5c3_Err != nil {
1599+ return templ_7745c5c3_Err
1600+ }
1601+ }
1602+ if c.Resolve != nil {
1603+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "<form class=\"c-act\" method=\"post\" action=\"")
1604+ if templ_7745c5c3_Err != nil {
1605+ return templ_7745c5c3_Err
1606+ }
1607+ var templ_7745c5c3_Var78 templ.SafeURL
1608+ templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Resolve.Action))
1609+ if templ_7745c5c3_Err != nil {
1610+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 292, Col: 77}
1611+ }
1612+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
1613+ if templ_7745c5c3_Err != nil {
1614+ return templ_7745c5c3_Err
1615+ }
1616+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "\"><input type=\"hidden\" name=\"resolved\" value=\"")
1617+ if templ_7745c5c3_Err != nil {
1618+ return templ_7745c5c3_Err
1619+ }
1620+ var templ_7745c5c3_Var79 string
1621+ templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolText(c.Resolve.Value))
1622+ if templ_7745c5c3_Err != nil {
1623+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 293, Col: 74}
1624+ }
1625+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var79)
1626+ if templ_7745c5c3_Err != nil {
1627+ return templ_7745c5c3_Err
1628+ }
1629+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "\"> ")
14361630 if templ_7745c5c3_Err != nil {
14371631 return templ_7745c5c3_Err
14381632 }
14391633 if c.Resolve.From > 0 {
1440- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "<input type=\"hidden\" name=\"from\" value=\"")
1634+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<input type=\"hidden\" name=\"from\" value=\"")
14411635 if templ_7745c5c3_Err != nil {
14421636 return templ_7745c5c3_Err
14431637 }
1444- var templ_7745c5c3_Var72 string
1445- templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(c.Resolve.From))
1638+ var templ_7745c5c3_Var80 string
1639+ templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(c.Resolve.From))
14461640 if templ_7745c5c3_Err != nil {
1447- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 256, Col: 66}
1641+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 295, Col: 66}
14481642 }
1449- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)
1643+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
14501644 if templ_7745c5c3_Err != nil {
14511645 return templ_7745c5c3_Err
14521646 }
1453- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "\"> ")
1647+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "\"> ")
14541648 if templ_7745c5c3_Err != nil {
14551649 return templ_7745c5c3_Err
14561650 }
14571651 }
1458- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<button type=\"submit\" class=\"btn\">")
1652+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<button type=\"submit\" class=\"btn\">")
14591653 if templ_7745c5c3_Err != nil {
14601654 return templ_7745c5c3_Err
14611655 }
1462- var templ_7745c5c3_Var73 string
1463- templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(c.Resolve.Label)
1656+ var templ_7745c5c3_Var81 string
1657+ templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(c.Resolve.Label)
14641658 if templ_7745c5c3_Err != nil {
1465- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 258, Col: 55}
1659+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 297, Col: 55}
14661660 }
1467- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73))
1661+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
14681662 if templ_7745c5c3_Err != nil {
14691663 return templ_7745c5c3_Err
14701664 }
1471- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</button></form>")
1665+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</button></form>")
14721666 if templ_7745c5c3_Err != nil {
14731667 return templ_7745c5c3_Err
14741668 }
14751669 }
1476- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "</div>")
1670+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "</div>")
14771671 if templ_7745c5c3_Err != nil {
14781672 return templ_7745c5c3_Err
14791673 }
@@ -1505,165 +1699,203 @@ func commentForm(f CommentForm, suffix string) templ.Component {
15051699 }()
15061700 }
15071701 ctx = templ.InitializeContext(ctx)
1508- templ_7745c5c3_Var74 := templ.GetChildren(ctx)
1509- if templ_7745c5c3_Var74 == nil {
1510- templ_7745c5c3_Var74 = templ.NopComponent
1702+ templ_7745c5c3_Var82 := templ.GetChildren(ctx)
1703+ if templ_7745c5c3_Var82 == nil {
1704+ templ_7745c5c3_Var82 = templ.NopComponent
15111705 }
15121706 ctx = templ.ClearChildren(ctx)
1513- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<form class=\"cform\" method=\"post\" action=\"")
1707+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "<form class=\"cform\" method=\"post\" action=\"")
15141708 if templ_7745c5c3_Err != nil {
15151709 return templ_7745c5c3_Err
15161710 }
1517- var templ_7745c5c3_Var75 templ.SafeURL
1518- templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Action))
1711+ var templ_7745c5c3_Var83 templ.SafeURL
1712+ templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Action))
15191713 if templ_7745c5c3_Err != nil {
1520- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 273, Col: 67}
1714+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 312, Col: 67}
15211715 }
1522- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))
1716+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83))
15231717 if templ_7745c5c3_Err != nil {
15241718 return templ_7745c5c3_Err
15251719 }
1526- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "\"><label for=\"")
1720+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "\"><label for=\"")
15271721 if templ_7745c5c3_Err != nil {
15281722 return templ_7745c5c3_Err
15291723 }
1530- var templ_7745c5c3_Var76 string
1531- templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID + suffix)
1724+ var templ_7745c5c3_Var84 string
1725+ templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID + suffix)
15321726 if templ_7745c5c3_Err != nil {
1533- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 274, Col: 28}
1727+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 313, Col: 28}
15341728 }
1535- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var76)
1729+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var84)
15361730 if templ_7745c5c3_Err != nil {
15371731 return templ_7745c5c3_Err
15381732 }
1539- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "\">")
1733+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "\">")
15401734 if templ_7745c5c3_Err != nil {
15411735 return templ_7745c5c3_Err
15421736 }
1543- var templ_7745c5c3_Var77 string
1544- templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(f.Title)
1737+ var templ_7745c5c3_Var85 string
1738+ templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(f.Title)
15451739 if templ_7745c5c3_Err != nil {
1546- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 274, Col: 40}
1740+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 313, Col: 40}
15471741 }
1548- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77))
1742+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85))
15491743 if templ_7745c5c3_Err != nil {
15501744 return templ_7745c5c3_Err
15511745 }
1552- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</label> <textarea id=\"")
1746+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</label> <textarea id=\"")
15531747 if templ_7745c5c3_Err != nil {
15541748 return templ_7745c5c3_Err
15551749 }
1556- var templ_7745c5c3_Var78 string
1557- templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID + suffix)
1750+ var templ_7745c5c3_Var86 string
1751+ templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID + suffix)
15581752 if templ_7745c5c3_Err != nil {
1559- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 276, Col: 21}
1753+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 315, Col: 21}
15601754 }
1561- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var78)
1755+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var86)
15621756 if templ_7745c5c3_Err != nil {
15631757 return templ_7745c5c3_Err
15641758 }
1565- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "\" name=\"text\" rows=\"3\" maxlength=\"8000\" required placeholder=\"plain text, 8000 characters at most\"")
1759+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "\" name=\"text\" rows=\"3\" maxlength=\"8000\" placeholder=\"Markdown, 8000 characters at most\"")
15661760 if templ_7745c5c3_Err != nil {
15671761 return templ_7745c5c3_Err
15681762 }
15691763 if f.Focus {
1570- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, " autofocus")
1764+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, " autofocus")
15711765 if templ_7745c5c3_Err != nil {
15721766 return templ_7745c5c3_Err
15731767 }
15741768 }
1575- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "></textarea> ")
1769+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "></textarea> ")
15761770 if templ_7745c5c3_Err != nil {
15771771 return templ_7745c5c3_Err
15781772 }
15791773 if f.Path != "" {
1580- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "<input type=\"hidden\" name=\"path\" value=\"")
1774+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<input type=\"hidden\" name=\"path\" value=\"")
15811775 if templ_7745c5c3_Err != nil {
15821776 return templ_7745c5c3_Err
15831777 }
1584- var templ_7745c5c3_Var79 string
1585- templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path)
1778+ var templ_7745c5c3_Var87 string
1779+ templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path)
15861780 if templ_7745c5c3_Err != nil {
1587- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 287, Col: 50}
1781+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 325, Col: 50}
15881782 }
1589- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var79)
1783+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var87)
15901784 if templ_7745c5c3_Err != nil {
15911785 return templ_7745c5c3_Err
15921786 }
1593- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "\"> <input type=\"hidden\" name=\"line\" value=\"")
1787+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "\"> <input type=\"hidden\" name=\"line\" value=\"")
15941788 if templ_7745c5c3_Err != nil {
15951789 return templ_7745c5c3_Err
15961790 }
1597- var templ_7745c5c3_Var80 string
1598- templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Line)
1791+ var templ_7745c5c3_Var88 string
1792+ templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Line)
15991793 if templ_7745c5c3_Err != nil {
1600- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 288, Col: 50}
1794+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 326, Col: 50}
16011795 }
1602- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
1796+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var88)
16031797 if templ_7745c5c3_Err != nil {
16041798 return templ_7745c5c3_Err
16051799 }
1606- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "\"> <input type=\"hidden\" name=\"side\" value=\"")
1800+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "\"> <input type=\"hidden\" name=\"side\" value=\"")
16071801 if templ_7745c5c3_Err != nil {
16081802 return templ_7745c5c3_Err
16091803 }
1610- var templ_7745c5c3_Var81 string
1611- templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Side)
1804+ var templ_7745c5c3_Var89 string
1805+ templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Side)
1806+ if templ_7745c5c3_Err != nil {
1807+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 327, Col: 50}
1808+ }
1809+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var89)
1810+ if templ_7745c5c3_Err != nil {
1811+ return templ_7745c5c3_Err
1812+ }
1813+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "\"> <label>from line <input type=\"number\" name=\"start_line\" min=\"1\" value=\"")
1814+ if templ_7745c5c3_Err != nil {
1815+ return templ_7745c5c3_Err
1816+ }
1817+ var templ_7745c5c3_Var90 string
1818+ templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.StartLine)
16121819 if templ_7745c5c3_Err != nil {
1613- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 289, Col: 50}
1820+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 328, Col: 86}
16141821 }
1615- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var81)
1822+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var90)
16161823 if templ_7745c5c3_Err != nil {
16171824 return templ_7745c5c3_Err
16181825 }
1619- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "\"> ")
1826+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "\"></label> ")
1827+ if templ_7745c5c3_Err != nil {
1828+ return templ_7745c5c3_Err
1829+ }
1830+ }
1831+ if f.ReplyTo != "" {
1832+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "<input type=\"hidden\" name=\"reply_to\" value=\"")
1833+ if templ_7745c5c3_Err != nil {
1834+ return templ_7745c5c3_Err
1835+ }
1836+ var templ_7745c5c3_Var91 string
1837+ templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ReplyTo)
1838+ if templ_7745c5c3_Err != nil {
1839+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 331, Col: 57}
1840+ }
1841+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var91)
1842+ if templ_7745c5c3_Err != nil {
1843+ return templ_7745c5c3_Err
1844+ }
1845+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "\"> ")
1846+ if templ_7745c5c3_Err != nil {
1847+ return templ_7745c5c3_Err
1848+ }
1849+ }
1850+ if f.Path == "" && f.ReplyTo == "" {
1851+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "<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>")
16201852 if templ_7745c5c3_Err != nil {
16211853 return templ_7745c5c3_Err
16221854 }
16231855 }
16241856 if f.From > 0 {
1625- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<input type=\"hidden\" name=\"from\" value=\"")
1857+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "<input type=\"hidden\" name=\"from\" value=\"")
16261858 if templ_7745c5c3_Err != nil {
16271859 return templ_7745c5c3_Err
16281860 }
1629- var templ_7745c5c3_Var82 string
1630- templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(f.From))
1861+ var templ_7745c5c3_Var92 string
1862+ templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(f.From))
16311863 if templ_7745c5c3_Err != nil {
1632- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 292, Col: 56}
1864+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 337, Col: 56}
16331865 }
1634- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82)
1866+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var92)
16351867 if templ_7745c5c3_Err != nil {
16361868 return templ_7745c5c3_Err
16371869 }
1638- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\">")
1870+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "\">")
16391871 if templ_7745c5c3_Err != nil {
16401872 return templ_7745c5c3_Err
16411873 }
16421874 }
1643- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "<div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Comment</button> ")
1875+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "<div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Comment</button> ")
16441876 if templ_7745c5c3_Err != nil {
16451877 return templ_7745c5c3_Err
16461878 }
16471879 if f.Cancel != "" {
1648- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<a class=\"btn\" href=\"")
1880+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "<a class=\"btn\" href=\"")
16491881 if templ_7745c5c3_Err != nil {
16501882 return templ_7745c5c3_Err
16511883 }
1652- var templ_7745c5c3_Var83 templ.SafeURL
1653- templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Cancel))
1884+ var templ_7745c5c3_Var93 templ.SafeURL
1885+ templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Cancel))
16541886 if templ_7745c5c3_Err != nil {
1655- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 297, Col: 49}
1887+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 342, Col: 49}
16561888 }
1657- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83))
1889+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))
16581890 if templ_7745c5c3_Err != nil {
16591891 return templ_7745c5c3_Err
16601892 }
1661- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "\">Cancel</a>")
1893+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "\">Cancel</a>")
16621894 if templ_7745c5c3_Err != nil {
16631895 return templ_7745c5c3_Err
16641896 }
16651897 }
1666- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "</div></form>")
1898+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "</div></form>")
16671899 if templ_7745c5c3_Err != nil {
16681900 return templ_7745c5c3_Err
16691901 }
@@ -1687,25 +1919,25 @@ func mrRail(p MRPage) templ.Component {
16871919 }()
16881920 }
16891921 ctx = templ.InitializeContext(ctx)
1690- templ_7745c5c3_Var84 := templ.GetChildren(ctx)
1691- if templ_7745c5c3_Var84 == nil {
1692- templ_7745c5c3_Var84 = templ.NopComponent
1922+ templ_7745c5c3_Var94 := templ.GetChildren(ctx)
1923+ if templ_7745c5c3_Var94 == nil {
1924+ templ_7745c5c3_Var94 = templ.NopComponent
16931925 }
16941926 ctx = templ.ClearChildren(ctx)
1695- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "<section aria-labelledby=\"r-files\" class=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
1927+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "<section aria-labelledby=\"r-files\" class=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
16961928 if templ_7745c5c3_Err != nil {
16971929 return templ_7745c5c3_Err
16981930 }
1699- var templ_7745c5c3_Var85 string
1700- templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
1931+ var templ_7745c5c3_Var95 string
1932+ templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
17011933 if templ_7745c5c3_Err != nil {
1702- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 306, Col: 49}
1934+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 351, Col: 49}
17031935 }
1704- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85))
1936+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))
17051937 if templ_7745c5c3_Err != nil {
17061938 return templ_7745c5c3_Err
17071939 }
1708- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "</span><kbd aria-hidden=\"true\">g f</kbd> <button type=\"button\" class=\"btn icon fold\" data-js aria-expanded=\"true\" title=\"fold the list of files\" hx-on:click=\"ermiteMR.foldFiles()\"><span class=\"ico i-chevron\" aria-hidden=\"true\"></span><span class=\"sr\">fold the list of files</span></button></h2>")
1940+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "</span><kbd aria-hidden=\"true\">g f</kbd> <button type=\"button\" class=\"btn icon fold\" data-js aria-expanded=\"true\" title=\"fold the list of files\" hx-on:click=\"ermiteMR.foldFiles()\"><span class=\"ico i-chevron\" aria-hidden=\"true\"></span><span class=\"sr\">fold the list of files</span></button></h2>")
17091941 if templ_7745c5c3_Err != nil {
17101942 return templ_7745c5c3_Err
17111943 }
@@ -1713,82 +1945,147 @@ func mrRail(p MRPage) templ.Component {
17131945 if templ_7745c5c3_Err != nil {
17141946 return templ_7745c5c3_Err
17151947 }
1716- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</section>")
1948+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "</section>")
17171949 if templ_7745c5c3_Err != nil {
17181950 return templ_7745c5c3_Err
17191951 }
1952+ if len(p.Verdicts) > 0 {
1953+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "<section><h2>Verdicts</h2><ul>")
1954+ if templ_7745c5c3_Err != nil {
1955+ return templ_7745c5c3_Err
1956+ }
1957+ for _, v := range p.Verdicts {
1958+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "<li><a href=\"")
1959+ if templ_7745c5c3_Err != nil {
1960+ return templ_7745c5c3_Err
1961+ }
1962+ var templ_7745c5c3_Var96 templ.SafeURL
1963+ templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(v.Href))
1964+ if templ_7745c5c3_Err != nil {
1965+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 364, Col: 37}
1966+ }
1967+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))
1968+ if templ_7745c5c3_Err != nil {
1969+ return templ_7745c5c3_Err
1970+ }
1971+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "\">")
1972+ if templ_7745c5c3_Err != nil {
1973+ return templ_7745c5c3_Err
1974+ }
1975+ var templ_7745c5c3_Var97 string
1976+ templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(v.Author)
1977+ if templ_7745c5c3_Err != nil {
1978+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 364, Col: 50}
1979+ }
1980+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))
1981+ if templ_7745c5c3_Err != nil {
1982+ return templ_7745c5c3_Err
1983+ }
1984+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, ": ")
1985+ if templ_7745c5c3_Err != nil {
1986+ return templ_7745c5c3_Err
1987+ }
1988+ var templ_7745c5c3_Var98 string
1989+ templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(v.Label)
1990+ if templ_7745c5c3_Err != nil {
1991+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 364, Col: 63}
1992+ }
1993+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98))
1994+ if templ_7745c5c3_Err != nil {
1995+ return templ_7745c5c3_Err
1996+ }
1997+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "</a> ")
1998+ if templ_7745c5c3_Err != nil {
1999+ return templ_7745c5c3_Err
2000+ }
2001+ if v.Stale {
2002+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "<span class=\"mute\">(stale)</span>")
2003+ if templ_7745c5c3_Err != nil {
2004+ return templ_7745c5c3_Err
2005+ }
2006+ }
2007+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "</li>")
2008+ if templ_7745c5c3_Err != nil {
2009+ return templ_7745c5c3_Err
2010+ }
2011+ }
2012+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "</ul></section>")
2013+ if templ_7745c5c3_Err != nil {
2014+ return templ_7745c5c3_Err
2015+ }
2016+ }
17202017 templ_7745c5c3_Err = MRChecks(p.Checks).Render(ctx, templ_7745c5c3_Buffer)
17212018 if templ_7745c5c3_Err != nil {
17222019 return templ_7745c5c3_Err
17232020 }
1724- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "<section aria-labelledby=\"r-commits\"><h2 id=\"r-commits\">Commits <span class=\"count\">")
2021+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "<section aria-labelledby=\"r-commits\"><h2 id=\"r-commits\">Commits <span class=\"count\">")
17252022 if templ_7745c5c3_Err != nil {
17262023 return templ_7745c5c3_Err
17272024 }
1728- var templ_7745c5c3_Var86 string
1729- templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Commits)))
2025+ var templ_7745c5c3_Var99 string
2026+ templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Commits)))
17302027 if templ_7745c5c3_Err != nil {
1731- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 315, Col: 71}
2028+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 375, Col: 71}
17322029 }
1733- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
2030+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))
17342031 if templ_7745c5c3_Err != nil {
17352032 return templ_7745c5c3_Err
17362033 }
1737- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "</span></h2><ul class=\"pcommits\">")
2034+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "</span></h2><ul class=\"pcommits\">")
17382035 if templ_7745c5c3_Err != nil {
17392036 return templ_7745c5c3_Err
17402037 }
17412038 for _, c := range p.Commits {
1742- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "<li")
2039+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "<li")
17432040 if templ_7745c5c3_Err != nil {
17442041 return templ_7745c5c3_Err
17452042 }
17462043 if c.New {
1747- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, " data-new")
2044+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, " data-new")
17482045 if templ_7745c5c3_Err != nil {
17492046 return templ_7745c5c3_Err
17502047 }
17512048 }
1752- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "><a href=\"")
2049+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "><a href=\"")
17532050 if templ_7745c5c3_Err != nil {
17542051 return templ_7745c5c3_Err
17552052 }
1756- var templ_7745c5c3_Var87 templ.SafeURL
1757- templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
2053+ var templ_7745c5c3_Var100 templ.SafeURL
2054+ templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
17582055 if templ_7745c5c3_Err != nil {
1759- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 319, Col: 36}
2056+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 379, Col: 36}
17602057 }
1761- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87))
2058+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100))
17622059 if templ_7745c5c3_Err != nil {
17632060 return templ_7745c5c3_Err
17642061 }
1765- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "\">")
2062+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "\">")
17662063 if templ_7745c5c3_Err != nil {
17672064 return templ_7745c5c3_Err
17682065 }
1769- var templ_7745c5c3_Var88 string
1770- templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
2066+ var templ_7745c5c3_Var101 string
2067+ templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
17712068 if templ_7745c5c3_Err != nil {
1772- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 319, Col: 48}
2069+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 379, Col: 48}
17732070 }
1774- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88))
2071+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101))
17752072 if templ_7745c5c3_Err != nil {
17762073 return templ_7745c5c3_Err
17772074 }
1778- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "</a> <span class=\"s\">")
2075+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "</a> <span class=\"s\">")
17792076 if templ_7745c5c3_Err != nil {
17802077 return templ_7745c5c3_Err
17812078 }
1782- var templ_7745c5c3_Var89 string
1783- templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
2079+ var templ_7745c5c3_Var102 string
2080+ templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
17842081 if templ_7745c5c3_Err != nil {
1785- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 320, Col: 32}
2082+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 380, Col: 32}
17862083 }
1787- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))
2084+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102))
17882085 if templ_7745c5c3_Err != nil {
17892086 return templ_7745c5c3_Err
17902087 }
1791- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</span> ")
2088+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "</span> ")
17922089 if templ_7745c5c3_Err != nil {
17932090 return templ_7745c5c3_Err
17942091 }
@@ -1798,138 +2095,138 @@ func mrRail(p MRPage) templ.Component {
17982095 return templ_7745c5c3_Err
17992096 }
18002097 }
1801- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<span class=\"mute\">")
2098+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "<span class=\"mute\">")
18022099 if templ_7745c5c3_Err != nil {
18032100 return templ_7745c5c3_Err
18042101 }
1805- var templ_7745c5c3_Var90 string
1806- templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
2102+ var templ_7745c5c3_Var103 string
2103+ templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
18072104 if templ_7745c5c3_Err != nil {
1808- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 325, Col: 16}
2105+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 385, Col: 16}
18092106 }
1810- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90))
2107+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
18112108 if templ_7745c5c3_Err != nil {
18122109 return templ_7745c5c3_Err
18132110 }
1814- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, " ")
2111+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, " ")
18152112 if templ_7745c5c3_Err != nil {
18162113 return templ_7745c5c3_Err
18172114 }
18182115 if c.WhenAt != "" {
1819- var templ_7745c5c3_Var91 string
1820- templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
2116+ var templ_7745c5c3_Var104 string
2117+ templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
18212118 if templ_7745c5c3_Err != nil {
1822- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 327, Col: 15}
2119+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 387, Col: 15}
18232120 }
1824- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91))
2121+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))
18252122 if templ_7745c5c3_Err != nil {
18262123 return templ_7745c5c3_Err
18272124 }
1828- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "<time datetime=\"")
2125+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "<time datetime=\"")
18292126 if templ_7745c5c3_Err != nil {
18302127 return templ_7745c5c3_Err
18312128 }
1832- var templ_7745c5c3_Var92 string
1833- templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
2129+ var templ_7745c5c3_Var105 string
2130+ templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
18342131 if templ_7745c5c3_Err != nil {
1835- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 327, Col: 42}
2132+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 387, Col: 42}
18362133 }
1837- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var92)
2134+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var105)
18382135 if templ_7745c5c3_Err != nil {
18392136 return templ_7745c5c3_Err
18402137 }
1841- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "\">")
2138+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "\">")
18422139 if templ_7745c5c3_Err != nil {
18432140 return templ_7745c5c3_Err
18442141 }
1845- var templ_7745c5c3_Var93 string
1846- templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
2142+ var templ_7745c5c3_Var106 string
2143+ templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
18472144 if templ_7745c5c3_Err != nil {
1848- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 327, Col: 53}
2145+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 387, Col: 53}
18492146 }
1850- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))
2147+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106))
18512148 if templ_7745c5c3_Err != nil {
18522149 return templ_7745c5c3_Err
18532150 }
1854- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "</time> ")
2151+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "</time> ")
18552152 if templ_7745c5c3_Err != nil {
18562153 return templ_7745c5c3_Err
18572154 }
18582155 }
18592156 if c.New {
1860- var templ_7745c5c3_Var94 string
1861- templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.JoinStringErrs(" · new")
2157+ var templ_7745c5c3_Var107 string
2158+ templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinStringErrs(" · new")
18622159 if templ_7745c5c3_Err != nil {
1863- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 330, Col: 18}
2160+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 390, Col: 18}
18642161 }
1865- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var94))
2162+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107))
18662163 if templ_7745c5c3_Err != nil {
18672164 return templ_7745c5c3_Err
18682165 }
18692166 }
1870- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "</span></li>")
2167+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, "</span></li>")
18712168 if templ_7745c5c3_Err != nil {
18722169 return templ_7745c5c3_Err
18732170 }
18742171 }
1875- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "</ul>")
2172+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "</ul>")
18762173 if templ_7745c5c3_Err != nil {
18772174 return templ_7745c5c3_Err
18782175 }
18792176 if len(p.Commits) == 0 {
1880- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "<p class=\"dim\">no commit to merge.</p>")
2177+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "<p class=\"dim\">no commit to merge.</p>")
18812178 if templ_7745c5c3_Err != nil {
18822179 return templ_7745c5c3_Err
18832180 }
18842181 }
18852182 if p.CommitsNote != "" {
1886- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "<p class=\"note\">")
2183+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "<p class=\"note\">")
18872184 if templ_7745c5c3_Err != nil {
18882185 return templ_7745c5c3_Err
18892186 }
1890- var templ_7745c5c3_Var95 string
1891- templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(p.CommitsNote)
2187+ var templ_7745c5c3_Var108 string
2188+ templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(p.CommitsNote)
18922189 if templ_7745c5c3_Err != nil {
1893- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 340, Col: 34}
2190+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 400, Col: 34}
18942191 }
1895- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))
2192+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
18962193 if templ_7745c5c3_Err != nil {
18972194 return templ_7745c5c3_Err
18982195 }
1899- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "</p>")
2196+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "</p>")
19002197 if templ_7745c5c3_Err != nil {
19012198 return templ_7745c5c3_Err
19022199 }
19032200 }
1904- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "</section><section aria-labelledby=\"r-settings\"><h2 id=\"r-settings\">Settings</h2><dl class=\"kv\"><dt>modes</dt><dd>")
2201+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "</section><section aria-labelledby=\"r-settings\"><h2 id=\"r-settings\">Settings</h2><dl class=\"kv\"><dt>modes</dt><dd>")
19052202 if templ_7745c5c3_Err != nil {
19062203 return templ_7745c5c3_Err
19072204 }
1908- var templ_7745c5c3_Var96 string
1909- templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(p.Modes, ", "))
2205+ var templ_7745c5c3_Var109 string
2206+ templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(p.Modes, ", "))
19102207 if templ_7745c5c3_Err != nil {
1911- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 347, Col: 36}
2208+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 407, Col: 36}
19122209 }
1913- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))
2210+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109))
19142211 if templ_7745c5c3_Err != nil {
19152212 return templ_7745c5c3_Err
19162213 }
1917- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "</dd><dt>checks</dt><dd>")
2214+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "</dd><dt>checks</dt><dd>")
19182215 if templ_7745c5c3_Err != nil {
19192216 return templ_7745c5c3_Err
19202217 }
19212218 if p.ChecksBlock {
1922- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "failing ones block the merge")
2219+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "failing ones block the merge")
19232220 if templ_7745c5c3_Err != nil {
19242221 return templ_7745c5c3_Err
19252222 }
19262223 } else {
1927- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "do not block the merge")
2224+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "do not block the merge")
19282225 if templ_7745c5c3_Err != nil {
19292226 return templ_7745c5c3_Err
19302227 }
19312228 }
1932- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "</dd></dl></section>")
2229+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "</dd></dl></section>")
19332230 if templ_7745c5c3_Err != nil {
19342231 return templ_7745c5c3_Err
19352232 }
@@ -1957,51 +2254,51 @@ func mrBar(p MRPage) templ.Component {
19572254 }()
19582255 }
19592256 ctx = templ.InitializeContext(ctx)
1960- templ_7745c5c3_Var97 := templ.GetChildren(ctx)
1961- if templ_7745c5c3_Var97 == nil {
1962- templ_7745c5c3_Var97 = templ.NopComponent
2257+ templ_7745c5c3_Var110 := templ.GetChildren(ctx)
2258+ if templ_7745c5c3_Var110 == nil {
2259+ templ_7745c5c3_Var110 = templ.NopComponent
19632260 }
19642261 ctx = templ.ClearChildren(ctx)
1965- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "<div class=\"diff-tools mr-bar\" role=\"group\" aria-label=\"Diff view and merge\"><span class=\"dim\">")
2262+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "<div class=\"diff-tools mr-bar\" role=\"group\" aria-label=\"Diff view and merge\"><span class=\"dim\">")
19662263 if templ_7745c5c3_Err != nil {
19672264 return templ_7745c5c3_Err
19682265 }
1969- var templ_7745c5c3_Var98 string
1970- templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
2266+ var templ_7745c5c3_Var111 string
2267+ templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
19712268 if templ_7745c5c3_Err != nil {
1972- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 366, Col: 55}
2269+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 426, Col: 55}
19732270 }
1974- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98))
2271+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111))
19752272 if templ_7745c5c3_Err != nil {
19762273 return templ_7745c5c3_Err
19772274 }
1978- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, " · <span class=\"plus\">+")
2275+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, " · <span class=\"plus\">+")
19792276 if templ_7745c5c3_Err != nil {
19802277 return templ_7745c5c3_Err
19812278 }
1982- var templ_7745c5c3_Var99 string
1983- templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
2279+ var templ_7745c5c3_Var112 string
2280+ templ_7745c5c3_Var112, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
19842281 if templ_7745c5c3_Err != nil {
1985- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 366, Col: 95}
2282+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 426, Col: 95}
19862283 }
1987- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))
2284+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var112))
19882285 if templ_7745c5c3_Err != nil {
19892286 return templ_7745c5c3_Err
19902287 }
1991- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "</span> <span class=\"minus\">-")
2288+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, "</span> <span class=\"minus\">-")
19922289 if templ_7745c5c3_Err != nil {
19932290 return templ_7745c5c3_Err
19942291 }
1995- var templ_7745c5c3_Var100 string
1996- templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
2292+ var templ_7745c5c3_Var113 string
2293+ templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
19972294 if templ_7745c5c3_Err != nil {
1998- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 366, Col: 140}
2295+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 426, Col: 140}
19992296 }
2000- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100))
2297+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var113))
20012298 if templ_7745c5c3_Err != nil {
20022299 return templ_7745c5c3_Err
20032300 }
2004- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "</span></span>")
2301+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "</span></span>")
20052302 if templ_7745c5c3_Err != nil {
20062303 return templ_7745c5c3_Err
20072304 }
@@ -2017,7 +2314,7 @@ func mrBar(p MRPage) templ.Component {
20172314 if templ_7745c5c3_Err != nil {
20182315 return templ_7745c5c3_Err
20192316 }
2020- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "<span class=\"grow\"></span>")
2317+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "<span class=\"grow\"></span>")
20212318 if templ_7745c5c3_Err != nil {
20222319 return templ_7745c5c3_Err
20232320 }
@@ -2030,7 +2327,7 @@ func mrBar(p MRPage) templ.Component {
20302327 if templ_7745c5c3_Err != nil {
20312328 return templ_7745c5c3_Err
20322329 }
2033- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, " ")
2330+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, " ")
20342331 if templ_7745c5c3_Err != nil {
20352332 return templ_7745c5c3_Err
20362333 }
@@ -2039,7 +2336,7 @@ func mrBar(p MRPage) templ.Component {
20392336 return templ_7745c5c3_Err
20402337 }
20412338 }
2042- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "</div>")
2339+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "</div>")
20432340 if templ_7745c5c3_Err != nil {
20442341 return templ_7745c5c3_Err
20452342 }
@@ -2066,219 +2363,219 @@ func mergeMenu(p MRPage) templ.Component {
20662363 }()
20672364 }
20682365 ctx = templ.InitializeContext(ctx)
2069- templ_7745c5c3_Var101 := templ.GetChildren(ctx)
2070- if templ_7745c5c3_Var101 == nil {
2071- templ_7745c5c3_Var101 = templ.NopComponent
2366+ templ_7745c5c3_Var114 := templ.GetChildren(ctx)
2367+ if templ_7745c5c3_Var114 == nil {
2368+ templ_7745c5c3_Var114 = templ.NopComponent
20722369 }
20732370 ctx = templ.ClearChildren(ctx)
2074- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "<details class=\"menu mmenu\" id=\"merge-menu\" data-state=\"open\"><summary class=\"btn primary\" aria-keyshortcuts=\"Shift+M\">")
2371+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "<details class=\"menu mmenu\" id=\"merge-menu\" data-state=\"open\"><summary class=\"btn primary\" aria-keyshortcuts=\"Shift+M\">")
20752372 if templ_7745c5c3_Err != nil {
20762373 return templ_7745c5c3_Err
20772374 }
20782375 if p.Box.ReadyAction != "" {
2079- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "Draft ")
2376+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "Draft ")
20802377 if templ_7745c5c3_Err != nil {
20812378 return templ_7745c5c3_Err
20822379 }
20832380 } else {
2084- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "Merge ")
2381+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, "Merge ")
20852382 if templ_7745c5c3_Err != nil {
20862383 return templ_7745c5c3_Err
20872384 }
20882385 }
2089- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "<kbd aria-hidden=\"true\">M</kbd></summary><div class=\"menu-pop\" role=\"group\" aria-label=\"Merge\"><dl class=\"kv\"><dt>head</dt><dd>")
2386+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "<kbd aria-hidden=\"true\">M</kbd></summary><div class=\"menu-pop\" role=\"group\" aria-label=\"Merge\"><dl class=\"kv\"><dt>head</dt><dd>")
20902387 if templ_7745c5c3_Err != nil {
20912388 return templ_7745c5c3_Err
20922389 }
20932390 if p.Box.HeadGone {
2094- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "gone: the source branch was deleted")
2391+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, "gone: the source branch was deleted")
20952392 if templ_7745c5c3_Err != nil {
20962393 return templ_7745c5c3_Err
20972394 }
20982395 } else {
2099- var templ_7745c5c3_Var102 string
2100- templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(p.HeadShort)
2396+ var templ_7745c5c3_Var115 string
2397+ templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.JoinStringErrs(p.HeadShort)
21012398 if templ_7745c5c3_Err != nil {
2102- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 399, Col: 19}
2399+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 459, Col: 19}
21032400 }
2104- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102))
2401+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var115))
21052402 if templ_7745c5c3_Err != nil {
21062403 return templ_7745c5c3_Err
21072404 }
21082405 }
2109- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "</dd><dt>into</dt><dd>")
2406+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, "</dd><dt>into</dt><dd>")
21102407 if templ_7745c5c3_Err != nil {
21112408 return templ_7745c5c3_Err
21122409 }
2113- var templ_7745c5c3_Var103 string
2114- templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2410+ var templ_7745c5c3_Var116 string
2411+ templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
21152412 if templ_7745c5c3_Err != nil {
2116- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 403, Col: 18}
2413+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 463, Col: 18}
21172414 }
2118- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
2415+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var116))
21192416 if templ_7745c5c3_Err != nil {
21202417 return templ_7745c5c3_Err
21212418 }
2122- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "</dd></dl>")
2419+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, "</dd></dl>")
21232420 if templ_7745c5c3_Err != nil {
21242421 return templ_7745c5c3_Err
21252422 }
21262423 if p.Box.ReadyAction != "" {
2127- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "<p class=\"d-draft\">this is a draft: it cannot be merged until it is ready for review.</p><form class=\"d-btns\" method=\"post\" action=\"")
2424+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, "<p class=\"d-draft\">this is a draft: it cannot be merged until it is ready for review.</p><form class=\"d-btns\" method=\"post\" action=\"")
21282425 if templ_7745c5c3_Err != nil {
21292426 return templ_7745c5c3_Err
21302427 }
2131- var templ_7745c5c3_Var104 templ.SafeURL
2132- templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadyAction))
2428+ var templ_7745c5c3_Var117 templ.SafeURL
2429+ templ_7745c5c3_Var117, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadyAction))
21332430 if templ_7745c5c3_Err != nil {
2134- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 407, Col: 80}
2431+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 467, Col: 80}
21352432 }
2136- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))
2433+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var117))
21372434 if templ_7745c5c3_Err != nil {
21382435 return templ_7745c5c3_Err
21392436 }
2140- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "\"><button type=\"submit\" class=\"btn primary\" data-default title=\"remove the draft prefix from the title\">Ready for review</button></form>")
2437+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, "\"><button type=\"submit\" class=\"btn primary\" data-default title=\"remove the draft prefix from the title\">Ready for review</button></form>")
21412438 if templ_7745c5c3_Err != nil {
21422439 return templ_7745c5c3_Err
21432440 }
21442441 } else if !p.Box.HeadGone {
2145- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "<form class=\"d-acts\" method=\"post\" action=\"")
2442+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, "<form class=\"d-acts\" method=\"post\" action=\"")
21462443 if templ_7745c5c3_Err != nil {
21472444 return templ_7745c5c3_Err
21482445 }
2149- var templ_7745c5c3_Var105 templ.SafeURL
2150- templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.MergeAction))
2446+ var templ_7745c5c3_Var118 templ.SafeURL
2447+ templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.MergeAction))
21512448 if templ_7745c5c3_Err != nil {
2152- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 411, Col: 80}
2449+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 471, Col: 80}
21532450 }
2154- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))
2451+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118))
21552452 if templ_7745c5c3_Err != nil {
21562453 return templ_7745c5c3_Err
21572454 }
2158- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
2455+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
21592456 if templ_7745c5c3_Err != nil {
21602457 return templ_7745c5c3_Err
21612458 }
2162- var templ_7745c5c3_Var106 string
2163- templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
2459+ var templ_7745c5c3_Var119 string
2460+ templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
21642461 if templ_7745c5c3_Err != nil {
2165- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 412, Col: 59}
2462+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 472, Col: 59}
21662463 }
2167- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var106)
2464+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var119)
21682465 if templ_7745c5c3_Err != nil {
21692466 return templ_7745c5c3_Err
21702467 }
2171- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "\"><fieldset class=\"d-modes\"><legend class=\"sr\">merge mode</legend> ")
2468+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, "\"><fieldset class=\"d-modes\"><legend class=\"sr\">merge mode</legend> ")
21722469 if templ_7745c5c3_Err != nil {
21732470 return templ_7745c5c3_Err
21742471 }
21752472 for i, m := range p.Box.Modes {
2176- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "<label><input type=\"radio\" name=\"mode\" value=\"")
2473+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, "<label><input type=\"radio\" name=\"mode\" value=\"")
21772474 if templ_7745c5c3_Err != nil {
21782475 return templ_7745c5c3_Err
21792476 }
2180- var templ_7745c5c3_Var107 string
2181- templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)
2477+ var templ_7745c5c3_Var120 string
2478+ templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)
21822479 if templ_7745c5c3_Err != nil {
2183- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 417, Col: 55}
2480+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 477, Col: 55}
21842481 }
2185- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var107)
2482+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var120)
21862483 if templ_7745c5c3_Err != nil {
21872484 return templ_7745c5c3_Err
21882485 }
2189- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "\"")
2486+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, "\"")
21902487 if templ_7745c5c3_Err != nil {
21912488 return templ_7745c5c3_Err
21922489 }
21932490 if i == 0 {
2194- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, " checked")
2491+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, " checked")
21952492 if templ_7745c5c3_Err != nil {
21962493 return templ_7745c5c3_Err
21972494 }
21982495 }
2199- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "> ")
2496+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 229, "> ")
22002497 if templ_7745c5c3_Err != nil {
22012498 return templ_7745c5c3_Err
22022499 }
2203- var templ_7745c5c3_Var108 string
2204- templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)
2500+ var templ_7745c5c3_Var121 string
2501+ templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)
22052502 if templ_7745c5c3_Err != nil {
2206- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 418, Col: 17}
2503+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 478, Col: 17}
22072504 }
2208- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
2505+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var121))
22092506 if templ_7745c5c3_Err != nil {
22102507 return templ_7745c5c3_Err
22112508 }
2212- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, " ")
2509+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, " ")
22132510 if templ_7745c5c3_Err != nil {
22142511 return templ_7745c5c3_Err
22152512 }
22162513 if i < 9 {
2217- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "<kbd aria-hidden=\"true\">")
2514+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "<kbd aria-hidden=\"true\">")
22182515 if templ_7745c5c3_Err != nil {
22192516 return templ_7745c5c3_Err
22202517 }
2221- var templ_7745c5c3_Var109 string
2222- templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(i + 1))
2518+ var templ_7745c5c3_Var122 string
2519+ templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(i + 1))
22232520 if templ_7745c5c3_Err != nil {
2224- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 420, Col: 46}
2521+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 480, Col: 46}
22252522 }
2226- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109))
2523+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var122))
22272524 if templ_7745c5c3_Err != nil {
22282525 return templ_7745c5c3_Err
22292526 }
2230- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "</kbd>")
2527+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, "</kbd>")
22312528 if templ_7745c5c3_Err != nil {
22322529 return templ_7745c5c3_Err
22332530 }
22342531 }
2235- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "</label>")
2532+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, "</label>")
22362533 if templ_7745c5c3_Err != nil {
22372534 return templ_7745c5c3_Err
22382535 }
22392536 }
2240- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "</fieldset>")
2537+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, "</fieldset>")
22412538 if templ_7745c5c3_Err != nil {
22422539 return templ_7745c5c3_Err
22432540 }
22442541 if p.Box.CanDeleteBranch {
2245- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "<label class=\"d-check\"><input type=\"checkbox\" name=\"delete_branch\" value=\"1\"> delete ")
2542+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "<label class=\"d-check\"><input type=\"checkbox\" name=\"delete_branch\" value=\"1\"> delete ")
22462543 if templ_7745c5c3_Err != nil {
22472544 return templ_7745c5c3_Err
22482545 }
2249- var templ_7745c5c3_Var110 string
2250- templ_7745c5c3_Var110, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
2546+ var templ_7745c5c3_Var123 string
2547+ templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
22512548 if templ_7745c5c3_Err != nil {
2252- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 426, Col: 102}
2549+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 486, Col: 102}
22532550 }
2254- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var110))
2551+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var123))
22552552 if templ_7745c5c3_Err != nil {
22562553 return templ_7745c5c3_Err
22572554 }
2258- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, " after the merge <kbd aria-hidden=\"true\">D</kbd></label>")
2555+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, " after the merge <kbd aria-hidden=\"true\">D</kbd></label>")
22592556 if templ_7745c5c3_Err != nil {
22602557 return templ_7745c5c3_Err
22612558 }
22622559 }
2263- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "<div class=\"d-btns\"><button type=\"submit\" class=\"btn primary\" data-default>Merge into ")
2560+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, "<div class=\"d-btns\"><button type=\"submit\" class=\"btn primary\" data-default>Merge into ")
22642561 if templ_7745c5c3_Err != nil {
22652562 return templ_7745c5c3_Err
22662563 }
2267- var templ_7745c5c3_Var111 string
2268- templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2564+ var templ_7745c5c3_Var124 string
2565+ templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
22692566 if templ_7745c5c3_Err != nil {
2270- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 429, Col: 82}
2567+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 489, Col: 82}
22712568 }
2272- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111))
2569+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var124))
22732570 if templ_7745c5c3_Err != nil {
22742571 return templ_7745c5c3_Err
22752572 }
2276- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "</button></div></form>")
2573+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, "</button></div></form>")
22772574 if templ_7745c5c3_Err != nil {
22782575 return templ_7745c5c3_Err
22792576 }
22802577 }
2281- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "</div></details>")
2578+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, "</div></details>")
22822579 if templ_7745c5c3_Err != nil {
22832580 return templ_7745c5c3_Err
22842581 }
@@ -2304,66 +2601,66 @@ func moreMenu(p MRPage) templ.Component {
23042601 }()
23052602 }
23062603 ctx = templ.InitializeContext(ctx)
2307- templ_7745c5c3_Var112 := templ.GetChildren(ctx)
2308- if templ_7745c5c3_Var112 == nil {
2309- templ_7745c5c3_Var112 = templ.NopComponent
2604+ templ_7745c5c3_Var125 := templ.GetChildren(ctx)
2605+ if templ_7745c5c3_Var125 == nil {
2606+ templ_7745c5c3_Var125 = templ.NopComponent
23102607 }
23112608 ctx = templ.ClearChildren(ctx)
2312- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "<details class=\"menu omenu\"><summary class=\"btn icon\" aria-label=\"More actions\" title=\"more actions\">⋯</summary><div class=\"menu-pop\" role=\"group\" aria-label=\"More actions\">")
2609+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, "<details class=\"menu omenu\"><summary class=\"btn icon\" aria-label=\"More actions\" title=\"more actions\">⋯</summary><div class=\"menu-pop\" role=\"group\" aria-label=\"More actions\">")
23132610 if templ_7745c5c3_Err != nil {
23142611 return templ_7745c5c3_Err
23152612 }
23162613 if p.Box.CanMarkRead {
2317- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "<form method=\"post\" action=\"")
2614+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "<form method=\"post\" action=\"")
23182615 if templ_7745c5c3_Err != nil {
23192616 return templ_7745c5c3_Err
23202617 }
2321- var templ_7745c5c3_Var113 templ.SafeURL
2322- templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadAction))
2618+ var templ_7745c5c3_Var126 templ.SafeURL
2619+ templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadAction))
23232620 if templ_7745c5c3_Err != nil {
2324- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 444, Col: 64}
2621+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 504, Col: 64}
23252622 }
2326- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var113))
2623+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))
23272624 if templ_7745c5c3_Err != nil {
23282625 return templ_7745c5c3_Err
23292626 }
2330- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
2627+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
23312628 if templ_7745c5c3_Err != nil {
23322629 return templ_7745c5c3_Err
23332630 }
2334- var templ_7745c5c3_Var114 string
2335- templ_7745c5c3_Var114, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
2631+ var templ_7745c5c3_Var127 string
2632+ templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
23362633 if templ_7745c5c3_Err != nil {
2337- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 445, Col: 59}
2634+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 505, Col: 59}
23382635 }
2339- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var114)
2636+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var127)
23402637 if templ_7745c5c3_Err != nil {
23412638 return templ_7745c5c3_Err
23422639 }
2343- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "\"> <button type=\"submit\" title=\"the next visit can show only what changed after this head\" class=\"btn\" aria-keyshortcuts=\"Shift+R\" data-read>Mark read <kbd aria-hidden=\"true\">R</kbd></button></form>")
2640+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, "\"> <button type=\"submit\" title=\"the next visit can show only what changed after this head\" class=\"btn\" aria-keyshortcuts=\"Shift+R\" data-read>Mark read <kbd aria-hidden=\"true\">R</kbd></button></form>")
23442641 if templ_7745c5c3_Err != nil {
23452642 return templ_7745c5c3_Err
23462643 }
23472644 }
2348- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "<form method=\"post\" action=\"")
2645+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 244, "<form method=\"post\" action=\"")
23492646 if templ_7745c5c3_Err != nil {
23502647 return templ_7745c5c3_Err
23512648 }
2352- var templ_7745c5c3_Var115 templ.SafeURL
2353- templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.CloseAction))
2649+ var templ_7745c5c3_Var128 templ.SafeURL
2650+ templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.CloseAction))
23542651 if templ_7745c5c3_Err != nil {
2355- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 449, Col: 64}
2652+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 509, Col: 64}
23562653 }
2357- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var115))
2654+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var128))
23582655 if templ_7745c5c3_Err != nil {
23592656 return templ_7745c5c3_Err
23602657 }
2361- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, "\"><button type=\"submit\" title=\"close without merging; the branch is kept\" class=\"btn danger\">Close</button></form>")
2658+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, "\"><button type=\"submit\" title=\"close without merging; the branch is kept\" class=\"btn danger\">Close</button></form>")
23622659 if templ_7745c5c3_Err != nil {
23632660 return templ_7745c5c3_Err
23642661 }
23652662 if !p.Box.HeadGone && p.Box.ReadyAction == "" {
2366- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "<details class=\"d-or\"><summary class=\"mute\">or from a terminal</summary>")
2663+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 246, "<details class=\"d-or\"><summary class=\"mute\">or from a terminal</summary>")
23672664 if templ_7745c5c3_Err != nil {
23682665 return templ_7745c5c3_Err
23692666 }
@@ -2371,12 +2668,12 @@ func moreMenu(p MRPage) templ.Component {
23712668 if templ_7745c5c3_Err != nil {
23722669 return templ_7745c5c3_Err
23732670 }
2374- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "</details>")
2671+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 247, "</details>")
23752672 if templ_7745c5c3_Err != nil {
23762673 return templ_7745c5c3_Err
23772674 }
23782675 }
2379- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "</div></details>")
2676+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 248, "</div></details>")
23802677 if templ_7745c5c3_Err != nil {
23812678 return templ_7745c5c3_Err
23822679 }
@@ -2400,12 +2697,12 @@ func Compare(l Layout, p ComparePage) templ.Component {
24002697 }()
24012698 }
24022699 ctx = templ.InitializeContext(ctx)
2403- templ_7745c5c3_Var116 := templ.GetChildren(ctx)
2404- if templ_7745c5c3_Var116 == nil {
2405- templ_7745c5c3_Var116 = templ.NopComponent
2700+ templ_7745c5c3_Var129 := templ.GetChildren(ctx)
2701+ if templ_7745c5c3_Var129 == nil {
2702+ templ_7745c5c3_Var129 = templ.NopComponent
24062703 }
24072704 ctx = templ.ClearChildren(ctx)
2408- templ_7745c5c3_Var117 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
2705+ templ_7745c5c3_Var130 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
24092706 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
24102707 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
24112708 if !templ_7745c5c3_IsBuffer {
@@ -2417,263 +2714,263 @@ func Compare(l Layout, p ComparePage) templ.Component {
24172714 }()
24182715 }
24192716 ctx = templ.InitializeContext(ctx)
2420- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, "<header class=\"show mr-h\"><h1>")
2717+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 249, "<header class=\"show mr-h\"><h1>")
24212718 if templ_7745c5c3_Err != nil {
24222719 return templ_7745c5c3_Err
24232720 }
2424- var templ_7745c5c3_Var118 string
2425- templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
2721+ var templ_7745c5c3_Var131 string
2722+ templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
24262723 if templ_7745c5c3_Err != nil {
2427- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 465, Col: 17}
2724+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 525, Col: 17}
24282725 }
2429- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118))
2726+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131))
24302727 if templ_7745c5c3_Err != nil {
24312728 return templ_7745c5c3_Err
24322729 }
2433- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, " → ")
2730+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 250, " → ")
24342731 if templ_7745c5c3_Err != nil {
24352732 return templ_7745c5c3_Err
24362733 }
2437- var templ_7745c5c3_Var119 string
2438- templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2734+ var templ_7745c5c3_Var132 string
2735+ templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
24392736 if templ_7745c5c3_Err != nil {
2440- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 465, Col: 34}
2737+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 525, Col: 34}
24412738 }
2442- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var119))
2739+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132))
24432740 if templ_7745c5c3_Err != nil {
24442741 return templ_7745c5c3_Err
24452742 }
2446- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, "</h1><dl class=\"kv show-kv\"><dt>branch</dt><dd><a href=\"")
2743+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 251, "</h1><dl class=\"kv show-kv\"><dt>branch</dt><dd><a href=\"")
24472744 if templ_7745c5c3_Err != nil {
24482745 return templ_7745c5c3_Err
24492746 }
2450- var templ_7745c5c3_Var120 templ.SafeURL
2451- templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))
2747+ var templ_7745c5c3_Var133 templ.SafeURL
2748+ templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))
24522749 if templ_7745c5c3_Err != nil {
2453- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 468, Col: 45}
2750+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 528, Col: 45}
24542751 }
2455- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var120))
2752+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133))
24562753 if templ_7745c5c3_Err != nil {
24572754 return templ_7745c5c3_Err
24582755 }
2459- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, "\">")
2756+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 252, "\">")
24602757 if templ_7745c5c3_Err != nil {
24612758 return templ_7745c5c3_Err
24622759 }
2463- var templ_7745c5c3_Var121 string
2464- templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
2760+ var templ_7745c5c3_Var134 string
2761+ templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
24652762 if templ_7745c5c3_Err != nil {
2466- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 468, Col: 58}
2763+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 528, Col: 58}
24672764 }
2468- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var121))
2765+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134))
24692766 if templ_7745c5c3_Err != nil {
24702767 return templ_7745c5c3_Err
24712768 }
2472- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, "</a></dd><dt>target</dt><dd><a href=\"")
2769+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 253, "</a></dd><dt>target</dt><dd><a href=\"")
24732770 if templ_7745c5c3_Err != nil {
24742771 return templ_7745c5c3_Err
24752772 }
2476- var templ_7745c5c3_Var122 templ.SafeURL
2477- templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.TargetHref))
2773+ var templ_7745c5c3_Var135 templ.SafeURL
2774+ templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.TargetHref))
24782775 if templ_7745c5c3_Err != nil {
2479- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 470, Col: 45}
2776+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 530, Col: 45}
24802777 }
2481- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var122))
2778+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var135))
24822779 if templ_7745c5c3_Err != nil {
24832780 return templ_7745c5c3_Err
24842781 }
2485- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, "\">")
2782+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 254, "\">")
24862783 if templ_7745c5c3_Err != nil {
24872784 return templ_7745c5c3_Err
24882785 }
2489- var templ_7745c5c3_Var123 string
2490- templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2786+ var templ_7745c5c3_Var136 string
2787+ templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
24912788 if templ_7745c5c3_Err != nil {
2492- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 470, Col: 58}
2789+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 530, Col: 58}
24932790 }
2494- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var123))
2791+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var136))
24952792 if templ_7745c5c3_Err != nil {
24962793 return templ_7745c5c3_Err
24972794 }
2498- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, "</a></dd><dt>ahead</dt><dd>")
2795+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 255, "</a></dd><dt>ahead</dt><dd>")
24992796 if templ_7745c5c3_Err != nil {
25002797 return templ_7745c5c3_Err
25012798 }
2502- var templ_7745c5c3_Var124 string
2503- templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.JoinStringErrs(p.AheadText)
2799+ var templ_7745c5c3_Var137 string
2800+ templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.JoinStringErrs(p.AheadText)
25042801 if templ_7745c5c3_Err != nil {
2505- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 472, Col: 21}
2802+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 532, Col: 21}
25062803 }
2507- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var124))
2804+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var137))
25082805 if templ_7745c5c3_Err != nil {
25092806 return templ_7745c5c3_Err
25102807 }
2511- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, "</dd></dl></header>")
2808+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 256, "</dd></dl></header>")
25122809 if templ_7745c5c3_Err != nil {
25132810 return templ_7745c5c3_Err
25142811 }
25152812 if p.Note != "" {
2516- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, "<p class=\"notice\">")
2813+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 257, "<p class=\"notice\">")
25172814 if templ_7745c5c3_Err != nil {
25182815 return templ_7745c5c3_Err
25192816 }
2520- var templ_7745c5c3_Var125 string
2521- templ_7745c5c3_Var125, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
2817+ var templ_7745c5c3_Var138 string
2818+ templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
25222819 if templ_7745c5c3_Err != nil {
2523- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 476, Col: 29}
2820+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 536, Col: 29}
25242821 }
2525- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var125))
2822+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138))
25262823 if templ_7745c5c3_Err != nil {
25272824 return templ_7745c5c3_Err
25282825 }
2529- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, "</p>")
2826+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 258, "</p>")
25302827 if templ_7745c5c3_Err != nil {
25312828 return templ_7745c5c3_Err
25322829 }
25332830 }
2534- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, " ")
2831+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 259, " ")
25352832 if templ_7745c5c3_Err != nil {
25362833 return templ_7745c5c3_Err
25372834 }
25382835 if p.Existing != nil {
2539- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, "<p class=\"notice\">an open merge request already covers these branches: <a href=\"")
2836+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 260, "<p class=\"notice\">an open merge request already covers these branches: <a href=\"")
25402837 if templ_7745c5c3_Err != nil {
25412838 return templ_7745c5c3_Err
25422839 }
2543- var templ_7745c5c3_Var126 templ.SafeURL
2544- templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Existing.Href))
2840+ var templ_7745c5c3_Var139 templ.SafeURL
2841+ templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Existing.Href))
25452842 if templ_7745c5c3_Err != nil {
2546- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 479, Col: 114}
2843+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 539, Col: 114}
25472844 }
2548- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))
2845+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139))
25492846 if templ_7745c5c3_Err != nil {
25502847 return templ_7745c5c3_Err
25512848 }
2552- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, "\">")
2849+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 261, "\">")
25532850 if templ_7745c5c3_Err != nil {
25542851 return templ_7745c5c3_Err
25552852 }
2556- var templ_7745c5c3_Var127 string
2557- templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.JoinStringErrs(p.Existing.Label)
2853+ var templ_7745c5c3_Var140 string
2854+ templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(p.Existing.Label)
25582855 if templ_7745c5c3_Err != nil {
2559- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 479, Col: 135}
2856+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 539, Col: 135}
25602857 }
2561- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var127))
2858+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140))
25622859 if templ_7745c5c3_Err != nil {
25632860 return templ_7745c5c3_Err
25642861 }
2565- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 229, "</a></p>")
2862+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 262, "</a></p>")
25662863 if templ_7745c5c3_Err != nil {
25672864 return templ_7745c5c3_Err
25682865 }
25692866 }
2570- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, " ")
2867+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 263, " ")
25712868 if templ_7745c5c3_Err != nil {
25722869 return templ_7745c5c3_Err
25732870 }
25742871 if p.Create != nil {
2575- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "<form class=\"cform create\" method=\"post\" action=\"")
2872+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 264, "<form class=\"cform create\" method=\"post\" action=\"")
25762873 if templ_7745c5c3_Err != nil {
25772874 return templ_7745c5c3_Err
25782875 }
2579- var templ_7745c5c3_Var128 templ.SafeURL
2580- templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Create.Action))
2876+ var templ_7745c5c3_Var141 templ.SafeURL
2877+ templ_7745c5c3_Var141, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Create.Action))
25812878 if templ_7745c5c3_Err != nil {
2582- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 482, Col: 83}
2879+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 542, Col: 83}
25832880 }
2584- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var128))
2881+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var141))
25852882 if templ_7745c5c3_Err != nil {
25862883 return templ_7745c5c3_Err
25872884 }
2588- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, "\"><input type=\"hidden\" name=\"source\" value=\"")
2885+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 265, "\"><input type=\"hidden\" name=\"source\" value=\"")
25892886 if templ_7745c5c3_Err != nil {
25902887 return templ_7745c5c3_Err
25912888 }
2592- var templ_7745c5c3_Var129 string
2593- templ_7745c5c3_Var129, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Source)
2889+ var templ_7745c5c3_Var142 string
2890+ templ_7745c5c3_Var142, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Source)
25942891 if templ_7745c5c3_Err != nil {
2595- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 483, Col: 62}
2892+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 543, Col: 62}
25962893 }
2597- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var129)
2894+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var142)
25982895 if templ_7745c5c3_Err != nil {
25992896 return templ_7745c5c3_Err
26002897 }
2601- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, "\"> <input type=\"hidden\" name=\"target\" value=\"")
2898+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 266, "\"> <input type=\"hidden\" name=\"target\" value=\"")
26022899 if templ_7745c5c3_Err != nil {
26032900 return templ_7745c5c3_Err
26042901 }
2605- var templ_7745c5c3_Var130 string
2606- templ_7745c5c3_Var130, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Target)
2902+ var templ_7745c5c3_Var143 string
2903+ templ_7745c5c3_Var143, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Target)
26072904 if templ_7745c5c3_Err != nil {
2608- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 62}
2905+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 544, Col: 62}
26092906 }
2610- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var130)
2907+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var143)
26112908 if templ_7745c5c3_Err != nil {
26122909 return templ_7745c5c3_Err
26132910 }
2614- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, "\"> <label for=\"mr-title\">title</label> <input id=\"mr-title\" name=\"title\" type=\"text\" maxlength=\"120\" required value=\"")
2911+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 267, "\"> <label for=\"mr-title\">title</label> <input id=\"mr-title\" name=\"title\" type=\"text\" maxlength=\"120\" required value=\"")
26152912 if templ_7745c5c3_Err != nil {
26162913 return templ_7745c5c3_Err
26172914 }
2618- var templ_7745c5c3_Var131 string
2619- templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Title)
2915+ var templ_7745c5c3_Var144 string
2916+ templ_7745c5c3_Var144, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Title)
26202917 if templ_7745c5c3_Err != nil {
2621- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 486, Col: 97}
2918+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 546, Col: 97}
26222919 }
2623- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var131)
2920+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var144)
26242921 if templ_7745c5c3_Err != nil {
26252922 return templ_7745c5c3_Err
26262923 }
2627- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "\" autocomplete=\"off\"> <label for=\"mr-desc\">description</label> <textarea id=\"mr-desc\" name=\"description\" rows=\"4\" maxlength=\"8000\" placeholder=\"Markdown, optional\"></textarea><div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Create merge request</button></div></form>")
2924+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 268, "\" autocomplete=\"off\"> <label for=\"mr-desc\">description</label> <textarea id=\"mr-desc\" name=\"description\" rows=\"4\" maxlength=\"8000\" placeholder=\"Markdown, optional\"></textarea><div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Create merge request</button></div></form>")
26282925 if templ_7745c5c3_Err != nil {
26292926 return templ_7745c5c3_Err
26302927 }
26312928 }
2632- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, " ")
2929+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 269, " ")
26332930 if templ_7745c5c3_Err != nil {
26342931 return templ_7745c5c3_Err
26352932 }
26362933 if p.HasDiff {
2637- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, "<div class=\"diff-tools\" role=\"group\" aria-label=\"Diff view\"><span class=\"dim\">")
2934+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 270, "<div class=\"diff-tools\" role=\"group\" aria-label=\"Diff view\"><span class=\"dim\">")
26382935 if templ_7745c5c3_Err != nil {
26392936 return templ_7745c5c3_Err
26402937 }
2641- var templ_7745c5c3_Var132 string
2642- templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
2938+ var templ_7745c5c3_Var145 string
2939+ templ_7745c5c3_Var145, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
26432940 if templ_7745c5c3_Err != nil {
2644- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 496, Col: 57}
2941+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 556, Col: 57}
26452942 }
2646- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132))
2943+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var145))
26472944 if templ_7745c5c3_Err != nil {
26482945 return templ_7745c5c3_Err
26492946 }
2650- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, " · <span class=\"plus\">+")
2947+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 271, " · <span class=\"plus\">+")
26512948 if templ_7745c5c3_Err != nil {
26522949 return templ_7745c5c3_Err
26532950 }
2654- var templ_7745c5c3_Var133 string
2655- templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
2951+ var templ_7745c5c3_Var146 string
2952+ templ_7745c5c3_Var146, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
26562953 if templ_7745c5c3_Err != nil {
2657- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 496, Col: 97}
2954+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 556, Col: 97}
26582955 }
2659- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133))
2956+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var146))
26602957 if templ_7745c5c3_Err != nil {
26612958 return templ_7745c5c3_Err
26622959 }
2663- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, "</span> <span class=\"minus\">-")
2960+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 272, "</span> <span class=\"minus\">-")
26642961 if templ_7745c5c3_Err != nil {
26652962 return templ_7745c5c3_Err
26662963 }
2667- var templ_7745c5c3_Var134 string
2668- templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
2964+ var templ_7745c5c3_Var147 string
2965+ templ_7745c5c3_Var147, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
26692966 if templ_7745c5c3_Err != nil {
2670- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 496, Col: 142}
2967+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 556, Col: 142}
26712968 }
2672- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134))
2969+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var147))
26732970 if templ_7745c5c3_Err != nil {
26742971 return templ_7745c5c3_Err
26752972 }
2676- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, "</span></span> <span class=\"grow\"></span>")
2973+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 273, "</span></span> <span class=\"grow\"></span>")
26772974 if templ_7745c5c3_Err != nil {
26782975 return templ_7745c5c3_Err
26792976 }
@@ -2689,64 +2986,64 @@ func Compare(l Layout, p ComparePage) templ.Component {
26892986 if templ_7745c5c3_Err != nil {
26902987 return templ_7745c5c3_Err
26912988 }
2692- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "</div>")
2989+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 274, "</div>")
26932990 if templ_7745c5c3_Err != nil {
26942991 return templ_7745c5c3_Err
26952992 }
26962993 if p.Invisible {
2697- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, "<p class=\"notice\">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class=\"inv\">&lt;U+XXXX&gt;</span> markers, never applied.</p>")
2994+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 275, "<p class=\"notice\">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class=\"inv\">&lt;U+XXXX&gt;</span> markers, never applied.</p>")
26982995 if templ_7745c5c3_Err != nil {
26992996 return templ_7745c5c3_Err
27002997 }
27012998 }
2702- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, " <div id=\"diffs\"")
2999+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 276, " <div id=\"diffs\"")
27033000 if templ_7745c5c3_Err != nil {
27043001 return templ_7745c5c3_Err
27053002 }
27063003 if p.Both {
2707- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 244, " data-diff=\"")
3004+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 277, " data-diff=\"")
27083005 if templ_7745c5c3_Err != nil {
27093006 return templ_7745c5c3_Err
27103007 }
2711- var templ_7745c5c3_Var135 string
2712- templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
3008+ var templ_7745c5c3_Var148 string
3009+ templ_7745c5c3_Var148, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
27133010 if templ_7745c5c3_Err != nil {
2714- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 511, Col: 23}
3011+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 571, Col: 23}
27153012 }
2716- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var135)
3013+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var148)
27173014 if templ_7745c5c3_Err != nil {
27183015 return templ_7745c5c3_Err
27193016 }
2720- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, "\"")
3017+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 278, "\"")
27213018 if templ_7745c5c3_Err != nil {
27223019 return templ_7745c5c3_Err
27233020 }
27243021 } else {
2725- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 246, " data-view=\"")
3022+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 279, " data-view=\"")
27263023 if templ_7745c5c3_Err != nil {
27273024 return templ_7745c5c3_Err
27283025 }
2729- var templ_7745c5c3_Var136 string
2730- templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
3026+ var templ_7745c5c3_Var149 string
3027+ templ_7745c5c3_Var149, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
27313028 if templ_7745c5c3_Err != nil {
2732- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 513, Col: 23}
3029+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 573, Col: 23}
27333030 }
2734- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var136)
3031+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var149)
27353032 if templ_7745c5c3_Err != nil {
27363033 return templ_7745c5c3_Err
27373034 }
2738- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 247, "\"")
3035+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 280, "\"")
27393036 if templ_7745c5c3_Err != nil {
27403037 return templ_7745c5c3_Err
27413038 }
27423039 }
27433040 if p.Both && !p.Pinned {
2744- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 248, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"")
3041+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 281, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"")
27453042 if templ_7745c5c3_Err != nil {
27463043 return templ_7745c5c3_Err
27473044 }
27483045 }
2749- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 249, ">")
3046+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 282, ">")
27503047 if templ_7745c5c3_Err != nil {
27513048 return templ_7745c5c3_Err
27523049 }
@@ -2756,14 +3053,14 @@ func Compare(l Layout, p ComparePage) templ.Component {
27563053 return templ_7745c5c3_Err
27573054 }
27583055 }
2759- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 250, "</div>")
3056+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 283, "</div>")
27603057 if templ_7745c5c3_Err != nil {
27613058 return templ_7745c5c3_Err
27623059 }
27633060 }
27643061 return nil
27653062 })
2766- templ_7745c5c3_Err = Shell(l, compareRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var117), templ_7745c5c3_Buffer)
3063+ templ_7745c5c3_Err = Shell(l, compareRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var130), templ_7745c5c3_Buffer)
27673064 if templ_7745c5c3_Err != nil {
27683065 return templ_7745c5c3_Err
27693066 }
@@ -2787,173 +3084,173 @@ func compareRail(p ComparePage) templ.Component {
27873084 }()
27883085 }
27893086 ctx = templ.InitializeContext(ctx)
2790- templ_7745c5c3_Var137 := templ.GetChildren(ctx)
2791- if templ_7745c5c3_Var137 == nil {
2792- templ_7745c5c3_Var137 = templ.NopComponent
3087+ templ_7745c5c3_Var150 := templ.GetChildren(ctx)
3088+ if templ_7745c5c3_Var150 == nil {
3089+ templ_7745c5c3_Var150 = templ.NopComponent
27933090 }
27943091 ctx = templ.ClearChildren(ctx)
2795- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 251, "<section aria-labelledby=\"r-ahead\"><h2 id=\"r-ahead\">Commits <span class=\"count\">")
3092+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 284, "<section aria-labelledby=\"r-ahead\"><h2 id=\"r-ahead\">Commits <span class=\"count\">")
27963093 if templ_7745c5c3_Err != nil {
27973094 return templ_7745c5c3_Err
27983095 }
2799- var templ_7745c5c3_Var138 string
2800- templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Ahead)))
3096+ var templ_7745c5c3_Var151 string
3097+ templ_7745c5c3_Var151, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Ahead)))
28013098 if templ_7745c5c3_Err != nil {
2802- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 529, Col: 67}
3099+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 589, Col: 67}
28033100 }
2804- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138))
3101+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var151))
28053102 if templ_7745c5c3_Err != nil {
28063103 return templ_7745c5c3_Err
28073104 }
2808- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 252, "</span></h2><ul class=\"pcommits\">")
3105+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 285, "</span></h2><ul class=\"pcommits\">")
28093106 if templ_7745c5c3_Err != nil {
28103107 return templ_7745c5c3_Err
28113108 }
28123109 for _, c := range p.Ahead {
2813- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 253, "<li><a href=\"")
3110+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 286, "<li><a href=\"")
28143111 if templ_7745c5c3_Err != nil {
28153112 return templ_7745c5c3_Err
28163113 }
2817- var templ_7745c5c3_Var139 templ.SafeURL
2818- templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
3114+ var templ_7745c5c3_Var152 templ.SafeURL
3115+ templ_7745c5c3_Var152, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
28193116 if templ_7745c5c3_Err != nil {
2820- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 533, Col: 37}
3117+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 593, Col: 37}
28213118 }
2822- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139))
3119+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var152))
28233120 if templ_7745c5c3_Err != nil {
28243121 return templ_7745c5c3_Err
28253122 }
2826- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 254, "\">")
3123+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 287, "\">")
28273124 if templ_7745c5c3_Err != nil {
28283125 return templ_7745c5c3_Err
28293126 }
2830- var templ_7745c5c3_Var140 string
2831- templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
3127+ var templ_7745c5c3_Var153 string
3128+ templ_7745c5c3_Var153, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
28323129 if templ_7745c5c3_Err != nil {
2833- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 533, Col: 49}
3130+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 593, Col: 49}
28343131 }
2835- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140))
3132+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var153))
28363133 if templ_7745c5c3_Err != nil {
28373134 return templ_7745c5c3_Err
28383135 }
2839- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 255, "</a> <span class=\"s\">")
3136+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 288, "</a> <span class=\"s\">")
28403137 if templ_7745c5c3_Err != nil {
28413138 return templ_7745c5c3_Err
28423139 }
2843- var templ_7745c5c3_Var141 string
2844- templ_7745c5c3_Var141, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
3140+ var templ_7745c5c3_Var154 string
3141+ templ_7745c5c3_Var154, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
28453142 if templ_7745c5c3_Err != nil {
2846- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 534, Col: 32}
3143+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 594, Col: 32}
28473144 }
2848- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var141))
3145+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var154))
28493146 if templ_7745c5c3_Err != nil {
28503147 return templ_7745c5c3_Err
28513148 }
2852- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 256, "</span> <span class=\"mute\">")
3149+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 289, "</span> <span class=\"mute\">")
28533150 if templ_7745c5c3_Err != nil {
28543151 return templ_7745c5c3_Err
28553152 }
2856- var templ_7745c5c3_Var142 string
2857- templ_7745c5c3_Var142, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
3153+ var templ_7745c5c3_Var155 string
3154+ templ_7745c5c3_Var155, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
28583155 if templ_7745c5c3_Err != nil {
2859- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 536, Col: 16}
3156+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 596, Col: 16}
28603157 }
2861- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var142))
3158+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var155))
28623159 if templ_7745c5c3_Err != nil {
28633160 return templ_7745c5c3_Err
28643161 }
2865- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 257, " ")
3162+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 290, " ")
28663163 if templ_7745c5c3_Err != nil {
28673164 return templ_7745c5c3_Err
28683165 }
28693166 if c.WhenAt != "" {
2870- var templ_7745c5c3_Var143 string
2871- templ_7745c5c3_Var143, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
3167+ var templ_7745c5c3_Var156 string
3168+ templ_7745c5c3_Var156, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
28723169 if templ_7745c5c3_Err != nil {
2873- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 538, Col: 15}
3170+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 598, Col: 15}
28743171 }
2875- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var143))
3172+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var156))
28763173 if templ_7745c5c3_Err != nil {
28773174 return templ_7745c5c3_Err
28783175 }
2879- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 258, "<time datetime=\"")
3176+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 291, "<time datetime=\"")
28803177 if templ_7745c5c3_Err != nil {
28813178 return templ_7745c5c3_Err
28823179 }
2883- var templ_7745c5c3_Var144 string
2884- templ_7745c5c3_Var144, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
3180+ var templ_7745c5c3_Var157 string
3181+ templ_7745c5c3_Var157, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
28853182 if templ_7745c5c3_Err != nil {
2886- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 538, Col: 42}
3183+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 598, Col: 42}
28873184 }
2888- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var144)
3185+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var157)
28893186 if templ_7745c5c3_Err != nil {
28903187 return templ_7745c5c3_Err
28913188 }
2892- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 259, "\">")
3189+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 292, "\">")
28933190 if templ_7745c5c3_Err != nil {
28943191 return templ_7745c5c3_Err
28953192 }
2896- var templ_7745c5c3_Var145 string
2897- templ_7745c5c3_Var145, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
3193+ var templ_7745c5c3_Var158 string
3194+ templ_7745c5c3_Var158, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
28983195 if templ_7745c5c3_Err != nil {
2899- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 538, Col: 53}
3196+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 598, Col: 53}
29003197 }
2901- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var145))
3198+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var158))
29023199 if templ_7745c5c3_Err != nil {
29033200 return templ_7745c5c3_Err
29043201 }
2905- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 260, "</time>")
3202+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 293, "</time>")
29063203 if templ_7745c5c3_Err != nil {
29073204 return templ_7745c5c3_Err
29083205 }
29093206 }
2910- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 261, "</span></li>")
3207+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 294, "</span></li>")
29113208 if templ_7745c5c3_Err != nil {
29123209 return templ_7745c5c3_Err
29133210 }
29143211 }
2915- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 262, "</ul>")
3212+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 295, "</ul>")
29163213 if templ_7745c5c3_Err != nil {
29173214 return templ_7745c5c3_Err
29183215 }
29193216 if len(p.Ahead) == 0 {
2920- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 263, "<p class=\"dim\">no commit ahead of ")
3217+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 296, "<p class=\"dim\">no commit ahead of ")
29213218 if templ_7745c5c3_Err != nil {
29223219 return templ_7745c5c3_Err
29233220 }
2924- var templ_7745c5c3_Var146 string
2925- templ_7745c5c3_Var146, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
3221+ var templ_7745c5c3_Var159 string
3222+ templ_7745c5c3_Var159, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
29263223 if templ_7745c5c3_Err != nil {
2927- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 545, Col: 47}
3224+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 605, Col: 47}
29283225 }
2929- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var146))
3226+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var159))
29303227 if templ_7745c5c3_Err != nil {
29313228 return templ_7745c5c3_Err
29323229 }
2933- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 264, ".</p>")
3230+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 297, ".</p>")
29343231 if templ_7745c5c3_Err != nil {
29353232 return templ_7745c5c3_Err
29363233 }
29373234 }
2938- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 265, "</section>")
3235+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 298, "</section>")
29393236 if templ_7745c5c3_Err != nil {
29403237 return templ_7745c5c3_Err
29413238 }
29423239 if p.HasDiff {
2943- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 266, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
3240+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 299, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
29443241 if templ_7745c5c3_Err != nil {
29453242 return templ_7745c5c3_Err
29463243 }
2947- var templ_7745c5c3_Var147 string
2948- templ_7745c5c3_Var147, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
3244+ var templ_7745c5c3_Var160 string
3245+ templ_7745c5c3_Var160, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
29493246 if templ_7745c5c3_Err != nil {
2950- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 550, Col: 66}
3247+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 610, Col: 66}
29513248 }
2952- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var147))
3249+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var160))
29533250 if templ_7745c5c3_Err != nil {
29543251 return templ_7745c5c3_Err
29553252 }
2956- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 267, "</span></h2>")
3253+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 300, "</span></h2>")
29573254 if templ_7745c5c3_Err != nil {
29583255 return templ_7745c5c3_Err
29593256 }
@@ -2961,12 +3258,12 @@ func compareRail(p ComparePage) templ.Component {
29613258 if templ_7745c5c3_Err != nil {
29623259 return templ_7745c5c3_Err
29633260 }
2964- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 268, "</section>")
3261+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 301, "</section>")
29653262 if templ_7745c5c3_Err != nil {
29663263 return templ_7745c5c3_Err
29673264 }
29683265 }
2969- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 269, "<section aria-labelledby=\"r-cli\"><h2 id=\"r-cli\">From a terminal</h2>")
3266+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 302, "<section aria-labelledby=\"r-cli\"><h2 id=\"r-cli\">From a terminal</h2>")
29703267 if templ_7745c5c3_Err != nil {
29713268 return templ_7745c5c3_Err
29723269 }
@@ -2974,7 +3271,7 @@ func compareRail(p ComparePage) templ.Component {
29743271 if templ_7745c5c3_Err != nil {
29753272 return templ_7745c5c3_Err
29763273 }
2977- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 270, "</section>")
3274+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 303, "</section>")
29783275 if templ_7745c5c3_Err != nil {
29793276 return templ_7745c5c3_Err
29803277 }
M internal/adapters/web/static/css/55-diff.css +4 -0
@@ -6,3 +6,7 @@
66 }
77 .eol { color: var(--ink-mute); padding-left: 1ch; user-select: none; }
88 }
9+
10+@layer components {
11+ .sel { outline: 1px solid var(--ink-dim); outline-offset: -1px; }
12+}
M internal/adapters/web/static/css/60-mr.css +8 -3
@@ -11,7 +11,6 @@
1111 .mr-desc.prose { max-width: none; padding: var(--s4) var(--s5); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); }
1212 .mr-desc.prose > * { max-width: var(--measure); }
1313 .mr-desc.prose > pre, .mr-desc.prose > table { max-width: 100%; }
14- .c-text a { text-decoration: underline; }
1514
1615 .since {
1716 display: inline-flex; flex-wrap: wrap; gap: var(--s1); padding: var(--s1);
@@ -147,12 +146,11 @@
147146 & p { white-space: normal; }
148147 & .c-who { font-weight: bold; }
149148 & .c-state { color: var(--ink-dim); }
150- & .c-text { white-space: pre-wrap; overflow-wrap: anywhere; }
151149 & .c-where { font-size: var(--fs-s); overflow-wrap: anywhere; }
152150 & .c-act { margin-top: var(--s2); }
153151 & .c-act button { height: 26px; font-size: var(--fs-xs); }
154152 &[data-resolved="true"] { border-left-color: var(--border-strong); }
155- &[data-resolved="true"] .c-text { color: var(--ink-dim); }
153+ &[data-resolved="true"] .c-body { color: var(--ink-dim); }
156154 }
157155 .dt .fnote td { background: var(--bg-canvas); padding: var(--s2) var(--s3); white-space: normal; }
158156 .dt .fnote .cmt, .dt .fnote .cform { margin: 0; }
@@ -190,3 +188,10 @@
190188 .list.mrs .c-who { display: none; }
191189 }
192190 }
191+
192+@layer components {
193+ .c-body { white-space: normal; overflow-wrap: anywhere; }
194+ .c-body.prose > * { margin-block: .5em; }
195+ .c-body .ref { font-variant-numeric: tabular-nums; text-decoration: underline; }
196+ .cmt > .cmt { margin-inline-start: 2ch; }
197+}
M internal/adapters/web/static/js/mr.js +51 -2
@@ -18,6 +18,25 @@
1818 window.ermiteMR = (() => {
1919 // core: the decisions, without the DOM.
2020 const core = {
21+ rangeURL(previous, current, base) {
22+ const a = new URL(previous, base), b = new URL(current, base);
23+ const parse = (u) => /^(new|old):(\d+):(.+)$/.exec(u.searchParams.get('on') || '');
24+ const x = parse(a), y = parse(b);
25+ if (!x || !y || a.pathname !== b.pathname || x[1] !== y[1] || x[3] !== y[3] || x[2] === y[2]) return null;
26+ const nums = [Number(x[2]), Number(y[2])].sort((a,b) => a-b);
27+ b.searchParams.set('on', `${y[1]}:${nums[0]}-${nums[1]}:${y[3]}`);
28+ return b.href;
29+ },
30+ quote(button) {
31+ const thread = button.closest('.cmt').closest('[data-thread]');
32+ const details = thread?.querySelector(':scope > details[data-reply]');
33+ const field = details?.querySelector('textarea');
34+ if (!field) return false;
35+ details.open = true;
36+ field.value = button.dataset.quote;
37+ field.focus();
38+ return true;
39+ },
2140 // windowOf is the start of the window of file i (files are f1, f2, ...).
2241 windowOf(id, size) {
2342 const n = Number(String(id).replace(/^f/, '')) - 1;
@@ -107,16 +126,34 @@ window.ermiteMR = (() => {
107126 function reveal(id, scroll = true) {
108127 // Any navigation, loaded or not, makes the reveals still loading stale.
109128 const gen = ++revealGen;
129+ const target = () => {
130+ // A page with both layouts says which shows in data-diff; a large diff
131+ // rendered in one layout only says it in data-view.
132+ const diffs = $('#diffs')?.dataset;
133+ if (/^c\d+$/.test(id) && (diffs?.diff || diffs?.view) === 'split') {
134+ const split = document.getElementById(id + '-s');
135+ if (split) return split;
136+ }
137+ return document.getElementById(id);
138+ };
110139 let ph = document.getElementById(id)?.querySelector(':scope > .dlazy');
111140 if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {
112141 const base = id.replace(/-s$/, '');
113142 ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));
114143 }
115- if (!ph) return false;
144+ if (!ph) {
145+ const el = target();
146+ if (el && el.id !== id) {
147+ const d = el.closest('details');
148+ if (d) d.open = true;
149+ if (scroll) el.scrollIntoView({ block: 'start' });
150+ }
151+ return false;
152+ }
116153 load(ph).then(() => {
117154 // A later reveal (another file asked meanwhile) owns the view.
118155 if (gen !== revealGen) return;
119- const el = document.getElementById(id);
156+ const el = target();
120157 if (!el) return;
121158 const d = el.closest('details');
122159 if (d) d.open = true;
@@ -262,6 +299,18 @@ window.ermiteMR = (() => {
262299 };
263300 addEventListener('hashchange', hash);
264301 hash();
302+ let lastLine = null;
303+ try { lastLine = sessionStorage.getItem('mr-last-line'); } catch { /* storage optional */ }
304+ addEventListener('click', (e) => {
305+ const quote = e.target.closest?.('[data-quote]');
306+ if (quote) { core.quote(quote); return; }
307+ const link = e.target.closest?.('a[href*="on="]');
308+ if (!link || e.ctrlKey || e.metaKey || e.altKey) return;
309+ const next = e.shiftKey && lastLine && core.rangeURL(lastLine, link.href, location.href);
310+ lastLine = link.href;
311+ try { sessionStorage.setItem('mr-last-line', lastLine); } catch { /* storage optional */ }
312+ if (next) { e.preventDefault(); location.assign(next); }
313+ });
265314 // A click outside an open menu closes it.
266315 addEventListener('click', (e) => {
267316 for (const d of $$('details.menu[open]')) if (!d.contains(e.target)) d.open = false;
M internal/adapters/web/web.go +2 -0
@@ -190,6 +190,8 @@ func (s *Server) routes() *http.ServeMux {
190190 mux.HandleFunc("POST /{repo}/merge-requests", s.mrAction(s.mrCreate))
191191 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments", s.mrAction(s.mrComment))
192192 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/resolve", s.mrAction(s.mrResolve))
193+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/edit", s.mrAction(s.mrEditComment))
194+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/delete", s.mrAction(s.mrDeleteComment))
193195 mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))
194196 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
195197 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
M internal/app/cli_mr.go +226 -25
@@ -141,12 +141,28 @@ type jsonComment struct {
141141 Text string `json:"text"`
142142 Resolved bool `json:"resolved"`
143143 CreatedAt *string `json:"created_at"`
144+ ReplyTo string `json:"reply_to"`
145+ StartLine int32 `json:"start_line"`
146+ Verdict string `json:"verdict"`
147+ EditedAt *string `json:"edited_at"`
148+ Deleted bool `json:"deleted"`
149+}
150+
151+func verdictName(v ermite.Verdict) string {
152+ switch v {
153+ case ermite.Verdict_VERDICT_APPROVE:
154+ return "approve"
155+ case ermite.Verdict_VERDICT_CHANGES_REQUESTED:
156+ return "changes_requested"
157+ }
158+ return ""
144159 }
145160
146161 func toJSONComment(c *ermite.Comment) jsonComment {
147162 return jsonComment{
148163 ID: c.GetId(), Author: c.GetAuthor(), Path: c.GetPath(), Line: c.GetLine(), Side: sideName(c.GetSide()),
149164 CommitID: c.GetCommitId(), Text: c.GetText(), Resolved: c.GetResolved(), CreatedAt: jsonTime(c.GetCreatedAt()),
165+ ReplyTo: c.GetReplyTo(), StartLine: c.GetStartLine(), Verdict: verdictName(c.GetVerdict()), EditedAt: jsonTime(c.GetEditedAt()), Deleted: c.GetDeleted(),
150166 }
151167 }
152168
@@ -160,7 +176,8 @@ unless merged).`
160176 const jsonCommentHelp = `
161177 JSON fields of a comment: id, author, path, line and side ("old" or "new"; empty path, line 0
162178 and empty side for a general comment), commit_id (the head when it was written), text,
163-resolved, created_at.`
179+resolved, created_at, reply_to (empty for roots), start_line (0 for a single line),
180+verdict ("", "approve", "changes_requested"), edited_at (nullable RFC 3339), deleted.`
164181
165182 func newMRCmd(env *cliEnv) *cobra.Command {
166183 mr := &cobra.Command{
@@ -172,7 +189,7 @@ func newMRCmd(env *cliEnv) *cobra.Command {
172189 }
173190 mr.AddCommand(
174191 newMRCreateCmd(env), newMRListCmd(env), newMRShowCmd(env), newMRCommentCmd(env),
175- newMRResolveCmd(env), newMRMergeCmd(env), newMRCloseCmd(env),
192+ newMRResolveCmd(env), newMREditCommentCmd(env), newMRDeleteCommentCmd(env), newMRMergeCmd(env), newMRCloseCmd(env),
176193 )
177194 return mr
178195 }
@@ -385,15 +402,25 @@ type jsonCommit struct {
385402 // jsonMRDetail is the stable JSON shape of `mr show --json`.
386403 type jsonMRDetail struct {
387404 jsonMergeRequest
388- Commits []jsonCommit `json:"commits"`
389- CommitsTruncated bool `json:"commits_truncated"`
390- DiffBaseID string `json:"diff_base_id"`
391- Stats jsonStats `json:"stats"`
392- DiffTruncated bool `json:"diff_truncated"`
393- Files []jsonFile `json:"files"`
394- Comments []jsonComment `json:"comments"`
395- LastReadID string `json:"last_read_id"`
396- LastReadLost bool `json:"last_read_lost"`
405+ Commits []jsonCommit `json:"commits"`
406+ CommitsTruncated bool `json:"commits_truncated"`
407+ DiffBaseID string `json:"diff_base_id"`
408+ Stats jsonStats `json:"stats"`
409+ DiffTruncated bool `json:"diff_truncated"`
410+ Files []jsonFile `json:"files"`
411+ Comments []jsonComment `json:"comments"`
412+ Verdicts []jsonAuthorVerdict `json:"verdicts"`
413+ LastReadID string `json:"last_read_id"`
414+ LastReadLost bool `json:"last_read_lost"`
415+}
416+
417+type jsonAuthorVerdict struct {
418+ Author string `json:"author"`
419+ Verdict string `json:"verdict"`
420+ CommentID string `json:"comment_id"`
421+ CommitID string `json:"commit_id"`
422+ CreatedAt *string `json:"created_at"`
423+ Stale bool `json:"stale"`
397424 }
398425
399426 func fileStatusName(s ermite.FileStatus) string {
@@ -413,7 +440,7 @@ func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool) jsonMRDetail
413440 FilesChanged: d.GetStats().GetFilesChanged(), Additions: d.GetStats().GetAdditions(), Deletions: d.GetStats().GetDeletions(),
414441 },
415442 DiffTruncated: d.GetDiff().GetTruncated(),
416- Files: []jsonFile{}, Comments: []jsonComment{},
443+ Files: []jsonFile{}, Comments: []jsonComment{}, Verdicts: []jsonAuthorVerdict{},
417444 LastReadID: d.GetLastReadId(), LastReadLost: d.GetLastReadLost(),
418445 }
419446 for _, c := range d.GetCommits() {
@@ -443,6 +470,9 @@ func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool) jsonMRDetail
443470 for _, c := range d.GetComments() {
444471 out.Comments = append(out.Comments, toJSONComment(c))
445472 }
473+ for _, v := range d.GetVerdicts() {
474+ out.Verdicts = append(out.Verdicts, jsonAuthorVerdict{Author: v.GetAuthor(), Verdict: verdictName(v.GetVerdict()), CommentID: v.GetCommentId(), CommitID: v.GetCommitId(), CreatedAt: jsonTime(v.GetCreatedAt()), Stale: v.GetStale()})
475+ }
446476 return out
447477 }
448478
@@ -461,7 +491,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
461491 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +
462492 "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" +
463493 "with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" +
464- "and last_read_lost.",
494+ "and last_read_lost; verdicts (author, verdict, comment_id, commit_id, created_at, stale).",
465495 Args: cobra.ExactArgs(2),
466496 ValidArgsFunction: completeMRRepoThenNumber(env),
467497 }
@@ -529,6 +559,25 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
529559 if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED {
530560 pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"})
531561 }
562+ if len(d.GetVerdicts()) > 0 {
563+ var verdicts []string
564+ for _, v := range d.GetVerdicts() {
565+ var label string
566+ switch v.GetVerdict() {
567+ case ermite.Verdict_VERDICT_APPROVE:
568+ label = "approved"
569+ case ermite.Verdict_VERDICT_CHANGES_REQUESTED:
570+ label = "changes requested"
571+ default:
572+ label = fmt.Sprintf("verdict %d", v.GetVerdict())
573+ }
574+ if v.GetStale() {
575+ label += " (stale)"
576+ }
577+ verdicts = append(verdicts, v.GetAuthor()+" "+label)
578+ }
579+ pairs = append(pairs, [2]string{"verdicts", strings.Join(verdicts, ", ")})
580+ }
532581 if err := fields(w, pairs); err != nil {
533582 return err
534583 }
@@ -561,16 +610,46 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
561610 }
562611
563612 _, _ = fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments()))
564- for _, c := range d.GetComments() {
613+ var ordered []*ermite.Comment
614+ for _, root := range d.GetComments() {
615+ if root.GetReplyTo() != "" {
616+ continue
617+ }
618+ ordered = append(ordered, root)
619+ for _, reply := range d.GetComments() {
620+ if reply.GetReplyTo() == root.GetId() {
621+ ordered = append(ordered, reply)
622+ }
623+ }
624+ }
625+ for _, c := range ordered {
565626 where := "general"
566627 if c.GetPath() != "" {
567628 where = fmt.Sprintf("%s:%d (%s)", clean(c.GetPath()), c.GetLine(), sideName(c.GetSide()))
568629 }
630+ if c.GetStartLine() > 0 {
631+ where = fmt.Sprintf("%s:lines %d-%d (%s)", clean(c.GetPath()), c.GetStartLine(), c.GetLine(), sideName(c.GetSide()))
632+ }
569633 mark := ""
634+ if c.GetEditedAt() != nil {
635+ mark += " (edited)"
636+ }
637+ if c.GetDeleted() {
638+ mark += " [deleted]"
639+ }
640+ if c.GetVerdict() != ermite.Verdict_VERDICT_UNSPECIFIED {
641+ mark += " [" + verdictName(c.GetVerdict()) + "]"
642+ }
570643 if c.GetResolved() {
571- mark = " [resolved]"
644+ mark += " [resolved]"
572645 }
573- _, _ = fmt.Fprintf(w, " #%s %s, %s, %s%s\n%s\n", c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), where, mark, indentText(c.GetText()))
646+ prefix := " "
647+ body := indentText(c.GetText())
648+ if c.GetReplyTo() != "" {
649+ prefix += " "
650+ body = " " + strings.ReplaceAll(body, "\n", "\n ")
651+ }
652+ _, _ = fmt.Fprintf(w, "%s#%s %s, %s, %s%s\n%s\n", prefix, c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), where, mark, body)
574653 }
575654 if withDiff {
576655 printDiff(w, d.GetDiff())
@@ -643,12 +722,14 @@ func printDiff(w io.Writer, d *ermite.Diff) {
643722 }
644723
645724 func newMRCommentCmd(env *cliEnv) *cobra.Command {
646- var message, path, side string
647- var line int
725+ var message, path, side, line, reply string
726+ var approve, requestChanges bool
648727 cmd := &cobra.Command{
649728 Use: "comment <repo> <number>",
650729 Short: "Comment on a merge request",
651- Long: "Add a plain-text comment (at most 8000 characters, no control characters except line breaks).\n" +
730+ Long: "Add a Markdown note (at most 8000 characters); ^N refers to a note in this merge request.\n" +
731+ "--reply replies to a root note. --line accepts N or A-B. --approve and --request-changes\n" +
732+ "set a verdict (needs mr:review) and allow an empty message.\n" +
652733 "-m - reads it from standard input. Without --path it is a general comment. With --path, --line and\n" +
653734 "--side (default new) it is attached to a line of the current diff: the path as in `mr show --diff`\n" +
654735 "(the new path, the old one for a deleted file), the line number on the old or new side; a line\n" +
@@ -656,11 +737,18 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command {
656737 Args: cobra.ExactArgs(2),
657738 ValidArgsFunction: completeMRRepoThenNumber(env),
658739 }
659- cmd.Flags().StringVarP(&message, "message", "m", "", "text of the comment, or - to read standard input (required)")
740+ cmd.Flags().StringVarP(&message, "message", "m", "", "Markdown text, or - to read standard input (required unless setting a verdict)")
660741 cmd.Flags().StringVar(&path, "path", "", "file the comment is attached to")
661- cmd.Flags().IntVar(&line, "line", 0, "1-based line number on --side")
742+ cmd.Flags().StringVar(&line, "line", "", "1-based line N or range A-B on --side")
743+ cmd.Flags().StringVar(&reply, "reply", "", "reply to root note (^12, #12 or 12)")
744+ cmd.Flags().BoolVar(&approve, "approve", false, "approve (needs mr:review)")
745+ cmd.Flags().BoolVar(&requestChanges, "request-changes", false, "request changes (needs mr:review)")
662746 cmd.Flags().StringVar(&side, "side", sideNew, "side the line counts on: old or new")
663- _ = cmd.MarkFlagRequired("message")
747+ cmd.MarkFlagsMutuallyExclusive("approve", "request-changes")
748+ for _, flag := range []string{"path", "line", "side", "approve", "request-changes"} {
749+ cmd.MarkFlagsMutuallyExclusive("reply", flag)
750+ }
751+ // A verdict note may omit its message.
664752 _ = cmd.RegisterFlagCompletionFunc("side", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
665753 return []string{sideOld, sideNew}, cobra.ShellCompDirectiveNoFileComp
666754 })
@@ -674,17 +762,36 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command {
674762 if err != nil {
675763 return err
676764 }
765+ if !cmd.Flags().Changed("message") && !approve && !requestChanges {
766+ return errors.New("--message is required unless --approve or --request-changes is set")
767+ }
677768 req := &ermite.AddCommentRequest{Repo: args[0], Number: n, Text: text}
769+ if approve {
770+ req.Verdict = ermite.Verdict_VERDICT_APPROVE
771+ }
772+ if requestChanges {
773+ req.Verdict = ermite.Verdict_VERDICT_CHANGES_REQUESTED
774+ }
775+ if cmd.Flags().Changed("reply") {
776+ req.ReplyTo, err = parseNoteID(reply)
777+ if err != nil {
778+ return err
779+ }
780+ }
678781 switch {
679782 case path != "":
680- if line < 1 {
783+ if line == "" {
681784 return errors.New("--line is required with --path: give the line number")
682785 }
786+ req.StartLine, req.Line, err = parseLineRange(line)
787+ if err != nil {
788+ return err
789+ }
683790 if req.Side, err = parseSide(side); err != nil {
684791 return err
685792 }
686- req.Path, req.Line = path, int32(line) //nolint:gosec // a line number typed by a person
687- case line != 0 || cmd.Flags().Changed("side"):
793+ req.Path = path
794+ case cmd.Flags().Changed("line") || cmd.Flags().Changed("side"):
688795 return errors.New("--line and --side need --path")
689796 }
690797 c, err := env.client(cmd)
@@ -704,6 +811,100 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command {
704811 return cmd
705812 }
706813
814+func parseNoteID(s string) (string, error) {
815+ id := strings.TrimLeft(strings.TrimSpace(s), "^#")
816+ n, err := strconv.ParseInt(id, 10, 64)
817+ if err != nil || n < 1 {
818+ return "", fmt.Errorf("invalid note id %q: use ^12, #12 or 12", s)
819+ }
820+ return strconv.FormatInt(n, 10), nil
821+}
822+
823+func parseLineRange(s string) (start, end int32, err error) {
824+ parts := strings.Split(s, "-")
825+ invalid := fmt.Errorf("invalid --line %q: use N or A-B with 1 <= A < B", s)
826+ if len(parts) < 1 || len(parts) > 2 {
827+ return 0, 0, invalid
828+ }
829+ parse := func(v string) (int32, error) {
830+ n, e := strconv.ParseInt(v, 10, 32)
831+ if e != nil || n < 1 {
832+ return 0, invalid
833+ }
834+ return int32(n), nil
835+ }
836+ end, err = parse(parts[len(parts)-1])
837+ if err != nil {
838+ return 0, 0, err
839+ }
840+ if len(parts) == 2 {
841+ start, err = parse(parts[0])
842+ if err != nil || start >= end {
843+ return 0, 0, invalid
844+ }
845+ }
846+ return start, end, nil
847+}
848+
849+func newMREditCommentCmd(env *cliEnv) *cobra.Command { return newMRNoteMutationCmd(env, true) }
850+func newMRDeleteCommentCmd(env *cliEnv) *cobra.Command { return newMRNoteMutationCmd(env, false) }
851+
852+func newMRNoteMutationCmd(env *cliEnv, edit bool) *cobra.Command {
853+ verb := "delete"
854+ if edit {
855+ verb = "edit"
856+ }
857+ var message string
858+ cmd := &cobra.Command{Use: verb + "-comment <repo> <number> <comment-id>", Short: verb + " your own merge request note", Long: "Edit or delete your own note on an open merge request. Needs mr:write. Verdicts are unchanged." + jsonCommentHelp, Args: cobra.ExactArgs(3), ValidArgsFunction: completeMRRepoThenNumber(env)}
859+ if edit {
860+ cmd.Flags().StringVarP(&message, "message", "m", "", "Markdown text, or - to read standard input (required)")
861+ _ = cmd.MarkFlagRequired("message")
862+ }
863+ asJSON := addJSONFlag(cmd)
864+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
865+ n, err := parseNumber(args[1])
866+ if err != nil {
867+ return err
868+ }
869+ id, err := parseNoteID(args[2])
870+ if err != nil {
871+ return err
872+ }
873+ text, err := readText(cmd, message)
874+ if err != nil {
875+ return err
876+ }
877+ c, err := env.client(cmd)
878+ if err != nil {
879+ return err
880+ }
881+ var note *ermite.Comment
882+ if edit {
883+ res, e := c.MergeRequests().EditComment(env.ctx(cmd), connect.NewRequest(&ermite.EditCommentRequest{Repo: args[0], Number: n, CommentId: id, Text: text}))
884+ if e != nil {
885+ return c.Describe(e)
886+ }
887+ note = res.Msg.GetComment()
888+ } else {
889+ res, e := c.MergeRequests().DeleteComment(env.ctx(cmd), connect.NewRequest(&ermite.DeleteCommentRequest{Repo: args[0], Number: n, CommentId: id}))
890+ if e != nil {
891+ return c.Describe(e)
892+ }
893+ note = res.Msg.GetComment()
894+ }
895+ if *asJSON {
896+ return writeJSON(cmd.OutOrStdout(), toJSONComment(note))
897+ }
898+ past := "deleted"
899+ if edit {
900+ past = "edited"
901+ }
902+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "comment %s of %s!%d %s\n", clean(id), clean(args[0]), n, past)
903+ return err
904+ }
905+ return cmd
906+}
907+
707908 func newMRResolveCmd(env *cliEnv) *cobra.Command {
708909 var undo bool
709910 cmd := &cobra.Command{
M internal/app/cli_mr_e2e_test.go +72 -0
@@ -420,3 +420,75 @@ func (w *watchProc) nextOfType(t *testing.T, typ string) eventOut {
420420 t.Fatalf("no %s event", typ)
421421 return eventOut{}
422422 }
423+
424+func TestMergeRequestNotesCLIEndToEnd(t *testing.T) {
425+ c := newCLIHarness(t)
426+ c.cli(t, adminToken, "repo", "create", "notes", "--visibility", "public").ok(t)
427+ wt := c.workTree("main", "README.md")
428+ c.mustGit(wt, "checkout", "-q", "-b", "feat/notes")
429+ c.writeFile(wt, "notes.txt", []byte("one\ntwo\nthree\nfour\n"))
430+ c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/notes.git"), "main", "feat/notes")
431+ c.cli(t, adminToken, "mr", "create", "notes", "--source", "feat/notes", "--title", "Notes").ok(t)
432+ w := c.startWatch(t, adminToken)
433+ res := c.cli(t, adminToken, "mr", "comment", "notes", "1", "--path", "notes.txt", "--line", "1-3", "-m", "**range**", "--json").ok(t)
434+ root := decode[jsonComment](t, res.stdout)
435+ assert.EqualValues(t, 1, root.StartLine)
436+ assert.EqualValues(t, 3, root.Line)
437+ w.nextOfType(t, "comment_added")
438+ res = c.cli(t, adminToken, "mr", "comment", "notes", "1", "--reply", "^"+root.ID, "-m", "reply to ^"+root.ID, "--json").ok(t)
439+ reply := decode[jsonComment](t, res.stdout)
440+ assert.Equal(t, root.ID, reply.ReplyTo)
441+ assert.Equal(t, root.Path, reply.Path)
442+ assert.Equal(t, root.StartLine, reply.StartLine)
443+ assert.Equal(t, root.Line, reply.Line)
444+ w.nextOfType(t, "comment_added")
445+ require.Error(t, c.cli(t, adminToken, "mr", "comment", "notes", "1", "--reply", reply.ID, "-m", "nested").err)
446+ require.Error(t, c.cli(t, adminToken, "mr", "resolve", "notes", "1", reply.ID).err)
447+ writer := c.token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite})
448+ denied := c.cli(t, writer, "mr", "comment", "notes", "1", "--approve")
449+ require.Error(t, denied.err)
450+ assert.Contains(t, denied.err.Error(), "mr:review")
451+ res = c.cli(t, adminToken, "mr", "comment", "notes", "1", "--approve", "--json").ok(t)
452+ verdict := decode[jsonComment](t, res.stdout)
453+ assert.Equal(t, "approve", verdict.Verdict)
454+ assert.True(t, verdict.Resolved)
455+ assert.Empty(t, verdict.Text)
456+ w.nextOfType(t, "comment_added")
457+ require.Error(t, c.cli(t, writer, "mr", "edit-comment", "notes", "1", verdict.ID, "-m", "not mine").err)
458+ res = runCLI(t, c.vars(adminToken), "edited from stdin\n", "mr", "edit-comment", "notes", "1", "#"+reply.ID, "-m", "-", "--json").ok(t)
459+ edited := decode[jsonComment](t, res.stdout)
460+ assert.Equal(t, "edited from stdin", edited.Text)
461+ require.NotNil(t, edited.EditedAt)
462+ w.nextOfType(t, "comment_edited")
463+ res = c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", verdict.ID, "--json").ok(t)
464+ deleted := decode[jsonComment](t, res.stdout)
465+ assert.True(t, deleted.Deleted)
466+ assert.Equal(t, "approve", deleted.Verdict)
467+ assert.Empty(t, deleted.Text)
468+ require.NotNil(t, deleted.EditedAt)
469+ w.nextOfType(t, "comment_deleted")
470+ c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", verdict.ID).ok(t)
471+ c.cli(t, adminToken, "mr", "resolve", "notes", "1", root.ID).ok(t)
472+ w.nextOfType(t, "comment_resolved")
473+ res = c.cli(t, "", "mr", "show", "notes", "1", "--json").ok(t)
474+ detail := decode[jsonMRDetail](t, res.stdout)
475+ require.Len(t, detail.Comments, 3)
476+ require.Len(t, detail.Verdicts, 1)
477+ assert.Equal(t, verdict.ID, detail.Verdicts[0].CommentID)
478+ assert.Equal(t, "approve", detail.Verdicts[0].Verdict)
479+ assert.False(t, detail.Verdicts[0].Stale)
480+ res = c.cli(t, "", "mr", "show", "notes", "1").ok(t)
481+ assert.Contains(t, res.stdout, "lines 1-3")
482+ assert.Contains(t, res.stdout, "(edited)")
483+ assert.Contains(t, res.stdout, "[deleted]")
484+ assert.Contains(t, res.stdout, "approved")
485+ assert.Contains(t, res.stdout, " #"+reply.ID)
486+ c.cli(t, adminToken, "mr", "comment", "notes", "1", "--request-changes", "-m", "needs work").ok(t)
487+ res = c.cli(t, "", "mr", "show", "notes", "1", "--json").ok(t)
488+ detail = decode[jsonMRDetail](t, res.stdout)
489+ require.Len(t, detail.Verdicts, 1)
490+ assert.Equal(t, "changes_requested", detail.Verdicts[0].Verdict)
491+ c.cli(t, adminToken, "mr", "close", "notes", "1").ok(t)
492+ require.Error(t, c.cli(t, adminToken, "mr", "edit-comment", "notes", "1", root.ID, "-m", "late").err)
493+ require.Error(t, c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", root.ID).err)
494+}
M internal/app/cli_mr_test.go +89 -0
@@ -381,3 +381,92 @@ func TestSigningKeyShowOutput(t *testing.T) {
381381 res = runCLI(t, envFor(url), "", "signing-key", "show", "--json").ok(t)
382382 assert.JSONEq(t, `{"public_key":"ssh-ed25519 AAAAC3Nz","fingerprint":"SHA256:abc","committer_name":"ermite","committer_email":"ermite@git.example.org","allowed_signers":"ermite@git.example.org ssh-ed25519 AAAAC3Nz"}`, res.stdout)
383383 }
384+
385+func TestNoteRangesAndIDs(t *testing.T) {
386+ for _, tc := range []struct {
387+ input string
388+ start, end int32
389+ }{{"1", 0, 1}, {"10-24", 10, 24}, {"2147483647", 0, 2147483647}} {
390+ start, end, err := parseLineRange(tc.input)
391+ require.NoError(t, err)
392+ assert.Equal(t, tc.start, start)
393+ assert.Equal(t, tc.end, end)
394+ }
395+ for _, input := range []string{"", "0", "-1", "1-1", "24-10", "0-2", "1-2-3", "2147483648", "a", "1-"} {
396+ _, _, err := parseLineRange(input)
397+ require.Error(t, err, input)
398+ }
399+ for _, input := range []string{"12", "^12", "#12"} {
400+ got, err := parseNoteID(input)
401+ require.NoError(t, err)
402+ assert.Equal(t, "12", got)
403+ }
404+}
405+
406+func TestNoteFlags(t *testing.T) {
407+ f := &fakeAPI{addComment: func(r *ermite.AddCommentRequest) (*ermite.AddCommentResponse, error) {
408+ return &ermite.AddCommentResponse{Comment: &ermite.Comment{Id: "1", ReplyTo: r.GetReplyTo(), StartLine: r.GetStartLine(), Line: r.GetLine(), Verdict: r.GetVerdict()}}, nil
409+ }}
410+ url := f.serve(t)
411+ for _, tc := range []struct {
412+ flags []string
413+ reply string
414+ start int32
415+ verdict string
416+ }{
417+ {[]string{"--reply", "^12", "-m", "reply"}, "12", 0, ""},
418+ {[]string{"--path", "x", "--line", "10-24", "-m", "range"}, "", 10, ""},
419+ {[]string{"--approve"}, "", 0, "approve"},
420+ {[]string{"--request-changes"}, "", 0, "changes_requested"},
421+ } {
422+ res := runCLI(t, envFor(url), "", append([]string{"mr", "comment", "alpha", "1", "--json"}, tc.flags...)...).ok(t)
423+ got := decode[jsonComment](t, res.stdout)
424+ assert.Equal(t, tc.reply, got.ReplyTo)
425+ assert.Equal(t, tc.start, got.StartLine)
426+ assert.Equal(t, tc.verdict, got.Verdict)
427+ }
428+ for _, flags := range [][]string{{"--approve", "--request-changes"}, {"--reply", "1", "--path", "x"}, {"--reply", "1", "--line", "2"}, {"--reply", "1", "--side", "new"}, {"--reply", "1", "--approve"}, {"--reply", "1", "--request-changes"}} {
429+ require.Error(t, runCLI(t, envFor(url), "", append([]string{"mr", "comment", "alpha", "1", "-m", "x"}, flags...)...).err)
430+ }
431+}
432+
433+func TestNoteDetailOutput(t *testing.T) {
434+ d := &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Comments: []*ermite.Comment{
435+ {Id: "1", Path: "x", StartLine: 10, Line: 24, Text: "root"},
436+ {Id: "3", Text: "other", Deleted: true},
437+ {Id: "2", ReplyTo: "1", Text: "reply", EditedAt: ts("2026-10-10T12:00:00Z")},
438+ }, Verdicts: []*ermite.AuthorVerdict{
439+ {Author: "owner", Verdict: ermite.Verdict_VERDICT_APPROVE, CommentId: "1", Stale: true},
440+ {Author: "bot", Verdict: ermite.Verdict_VERDICT_CHANGES_REQUESTED, CommentId: "3"},
441+ }}
442+ var out strings.Builder
443+ require.NoError(t, printMRDetail(&out, d, false, false))
444+ text := out.String()
445+ assert.Contains(t, text, "owner approved (stale), bot changes requested")
446+ assert.Contains(t, text, "lines 10-24")
447+ assert.Contains(t, text, "(edited)")
448+ assert.Contains(t, text, "[deleted]")
449+ assert.Less(t, strings.Index(text, " #2"), strings.Index(text, " #3"))
450+ got := toJSONDetail(d, false)
451+ require.Len(t, got.Verdicts, 2)
452+ assert.True(t, got.Verdicts[0].Stale)
453+ assert.Equal(t, "changes_requested", got.Verdicts[1].Verdict)
454+ assert.Equal(t, "1", got.Comments[2].ReplyTo)
455+ assert.NotNil(t, got.Comments[2].EditedAt)
456+}
457+
458+func TestUnknownVerdictDetailOutput(t *testing.T) {
459+ var out strings.Builder
460+ require.NoError(t, printMRDetail(&out, &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Verdicts: []*ermite.AuthorVerdict{{Author: "future", Verdict: ermite.Verdict(99)}}}, false, false))
461+ assert.Contains(t, out.String(), "future verdict 99")
462+ assert.NotContains(t, out.String(), "changes requested")
463+ assert.NotContains(t, out.String(), "approved")
464+}
465+
466+func TestMRReviewScopeCompletion(t *testing.T) {
467+ cmd := newTokenCreateCmd(&cliEnv{})
468+ complete, ok := cmd.GetFlagCompletionFunc("scope")
469+ require.True(t, ok)
470+ values, _ := complete(cmd, nil, "")
471+ assert.Contains(t, values, "mr:review")
472+}
M internal/app/cli_token.go +1 -1
@@ -86,7 +86,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
8686 "or store it right away); details go to standard error. With --json, standard output is one\n" +
8787 "object holding the token fields and the secret.\n\n" +
8888 "What the token may do is chosen with --preset and/or --scope (they add up); there is no default:\n" +
89- " cli every scope, for your own daily use\n" +
89+ " cli every scope, including mr:review for verdicts, for your own daily use\n" +
9090 " agent repo:write, mr:write\n" +
9191 " runner runner:register (a CI runner host)\n" +
9292 " ci repo:read\n" +
M internal/app/cli_watch.go +21 -8
@@ -45,10 +45,13 @@ const (
4545 eventPushed = "pushed"
4646 eventGap = "gap"
4747
48- eventMROpened = "merge_request_opened"
49- eventMRMerged = "merge_request_merged"
50- eventMRClosed = "merge_request_closed"
51- eventCommentAdded = "comment_added"
48+ eventMROpened = "merge_request_opened"
49+ eventMRMerged = "merge_request_merged"
50+ eventMRClosed = "merge_request_closed"
51+ eventCommentAdded = "comment_added"
52+ eventCommentEdited = "comment_edited"
53+ eventCommentDeleted = "comment_deleted"
54+ eventCommentResolved = "comment_resolved"
5255 )
5356
5457 // toJSONEvent maps an event; ok is false for kinds this client does not know,
@@ -82,6 +85,15 @@ func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) {
8285 case *ermite.Event_CommentAdded:
8386 c := toJSONComment(k.CommentAdded.GetComment())
8487 out.Type, out.Repo, out.Number, out.Comment = eventCommentAdded, k.CommentAdded.GetRepo(), k.CommentAdded.GetNumber(), &c
88+ case *ermite.Event_CommentEdited:
89+ c := toJSONComment(k.CommentEdited.GetComment())
90+ out.Type, out.Repo, out.Number, out.Comment = eventCommentEdited, k.CommentEdited.GetRepo(), k.CommentEdited.GetNumber(), &c
91+ case *ermite.Event_CommentDeleted:
92+ c := toJSONComment(k.CommentDeleted.GetComment())
93+ out.Type, out.Repo, out.Number, out.Comment = eventCommentDeleted, k.CommentDeleted.GetRepo(), k.CommentDeleted.GetNumber(), &c
94+ case *ermite.Event_CommentResolved:
95+ c := toJSONComment(k.CommentResolved.GetComment())
96+ out.Type, out.Repo, out.Number, out.Comment = eventCommentResolved, k.CommentResolved.GetRepo(), k.CommentResolved.GetNumber(), &c
8597 case *ermite.Event_Gap:
8698 out.Type = eventGap
8799 default:
@@ -112,7 +124,7 @@ func eventLine(e jsonEvent) string {
112124 detail = e.Repo + " " + strings.Join(parts, ", ")
113125 case eventMROpened, eventMRMerged, eventMRClosed:
114126 detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch)
115- case eventCommentAdded:
127+ case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved:
116128 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)
117129 case eventGap:
118130 detail = "events were lost, reload what you display"
@@ -142,17 +154,18 @@ func newWatchCmd(env *cliEnv) *cobra.Command {
142154 Use: "watch [repo]",
143155 Short: "Print repository events as they happen",
144156 Long: "Print one line per event (repository created, updated or deleted, refs pushed, merge requests\n" +
145- "opened, merged or closed, comments added) for every\n" +
157+ "opened, merged or closed, notes added, edited, deleted or resolved) for every\n" +
146158 "repository you can see, or only for [repo]. It runs until interrupted. When the connection drops it\n" +
147159 "reconnects with a growing delay and resumes after the last event it printed; if the server no longer\n" +
148160 "has that event it prints a gap event: reload what you display. Connection notices go to standard\n" +
149161 "error.\n\n" +
150162 "--json prints one JSON object per line (NDJSON). Fields: id, time (RFC 3339 UTC), type\n" +
151163 "(repo_created, repo_updated, repo_deleted, pushed, merge_request_opened, merge_request_merged,\n" +
152- "merge_request_closed, comment_added or gap) and, depending on the type, repo (name),\n" +
164+ "merge_request_closed, comment_added, comment_edited, comment_deleted, comment_resolved or gap)\n" +
165+ "and, depending on the type, repo (name),\n" +
153166 "repository (the repository fields of `repo show --json`), ref_updates (array of ref, old_id,\n" +
154167 "new_id; an empty id means the ref was created or deleted), merge_request (the fields of\n" +
155- "`mr show --json`) and, for comment_added, number (of the merge request) and comment. Unknown\n" +
168+ "`mr show --json`) and, for comment events, number (of the merge request) and comment. Unknown\n" +
156169 "event types are skipped.",
157170 Args: cobra.MaximumNArgs(1),
158171 ValidArgsFunction: completeRepoNames(env),
M internal/app/cli_watch_test.go +24 -0
@@ -199,3 +199,27 @@ func TestWatchMergeRequestEvents(t *testing.T) {
199199 assert.Equal(t, "merge_request_merged", decode[eventOut](t, lines[2]).Type)
200200 assert.Equal(t, "merge_request_closed", decode[eventOut](t, lines[3]).Type)
201201 }
202+
203+func TestNoteWatchEvents(t *testing.T) {
204+ note := &ermite.Comment{Id: "4", ReplyTo: "2", StartLine: 10, Line: 24, Verdict: ermite.Verdict_VERDICT_APPROVE, EditedAt: ts("2026-10-10T12:00:00Z"), Deleted: true, Author: "owner"}
205+ events := []*ermite.Event{
206+ {Kind: &ermite.Event_CommentEdited{CommentEdited: &ermite.CommentEdited{Repo: "alpha", Number: 1, Comment: note}}},
207+ {Kind: &ermite.Event_CommentDeleted{CommentDeleted: &ermite.CommentDeleted{Repo: "alpha", Number: 1, Comment: note}}},
208+ {Kind: &ermite.Event_CommentResolved{CommentResolved: &ermite.CommentResolved{Repo: "alpha", Number: 1, Comment: note}}},
209+ }
210+ for i, ev := range events {
211+ got, ok := toJSONEvent(ev)
212+ require.True(t, ok)
213+ assert.Equal(t, []string{"comment_edited", "comment_deleted", "comment_resolved"}[i], got.Type)
214+ assert.Equal(t, "alpha", got.Repo)
215+ assert.EqualValues(t, 1, got.Number)
216+ require.NotNil(t, got.Comment)
217+ assert.True(t, got.Comment.Deleted)
218+ assert.Equal(t, "2", got.Comment.ReplyTo)
219+ assert.Equal(t, "approve", got.Comment.Verdict)
220+ assert.Contains(t, eventLine(got), got.Type+" alpha!1 by owner")
221+ raw, err := json.Marshal(got)
222+ require.NoError(t, err)
223+ assert.Contains(t, string(raw), `"edited_at":"2026-10-10T12:00:00Z"`)
224+ }
225+}
M internal/app/web_mr_test.go +6 -3
@@ -284,7 +284,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
284284 require.GreaterOrEqual(t, i, 0, "a note row under the line")
285285 note := anon.body[i:]
286286 assert.Less(t, strings.Index(note, "Why &lt;i&gt;empty&lt;/i&gt;?"), strings.Index(note, "</tr>"), "the note sits in the row under its line")
287- assert.Contains(t, anon.body, `<a href="https://example.org/a" rel="noopener nofollow ugc">https://example.org/a</a>.`)
287+ assert.Contains(t, anon.body, `<a href="https://example.org/a" rel="nofollow noopener noreferrer">https://example.org/a</a>.`)
288288 assert.NotContains(t, anon.body, "<i>empty</i>")
289289 assert.Contains(t, anon.body, "Looks fine overall.")
290290 assert.Equal(t, 2, len(reNoteID.FindAllString(anon.body, -1)))
@@ -338,7 +338,8 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
338338 assert.Contains(t, since.body, "changes since "+head1[:7])
339339 assert.Contains(t, since.body, "extra</td>")
340340 assert.NotContains(t, since.body, `<span class="kn">package</span> search</td>`, "only what changed after the read head")
341- assert.Contains(t, since.body, "Why &lt;i&gt;empty&lt;/i&gt;?", "the notes are listed below, their lines are not in this diff")
341+ assert.Contains(t, since.body, "empty", "the notes are listed below, their lines are not in this diff")
342+ assert.NotContains(t, since.body, "<i>", "raw HTML is dropped")
342343 assert.Contains(t, since.body, "search.go:3 (new)")
343344 assert.NotContains(t, since.body, `<form class="cform"`, "no comment form on this diff")
344345
@@ -554,7 +555,9 @@ func TestWebMergeRequestEscapesText(t *testing.T) {
554555 assert.NotContains(t, body, "<img src=x")
555556 assert.NotContains(t, body, "<script>x</script>")
556557 assert.Contains(t, html.UnescapeString(body), `"><script>alert(1)</script>`)
557- assert.Contains(t, body, "&lt;script&gt;x&lt;/script&gt; &amp; more")
558+ // A note is safe Markdown: a line that opens with an HTML tag is an HTML
559+ // block, dropped whole.
560+ assert.Contains(t, body, `class="c-body prose"><!-- raw HTML omitted -->`)
558561 assertNoInline(t, body)
559562 assert.NotContains(t, body, `onmouseover="alert`, "an address stops at a quote")
560563 }
M internal/domain/event.go +39 -0
@@ -173,6 +173,45 @@ type CommentAdded struct {
173173 // EventKind implements Event.
174174 func (CommentAdded) EventKind() string { return "mr.comment_added" }
175175
176+// CommentEdited is published when the text of a note was edited.
177+type CommentEdited struct {
178+ Repo RepoName
179+ Number int64
180+ Comment Comment
181+ At time.Time
182+ Visibility Visibility
183+ CreatedAt time.Time
184+}
185+
186+// EventKind implements Event.
187+func (CommentEdited) EventKind() string { return "mr.comment_edited" }
188+
189+// CommentDeleted is published when the text of a note was deleted.
190+type CommentDeleted struct {
191+ Repo RepoName
192+ Number int64
193+ Comment Comment
194+ At time.Time
195+ Visibility Visibility
196+ CreatedAt time.Time
197+}
198+
199+// EventKind implements Event.
200+func (CommentDeleted) EventKind() string { return "mr.comment_deleted" }
201+
202+// CommentResolved is published when a thread was resolved or reopened.
203+type CommentResolved struct {
204+ Repo RepoName
205+ Number int64
206+ Comment Comment
207+ At time.Time
208+ Visibility Visibility
209+ CreatedAt time.Time
210+}
211+
212+// EventKind implements Event.
213+func (CommentResolved) EventKind() string { return "mr.comment_resolved" }
214+
176215 // EventGap tells a subscriber that it missed events because it was too slow;
177216 // it must refresh its whole view.
178217 type EventGap struct{}
M internal/domain/mergerequest.go +80 -0
@@ -186,6 +186,11 @@ func (m MergeRequest) IsOpen() bool { return m.State == MRStateOpen }
186186
187187 // Comment is a note on a merge request, general or on a line of its diff.
188188 type Comment struct {
189+ ReplyTo int64
190+ StartLine int
191+ Verdict Verdict
192+ EditedAt time.Time
193+ Deleted bool
189194 // ID is unique within the merge request, in order of creation.
190195 ID int64
191196 Author string
@@ -348,3 +353,78 @@ func singleLine(s string) string {
348353 return r
349354 }, s)
350355 }
356+
357+// Verdict is an immutable review decision on a root note.
358+type Verdict uint8
359+
360+// Review verdicts.
361+const (
362+ VerdictNone Verdict = iota
363+ VerdictApprove
364+ VerdictChangesRequested
365+)
366+
367+// String returns the wire name of a verdict.
368+func (v Verdict) String() string {
369+ switch v {
370+ case VerdictApprove:
371+ return "approve"
372+ case VerdictChangesRequested:
373+ return "changes_requested"
374+ }
375+ return ""
376+}
377+
378+// ParseVerdict parses a review decision.
379+func ParseVerdict(s string) (Verdict, error) {
380+ switch s {
381+ case "":
382+ return VerdictNone, nil
383+ case "approve":
384+ return VerdictApprove, nil
385+ case "changes_requested":
386+ return VerdictChangesRequested, nil
387+ }
388+ return VerdictNone, fmt.Errorf("%w: verdict %q", ErrInvalid, s)
389+}
390+
391+// IsReply reports whether this note replies to a root.
392+func (c Comment) IsReply() bool { return c.ReplyTo != 0 }
393+
394+// IsRange reports whether this note anchors a range.
395+func (c Comment) IsRange() bool { return c.StartLine != 0 }
396+
397+// AuthorVerdict is an author's latest review decision.
398+type AuthorVerdict struct {
399+ Author string
400+ Verdict Verdict
401+ CommentID int64
402+ CommitID ObjectID
403+ At time.Time
404+ Stale bool
405+}
406+
407+// CurrentVerdicts returns the latest verdict per author, including deleted notes,
408+// in note ID order. Only approvals become stale when the head changes.
409+func CurrentVerdicts(comments []Comment, head ObjectID) []AuthorVerdict {
410+ latest := make(map[string]Comment)
411+ for _, c := range comments {
412+ if c.Verdict != VerdictNone && c.ID > latest[c.Author].ID {
413+ latest[c.Author] = c
414+ }
415+ }
416+ out := make([]AuthorVerdict, 0, len(latest))
417+ for _, c := range latest {
418+ out = append(out, AuthorVerdict{Author: c.Author, Verdict: c.Verdict, CommentID: c.ID, CommitID: c.CommitID, At: c.CreatedAt, Stale: c.Verdict == VerdictApprove && (head == (ObjectID{}) || c.CommitID != head)})
419+ }
420+ slices.SortFunc(out, func(a, b AuthorVerdict) int {
421+ if a.CommentID < b.CommentID {
422+ return -1
423+ }
424+ if a.CommentID > b.CommentID {
425+ return 1
426+ }
427+ return 0
428+ })
429+ return out
430+}
M internal/domain/mergerequest_test.go +48 -0
@@ -235,3 +235,51 @@ func TestMergeRequestDraft(t *testing.T) {
235235 assert.Equal(t, tc.ready, ready.String(), tc.title)
236236 }
237237 }
238+
239+func TestNoteVerdicts(t *testing.T) {
240+ head, old := oid(t, 'a'), oid(t, 'b')
241+ for _, tc := range []struct {
242+ text string
243+ verdict domain.Verdict
244+ }{{"", domain.VerdictNone}, {"approve", domain.VerdictApprove}, {"changes_requested", domain.VerdictChangesRequested}} {
245+ v, err := domain.ParseVerdict(tc.text)
246+ require.NoError(t, err)
247+ assert.Equal(t, tc.verdict, v)
248+ assert.Equal(t, tc.text, v.String())
249+ }
250+ _, err := domain.ParseVerdict("unknown")
251+ require.ErrorIs(t, err, domain.ErrInvalid)
252+ comments := []domain.Comment{{ID: 3, Author: "a", Verdict: domain.VerdictApprove, CommitID: old, Deleted: true}, {ID: 1, Author: "a", Verdict: domain.VerdictChangesRequested}, {ID: 2, Author: "b", Verdict: domain.VerdictChangesRequested, CommitID: old}, {ID: 4, Author: "a"}}
253+ for _, h := range []domain.ObjectID{head, {}} {
254+ got := domain.CurrentVerdicts(comments, h)
255+ require.Len(t, got, 2)
256+ assert.Equal(t, int64(2), got[0].CommentID)
257+ assert.False(t, got[0].Stale)
258+ assert.Equal(t, int64(3), got[1].CommentID)
259+ assert.True(t, got[1].Stale)
260+ }
261+ comments[0].CommitID = head
262+ assert.False(t, domain.CurrentVerdicts(comments, head)[1].Stale)
263+ assert.True(t, (domain.Comment{ReplyTo: 1}).IsReply())
264+ assert.True(t, (domain.Comment{StartLine: 1}).IsRange())
265+}
266+
267+func TestReviewScopeOwnerRights(t *testing.T) {
268+ var old []domain.Scope
269+ for _, s := range domain.AllScopes() {
270+ if s != domain.ScopeMRReview {
271+ old = append(old, s)
272+ }
273+ }
274+ assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: old}).HasOwnerRights())
275+ assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes()}).HasOwnerRights())
276+ s, err := domain.ParseScope("mr:review")
277+ require.NoError(t, err)
278+ assert.Equal(t, domain.ScopeMRReview, s)
279+ for _, tc := range []struct {
280+ p domain.Principal
281+ want string
282+ }{{domain.Root(), "root"}, {domain.Owner(), "owner"}, {domain.Anonymous(), "owner"}, {domain.TokenPrincipal(domain.Token{Label: "bot"}), "bot"}} {
283+ assert.Equal(t, tc.want, domain.AuthorName(tc.p))
284+ }
285+}
M internal/domain/principal.go +15 -0
@@ -230,3 +230,18 @@ func (p Principal) ArchiveGrant() (repo RepoName, sha string, ok bool) {
230230 }
231231 return p.archiveRepo, p.archiveSha, true
232232 }
233+
234+// AuthorName names who acts, for the author of a merge request or a comment: the
235+// label of the token, "root" for the root token, "owner" for the owner signed
236+// in on the web and for any other caller without a label.
237+func AuthorName(p Principal) string {
238+ switch {
239+ case p.IsOwner():
240+ return "owner"
241+ case p.IsRoot():
242+ return "root"
243+ case p.TokenLabel() != "":
244+ return p.TokenLabel()
245+ }
246+ return "owner"
247+}
M internal/domain/token.go +4 -2
@@ -30,6 +30,8 @@ const (
3030 ScopeMRWrite Scope = "mr:write"
3131 // ScopeMRMerge merges, and deletes the merged source branch on request.
3232 ScopeMRMerge Scope = "mr:merge"
33+ // ScopeMRReview sets a verdict on a merge request.
34+ ScopeMRReview Scope = "mr:review"
3335 // ScopeCIWrite re-runs or cancels CI runs.
3436 ScopeCIWrite Scope = "ci:write"
3537 // ScopeSecretsWrite sets and deletes secrets and variables.
@@ -40,11 +42,11 @@ const (
4042 ScopeTokenAdmin Scope = "token:admin"
4143 )
4244
43-// AllScopes returns the nine scopes in a stable order.
45+// AllScopes returns the ten scopes in a stable order.
4446 func AllScopes() []Scope {
4547 return []Scope{
4648 ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,
47- ScopeMRWrite, ScopeMRMerge,
49+ ScopeMRWrite, ScopeMRMerge, ScopeMRReview,
4850 ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,
4951 ScopeTokenAdmin,
5052 }
M internal/domain/token_test.go +1 -1
@@ -16,7 +16,7 @@ import (
1616 func TestScopes(t *testing.T) {
1717 t.Parallel()
1818 all := domain.AllScopes()
19- assert.Len(t, all, 9)
19+ assert.Len(t, all, 10)
2020 for _, s := range all {
2121 got, err := domain.ParseScope(s.String())
2222 require.NoError(t, err)
M internal/ports/dto.go +8 -0
@@ -212,6 +212,8 @@ type MergeRequestDetail struct {
212212 Diff domain.Diff
213213 // Comments are the comments, oldest first.
214214 Comments []domain.Comment
215+ // Verdicts are the latest verdict per author in note ID order.
216+ Verdicts []domain.AuthorVerdict
215217 // LastReadID is the head recorded by MarkRead; zero when none.
216218 LastReadID domain.ObjectID
217219 // LastReadLost tells that since-last-read was asked for, a head was
@@ -241,6 +243,12 @@ type FileWindow struct{ From, To int }
241243
242244 // AddCommentParams describes the comment to add.
243245 type AddCommentParams struct {
246+ // ReplyTo names a non-deleted root; replies cannot specify an anchor or verdict.
247+ ReplyTo int64
248+ // StartLine starts an inclusive range, requiring a path and 1 <= StartLine < Line.
249+ StartLine int
250+ // Verdict requires mr:review and is only permitted on roots.
251+ Verdict domain.Verdict
244252 // Path, Line and Side designate a line of the diff; all empty for a
245253 // general comment.
246254 Path string
M internal/ports/mocks/MergeRequestService.go +174 -0
@@ -360,6 +360,180 @@ func (_c *MergeRequestService_Create_Call) RunAndReturn(run func(ctx context.Con
360360 return _c
361361 }
362362
363+// DeleteComment provides a mock function for the type MergeRequestService
364+func (_mock *MergeRequestService) DeleteComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64) (domain.Comment, error) {
365+ ret := _mock.Called(ctx, p, name, number, commentID)
366+
367+ if len(ret) == 0 {
368+ panic("no return value specified for DeleteComment")
369+ }
370+
371+ var r0 domain.Comment
372+ var r1 error
373+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int64) (domain.Comment, error)); ok {
374+ return returnFunc(ctx, p, name, number, commentID)
375+ }
376+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int64) domain.Comment); ok {
377+ r0 = returnFunc(ctx, p, name, number, commentID)
378+ } else {
379+ r0 = ret.Get(0).(domain.Comment)
380+ }
381+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, int64) error); ok {
382+ r1 = returnFunc(ctx, p, name, number, commentID)
383+ } else {
384+ r1 = ret.Error(1)
385+ }
386+ return r0, r1
387+}
388+
389+// MergeRequestService_DeleteComment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteComment'
390+type MergeRequestService_DeleteComment_Call struct {
391+ *mock.Call
392+}
393+
394+// DeleteComment is a helper method to define mock.On call
395+// - ctx context.Context
396+// - p domain.Principal
397+// - name domain.RepoName
398+// - number int64
399+// - commentID int64
400+func (_e *MergeRequestService_Expecter) DeleteComment(ctx any, p any, name any, number any, commentID any) *MergeRequestService_DeleteComment_Call {
401+ return &MergeRequestService_DeleteComment_Call{Call: _e.mock.On("DeleteComment", ctx, p, name, number, commentID)}
402+}
403+
404+func (_c *MergeRequestService_DeleteComment_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64)) *MergeRequestService_DeleteComment_Call {
405+ _c.Call.Run(func(args mock.Arguments) {
406+ var arg0 context.Context
407+ if args[0] != nil {
408+ arg0 = args[0].(context.Context)
409+ }
410+ var arg1 domain.Principal
411+ if args[1] != nil {
412+ arg1 = args[1].(domain.Principal)
413+ }
414+ var arg2 domain.RepoName
415+ if args[2] != nil {
416+ arg2 = args[2].(domain.RepoName)
417+ }
418+ var arg3 int64
419+ if args[3] != nil {
420+ arg3 = args[3].(int64)
421+ }
422+ var arg4 int64
423+ if args[4] != nil {
424+ arg4 = args[4].(int64)
425+ }
426+ run(
427+ arg0,
428+ arg1,
429+ arg2,
430+ arg3,
431+ arg4,
432+ )
433+ })
434+ return _c
435+}
436+
437+func (_c *MergeRequestService_DeleteComment_Call) Return(comment domain.Comment, err error) *MergeRequestService_DeleteComment_Call {
438+ _c.Call.Return(comment, err)
439+ return _c
440+}
441+
442+func (_c *MergeRequestService_DeleteComment_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64) (domain.Comment, error)) *MergeRequestService_DeleteComment_Call {
443+ _c.Call.Return(run)
444+ return _c
445+}
446+
447+// EditComment provides a mock function for the type MergeRequestService
448+func (_mock *MergeRequestService) EditComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, text string) (domain.Comment, error) {
449+ ret := _mock.Called(ctx, p, name, number, commentID, text)
450+
451+ if len(ret) == 0 {
452+ panic("no return value specified for EditComment")
453+ }
454+
455+ var r0 domain.Comment
456+ var r1 error
457+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int64, string) (domain.Comment, error)); ok {
458+ return returnFunc(ctx, p, name, number, commentID, text)
459+ }
460+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int64, string) domain.Comment); ok {
461+ r0 = returnFunc(ctx, p, name, number, commentID, text)
462+ } else {
463+ r0 = ret.Get(0).(domain.Comment)
464+ }
465+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, int64, string) error); ok {
466+ r1 = returnFunc(ctx, p, name, number, commentID, text)
467+ } else {
468+ r1 = ret.Error(1)
469+ }
470+ return r0, r1
471+}
472+
473+// MergeRequestService_EditComment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EditComment'
474+type MergeRequestService_EditComment_Call struct {
475+ *mock.Call
476+}
477+
478+// EditComment is a helper method to define mock.On call
479+// - ctx context.Context
480+// - p domain.Principal
481+// - name domain.RepoName
482+// - number int64
483+// - commentID int64
484+// - text string
485+func (_e *MergeRequestService_Expecter) EditComment(ctx any, p any, name any, number any, commentID any, text any) *MergeRequestService_EditComment_Call {
486+ return &MergeRequestService_EditComment_Call{Call: _e.mock.On("EditComment", ctx, p, name, number, commentID, text)}
487+}
488+
489+func (_c *MergeRequestService_EditComment_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, text string)) *MergeRequestService_EditComment_Call {
490+ _c.Call.Run(func(args mock.Arguments) {
491+ var arg0 context.Context
492+ if args[0] != nil {
493+ arg0 = args[0].(context.Context)
494+ }
495+ var arg1 domain.Principal
496+ if args[1] != nil {
497+ arg1 = args[1].(domain.Principal)
498+ }
499+ var arg2 domain.RepoName
500+ if args[2] != nil {
501+ arg2 = args[2].(domain.RepoName)
502+ }
503+ var arg3 int64
504+ if args[3] != nil {
505+ arg3 = args[3].(int64)
506+ }
507+ var arg4 int64
508+ if args[4] != nil {
509+ arg4 = args[4].(int64)
510+ }
511+ var arg5 string
512+ if args[5] != nil {
513+ arg5 = args[5].(string)
514+ }
515+ run(
516+ arg0,
517+ arg1,
518+ arg2,
519+ arg3,
520+ arg4,
521+ arg5,
522+ )
523+ })
524+ return _c
525+}
526+
527+func (_c *MergeRequestService_EditComment_Call) Return(comment domain.Comment, err error) *MergeRequestService_EditComment_Call {
528+ _c.Call.Return(comment, err)
529+ return _c
530+}
531+
532+func (_c *MergeRequestService_EditComment_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, text string) (domain.Comment, error)) *MergeRequestService_EditComment_Call {
533+ _c.Call.Return(run)
534+ return _c
535+}
536+
363537 // Get provides a mock function for the type MergeRequestService
364538 func (_mock *MergeRequestService) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) {
365539 ret := _mock.Called(ctx, p, name, number, opts)
M internal/ports/services.go +2 -0
@@ -113,6 +113,8 @@ type MergeRequestService interface {
113113 List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error)
114114 Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts GetMergeRequestOptions) (MergeRequestDetail, error)
115115 AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params AddCommentParams) (domain.Comment, error)
116+ EditComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, text string) (domain.Comment, error)
117+ DeleteComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64) (domain.Comment, error)
116118 ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, resolved bool) (domain.Comment, error)
117119 Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)
118120 MarkRead(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, head domain.ObjectID) (domain.ObjectID, error)
M internal/usecase/access.go +0 -15
@@ -91,18 +91,3 @@ func scopeAccess(p domain.Principal, repo domain.Repo, scope domain.Scope) error
9191 }
9292 return fmt.Errorf("%w: this needs the %s scope on %s", domain.ErrForbidden, scope, repo.Name)
9393 }
94-
95-// authorOf names who acts, for the author of a merge request or a comment: the
96-// label of the token, "root" for the root token, "owner" for the owner signed
97-// in on the web and for any other caller without a label.
98-func authorOf(p domain.Principal) string {
99- switch {
100- case p.IsOwner():
101- return "owner"
102- case p.IsRoot():
103- return "root"
104- case p.TokenLabel() != "":
105- return p.TokenLabel()
106- }
107- return "owner"
108-}
M internal/usecase/ci_dispatch.go +1 -1
@@ -135,7 +135,7 @@ func (c *CI) dispatchCaller(ctx context.Context, p domain.Principal, name domain
135135 if err != nil {
136136 return repo, "", pusher, false, err
137137 }
138- return repo, authorOf(p), domain.PusherOf(p), domain.TrustedActor(p), nil
138+ return repo, domain.AuthorName(p), domain.PusherOf(p), domain.TrustedActor(p), nil
139139 }
140140
141141 // dispatchPath checks the workflow file of a dispatch: a file of
M internal/usecase/ci_pull.go +1 -1
@@ -203,7 +203,7 @@ func (c *CI) OnPullRequest(ctx context.Context, repo domain.RepoName, ev ports.P
203203 }
204204 e := pullEvent{
205205 repo: repo, number: ev.Number, action: ev.Action, head: ev.Head, base: ev.Base,
206- actor: authorOf(p), pusher: domain.PusherOf(p), run: !p.ViaJobToken(), log: zerowrap.FromCtx(ctx),
206+ actor: domain.AuthorName(p), pusher: domain.PusherOf(p), run: !p.ViaJobToken(), log: zerowrap.FromCtx(ctx),
207207 actorTrusted: domain.TrustedActor(p), target: c.defaultTipOf(ctx, repo),
208208 }
209209 if e.actorTrusted {
M internal/usecase/ci_runs.go +1 -1
@@ -332,7 +332,7 @@ func (c *CI) CancelRun(ctx context.Context, p domain.Principal, name domain.Repo
332332 }
333333 if !rs.run.CancelRequested {
334334 rs.run.CancelRequested, rs.run.CancelRequestedAt, rs.runDirty = true, c.now(), true
335- rs.cancelAll("Canceled by " + authorOf(p))
335+ rs.cancelAll("Canceled by " + domain.AuthorName(p))
336336 }
337337 if err := rs.flush(ctx); err != nil {
338338 return err
M internal/usecase/ci_tips.go +1 -1
@@ -85,7 +85,7 @@ func (c *CI) scheduleActor(ctx context.Context, repo domain.RepoName, branch str
8585 if t, ok := c.refTip(ctx, repo, "refs/heads/"+branch, tip); ok {
8686 return t.Actor, t.Pusher, t.Trusted
8787 }
88- return authorOf(domain.Owner()), domain.CIPusher{}, false
88+ return domain.AuthorName(domain.Owner()), domain.CIPusher{}, false
8989 }
9090
9191 // refTrusted reports whether the ref was set to exactly the pinned object by a
M internal/usecase/ci_trigger.go +4 -4
@@ -59,13 +59,13 @@ func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.
5959 c.markSchedulesDirty(repo, updates)
6060 // The merge requests follow the push whoever made it (their refs); only a
6161 // push that is not a job's starts pull_request runs.
62- defer c.onBranchesMoved(ctx, repo, updates, authorOf(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher), !pusher.ViaJobToken())
62+ defer c.onBranchesMoved(ctx, repo, updates, domain.AuthorName(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher), !pusher.ViaJobToken())
6363 if pusher.ViaJobToken() {
6464 log := zerowrap.FromCtx(ctx)
6565 log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("push by a job token starts no run")
6666 return
6767 }
68- c.enqueue(ctx, repo, updates, authorOf(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher))
68+ c.enqueue(ctx, repo, updates, domain.AuthorName(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher))
6969 }
7070
7171 // OnMerge queues the move of a target branch by a merge. The actor of the run is
@@ -79,13 +79,13 @@ func (c *CI) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain
7979 // The merge moved a target branch: the test merges of the other requests
8080 // into it are computed again (a merge starts no synchronize run), after the
8181 // push workflows are queued.
82- defer c.onBranchesMoved(ctx, repo, updates, authorOf(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger), false)
82+ defer c.onBranchesMoved(ctx, repo, updates, domain.AuthorName(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger), false)
8383 if merger.ViaJobToken() {
8484 log := zerowrap.FromCtx(ctx)
8585 log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("merge by a job token starts no run")
8686 return
8787 }
88- c.enqueue(ctx, repo, updates, authorOf(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger))
88+ c.enqueue(ctx, repo, updates, domain.AuthorName(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger))
8989 }
9090
9191 func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, actor string, pusher domain.CIPusher, trusted bool) {
M internal/usecase/events.go +12 -0
@@ -178,6 +178,12 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
178178 return repoSnapshot(ev.Visibility, ev.CreatedAt)
179179 case domain.CommentAdded:
180180 return repoSnapshot(ev.Visibility, ev.CreatedAt)
181+ case domain.CommentEdited:
182+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
183+ case domain.CommentDeleted:
184+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
185+ case domain.CommentResolved:
186+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
181187 case domain.RunUpdated:
182188 return repoSnapshot(ev.Visibility, ev.CreatedAt)
183189 case domain.JobUpdated:
@@ -487,6 +493,12 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
487493 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
488494 case domain.CommentAdded:
489495 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
496+ case domain.CommentEdited:
497+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
498+ case domain.CommentDeleted:
499+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
500+ case domain.CommentResolved:
501+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
490502 case domain.RunUpdated:
491503 // Home shows the CI state of each repository.
492504 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)
M internal/usecase/events_test.go +32 -0
@@ -922,3 +922,35 @@ func TestWatchHomeGetsRunUpdates(t *testing.T) {
922922 root.quiet(t, r, pub)
923923 })
924924 }
925+
926+func TestNoteMutationEventVisibility(t *testing.T) {
927+ synctest.Test(t, func(t *testing.T) {
928+ r := newEventRig(t, 0)
929+ pub := r.repo("pub", domain.VisibilityPublic)
930+ priv := r.repo("priv", domain.VisibilityPrivate)
931+ ctx, cancel := context.WithCancel(context.Background())
932+ defer cancel()
933+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{})
934+ owner := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:priv"}})
935+ synctest.Wait()
936+ for _, kind := range []string{"edited", "deleted", "resolved"} {
937+ for _, n := range []domain.RepoName{priv, pub} {
938+ vis := domain.VisibilityPublic
939+ if n == priv {
940+ vis = domain.VisibilityPrivate
941+ }
942+ switch kind {
943+ case "edited":
944+ r.feed <- domain.CommentEdited{Repo: n, Visibility: vis, CreatedAt: now, At: now}
945+ case "deleted":
946+ r.feed <- domain.CommentDeleted{Repo: n, Visibility: vis, CreatedAt: now, At: now}
947+ case "resolved":
948+ r.feed <- domain.CommentResolved{Repo: n, Visibility: vis, CreatedAt: now, At: now}
949+ }
950+ }
951+ assert.Equal(t, "mr.comment_"+kind, anon.next(t).Event.EventKind())
952+ assert.Equal(t, "mr.comment_"+kind, owner.next(t).Event.EventKind())
953+ anon.quiet(t, r, pub)
954+ }
955+ })
956+}
M internal/usecase/export_test.go +0 -3
@@ -15,9 +15,6 @@ import (
1515 "bnema.dev/ermite/internal/ports"
1616 )
1717
18-// AuthorOf exposes authorOf to the tests.
19-func AuthorOf(p domain.Principal) string { return authorOf(p) }
20-
2118 // Sweep exposes the daily sweep to the tests.
2219 func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }
2320
M internal/usecase/mergerequests.go +137 -9
@@ -241,7 +241,7 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom
241241 now := m.clock.Now().UTC()
242242 mr, err := m.store.Create(ctx, domain.MergeRequest{
243243 Repo: name, Title: title, Description: desc, Source: source, Target: target,
244- State: domain.MRStateOpen, Author: authorOf(p), CreatedAt: now, UpdatedAt: now,
244+ State: domain.MRStateOpen, Author: domain.AuthorName(p), CreatedAt: now, UpdatedAt: now,
245245 })
246246 if err != nil {
247247 return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", name, err)
@@ -468,6 +468,7 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
468468 if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil {
469469 return ports.MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err)
470470 }
471+ d.Verdicts = domain.CurrentVerdicts(d.Comments, mr.HeadID)
471472 if d.LastReadID, err = m.store.GetRead(ctx, name, number); err != nil {
472473 return ports.MergeRequestDetail{}, err
473474 }
@@ -547,7 +548,7 @@ func (m *MergeRequests) rangeOf(ctx context.Context, mr domain.MergeRequest) (ex
547548 }
548549
549550 // AddComment adds a comment and publishes CommentAdded. The principal needs
550-// mr:write. The text must follow the rules of ADR 0017 and not be blank; a
551+// mr:write. The text must follow the rules of ADR 0032 and not be blank unless carrying a verdict; a
551552 // comment on a line must name a line of the current diff of the request (see
552553 // domain.Diff.HasLine), else the call fails with domain.ErrInvalid. A request
553554 // that is not open gives domain.ErrPreconditionFailed, and one that already
@@ -566,21 +567,44 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
566567 switch {
567568 case err != nil:
568569 v.AddErr("text", err)
569- case text.IsBlank():
570+ case text.IsBlank() && params.Verdict == domain.VerdictNone:
570571 v.Add("text", "is required")
571572 }
572573 general := params.Path == ""
573574 switch {
574- case general && (params.Line != 0 || params.Side != domain.SideNone):
575+ case general && (params.Line != 0 || params.Side != domain.SideNone || params.StartLine != 0):
575576 v.Add("path", "is required with line and side")
576577 case !general && params.Line < 1:
577578 v.Add("line", "must be 1 or more with a path")
578579 case !general && params.Side != domain.SideOld && params.Side != domain.SideNew:
579580 v.Add("side", "is required with a path")
580581 }
582+ if params.StartLine != 0 && (params.StartLine < 1 || params.StartLine >= params.Line) {
583+ v.Add("start_line", "must be 1 or more and less than line")
584+ }
585+ if params.Verdict > domain.VerdictChangesRequested {
586+ v.Add("verdict", "is unknown")
587+ }
588+ if params.ReplyTo != 0 {
589+ if params.Path != "" || params.Line != 0 || params.StartLine != 0 || params.Side != domain.SideNone || params.Verdict != domain.VerdictNone {
590+ v.Add("reply_to", "a reply cannot specify an anchor or verdict")
591+ }
592+ }
581593 if err := v.Err(); err != nil {
582594 return domain.Comment{}, err
583595 }
596+ if params.ReplyTo != 0 {
597+ root, err := m.replyRoot(ctx, name, number, params.ReplyTo)
598+ if err != nil {
599+ return domain.Comment{}, err
600+ }
601+ params.Path, params.Line, params.StartLine, params.Side = root.Path, root.Line, root.StartLine, root.Side
602+ }
603+ if params.Verdict != domain.VerdictNone {
604+ if err := scopeAccess(p, repo, domain.ScopeMRReview); err != nil {
605+ return domain.Comment{}, err
606+ }
607+ }
584608 if !mr.IsOpen() {
585609 return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
586610 }
@@ -588,7 +612,7 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
588612 if mr, err = m.withHead(ctx, mr); err != nil {
589613 return domain.Comment{}, err
590614 }
591- if !general {
615+ if !general && params.ReplyTo == 0 {
592616 if err := m.checkLine(ctx, mr, params); err != nil {
593617 return domain.Comment{}, err
594618 }
@@ -600,7 +624,8 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
600624 return domain.Comment{}, fmt.Errorf("%w: !%d already has %d comments", domain.ErrResourceExhausted, number, MaxCommentsPerMergeRequest)
601625 }
602626 c = domain.Comment{
603- ID: mr.CommentSeq + 1, Author: authorOf(p), Path: params.Path, Line: params.Line, Side: params.Side,
627+ ID: mr.CommentSeq + 1, Author: domain.AuthorName(p), Path: params.Path, Line: params.Line, Side: params.Side,
628+ ReplyTo: params.ReplyTo, StartLine: params.StartLine, Verdict: params.Verdict, Resolved: params.Verdict == domain.VerdictApprove,
604629 CommitID: mr.HeadID, Text: text, CreatedAt: m.clock.Now().UTC(),
605630 }
606631 mr.CommentSeq, mr.UpdatedAt = c.ID, c.CreatedAt
@@ -616,6 +641,12 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
616641 if !fresh.IsOpen() {
617642 return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, fresh.State)
618643 }
644+ if params.ReplyTo != 0 {
645+ // The root may have been deleted by the write that got in between.
646+ if _, rerr := m.replyRoot(ctx, name, number, params.ReplyTo); rerr != nil {
647+ return domain.Comment{}, rerr
648+ }
649+ }
619650 fresh.HeadID = mr.HeadID
620651 mr = fresh
621652 }
@@ -624,7 +655,7 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
624655 }
625656 log := zerowrap.FromCtx(ctx)
626657 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
627- Bool("on_line", !general).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment added")
658+ Int64("reply_to", c.ReplyTo).Str("verdict", c.Verdict.String()).Bool("on_line", !general).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment added")
628659 m.bus.Publish(ctx, domain.CommentAdded{
629660 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
630661 })
@@ -667,6 +698,9 @@ func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, p
667698 if idx < 0 || !diff.HasLine(params.Path, params.Side, params.Line) {
668699 v.Add("path", fmt.Sprintf("%s:%d on the %s side is not a line of the diff", params.Path, params.Line, params.Side))
669700 }
701+ if params.StartLine != 0 && !diff.HasLine(params.Path, params.Side, params.StartLine) {
702+ v.Add("start_line", "is not a line of the diff")
703+ }
670704 return v.Err()
671705 }
672706
@@ -683,7 +717,8 @@ func diffFileName(f domain.FileDiff) string {
683717 // not open domain.ErrPreconditionFailed. Setting the state the comment already
684718 // has succeeds and writes nothing.
685719 func (m *MergeRequests) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, resolved bool) (domain.Comment, error) {
686- if _, err := m.writable(ctx, p, name, domain.ScopeMRWrite); err != nil {
720+ repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
721+ if err != nil {
687722 return domain.Comment{}, err
688723 }
689724 for range maxWriteRetries {
@@ -698,6 +733,11 @@ func (m *MergeRequests) ResolveComment(ctx context.Context, p domain.Principal,
698733 if !mr.IsOpen() {
699734 return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
700735 }
736+ if c.IsReply() {
737+ var v domain.Violations
738+ v.Add("comment_id", fmt.Sprintf("resolve the thread on its root ^%d", c.ReplyTo))
739+ return domain.Comment{}, v.Err()
740+ }
701741 if c.Resolved == resolved {
702742 return c, nil
703743 }
@@ -710,6 +750,7 @@ func (m *MergeRequests) ResolveComment(ctx context.Context, p domain.Principal,
710750 if err != nil {
711751 return domain.Comment{}, fmt.Errorf("resolve comment %d of !%d in %q: %w", commentID, number, name, err)
712752 }
753+ m.bus.Publish(ctx, domain.CommentResolved{Repo: name, Number: number, Comment: c, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
713754 return c, nil
714755 }
715756 return domain.Comment{}, fmt.Errorf("resolve comment %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)
@@ -1090,7 +1131,7 @@ func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest,
10901131 // does, under the merge lock (domain.CIRefTip). The run shows the owner, as a
10911132 // merge's push does on GitHub.
10921133 if err := recordRefTips(ctx, m.tips, mr.Repo, []domain.RefUpdate{{Old: tip.String(), New: newTip.String(), Ref: "refs/heads/" + mr.Target}},
1093- authorOf(domain.Owner()), domain.PusherOf(p), domain.TrustedActor(p)); err != nil {
1134+ domain.AuthorName(domain.Owner()), domain.PusherOf(p), domain.TrustedActor(p)); err != nil {
10941135 return domain.ObjectID{}, err
10951136 }
10961137 err := m.git.UpdateBranch(ctx, mr.Repo, mr.Target, tip, newTip)
@@ -1129,3 +1170,90 @@ func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, nu
11291170 Msg("the target branch moved but the merge request could not be marked merged")
11301171 return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, domain.ErrConflict)
11311172 }
1173+
1174+// EditComment changes only the author's text, preserving the immutable verdict.
1175+func (m *MergeRequests) EditComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, text string) (domain.Comment, error) {
1176+ return m.changeComment(ctx, p, name, number, commentID, text, false)
1177+}
1178+
1179+// DeleteComment removes text but retains the anchor, ID and verdict. Repeated deletion is a no-op.
1180+func (m *MergeRequests) DeleteComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64) (domain.Comment, error) {
1181+ return m.changeComment(ctx, p, name, number, commentID, "", true)
1182+}
1183+
1184+func (m *MergeRequests) changeComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, text string, remove bool) (domain.Comment, error) {
1185+ repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
1186+ if err != nil {
1187+ return domain.Comment{}, err
1188+ }
1189+ for range maxWriteRetries {
1190+ mr, err := m.store.Get(ctx, name, number)
1191+ if err != nil {
1192+ return domain.Comment{}, err
1193+ }
1194+ c, err := m.store.GetComment(ctx, name, number, commentID)
1195+ if err != nil {
1196+ return domain.Comment{}, err
1197+ }
1198+ if !mr.IsOpen() {
1199+ return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
1200+ }
1201+ if domain.AuthorName(p) != c.Author {
1202+ return domain.Comment{}, fmt.Errorf("%w: only the author of a note can edit or delete it", domain.ErrForbidden)
1203+ }
1204+ if c.Deleted {
1205+ if remove {
1206+ return c, nil
1207+ }
1208+ return domain.Comment{}, fmt.Errorf("%w: the note was deleted", domain.ErrPreconditionFailed)
1209+ }
1210+ parsed, err := domain.ParsePlainText(text)
1211+ var v domain.Violations
1212+ if err != nil {
1213+ v.AddErr("text", err)
1214+ } else if !remove && parsed.IsBlank() && c.Verdict == domain.VerdictNone {
1215+ v.Add("text", "is required")
1216+ }
1217+ if err := v.Err(); err != nil {
1218+ return domain.Comment{}, err
1219+ }
1220+ if !remove && parsed == c.Text {
1221+ return c, nil
1222+ }
1223+ c.Text, c.Deleted, c.EditedAt = parsed, remove, m.clock.Now().UTC()
1224+ mr.UpdatedAt = c.EditedAt
1225+ if err := m.store.SaveComment(ctx, mr, c); errors.Is(err, domain.ErrConflict) {
1226+ continue
1227+ } else if err != nil {
1228+ return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, err)
1229+ }
1230+ log := zerowrap.FromCtx(ctx)
1231+ log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).Bool("deleted", remove).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment changed")
1232+ if remove {
1233+ m.bus.Publish(ctx, domain.CommentDeleted{Repo: name, Number: number, Comment: c, At: c.EditedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1234+ } else {
1235+ m.bus.Publish(ctx, domain.CommentEdited{Repo: name, Number: number, Comment: c, At: c.EditedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1236+ }
1237+ return c, nil
1238+ }
1239+ return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)
1240+}
1241+
1242+// replyRoot returns the note a reply targets, or a violation when it is
1243+// missing, itself a reply, or deleted.
1244+func (m *MergeRequests) replyRoot(ctx context.Context, name domain.RepoName, number, id int64) (domain.Comment, error) {
1245+ root, err := m.store.GetComment(ctx, name, number, id)
1246+ var v domain.Violations
1247+ switch {
1248+ case errors.Is(err, domain.ErrNotFound):
1249+ v.Add("reply_to", fmt.Sprintf("no note ^%d", id))
1250+ case err != nil:
1251+ return domain.Comment{}, err
1252+ case root.IsReply() || root.Deleted:
1253+ v.Add("reply_to", "must name a non-deleted root note")
1254+ }
1255+ if err := v.Err(); err != nil {
1256+ return domain.Comment{}, err
1257+ }
1258+ return root, nil
1259+}
M internal/usecase/mergerequests_integration_test.go +107 -1
@@ -689,7 +689,10 @@ func TestResolveComment(t *testing.T) {
689689 _, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, 0, true)
690690 assert.ErrorIs(t, err, domain.ErrNotFound)
691691
692- assert.Empty(t, r.drain(), "a resolved comment has no event")
692+ events := r.drain()
693+ require.Len(t, events, 2, "only state changes publish resolution events")
694+ assert.True(t, events[0].(domain.CommentResolved).Comment.Resolved)
695+ assert.False(t, events[1].(domain.CommentResolved).Comment.Resolved)
693696 }
694697
695698 func TestEventsOfAMergeRequest(t *testing.T) {
@@ -1342,3 +1345,106 @@ func TestCommentsPerMergeRequestAreBounded(t *testing.T) {
13421345 _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, other.Number, ports.AddCommentParams{Text: "fine"})
13431346 require.NoError(t, err)
13441347 }
1348+
1349+func TestNoteThreadsRangesAndVerdicts(t *testing.T) {
1350+ t.Run("start outside diff", func(t *testing.T) {
1351+ r := newMRRig(t)
1352+ r.seed()
1353+ before := strings.Repeat("unchanged\n", 30)
1354+ r.commit(author, "long.txt", before, "long base")
1355+ r.push("main")
1356+ r.sh("checkout", "-q", "-B", "range", "main")
1357+ r.commit(author, "long.txt", before+"changed\n", "late hunk")
1358+ r.push("range")
1359+ mr := r.open("range")
1360+ _, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Path: "long.txt", Line: 31, Side: domain.SideNew, Text: "valid"})
1361+ require.NoError(t, err)
1362+ _, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Path: "long.txt", StartLine: 1, Line: 31, Side: domain.SideNew, Text: "invalid range"})
1363+ require.ErrorIs(t, err, domain.ErrInvalid)
1364+ assert.Contains(t, fieldsOf(t, err), "start_line")
1365+ })
1366+ r := newMRRig(t)
1367+ r.seed()
1368+ head := r.feature("feat")
1369+ mr := r.open("feat")
1370+ add := func(p domain.Principal, params ports.AddCommentParams) (domain.Comment, error) {
1371+ return r.mrs.AddComment(r.ctx, p, r.repo, mr.Number, params)
1372+ }
1373+ rootNote, err := add(root, ports.AddCommentParams{Path: "a.txt", StartLine: 1, Line: 3, Side: domain.SideNew, Text: "range"})
1374+ require.NoError(t, err)
1375+ reply, err := add(mrW, ports.AddCommentParams{ReplyTo: rootNote.ID, Text: "reply"})
1376+ require.NoError(t, err)
1377+ assert.Equal(t, rootNote.Path, reply.Path)
1378+ assert.Equal(t, rootNote.StartLine, reply.StartLine)
1379+ assert.Equal(t, rootNote.Line, reply.Line)
1380+ assert.Equal(t, rootNote.Side, reply.Side)
1381+ assert.Equal(t, head, reply.CommitID)
1382+ assert.False(t, reply.Resolved)
1383+ for _, params := range []ports.AddCommentParams{
1384+ {ReplyTo: reply.ID, Text: "x"}, {ReplyTo: 999, Text: "x"}, {ReplyTo: rootNote.ID, Path: "a.txt", Text: "x"}, {ReplyTo: rootNote.ID, Line: 1, Text: "x"}, {ReplyTo: rootNote.ID, Side: domain.SideNew, Text: "x"}, {ReplyTo: rootNote.ID, StartLine: 1, Text: "x"}, {ReplyTo: rootNote.ID, Verdict: domain.VerdictApprove},
1385+ {StartLine: 1, Text: "x"}, {Path: "a.txt", StartLine: 3, Line: 3, Side: domain.SideNew, Text: "x"}, {Path: "a.txt", StartLine: -1, Line: 3, Side: domain.SideNew, Text: "x"}, {Path: "a.txt", StartLine: 1, Line: 99, Side: domain.SideNew, Text: "x"},
1386+ } {
1387+ _, err := add(root, params)
1388+ assert.ErrorIs(t, err, domain.ErrInvalid, "%+v", params)
1389+ }
1390+ _, err = r.mrs.ResolveComment(r.ctx, root, r.repo, mr.Number, reply.ID, true)
1391+ assert.ErrorIs(t, err, domain.ErrInvalid)
1392+ assert.ErrorContains(t, err, fmt.Sprintf("root ^%d", rootNote.ID))
1393+ _, err = add(mrW, ports.AddCommentParams{Verdict: domain.VerdictApprove})
1394+ assert.ErrorIs(t, err, domain.ErrForbidden)
1395+ assert.ErrorContains(t, err, "this needs the mr:review scope")
1396+ reviewer := domain.TokenPrincipal(domain.Token{Label: "reviewer", Scopes: []domain.Scope{domain.ScopeMRWrite, domain.ScopeMRReview}})
1397+ approve, err := add(reviewer, ports.AddCommentParams{Verdict: domain.VerdictApprove})
1398+ require.NoError(t, err)
1399+ assert.True(t, approve.Resolved)
1400+ changes, err := add(root, ports.AddCommentParams{Verdict: domain.VerdictChangesRequested})
1401+ require.NoError(t, err)
1402+ assert.False(t, changes.Resolved)
1403+ _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, approve.ID, "no")
1404+ assert.ErrorIs(t, err, domain.ErrForbidden)
1405+ _, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, approve.ID)
1406+ assert.ErrorIs(t, err, domain.ErrForbidden)
1407+ r.drain()
1408+ edited, err := r.mrs.EditComment(r.ctx, reviewer, r.repo, mr.Number, approve.ID, "updated")
1409+ require.NoError(t, err)
1410+ assert.False(t, edited.EditedAt.IsZero())
1411+ assert.Equal(t, approve.Verdict, edited.Verdict)
1412+ _, err = r.mrs.EditComment(r.ctx, reviewer, r.repo, mr.Number, approve.ID, "updated")
1413+ require.NoError(t, err)
1414+ events := r.drain()
1415+ require.Len(t, events, 1)
1416+ assert.IsType(t, domain.CommentEdited{}, events[0])
1417+ deleted, err := r.mrs.DeleteComment(r.ctx, reviewer, r.repo, mr.Number, approve.ID)
1418+ require.NoError(t, err)
1419+ assert.True(t, deleted.Deleted)
1420+ assert.Equal(t, "", deleted.Text.String())
1421+ assert.Equal(t, approve.Verdict, deleted.Verdict)
1422+ again, err := r.mrs.DeleteComment(r.ctx, reviewer, r.repo, mr.Number, approve.ID)
1423+ require.NoError(t, err)
1424+ deleted.Version = again.Version // SaveComment assigns the new version on the next read.
1425+ assert.Equal(t, deleted, again)
1426+ events = r.drain()
1427+ require.Len(t, events, 1)
1428+ assert.IsType(t, domain.CommentDeleted{}, events[0])
1429+ _, err = r.mrs.EditComment(r.ctx, reviewer, r.repo, mr.Number, approve.ID, "x")
1430+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
1431+ _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, rootNote.ID, "")
1432+ assert.ErrorIs(t, err, domain.ErrInvalid)
1433+ _, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, rootNote.ID)
1434+ require.NoError(t, err)
1435+ _, err = add(root, ports.AddCommentParams{ReplyTo: rootNote.ID, Text: "x"})
1436+ assert.ErrorIs(t, err, domain.ErrInvalid)
1437+ r.commit(author, "a.txt", "changed\n2\nthree\n", "advance")
1438+ r.push("feat")
1439+ detail, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{})
1440+ require.NoError(t, err)
1441+ require.Len(t, detail.Verdicts, 2)
1442+ assert.True(t, detail.Verdicts[0].Stale)
1443+ assert.False(t, detail.Verdicts[1].Stale)
1444+ _, err = r.mrs.Close(r.ctx, root, r.repo, mr.Number)
1445+ require.NoError(t, err)
1446+ _, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, changes.ID, "x")
1447+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
1448+ _, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, changes.ID)
1449+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
1450+}
M internal/usecase/mergerequests_test.go +116 -0
@@ -6,6 +6,7 @@ import (
66 "context"
77 "errors"
88 "testing"
9+ "time"
910
1011 "github.com/stretchr/testify/assert"
1112 "github.com/stretchr/testify/mock"
@@ -117,3 +118,118 @@ func TestMergeReturnsTheFailureToReadTheSettings(t *testing.T) {
117118 })
118119 }
119120 }
121+
122+func TestNoteMutationRetries(t *testing.T) {
123+ for _, action := range []string{"edit", "delete", "resolve"} {
124+ t.Run(action, func(t *testing.T) {
125+ u := newMRUnit(t)
126+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
127+ mr := domain.MergeRequest{Repo: u.repo.Name, Number: 1, State: domain.MRStateOpen}
128+ text, err := domain.ParsePlainText("before")
129+ require.NoError(t, err)
130+ c := domain.Comment{ID: 1, Author: "root", Text: text}
131+ freshMR := mr
132+ freshMR.Version = 2
133+ fresh := c
134+ fresh.Version = 3
135+ fresh.CreatedAt = now.Add(-time.Hour)
136+ if action != "resolve" {
137+ fresh.Resolved = true
138+ }
139+ want := fresh
140+ switch action {
141+ case "edit":
142+ want.Text, err = domain.ParsePlainText("after")
143+ require.NoError(t, err)
144+ want.EditedAt = now.UTC()
145+ case "delete":
146+ want.Text, err = domain.ParsePlainText("")
147+ require.NoError(t, err)
148+ want.Deleted = true
149+ want.EditedAt = now.UTC()
150+ case "resolve":
151+ want.Resolved = true
152+ }
153+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(mr, nil).Once()
154+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(freshMR, nil).Once()
155+ u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(c, nil).Once()
156+ u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(fresh, nil).Once()
157+ u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
158+ u.store.EXPECT().SaveComment(mock.Anything, mock.MatchedBy(func(saved domain.MergeRequest) bool {
159+ return saved.Version == freshMR.Version
160+ }), mock.MatchedBy(func(saved domain.Comment) bool { return saved == want })).Return(nil).Once()
161+ bus := mocks.NewEventBus(t)
162+ bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(e domain.Event) bool {
163+ switch event := e.(type) {
164+ case domain.CommentEdited:
165+ return action == "edit" && event.Comment == want
166+ case domain.CommentDeleted:
167+ return action == "delete" && event.Comment == want
168+ case domain.CommentResolved:
169+ return action == "resolve" && event.Comment == want
170+ }
171+ return false
172+ })).Return().Once()
173+ clock := mocks.NewClock(t)
174+ clock.EXPECT().Now().Return(now)
175+ svc := usecase.NewMergeRequests(usecase.MergeRequestsConfig{Meta: u.meta, Store: u.store, Bus: bus, Clock: clock})
176+ var got domain.Comment
177+ switch action {
178+ case "edit":
179+ got, err = svc.EditComment(context.Background(), domain.Root(), u.repo.Name, 1, 1, "after")
180+ case "delete":
181+ got, err = svc.DeleteComment(context.Background(), domain.Root(), u.repo.Name, 1, 1)
182+ case "resolve":
183+ got, err = svc.ResolveComment(context.Background(), domain.Root(), u.repo.Name, 1, 1, true)
184+ }
185+ require.NoError(t, err)
186+ assert.Equal(t, want, got)
187+ })
188+ }
189+}
190+
191+// A reply whose root is deleted while the save is retried is rejected.
192+func TestAddNoteRechecksReplyRootOnConflict(t *testing.T) {
193+ u := newMRUnit(t)
194+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
195+ u.git.EXPECT().BranchHead(mock.Anything, mock.Anything, mock.Anything).Return(domain.ObjectID{}, domain.ErrNotFound)
196+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{State: domain.MRStateOpen}, nil).Twice()
197+ u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(domain.Comment{ID: 1}, nil).Once()
198+ u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(domain.Comment{ID: 1, Deleted: true}, nil).Once()
199+ u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
200+ _, err := u.mrs.AddComment(context.Background(), domain.Root(), u.repo.Name, 1, ports.AddCommentParams{ReplyTo: 1, Text: "x"})
201+ require.ErrorIs(t, err, domain.ErrInvalid)
202+}
203+
204+func TestAddNoteValidation(t *testing.T) {
205+ for _, tc := range []struct {
206+ name string
207+ params ports.AddCommentParams
208+ target domain.Comment
209+ missing bool
210+ }{
211+ {name: "range without path", params: ports.AddCommentParams{StartLine: 1, Text: "x"}},
212+ {name: "equal ends", params: ports.AddCommentParams{Path: "a", StartLine: 3, Line: 3, Side: domain.SideNew, Text: "x"}},
213+ {name: "reversed ends", params: ports.AddCommentParams{Path: "a", StartLine: 4, Line: 3, Side: domain.SideNew, Text: "x"}},
214+ {name: "reply anchor", params: ports.AddCommentParams{ReplyTo: 1, Path: "a", Line: 1, Side: domain.SideNew, Text: "x"}},
215+ {name: "reply verdict", params: ports.AddCommentParams{ReplyTo: 1, Verdict: domain.VerdictApprove}},
216+ {name: "reply to reply", params: ports.AddCommentParams{ReplyTo: 1, Text: "x"}, target: domain.Comment{ReplyTo: 2}},
217+ {name: "deleted root", params: ports.AddCommentParams{ReplyTo: 1, Text: "x"}, target: domain.Comment{Deleted: true}},
218+ {name: "missing root", params: ports.AddCommentParams{ReplyTo: 1, Text: "x"}, missing: true},
219+ } {
220+ t.Run(tc.name, func(t *testing.T) {
221+ u := newMRUnit(t)
222+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
223+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{State: domain.MRStateOpen}, nil)
224+ if tc.name == "reply to reply" || tc.name == "deleted root" || tc.missing {
225+ var err error
226+ if tc.missing {
227+ err = domain.ErrNotFound
228+ }
229+ u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(tc.target, err)
230+ }
231+ _, err := u.mrs.AddComment(context.Background(), domain.Root(), u.repo.Name, 1, tc.params)
232+ require.ErrorIs(t, err, domain.ErrInvalid)
233+ })
234+ }
235+}
M internal/usecase/pushes.go +1 -1
@@ -179,7 +179,7 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo
179179 // recorded before Git moves them, so that a tip never has the record of an
180180 // earlier pusher at the same commit (domain.CIRefTip). A record that cannot
181181 // be written refuses the push, as it cannot be run later with a known trust.
182- if err := recordRefTips(ctx, u.tips, name, updates, authorOf(p), domain.PusherOf(p), domain.TrustedActor(p)); err != nil {
182+ if err := recordRefTips(ctx, u.tips, name, updates, domain.AuthorName(p), domain.PusherOf(p), domain.TrustedActor(p)); err != nil {
183183 return err
184184 }
185185 log := zerowrap.FromCtx(ctx)
M internal/usecase/sessions_test.go +0 -9
@@ -334,12 +334,3 @@ func TestSignOutEverywhereEndsSessions(t *testing.T) {
334334 _, err = f.sessions.Verify(ctx, c)
335335 assert.NoError(t, err, "a new session works")
336336 }
337-
338-// The owner signed in on the web signs what it writes as "owner", where root
339-// signs as "root" although both hold every right.
340-func TestAuthorOfOwnerSession(t *testing.T) {
341- t.Parallel()
342- assert.Equal(t, "owner", usecase.AuthorOf(domain.Owner()))
343- assert.Equal(t, "root", usecase.AuthorOf(domain.Root()))
344- assert.Equal(t, "ci", usecase.AuthorOf(domain.TokenPrincipal(domain.Token{Label: "ci", Scopes: domain.AllScopes()})))
345-}
M proto/ermite/storage/merge_request.proto +12 -0
@@ -64,6 +64,13 @@ message MergeRequest {
6464 int64 comment_seq = 14;
6565 }
6666
67+// Verdict is the immutable review decision of a note.
68+enum Verdict {
69+ VERDICT_UNSPECIFIED = 0;
70+ VERDICT_APPROVE = 1;
71+ VERDICT_CHANGES_REQUESTED = 2;
72+}
73+
6774 // Comment is stored under the key "mrc/<repo>/<number:010d>/<id:010d>".
6875 message Comment {
6976 int64 id = 1;
@@ -75,6 +82,11 @@ message Comment {
7582 string text = 7;
7683 bool resolved = 8;
7784 google.protobuf.Timestamp created_at = 9;
85+ int64 reply_to = 10;
86+ int32 start_line = 11;
87+ Verdict verdict = 12;
88+ google.protobuf.Timestamp edited_at = 13;
89+ bool deleted = 14;
7890 }
7991
8092 // Sequence is stored under the key "mrseq/<repo>": the last merge request
M skills/ermite/CLI.md +11 -6
@@ -28,7 +28,7 @@ Presets add up with `--scope` (repeatable or comma-separated):
2828
2929 | Preset | Scopes |
3030 |---|---|
31-| `cli` | every scope, for the owner's daily use |
31+| `cli` | every scope (including `mr:review`), for the owner's daily use; a token created before `mr:review` existed lacks it and loses owner rights: recreate it with `--preset cli` using the root token or from a signed-in owner web session (an old token lacking `mr:review` cannot grant it), then replace the stored CLI secret and revoke the old token |
3232 | `agent` | `repo:write`, `mr:write` |
3333 | `ci` | `repo:read` |
3434
@@ -39,7 +39,8 @@ Scopes:
3939 | `repo:read` | read private repositories |
4040 | `repo:write` | push, create a repository |
4141 | `repo:admin` | delete a repository, change visibility, description, default branch, merge settings |
42-| `mr:write` | open, comment on, resolve, close, mark read |
42+| `mr:write` | open, comment on, reply, edit and delete own notes, resolve, close, mark read |
43+| `mr:review` | give a verdict (`--approve`, `--request-changes`) |
4344 | `mr:merge` | merge, and delete the merged source branch with `--delete-branch` |
4445 | `token:admin` | create, list and revoke tokens |
4546 | `ci:write`, `secrets:write`, `runner:register` | reserved for CI |
@@ -55,12 +56,16 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
5556 | `mr create <repo> --source B --title T [--target B] [--description TEXT\|-]` | `mr:write` | target defaults to the default branch; title of 120 characters max; `-` reads standard input |
5657 | `mr list <repo> [--state open\|merged\|closed\|all] [--json]` | none | default `open` |
5758 | `mr show <repo> <n> [--diff] [--since-last-read] [--mark-read] [--json]` | `mr:write` for `--mark-read` | commits, changed files and comments; `--diff` adds hunks with line numbers |
58-| `mr comment <repo> <n> -m TEXT\|- [--path P --line N --side old\|new]` | `mr:write` | the line must be in the current diff; a deleted file uses its old path |
59-| `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | doing it twice is not an error |
59+| `mr comment <repo> <n> -m TEXT\|- [--path P --line N\|A-B --side old\|new] [--reply ID] [--approve\|--request-changes]` | `mr:write`; verdicts `mr:review` | Markdown note (8000 characters max); the lines must be in the current diff on one side; a deleted file uses its old path; `--reply` takes a root note (`12`, `#12` or `^12`), inherits its anchor and cannot carry a verdict; a verdict note may have no message |
60+| `mr edit-comment <repo> <n> <comment-id> -m TEXT\|-` | `mr:write` | author only, open merge request; the verdict is unchanged |
61+| `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept |
62+| `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | on the thread root, covers its replies; doing it twice is not an error |
6063 | `mr merge <repo> <n> --head <full-id> [--squash] [--delete-branch]` | `mr:merge` | refused if the branch moved or the target is not an ancestor of the head; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |
6164 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |
6265
63-Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, `target_branch`, `state`, `author` (token label), `created_at`, `updated_at`, `head_id`, `merged_commit_id`, `merge_mode`. `mr show --json` adds `commits`, `diff_base_id`, `stats`, `files` (with `hunks` under `--diff`: `kind`, `old_number`, `new_number`, `text`), `comments`, `last_read_id`, `last_read_lost`.
66+Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, `target_branch`, `state`, `author` (token label), `created_at`, `updated_at`, `head_id`, `merged_commit_id`, `merge_mode`. `mr show --json` adds `commits`, `diff_base_id`, `stats`, `files` (with `hunks` under `--diff`: `kind`, `old_number`, `new_number`, `text`), `comments`, `verdicts`, `last_read_id`, `last_read_lost`.
67+
68+Comment JSON: `id`, `author`, `path`, `line`, `side`, `commit_id`, `text`, `resolved`, `created_at`, `reply_to` (empty for roots), `start_line` (0 for a single line), `verdict` (`""`, `approve`, `changes_requested`), `edited_at`, `deleted`. Verdict JSON (latest per author): `author`, `verdict`, `comment_id`, `commit_id`, `created_at`, `stale` (an approval given on another head; changes requested are never stale). Verdicts are informative and never block a merge. In notes, `^N` refers to note N.
6469
6570 ## Workflows
6671
@@ -82,7 +87,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the `
8287 | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` |
8388 | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) |
8489 | `merge_request_opened`, `merge_request_merged`, `merge_request_closed` | `repo`, `merge_request` |
85-| `comment_added` | `repo`, `number`, `comment` |
90+| `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) |
8691 | `gap` | none: events were lost, reload the state |
8792
8893 ## Server and web
M skills/ermite/SKILL.md +13 -4
@@ -35,11 +35,20 @@ git push https://git.example.com/<repo>.git main # password = token,
3535
3636 1. List: `ermite mr list <repo> --json`.
3737 2. Read: `ermite mr show <repo> <n> --diff --json`. Keep `head_id`; take line numbers from the hunks.
38-3. Comment on a line: `ermite mr comment <repo> <n> -m "text" --path <file> --line <n> --side new`. Without `--path` the comment is general. The line must appear in the diff.
39-4. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--squash` for one commit signed by the server and `--delete-branch` to delete the source branch afterwards.
38+3. Comment on a line: `ermite mr comment <repo> <n> -m "text" --path <file> --line <n> --side new`; `--line 10-14` covers a range on one side. Without `--path` the comment is general. The line must appear in the diff. Notes are Markdown; `^N` refers to note N.
39+4. End a review with a verdict (needs `mr:review`): `--approve` or `--request-changes`, alone or with a message. Verdicts are informative and cannot be edited; a new verdict replaces the previous one.
40+5. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--squash` for one commit signed by the server and `--delete-branch` to delete the source branch afterwards.
4041
4142 The merge is refused when the branch moved since `head_id` (review again) or when the target is not an ancestor of the head (rebase and push again). The repository may allow only one merge mode.
4243
44+### Act on a review
45+
46+Notes are visible as soon as they are written, so a review is not finished until its verdict. Act on a verdict, not on each note (`ermite watch --json` shows `verdict` on the `comment_added` event).
47+
48+On `changes_requested`: run `ermite mr show <repo> <n> --json`, read the open threads (roots with `resolved` false and their replies), fix the code, push, then answer each thread with `ermite mr comment <repo> <n> --reply <id> -m "Fixed in ^N"`. Leave resolving to the reviewer: do not run `mr resolve` on their threads. Edit or delete only your own notes (`mr edit-comment`, `mr delete-comment`).
49+
50+An approval is stale after a push; changes requested stay until the reviewer gives a new verdict.
51+
4352 Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new pushes, `mr show --since-last-read` shows only what changed since `--mark-read`.
4453
4554 ## Tokens
@@ -48,11 +57,11 @@ Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new p
4857 ermite token create my-agent --preset agent --repo <repo> --expires 30d
4958 ```
5059
51-The secret is printed once. Presets: `agent` (push, open and comment on merge requests), `ci` (read), `cli` (everything). Add `--scope mr:merge` to let an agent merge. A token can never grant more than the token that creates it. Full scope list: [CLI.md](CLI.md).
60+The secret is printed once. Presets: `agent` (push, open and comment on merge requests; no verdicts), `ci` (read), `cli` (everything). Add `--scope mr:review` to let an agent give verdicts, `--scope mr:merge` to let it merge. A token can never grant more than the token that creates it. Full scope list: [CLI.md](CLI.md).
5261
5362 ## Follow events
5463
55-`ermite watch [repo] --json` prints one JSON object per event (pushes, repositories, merge requests, comments). It reconnects and resumes on its own; a `gap` event means some events were lost, so reload the state.
64+`ermite watch [repo] --json` prints one JSON object per event (pushes, repositories, merge requests, notes: added, edited, deleted, resolved). It reconnects and resumes on its own; a `gap` event means some events were lost, so reload the state.
5665
5766 ## Run a server
5867

Keyboard shortcuts

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

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

Branches 4

Tags 45

↑↓ moveenter openesc close