detached fa37646

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

commit
fa37646691ec7579256a6c4efe51b98a97ef79e1
author
brice · · 2026-10-19 12:47 UTC
merge of
346e37f + 86ac875 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
70 files · +3215 -701
M .github/workflows/ci.yml +8 -0
@@ -1,9 +1,17 @@
11 name: CI
22
3+# A change that only touches these files skips the whole run: none of them
4+# affects the build, the tests or the generated code.
35 on:
46 push:
57 branches: [main]
8+ paths-ignore: &docs-only
9+ - '**.md'
10+ - '**.png'
11+ - 'docs/**'
12+ - 'LICENSE'
613 pull_request:
14+ paths-ignore: *docs-only
715
816 env:
917 # Test the module alone, with the versions in go.mod (never a go.work).
M README.md +4 -0
@@ -82,6 +82,10 @@ ermite web login # one-time link to sign in to the web interface
8282
8383 Pushing to a new name also creates a private repository.
8484
85+`ermite mr create` and `ermite mr close` print a confirmation followed by the full
86+merge request web URL on its own line. With `--json`, merge request objects
87+(create, show, list, close and merge) include `url` alongside the other fields.
88+
8589 ## Agent workflow
8690
8791 You and the agent use the same CLI. The difference is the token: the agent's is limited to its own branches. It can never push `main`, tags or settings, and its CI runs get no secrets.
M docs/cli.md +6 -1
@@ -55,7 +55,8 @@ ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo
5555 ermite token list | revoke <id>
5656
5757 ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff]
58-ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment>
58+ermite mr comment <repo> <n> -m … [--path … --line N|A-B --side old|new] [--reply <id>] [--approve|--request-changes]
59+ermite mr resolve <repo> <n> <comment> | edit-comment <repo> <n> <comment> -m … | delete-comment <repo> <n> <comment>
5960 ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>
6061
6162 ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]
@@ -92,3 +93,7 @@ Issues need a server with the `issues` feature and `ermite repo edit <repo> --is
9293 - `watch --json` prints one object per line.
9394 - Errors are one line on stderr (`ermite: …`) with exit status 1.
9495 - Shell completion completes repository names from the server.
96+
97+`ermite mr create` and `ermite mr close` print a confirmation followed by the full
98+merge request web URL on its own line. With `--json`, merge request objects
99+(create, show, list, close and merge) include `url` alongside the other fields.
M docs/merge-requests.md +34 -1
@@ -8,15 +8,48 @@ ermite mr show myrepo 1 --diff
88 ermite mr merge myrepo 1 --head <commit> [--squash] [--delete-branch]
99 ```
1010
11+`ermite mr create` and `ermite mr close` print a confirmation followed by the full
12+merge request web URL on its own line. With `--json`, merge request objects
13+(create, show, list, close and merge) include `url` alongside the other fields.
14+
1115 - **Fast-forward** moves the target. **Squash** writes one commit signed by the server's SSH key.
1216 - The merge is refused when the branch moved since the reviewed `--head`, or when the target is not an ancestor of it.
1317 - With `failing_checks_block` on (default), the merge is refused while a check of the head failed or is still running.
1418 - `--delete-branch` deletes the source branch, unless it is the default branch or another open request uses it.
1519 - `ermite repo edit --merge-modes` limits the modes per repository.
16-- Scopes: `mr:write` to open and comment, `mr:merge` to merge.
20+- Scopes: `mr:write` to open and comment, `mr:review` to give a verdict, `mr:merge` to merge.
1721
1822 Verify squash commits with the `allowed_signers` line from `ermite signing-key show`:
1923
2024 ```sh
2125 git verify-commit <commit>
2226 ```
27+
28+## Notes
29+
30+A note is the only review object. It is published as soon as it is written: there is no draft and no submit step.
31+
32+```sh
33+ermite mr comment myrepo 1 -m "General remark"
34+ermite mr comment myrepo 1 -m "Why?" --path main.go --line 10-14 --side new
35+ermite mr comment myrepo 1 -m "Fixed in ^3" --reply 2
36+ermite mr resolve myrepo 1 2
37+ermite mr edit-comment myrepo 1 2 -m "New text"
38+ermite mr delete-comment myrepo 1 2
39+```
40+
41+- Notes are Markdown, up to 8000 characters, rendered without raw HTML. Quote with `> `.
42+- `^N` in a note or a description links to note N of the same merge request. A reference to a missing note stays plain text.
43+- A note is general, on one line, or on a range `A-B` of lines on one side (`old` or `new`) of the diff. The lines must be in the diff.
44+- Threads have one level: a reply (`--reply <id>`) takes the anchor of its root and cannot carry a verdict. Resolving or reopening is done on the root and covers its replies.
45+- Only the author edits or deletes a note. An edited note shows "edited". A deleted note loses its text; a deleted root with replies stays as "deleted note".
46+
47+## Verdicts
48+
49+A root note can carry a verdict: `--approve` or `--request-changes`. The text may then be empty. A verdict needs the `mr:review` scope (or the owner); `mr:write` comments but cannot give one.
50+
51+- A verdict ends a review: agents and `ermite watch` act on it, not on each note.
52+- Verdicts are informative: none blocks or allows a merge.
53+- `mr show` lists the latest verdict of each author (`verdicts` in `--json`).
54+- An approval is stale once the branch head differs from the head it was given on. Changes requested stay current until their author gives a new verdict, whatever is pushed.
55+- Verdicts are immutable: editing or deleting the note keeps its verdict. Only a new verdict of the same author replaces it.
M docs/tokens.md +2 -2
@@ -6,7 +6,7 @@ ermite token list
66 ermite token revoke <id>
77 ```
88
9-The secret is printed once. Presets and `--scope` add up (`issue:write` lets a token write issues). The full scope list is in [`skills/ermite/CLI.md`](../skills/ermite/CLI.md).
9+The secret is printed once. Presets and `--scope` add up (`issue:write` lets a token write issues). The full scope list is in [`skills/ermite/CLI.md`](../skills/ermite/CLI.md). `mr:review` lets a token give a merge request verdict (`--approve`, `--request-changes`); the `cli` preset includes it, the `agent` preset does not. A token created before `mr:review` existed lacks it: recreate it with `--preset cli` using the root token or from a signed-in owner web session (an old token lacking `mr:review` cannot grant it), then replace the stored CLI secret and revoke the old token to keep the owner's rights.
1010
1111 | Preset | For |
1212 |---|---|
@@ -28,7 +28,7 @@ ermite token create claude --preset agent --scope repo:read --repo myrepo --bran
2828 The token **can**:
2929
3030 - push, force-push and delete branches under its prefixes;
31-- open, comment on and close merge requests;
31+- open, comment on and close merge requests; with `mr:review`, give verdicts;
3232 - with `issue:write`, open, comment on, close and reopen issues of the repositories where they are enabled;
3333 - with `mr:merge`, merge into a branch under its prefixes;
3434 - read CI runs, logs and checks; with `ci:write`, cancel and re-run runs of its own branches;
M gen/ermite/storage/merge_request.pb.go +127 -23 generated file: collapsed
M go.mod +1 -1
@@ -13,7 +13,7 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.7.0
16+ bnema.dev/ermite-api v0.8.0
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.1.0
1919 connectrpc.com/connect v1.21.0
M internal/adapters/client/scopes.go +3 -5
@@ -35,14 +35,12 @@ func ScopeName(s ermite.Scope) string {
3535 return strings.Replace(strings.ToLower(strings.TrimPrefix(name, "SCOPE_")), "_", ":", 1)
3636 }
3737
38-// AllScopes lists every scope the contract defines that this server accepts, in
39-// enum order. Deriving it keeps the `cli` preset ("every scope") true when the
40-// contract grows. SCOPE_MR_REVIEW is in the contract but not yet implemented
41-// here, and the server rejects it like any unknown scope, so it is left out.
38+// AllScopes lists every scope the contract defines, in enum order. Deriving it
39+// keeps the `cli` preset ("every scope") true when the contract grows.
4240 func AllScopes() []ermite.Scope {
4341 var scopes []ermite.Scope
4442 for n := range ermite.Scope_name {
45- if s := ermite.Scope(n); s != ermite.Scope_SCOPE_UNSPECIFIED && s != ermite.Scope_SCOPE_MR_REVIEW {
43+ if s := ermite.Scope(n); s != ermite.Scope_SCOPE_UNSPECIFIED {
4644 scopes = append(scopes, s)
4745 }
4846 }
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
@@ -662,6 +662,11 @@ func encodeComment(c domain.Comment) ([]byte, error) {
662662 return nil, err
663663 }
664664 return mustMarshal(&storagepb.Comment{
665+ ReplyTo: c.ReplyTo,
666+ StartLine: int32(c.StartLine), //nolint:gosec // validated against the diff
667+ Verdict: storagepb.Verdict(c.Verdict),
668+ EditedAt: encodeTime(c.EditedAt),
669+ Deleted: c.Deleted,
665670 Id: c.ID,
666671 Author: c.Author,
667672 Path: c.Path,
@@ -685,6 +690,7 @@ func decodeComment(item kv.Item) (domain.Comment, error) {
685690 return domain.Comment{}, fmt.Errorf("decode comment %d: %w", pb.GetId(), err)
686691 }
687692 c := domain.Comment{
693+ ReplyTo: pb.GetReplyTo(), StartLine: int(pb.GetStartLine()), Verdict: domain.Verdict(pb.GetVerdict()), EditedAt: decodeTime(pb.GetEditedAt()), Deleted: pb.GetDeleted(),
688694 ID: pb.GetId(), Author: pb.GetAuthor(), Path: pb.GetPath(), Line: int(pb.GetLine()),
689695 Side: decodeSide(pb.GetSide()), Text: text, Resolved: pb.GetResolved(),
690696 CreatedAt: decodeTime(pb.GetCreatedAt()), Version: item.Version,
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,
@@ -487,6 +488,15 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
487488 case domain.IssueSubmitted:
488489 out.Time = timestamp(ev.At)
489490 out.Kind = &pb.Event_IssueSubmitted{IssueSubmitted: &pb.IssueSubmitted{Repo: ev.Repo.String(), Id: ev.ID.String()}}
491+ case domain.CommentEdited:
492+ out.Time = timestamp(ev.At)
493+ out.Kind = &pb.Event_CommentEdited{CommentEdited: &pb.CommentEdited{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
494+ case domain.CommentDeleted:
495+ out.Time = timestamp(ev.At)
496+ out.Kind = &pb.Event_CommentDeleted{CommentDeleted: &pb.CommentDeleted{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
497+ case domain.CommentResolved:
498+ out.Time = timestamp(ev.At)
499+ out.Kind = &pb.Event_CommentResolved{CommentResolved: &pb.CommentResolved{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
490500 case domain.RunUpdated:
491501 out.Time = timestamp(ev.At)
492502 out.Kind = runEventToPB(ev)
M internal/adapters/rpc/convert_test.go +1 -6
@@ -50,17 +50,12 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) {
5050 }
5151 _, err := scopeFromPB(pb.Scope_SCOPE_UNSPECIFIED)
5252 assert.ErrorIs(t, err, domain.ErrInvalidScope)
53- // Every scope of the contract is a scope of the domain, except mr:review.
53+ // Every scope of the contract is a scope of the domain.
5454 for v := range pb.Scope_name {
5555 if pb.Scope(v) == pb.Scope_SCOPE_UNSPECIFIED {
5656 continue
5757 }
5858 _, err := scopeFromPB(pb.Scope(v))
59- if pb.Scope(v) == pb.Scope_SCOPE_MR_REVIEW {
60- // In the contract, not implemented here yet: rejected like an unknown scope.
61- assert.ErrorIs(t, err, domain.ErrInvalidScope, pb.Scope(v))
62- continue
63- }
6459 assert.NoError(t, err, pb.Scope(v))
6560 }
6661 })
M internal/adapters/rpc/mergerequest.go +70 -4
@@ -10,19 +10,61 @@ import (
1010 "connectrpc.com/connect"
1111
1212 pb "bnema.dev/ermite-api/gen/ermite"
13- "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
1413 "bnema.dev/ermite/internal/domain"
1514 "bnema.dev/ermite/internal/ports"
1615 )
1716
1817 // mergeRequestService implements ermiteconnect.MergeRequestServiceHandler.
1918 type mergeRequestService struct {
20- // The note methods (edit, delete, verdicts) are implemented by the merge
21- // request notes work; until then they answer CodeUnimplemented.
22- ermiteconnect.UnimplementedMergeRequestServiceHandler
2319 mrs ports.MergeRequestService
2420 }
2521
22+func (s *mergeRequestService) EditComment(ctx context.Context, req *connect.Request[pb.EditCommentRequest]) (*connect.Response[pb.EditCommentResponse], error) {
23+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
24+ if err != nil {
25+ return nil, connectError(ctx, err)
26+ }
27+ c, err := s.mrs.EditComment(ctx, caller(ctx), name, req.Msg.GetNumber(), commentID(req.Msg.GetCommentId()), req.Msg.GetText())
28+ if err != nil {
29+ return nil, connectError(ctx, err)
30+ }
31+ return connect.NewResponse(&pb.EditCommentResponse{Comment: commentToPB(c)}), nil
32+}
33+
34+func (s *mergeRequestService) DeleteComment(ctx context.Context, req *connect.Request[pb.DeleteCommentRequest]) (*connect.Response[pb.DeleteCommentResponse], error) {
35+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
36+ if err != nil {
37+ return nil, connectError(ctx, err)
38+ }
39+ c, err := s.mrs.DeleteComment(ctx, caller(ctx), name, req.Msg.GetNumber(), commentID(req.Msg.GetCommentId()))
40+ if err != nil {
41+ return nil, connectError(ctx, err)
42+ }
43+ return connect.NewResponse(&pb.DeleteCommentResponse{Comment: commentToPB(c)}), nil
44+}
45+
46+func verdictToPB(v domain.Verdict) pb.Verdict {
47+ switch v {
48+ case domain.VerdictApprove:
49+ return pb.Verdict_VERDICT_APPROVE
50+ case domain.VerdictChangesRequested:
51+ return pb.Verdict_VERDICT_CHANGES_REQUESTED
52+ }
53+ return pb.Verdict_VERDICT_UNSPECIFIED
54+}
55+
56+func verdictFromPB(v pb.Verdict) (domain.Verdict, error) {
57+ switch v {
58+ case pb.Verdict_VERDICT_UNSPECIFIED:
59+ return domain.VerdictNone, nil
60+ case pb.Verdict_VERDICT_APPROVE:
61+ return domain.VerdictApprove, nil
62+ case pb.Verdict_VERDICT_CHANGES_REQUESTED:
63+ return domain.VerdictChangesRequested, nil
64+ }
65+ return domain.VerdictNone, fmt.Errorf("%w: verdict %v", domain.ErrInvalid, v)
66+}
67+
2668 func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState {
2769 switch s {
2870 case domain.MRStateOpen:
@@ -102,9 +144,19 @@ func commentToPB(c domain.Comment) *pb.Comment {
102144 Text: c.Text.String(),
103145 Resolved: c.Resolved,
104146 CreatedAt: timestamp(c.CreatedAt),
147+ ReplyTo: optionalCommentID(c.ReplyTo),
148+ StartLine: int32(c.StartLine), //nolint:gosec // validated diff line
149+ Verdict: verdictToPB(c.Verdict), EditedAt: timestamp(c.EditedAt), Deleted: c.Deleted,
105150 }
106151 }
107152
153+func optionalCommentID(id int64) string {
154+ if id == 0 {
155+ return ""
156+ }
157+ return strconv.FormatInt(id, 10)
158+}
159+
108160 // commentID parses the opaque id of a comment. One that this server never
109161 // issued is 0, which names no comment.
110162 func commentID(s string) int64 {
@@ -191,6 +243,9 @@ func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect.
191243 for _, c := range d.Comments {
192244 out.Comments = append(out.Comments, commentToPB(c))
193245 }
246+ for _, v := range d.Verdicts {
247+ out.Verdicts = append(out.Verdicts, &pb.AuthorVerdict{Author: v.Author, Verdict: verdictToPB(v.Verdict), CommentId: strconv.FormatInt(v.CommentID, 10), CommitId: v.CommitID.String(), CreatedAt: timestamp(v.At), Stale: v.Stale})
248+ }
194249 return connect.NewResponse(out), nil
195250 }
196251
@@ -203,7 +258,18 @@ func (s *mergeRequestService) AddComment(ctx context.Context, req *connect.Reque
203258 if err != nil {
204259 return nil, connectError(ctx, err)
205260 }
261+ verdict, err := verdictFromPB(req.Msg.GetVerdict())
262+ if err != nil {
263+ return nil, connectError(ctx, err)
264+ }
265+ replyTo := commentID(req.Msg.GetReplyTo())
266+ if req.Msg.GetReplyTo() != "" && replyTo == 0 {
267+ var v domain.Violations
268+ v.Add("reply_to", "is not a note id")
269+ return nil, connectError(ctx, v.Err())
270+ }
206271 c, err := s.mrs.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.AddCommentParams{
272+ ReplyTo: replyTo, StartLine: int(req.Msg.GetStartLine()), Verdict: verdict,
207273 Path: req.Msg.GetPath(), Line: int(req.Msg.GetLine()), Side: side, Text: req.Msg.GetText(),
208274 })
209275 if err != nil {
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
@@ -60,7 +60,7 @@ func liveEvent(we ports.WatchEvent) bool {
6060 }
6161 switch we.Event.(type) {
6262 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
63- domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded,
63+ domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
6464 domain.RunUpdated, domain.JobUpdated,
6565 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved:
6666 return true
@@ -170,6 +170,12 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
170170 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
171171 case domain.MergeRequestReady:
172172 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")
173+ case domain.CommentEdited:
174+ return l.mrNotice(ctx, ev.Number, "a note was edited — reload")
175+ case domain.CommentDeleted:
176+ return l.mrNotice(ctx, ev.Number, "a note was deleted — reload")
177+ case domain.CommentResolved:
178+ return l.mrNotice(ctx, ev.Number, "a thread was resolved — reload")
173179 case domain.CommentAdded:
174180 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")
175181 case domain.IssueOpened:
M internal/adapters/web/inbox.go +1 -1
@@ -264,7 +264,7 @@ func (s *Server) inboxCount(ctx context.Context) (n int, show bool) {
264264 // on the inbox page: the counters, the filter and the two lists.
265265 func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
266266 switch we.Event.(type) {
267- case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded,
267+ case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
268268 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,
269269 domain.RepoUpdated, domain.RepoDeleted:
270270 default:
M internal/adapters/web/issues.go +1 -1
@@ -275,7 +275,7 @@ func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Componen
275275 p.Comments = append(p.Comments, v)
276276 }
277277 if signed {
278- p.Form = &CommentForm{ID: "cf-general", Action: issueHref(d.name(), n, "comments"), Title: "comment on the issue"}
278+ p.Form = &CommentForm{ID: "cf-general", Action: issueHref(d.name(), n, "comments"), Title: "comment on the issue", Plain: true}
279279 switch i.State {
280280 case domain.IssueStateOpen:
281281 p.Transition = &IssueActions{Action: issueHref(d.name(), n, "close"), Label: "Close issue"}
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 +78 -3
@@ -7,6 +7,8 @@ import (
77 "html/template"
88 "net/url"
99 "path"
10+ "regexp"
11+ "strconv"
1012 "strings"
1113
1214 "github.com/yuin/goldmark"
@@ -62,7 +64,13 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML,
6264 // #fragment links chosen, so that several documents on one page (the summaries
6365 // of a run) do not share ids.
6466 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {
65- return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix})
67+ return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix}, nil)
68+}
69+
70+// renderNote uses the repository Markdown policy, then adds local note links.
71+func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes func(int64) bool) (template.HTML, error) {
72+ h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, notes)
73+ return h, err
6674 }
6775
6876 // renderUntrusted renders Markdown written by an anonymous visitor (an issue
@@ -73,15 +81,35 @@ func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string)
7381 // every other scheme lose their link and keep the text;
7482 // - nothing points into the repository.
7583 func (m *markdown) renderUntrusted(source []byte, idPrefix string) (template.HTML, error) {
76- h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true})
84+ h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}, nil)
7785 return h, err
7886 }
7987
80-func (m *markdown) renderPolicy(source []byte, pol linkPolicy) (template.HTML, []Heading, error) {
88+func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64) bool) (template.HTML, []Heading, error) {
8189 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))
8290 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
8391 pol.source = source
92+ // Collect eligible text before the policy unwraps rejected links: their
93+ // labels must not accidentally become references either.
94+ var refs []*ast.Text
95+ if notes != nil {
96+ _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
97+ if !entering {
98+ return ast.WalkContinue, nil
99+ }
100+ switch n := n.(type) {
101+ case *ast.CodeSpan, *ast.Link, *ast.AutoLink, *ast.Image, *ast.CodeBlock, *ast.FencedCodeBlock:
102+ return ast.WalkSkipChildren, nil
103+ case *ast.Text:
104+ refs = append(refs, n)
105+ }
106+ return ast.WalkContinue, nil
107+ })
108+ }
84109 pol.apply(doc)
110+ for _, t := range refs {
111+ noteReferences(t, source, notes)
112+ }
85113
86114 var buf bytes.Buffer
87115 if err := m.md.Renderer().Render(&buf, source, doc); err != nil {
@@ -361,3 +389,50 @@ func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
361389 return ast.WalkContinue, nil
362390 })
363391 }
392+
393+var noteReferencePattern = regexp.MustCompile(`(^|[^0-9A-Za-z_^])\^([1-9][0-9]{0,5})\b`)
394+
395+// noteReferences changes only text segments; code and link labels never enter
396+// this function. New fragment links deliberately bypass heading-prefix rewriting.
397+func noteReferences(t *ast.Text, source []byte, exists func(int64) bool) {
398+ parent := t.Parent()
399+ if parent == nil {
400+ return
401+ }
402+ value := t.Value(source)
403+ offset := 0
404+ for _, match := range noteReferencePattern.FindAllSubmatchIndex(value, -1) {
405+ id := string(value[match[4]:match[5]])
406+ n, err := strconv.ParseInt(id, 10, 64)
407+ if err != nil || !exists(n) {
408+ continue
409+ }
410+ start := match[4] - 1
411+ // Markdown punctuation may split adjacent text into separate AST nodes.
412+ // Check the original preceding byte as well as the segment-local regex.
413+ if at := t.Segment.Start + start; at > 0 {
414+ prev := source[at-1]
415+ if prev >= '0' && prev <= '9' || prev >= 'A' && prev <= 'Z' || prev >= 'a' && prev <= 'z' || prev == '_' || prev == '^' {
416+ continue
417+ }
418+ }
419+ if start > offset {
420+ parent.InsertBefore(parent, t, ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Start+start)))
421+ }
422+ link := ast.NewLink()
423+ link.Destination = []byte("#c" + id)
424+ link.Title = []byte("note " + id)
425+ link.SetAttributeString("class", []byte("ref"))
426+ link.AppendChild(link, ast.NewString(value[start:match[5]]))
427+ parent.InsertBefore(parent, t, link)
428+ offset = match[5]
429+ }
430+ if offset == 0 {
431+ return
432+ }
433+ tail := ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Stop))
434+ tail.SetSoftLineBreak(t.SoftLineBreak())
435+ tail.SetHardLineBreak(t.HardLineBreak())
436+ parent.InsertBefore(parent, t, tail)
437+ parent.RemoveChild(parent, t)
438+}
M internal/adapters/web/markdown_test.go +29 -0
@@ -203,3 +203,32 @@ func TestMarkdownUntrustedBigInputs(t *testing.T) {
203203
204204 assert.NotContains(t, renderUntrustedMD(t, "[x](https://a.test/\"onmouseover=\"alert(1))\n"), "onmouseover=\"")
205205 }
206+
207+func TestNoteMarkdown(t *testing.T) {
208+ t.Parallel()
209+ tests := []struct {
210+ source string
211+ links int
212+ }{
213+ {"^2 ^99 a^2 _^2 ^^2 1^2", 1},
214+ {"`^2` [^2](https://example.org) [^2](javascript:alert(1))", 0},
215+ {"```\n^2\n```", 0},
216+ {"^2, ^2\n^2", 3},
217+ }
218+ for _, tt := range tests {
219+ t.Run(tt.source, func(t *testing.T) {
220+ t.Parallel()
221+ h, err := newMarkdown().renderNote([]byte(tt.source), "demo", "main", "c1-", func(n int64) bool { return n == 2 })
222+ require.NoError(t, err)
223+ assert.Equal(t, tt.links, strings.Count(string(h), `href="#c2"`), string(h))
224+ assert.NotContains(t, string(h), `href="#c99"`)
225+ assert.NotContains(t, string(h), "javascript:")
226+ })
227+ }
228+ h, err := newMarkdown().renderNote([]byte("# Heading\n\n<script>alert(1)</script>\n\n**bold** ^2"), "demo", "main", "c1-", func(n int64) bool { return n == 2 })
229+ require.NoError(t, err)
230+ assert.Contains(t, string(h), `id="c1-heading"`)
231+ assert.Contains(t, string(h), "<strong>bold</strong>")
232+ assert.NotContains(t, string(h), "<script")
233+ assert.Contains(t, string(h), `title="note 2"`)
234+}
M internal/adapters/web/mr.go +134 -25
@@ -101,13 +101,22 @@ type MRListPage struct {
101101
102102 // CommentView is a comment of a merge request, ready to show.
103103 type CommentView struct {
104- ID string
105- Author string
106- When string
107- WhenAt string
108- Abs string
109- Parts []TextPart
110- Resolved bool
104+ ID string
105+ Author string
106+ When string
107+ WhenAt string
108+ Abs string
109+ Body, BodySplit template.HTML
110+ // Parts is a plain-text body (an issue comment); Body is the Markdown of
111+ // a merge request note.
112+ Parts []TextPart
113+ Replies []CommentView
114+ Edited, Deleted bool
115+ Verdict, Range, Quote string
116+ Edit *EditForm
117+ Delete *DeleteForm
118+ Reply *CommentForm
119+ Resolved bool
111120 // Outdated is true when the head moved since the comment was written, so
112121 // its line may have moved too.
113122 Outdated bool
@@ -119,6 +128,19 @@ type CommentView struct {
119128 Resolve *ResolveForm
120129 }
121130
131+type EditForm struct {
132+ Action, Text string
133+ From int
134+}
135+type DeleteForm struct {
136+ Action string
137+ From int
138+}
139+type VerdictView struct {
140+ Author, Label, Href, Short string
141+ Stale bool
142+}
143+
122144 // ResolveForm is the POST that changes the resolved state of a comment.
123145 type ResolveForm struct {
124146 Action string
@@ -135,9 +157,12 @@ type CommentForm struct {
135157 Action string
136158 Title string
137159 // Path, Line and Side designate the line; empty for a general comment.
138- Path, Line, Side string
139- Cancel string
140- Focus bool
160+ Path, Line, Side string
161+ StartLine, ReplyTo string
162+ // Plain is a form of an issue comment: plain text, no verdict.
163+ Plain bool
164+ Cancel string
165+ Focus bool
141166 // From is the window of the page the form is on (see mrWindow), sent
142167 // back so that the answer returns to it; 0 for the first.
143168 From int
@@ -192,6 +217,7 @@ type MRPage struct {
192217 State string
193218 Open bool
194219 Signed bool
220+ NoteAuthor string
195221 Source, Target string
196222 SourceHref string
197223 TargetHref string
@@ -226,6 +252,7 @@ type MRPage struct {
226252 NComments int
227253 NUnresolved int
228254 GeneralForm *CommentForm
255+ Verdicts []VerdictView
229256
230257 Commits []MRCommit
231258 CommitsNote string
@@ -471,7 +498,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
471498
472499 p := MRPage{
473500 Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()),
474- State: mrStateName(m), Open: open, Signed: signed,
501+ State: mrStateName(m), Open: open, Signed: signed, NoteAuthor: domain.AuthorName(principal(ctx)),
475502 Source: visibleText(m.Source), Target: visibleText(m.Target),
476503 Author: s.who(m.Author),
477504 Head: head, HeadShort: short(head),
@@ -485,12 +512,15 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
485512 // Relative links point into the source branch as it is now (its head
486513 // commit); the heading ids carry their own prefix, apart from the
487514 // ids of the diff.
488- h, _, err := s.md.renderWithIDs([]byte(desc), d.name(), head, "", "mr-desc-")
515+ h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))
489516 if err != nil {
490517 return nil, 0, err
491518 }
492519 p.Desc = h
493520 }
521+ for _, v := range res.Verdicts {
522+ p.Verdicts = append(p.Verdicts, VerdictView{Author: s.who(v.Author), Label: v.Verdict.String(), Stale: v.Stale, Href: "#c" + strconv.FormatInt(v.CommentID, 10), Short: short(v.CommitID.String())})
523+ }
494524 p.Opened.Name = p.Author
495525 p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)
496526 if !m.CreatedAt.IsZero() {
@@ -684,9 +714,10 @@ func shellWord(s string) string {
684714 // lineRef designates a line of the diff in the query of the comment form:
685715 // "?on=new:12:path/to/file".
686716 type lineRef struct {
687- side string
688- line int
689- path string
717+ side string
718+ line int
719+ start int
720+ path string
690721 }
691722
692723 func parseOn(s string) *lineRef {
@@ -695,11 +726,21 @@ func parseOn(s string) *lineRef {
695726 return nil
696727 }
697728 num, path, ok := strings.Cut(rest, ":")
729+ start := 0
730+ if a, b, rangeOK := strings.Cut(num, "-"); rangeOK {
731+ var e error
732+ start, e = strconv.Atoi(a)
733+ end, e2 := strconv.Atoi(b)
734+ if e != nil || e2 != nil || start < 1 || start >= end {
735+ return nil
736+ }
737+ num = b
738+ }
698739 line, err := strconv.Atoi(num)
699740 if !ok || err != nil || line < 1 || path == "" {
700741 return nil
701742 }
702- return &lineRef{side: side, line: line, path: path}
743+ return &lineRef{side: side, line: line, start: start, path: path}
703744 }
704745
705746 // placeComments puts every comment under its line when the diff shown has it,
@@ -757,21 +798,66 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
757798 return index[lineKey{path, side, line}]
758799 }
759800
801+ exists := noteIDs(comments)
802+ children := map[int64][]domain.Comment{}
760803 for _, c := range comments {
804+ if c.ReplyTo != 0 && !c.Deleted {
805+ children[c.ReplyTo] = append(children[c.ReplyTo], c)
806+ }
807+ if !c.Deleted {
808+ p.NComments++
809+ }
810+ if c.ReplyTo == 0 && !c.Deleted && !c.Resolved {
811+ p.NUnresolved++
812+ }
813+ }
814+ var view func(domain.Comment, bool) CommentView
815+ view = func(c domain.Comment, replyable bool) CommentView {
761816 cid := strconv.FormatInt(c.ID, 10)
762- v := CommentView{
763- ID: cid, Author: s.who(c.Author),
764- Parts: plainParts(c.Text.String()), Resolved: c.Resolved,
765- Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head,
817+ body, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"-", exists)
818+ if err != nil {
819+ body = ""
766820 }
821+ bodySplit, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"s-", exists)
822+ if err != nil {
823+ bodySplit = ""
824+ }
825+ v := CommentView{BodySplit: bodySplit, ID: cid, Author: s.who(c.Author), Body: body, Resolved: c.Resolved, Deleted: c.Deleted, Edited: !c.EditedAt.IsZero(), Verdict: c.Verdict.String(), Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head}
767826 v.When, v.WhenAt = whenOf(now, c.CreatedAt)
768827 if !c.CreatedAt.IsZero() {
769828 v.Abs = absolute(c.CreatedAt)
770829 }
771- p.NComments++
772- if !c.Resolved {
773- p.NUnresolved++
830+ if c.StartLine > 0 {
831+ v.Range = "lines " + itoa(c.StartLine) + "–" + itoa(c.Line)
774832 }
833+ if canWrite && !c.Deleted {
834+ from := windowStart(fileAt[c.Path])
835+ root := cid
836+ if c.ReplyTo != 0 {
837+ root = strconv.FormatInt(c.ReplyTo, 10)
838+ }
839+ if replyable {
840+ v.Quote = "> " + strings.ReplaceAll(c.Text.String(), "\n", "\n> ") + "\n\n^" + cid + " "
841+ }
842+ if c.ReplyTo == 0 {
843+ v.Reply = &CommentForm{ID: "reply-" + cid, Action: mrHref(repo, n, "comments"), Title: "Reply", ReplyTo: root, From: from}
844+ }
845+ if c.Author == p.NoteAuthor {
846+ v.Edit = &EditForm{Action: mrHref(repo, n, "comments", cid, "edit"), Text: c.Text.String(), From: from}
847+ v.Delete = &DeleteForm{Action: mrHref(repo, n, "comments", cid, "delete"), From: from}
848+ }
849+ }
850+ for _, reply := range children[c.ID] {
851+ v.Replies = append(v.Replies, view(reply, replyable))
852+ }
853+ return v
854+ }
855+ for _, c := range comments {
856+ if c.ReplyTo != 0 || c.Deleted && c.Verdict == domain.VerdictNone && len(children[c.ID]) == 0 {
857+ continue
858+ }
859+ cid := strconv.FormatInt(c.ID, 10)
860+ v := view(c, canWrite && !c.Deleted)
775861 general := c.Path == ""
776862 fi, listed := fileAt[c.Path]
777863 if !general && listed && p.Files[fi].Lazy {
@@ -789,7 +875,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
789875 v.Where = visibleText(c.Path) + ":" + itoa(c.Line) + " (" + side + ")"
790876 }
791877 }
792- if canWrite {
878+ if canWrite && !c.Deleted {
793879 v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"}
794880 if !general && listed {
795881 v.Resolve.From = windowStart(fi)
@@ -815,9 +901,18 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
815901 if on.side == "old" {
816902 side = domain.SideOld
817903 }
904+ start := on.start
905+ if start == 0 {
906+ start = on.line
907+ }
908+ for key, ln := range index {
909+ if key.path == on.path && key.side == side && key.line >= start && key.line <= on.line {
910+ ln.Selected = true
911+ }
912+ }
818913 if ln := find(on.path, side, on.line); ln != nil {
819914 ln.Form = &CommentForm{
820- ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
915+ StartLine: optionalLine(on.start), ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
821916 Title: "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",
822917 Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true,
823918 From: windowStart(fileAt[on.path]),
@@ -842,3 +937,17 @@ func userText(parts []TextPart) templ.Component {
842937 return err
843938 })
844939 }
940+
941+func noteIDs(comments []domain.Comment) func(int64) bool {
942+ ids := make(map[int64]bool, len(comments))
943+ for _, c := range comments {
944+ ids[c.ID] = true
945+ }
946+ return func(n int64) bool { return ids[n] }
947+}
948+func optionalLine(n int) string {
949+ if n == 0 {
950+ return ""
951+ }
952+ return itoa(n)
953+}
M internal/adapters/web/mr.templ +73 -6
@@ -229,10 +229,9 @@ templ lineNotes(notes []CommentView, form *CommentForm, suffix string) {
229229 }
230230
231231 // commentView is a note: a ruled block, under its line in the diff or in the
232-// list of comments. The text is plain: escaped, its line breaks kept, its
233-// http(s) addresses linked.
232+// list of comments. Body comes only from the safe Markdown renderer.
234233 templ commentView(c CommentView, suffix string) {
235- <div class="cmt" id={ "c" + c.ID + suffix } data-resolved={ boolText(c.Resolved) }>
234+ <div class="cmt" id={ "c" + c.ID + suffix } data-resolved={ boolText(c.Resolved) } data-thread?={ c.Reply != nil }>
236235 <p class="c-head">
237236 <span class="c-who">{ c.Author }</span>
238237 if c.WhenAt != "" {
@@ -248,7 +247,49 @@ templ commentView(c CommentView, suffix string) {
248247 { " " }<span class="c-where mute">{ c.Where }</span>
249248 }
250249 </p>
251- <p class="c-text">@userText(c.Parts)</p>
250+ if len(c.Parts) > 0 {
251+ <p class="c-text">@userText(c.Parts)</p>
252+ } else if c.Deleted {
253+ if c.Verdict != "" {
254+ <p class="mute">text deleted</p>
255+ } else {
256+ <p class="mute">deleted note</p>
257+ }
258+ } else {
259+ <div class="c-body prose">
260+ if suffix == "-s" {
261+ @templ.Raw(string(c.BodySplit))
262+ } else {
263+ @templ.Raw(string(c.Body))
264+ }
265+ </div>
266+ }
267+ if c.Edited {
268+ <span class="mute">edited</span>
269+ }
270+ if c.Verdict != "" {
271+ <span class="c-state">{ c.Verdict }</span>
272+ }
273+ if c.Range != "" {
274+ <span class="mute">{ c.Range }</span>
275+ }
276+ if c.Quote != "" {
277+ <button type="button" class="btn" data-js data-quote={ c.Quote }>Quote</button>
278+ }
279+ if c.Edit != nil {
280+ <details><summary>Edit</summary><form method="post" action={ templ.SafeURL(c.Edit.Action) }><input type="hidden" name="from" value={ itoa(c.Edit.From) }/><textarea name="text" maxlength="8000">{ c.Edit.Text }</textarea><button class="btn" type="submit">Save</button></form></details>
281+ }
282+ if c.Delete != nil {
283+ <details><summary>Delete</summary><form method="post" action={ templ.SafeURL(c.Delete.Action) }><input type="hidden" name="from" value={ itoa(c.Delete.From) }/><p>Delete this note?</p><button class="btn" type="submit">Confirm delete</button></form></details>
284+ }
285+ for _, reply := range c.Replies {
286+ @commentView(reply, suffix)
287+ }
288+ if c.Reply != nil {
289+ <details data-reply>
290+ <summary>Reply</summary>@commentForm(*c.Reply, suffix)
291+ </details>
292+ }
252293 if c.Resolve != nil {
253294 <form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>
254295 <input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/>
@@ -277,8 +318,12 @@ templ commentForm(f CommentForm, suffix string) {
277318 name="text"
278319 rows="3"
279320 maxlength="8000"
280- required
281- placeholder="plain text, 8000 characters at most"
321+ if f.Plain {
322+ required
323+ placeholder="plain text, 8000 characters at most"
324+ } else {
325+ placeholder="Markdown, 8000 characters at most"
326+ }
282327 if f.Focus {
283328 autofocus
284329 }
@@ -287,6 +332,13 @@ templ commentForm(f CommentForm, suffix string) {
287332 <input type="hidden" name="path" value={ f.Path }/>
288333 <input type="hidden" name="line" value={ f.Line }/>
289334 <input type="hidden" name="side" value={ f.Side }/>
335+ <label>from line <input type="number" name="start_line" min="1" value={ f.StartLine }/></label>
336+ }
337+ if f.ReplyTo != "" {
338+ <input type="hidden" name="reply_to" value={ f.ReplyTo }/>
339+ }
340+ if f.Path == "" && f.ReplyTo == "" && !f.Plain {
341+ <fieldset><legend>Verdict</legend><label><input type="radio" name="verdict" value="" checked/>Note</label><label><input type="radio" name="verdict" value="approve"/>Approve</label><label><input type="radio" name="verdict" value="changes_requested"/>Request changes</label></fieldset>
290342 }
291343 if f.From > 0 {
292344 <input type="hidden" name="from" value={ itoa(f.From) }/>
@@ -310,6 +362,21 @@ templ mrRail(p MRPage) {
310362 </h2>
311363 @FileList(p.Files)
312364 </section>
365+ if len(p.Verdicts) > 0 {
366+ <section>
367+ <h2>Verdicts</h2>
368+ <ul>
369+ for _, v := range p.Verdicts {
370+ <li>
371+ <a href={ templ.SafeURL(v.Href) }>{ v.Author }: { v.Label }</a>
372+ if v.Stale {
373+ <span class="mute">(stale)</span>
374+ }
375+ </li>
376+ }
377+ </ul>
378+ </section>
379+ }
313380 @MRChecks(p.Checks)
314381 <section aria-labelledby="r-commits">
315382 <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2>
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
@@ -184,6 +184,23 @@ func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string
184184 return "", back, formError("side must be new or old")
185185 }
186186 }
187+ if raw := r.PostFormValue("reply_to"); raw != "" {
188+ params.ReplyTo = commentID(raw)
189+ if params.ReplyTo == 0 {
190+ return "", back, formError("reply_to is not a note id")
191+ }
192+ }
193+ if raw := r.PostFormValue("start_line"); raw != "" {
194+ v, e := strconv.ParseInt(raw, 10, 32)
195+ if e != nil {
196+ return "", back, formError("start_line is not a number")
197+ }
198+ params.StartLine = int(v)
199+ }
200+ params.Verdict, err = domain.ParseVerdict(r.PostFormValue("verdict"))
201+ if err != nil {
202+ return "", back, err
203+ }
187204 name, err := domain.ParseRepoName(repo)
188205 if err != nil {
189206 return "", back, err
@@ -363,3 +380,33 @@ func formBool(s string) (bool, error) {
363380 func normalizeNewlines(s string) string {
364381 return strings.ReplaceAll(s, "\r\n", "\n")
365382 }
383+
384+// mrEditComment passes source text to the author-checked use case.
385+func (s *Server) mrEditComment(ctx context.Context, r *http.Request) (string, string, error) {
386+ n, err := number(r)
387+ if err != nil {
388+ return "", "", err
389+ }
390+ back := withFrom(mrHref(r.PathValue("repo"), n), formFrom(r))
391+ name, err := domain.ParseRepoName(r.PathValue("repo"))
392+ if err != nil {
393+ return "", back, err
394+ }
395+ c, err := s.cfg.MergeRequests.EditComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), normalizeNewlines(r.PostFormValue("text")))
396+ return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
397+}
398+
399+// mrDeleteComment passes the note id to the author-checked use case.
400+func (s *Server) mrDeleteComment(ctx context.Context, r *http.Request) (string, string, error) {
401+ n, err := number(r)
402+ if err != nil {
403+ return "", "", err
404+ }
405+ back := withFrom(mrHref(r.PathValue("repo"), n), formFrom(r))
406+ name, err := domain.ParseRepoName(r.PathValue("repo"))
407+ if err != nil {
408+ return "", back, err
409+ }
410+ c, err := s.cfg.MergeRequests.DeleteComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")))
411+ return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
412+}
M internal/adapters/web/mr_templ.go +801 -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,309 @@ 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 len(c.Parts) > 0 {
1410+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "<p class=\"c-text\">")
1411+ if templ_7745c5c3_Err != nil {
1412+ return templ_7745c5c3_Err
1413+ }
1414+ templ_7745c5c3_Err = userText(c.Parts).Render(ctx, templ_7745c5c3_Buffer)
1415+ if templ_7745c5c3_Err != nil {
1416+ return templ_7745c5c3_Err
1417+ }
1418+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "</p>")
1419+ if templ_7745c5c3_Err != nil {
1420+ return templ_7745c5c3_Err
1421+ }
1422+ } else if c.Deleted {
1423+ if c.Verdict != "" {
1424+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "<p class=\"mute\">text deleted</p>")
1425+ if templ_7745c5c3_Err != nil {
1426+ return templ_7745c5c3_Err
1427+ }
1428+ } else {
1429+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<p class=\"mute\">deleted note</p>")
1430+ if templ_7745c5c3_Err != nil {
1431+ return templ_7745c5c3_Err
1432+ }
1433+ }
1434+ } else {
1435+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<div class=\"c-body prose\">")
1436+ if templ_7745c5c3_Err != nil {
1437+ return templ_7745c5c3_Err
1438+ }
1439+ if suffix == "-s" {
1440+ templ_7745c5c3_Err = templ.Raw(string(c.BodySplit)).Render(ctx, templ_7745c5c3_Buffer)
1441+ if templ_7745c5c3_Err != nil {
1442+ return templ_7745c5c3_Err
1443+ }
1444+ } else {
1445+ templ_7745c5c3_Err = templ.Raw(string(c.Body)).Render(ctx, templ_7745c5c3_Buffer)
1446+ if templ_7745c5c3_Err != nil {
1447+ return templ_7745c5c3_Err
1448+ }
1449+ }
1450+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</div>")
1451+ if templ_7745c5c3_Err != nil {
1452+ return templ_7745c5c3_Err
1453+ }
14031454 }
1404- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "</p>")
1405- if templ_7745c5c3_Err != nil {
1406- return templ_7745c5c3_Err
1455+ if c.Edited {
1456+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<span class=\"mute\">edited</span> ")
1457+ if templ_7745c5c3_Err != nil {
1458+ return templ_7745c5c3_Err
1459+ }
14071460 }
1408- if c.Resolve != nil {
1409- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "<form class=\"c-act\" method=\"post\" action=\"")
1461+ if c.Verdict != "" {
1462+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<span class=\"c-state\">")
14101463 if templ_7745c5c3_Err != nil {
14111464 return templ_7745c5c3_Err
14121465 }
1413- var templ_7745c5c3_Var70 templ.SafeURL
1414- templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Resolve.Action))
1466+ var templ_7745c5c3_Var70 string
1467+ templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(c.Verdict)
14151468 if templ_7745c5c3_Err != nil {
1416- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 253, Col: 77}
1469+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 271, Col: 36}
14171470 }
14181471 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
14191472 if templ_7745c5c3_Err != nil {
14201473 return templ_7745c5c3_Err
14211474 }
1422- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\"><input type=\"hidden\" name=\"resolved\" value=\"")
1475+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</span> ")
1476+ if templ_7745c5c3_Err != nil {
1477+ return templ_7745c5c3_Err
1478+ }
1479+ }
1480+ if c.Range != "" {
1481+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "<span class=\"mute\">")
14231482 if templ_7745c5c3_Err != nil {
14241483 return templ_7745c5c3_Err
14251484 }
14261485 var templ_7745c5c3_Var71 string
1427- templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolText(c.Resolve.Value))
1486+ templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(c.Range)
1487+ if templ_7745c5c3_Err != nil {
1488+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 274, Col: 31}
1489+ }
1490+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))
1491+ if templ_7745c5c3_Err != nil {
1492+ return templ_7745c5c3_Err
1493+ }
1494+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</span> ")
1495+ if templ_7745c5c3_Err != nil {
1496+ return templ_7745c5c3_Err
1497+ }
1498+ }
1499+ if c.Quote != "" {
1500+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "<button type=\"button\" class=\"btn\" data-js data-quote=\"")
1501+ if templ_7745c5c3_Err != nil {
1502+ return templ_7745c5c3_Err
1503+ }
1504+ var templ_7745c5c3_Var72 string
1505+ templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Quote)
1506+ if templ_7745c5c3_Err != nil {
1507+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 277, Col: 65}
1508+ }
1509+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)
1510+ if templ_7745c5c3_Err != nil {
1511+ return templ_7745c5c3_Err
1512+ }
1513+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "\">Quote</button> ")
1514+ if templ_7745c5c3_Err != nil {
1515+ return templ_7745c5c3_Err
1516+ }
1517+ }
1518+ if c.Edit != nil {
1519+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "<details><summary>Edit</summary><form method=\"post\" action=\"")
1520+ if templ_7745c5c3_Err != nil {
1521+ return templ_7745c5c3_Err
1522+ }
1523+ var templ_7745c5c3_Var73 templ.SafeURL
1524+ templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Edit.Action))
1525+ if templ_7745c5c3_Err != nil {
1526+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 280, Col: 92}
1527+ }
1528+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73))
1529+ if templ_7745c5c3_Err != nil {
1530+ return templ_7745c5c3_Err
1531+ }
1532+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "\"><input type=\"hidden\" name=\"from\" value=\"")
1533+ if templ_7745c5c3_Err != nil {
1534+ return templ_7745c5c3_Err
1535+ }
1536+ var templ_7745c5c3_Var74 string
1537+ templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(c.Edit.From))
1538+ if templ_7745c5c3_Err != nil {
1539+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 280, Col: 153}
1540+ }
1541+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var74)
1542+ if templ_7745c5c3_Err != nil {
1543+ return templ_7745c5c3_Err
1544+ }
1545+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "\"><textarea name=\"text\" maxlength=\"8000\">")
1546+ if templ_7745c5c3_Err != nil {
1547+ return templ_7745c5c3_Err
1548+ }
1549+ var templ_7745c5c3_Var75 string
1550+ templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(c.Edit.Text)
1551+ if templ_7745c5c3_Err != nil {
1552+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 280, Col: 209}
1553+ }
1554+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))
1555+ if templ_7745c5c3_Err != nil {
1556+ return templ_7745c5c3_Err
1557+ }
1558+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</textarea><button class=\"btn\" type=\"submit\">Save</button></form></details> ")
1559+ if templ_7745c5c3_Err != nil {
1560+ return templ_7745c5c3_Err
1561+ }
1562+ }
1563+ if c.Delete != nil {
1564+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "<details><summary>Delete</summary><form method=\"post\" action=\"")
1565+ if templ_7745c5c3_Err != nil {
1566+ return templ_7745c5c3_Err
1567+ }
1568+ var templ_7745c5c3_Var76 templ.SafeURL
1569+ templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Delete.Action))
1570+ if templ_7745c5c3_Err != nil {
1571+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 283, Col: 96}
1572+ }
1573+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
1574+ if templ_7745c5c3_Err != nil {
1575+ return templ_7745c5c3_Err
1576+ }
1577+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "\"><input type=\"hidden\" name=\"from\" value=\"")
1578+ if templ_7745c5c3_Err != nil {
1579+ return templ_7745c5c3_Err
1580+ }
1581+ var templ_7745c5c3_Var77 string
1582+ templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(c.Delete.From))
1583+ if templ_7745c5c3_Err != nil {
1584+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 283, Col: 159}
1585+ }
1586+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var77)
1587+ if templ_7745c5c3_Err != nil {
1588+ return templ_7745c5c3_Err
1589+ }
1590+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\"><p>Delete this note?</p><button class=\"btn\" type=\"submit\">Confirm delete</button></form></details> ")
1591+ if templ_7745c5c3_Err != nil {
1592+ return templ_7745c5c3_Err
1593+ }
1594+ }
1595+ for _, reply := range c.Replies {
1596+ templ_7745c5c3_Err = commentView(reply, suffix).Render(ctx, templ_7745c5c3_Buffer)
1597+ if templ_7745c5c3_Err != nil {
1598+ return templ_7745c5c3_Err
1599+ }
1600+ }
1601+ if c.Reply != nil {
1602+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "<details data-reply><summary>Reply</summary>")
1603+ if templ_7745c5c3_Err != nil {
1604+ return templ_7745c5c3_Err
1605+ }
1606+ templ_7745c5c3_Err = commentForm(*c.Reply, suffix).Render(ctx, templ_7745c5c3_Buffer)
1607+ if templ_7745c5c3_Err != nil {
1608+ return templ_7745c5c3_Err
1609+ }
1610+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "</details> ")
1611+ if templ_7745c5c3_Err != nil {
1612+ return templ_7745c5c3_Err
1613+ }
1614+ }
1615+ if c.Resolve != nil {
1616+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "<form class=\"c-act\" method=\"post\" action=\"")
1617+ if templ_7745c5c3_Err != nil {
1618+ return templ_7745c5c3_Err
1619+ }
1620+ var templ_7745c5c3_Var78 templ.SafeURL
1621+ templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Resolve.Action))
14281622 if templ_7745c5c3_Err != nil {
1429- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 254, Col: 74}
1623+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 294, Col: 77}
14301624 }
1431- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var71)
1625+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
14321626 if templ_7745c5c3_Err != nil {
14331627 return templ_7745c5c3_Err
14341628 }
1435- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\"> ")
1629+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "\"><input type=\"hidden\" name=\"resolved\" value=\"")
1630+ if templ_7745c5c3_Err != nil {
1631+ return templ_7745c5c3_Err
1632+ }
1633+ var templ_7745c5c3_Var79 string
1634+ templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolText(c.Resolve.Value))
1635+ if templ_7745c5c3_Err != nil {
1636+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 295, Col: 74}
1637+ }
1638+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var79)
1639+ if templ_7745c5c3_Err != nil {
1640+ return templ_7745c5c3_Err
1641+ }
1642+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "\"> ")
14361643 if templ_7745c5c3_Err != nil {
14371644 return templ_7745c5c3_Err
14381645 }
14391646 if c.Resolve.From > 0 {
1440- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "<input type=\"hidden\" name=\"from\" value=\"")
1647+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<input type=\"hidden\" name=\"from\" value=\"")
14411648 if templ_7745c5c3_Err != nil {
14421649 return templ_7745c5c3_Err
14431650 }
1444- var templ_7745c5c3_Var72 string
1445- templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(c.Resolve.From))
1651+ var templ_7745c5c3_Var80 string
1652+ templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(c.Resolve.From))
14461653 if templ_7745c5c3_Err != nil {
1447- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 256, Col: 66}
1654+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 297, Col: 66}
14481655 }
1449- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)
1656+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
14501657 if templ_7745c5c3_Err != nil {
14511658 return templ_7745c5c3_Err
14521659 }
1453- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "\"> ")
1660+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "\"> ")
14541661 if templ_7745c5c3_Err != nil {
14551662 return templ_7745c5c3_Err
14561663 }
14571664 }
1458- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<button type=\"submit\" class=\"btn\">")
1665+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "<button type=\"submit\" class=\"btn\">")
14591666 if templ_7745c5c3_Err != nil {
14601667 return templ_7745c5c3_Err
14611668 }
1462- var templ_7745c5c3_Var73 string
1463- templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(c.Resolve.Label)
1669+ var templ_7745c5c3_Var81 string
1670+ templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(c.Resolve.Label)
14641671 if templ_7745c5c3_Err != nil {
1465- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 258, Col: 55}
1672+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 299, Col: 55}
14661673 }
1467- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73))
1674+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
14681675 if templ_7745c5c3_Err != nil {
14691676 return templ_7745c5c3_Err
14701677 }
1471- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</button></form>")
1678+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "</button></form>")
14721679 if templ_7745c5c3_Err != nil {
14731680 return templ_7745c5c3_Err
14741681 }
14751682 }
1476- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "</div>")
1683+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "</div>")
14771684 if templ_7745c5c3_Err != nil {
14781685 return templ_7745c5c3_Err
14791686 }
@@ -1505,165 +1712,214 @@ func commentForm(f CommentForm, suffix string) templ.Component {
15051712 }()
15061713 }
15071714 ctx = templ.InitializeContext(ctx)
1508- templ_7745c5c3_Var74 := templ.GetChildren(ctx)
1509- if templ_7745c5c3_Var74 == nil {
1510- templ_7745c5c3_Var74 = templ.NopComponent
1715+ templ_7745c5c3_Var82 := templ.GetChildren(ctx)
1716+ if templ_7745c5c3_Var82 == nil {
1717+ templ_7745c5c3_Var82 = templ.NopComponent
15111718 }
15121719 ctx = templ.ClearChildren(ctx)
1513- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<form class=\"cform\" method=\"post\" action=\"")
1720+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "<form class=\"cform\" method=\"post\" action=\"")
15141721 if templ_7745c5c3_Err != nil {
15151722 return templ_7745c5c3_Err
15161723 }
1517- var templ_7745c5c3_Var75 templ.SafeURL
1518- templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Action))
1724+ var templ_7745c5c3_Var83 templ.SafeURL
1725+ templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Action))
15191726 if templ_7745c5c3_Err != nil {
1520- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 273, Col: 67}
1727+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 314, Col: 67}
15211728 }
1522- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))
1729+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83))
15231730 if templ_7745c5c3_Err != nil {
15241731 return templ_7745c5c3_Err
15251732 }
1526- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "\"><label for=\"")
1733+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "\"><label for=\"")
15271734 if templ_7745c5c3_Err != nil {
15281735 return templ_7745c5c3_Err
15291736 }
1530- var templ_7745c5c3_Var76 string
1531- templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID + suffix)
1737+ var templ_7745c5c3_Var84 string
1738+ templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID + suffix)
15321739 if templ_7745c5c3_Err != nil {
1533- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 274, Col: 28}
1740+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 315, Col: 28}
15341741 }
1535- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var76)
1742+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var84)
15361743 if templ_7745c5c3_Err != nil {
15371744 return templ_7745c5c3_Err
15381745 }
1539- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "\">")
1746+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "\">")
15401747 if templ_7745c5c3_Err != nil {
15411748 return templ_7745c5c3_Err
15421749 }
1543- var templ_7745c5c3_Var77 string
1544- templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(f.Title)
1750+ var templ_7745c5c3_Var85 string
1751+ templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(f.Title)
15451752 if templ_7745c5c3_Err != nil {
1546- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 274, Col: 40}
1753+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 315, Col: 40}
15471754 }
1548- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77))
1755+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85))
15491756 if templ_7745c5c3_Err != nil {
15501757 return templ_7745c5c3_Err
15511758 }
1552- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</label> <textarea id=\"")
1759+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "</label> <textarea id=\"")
15531760 if templ_7745c5c3_Err != nil {
15541761 return templ_7745c5c3_Err
15551762 }
1556- var templ_7745c5c3_Var78 string
1557- templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID + suffix)
1763+ var templ_7745c5c3_Var86 string
1764+ templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID + suffix)
15581765 if templ_7745c5c3_Err != nil {
1559- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 276, Col: 21}
1766+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 317, Col: 21}
15601767 }
1561- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var78)
1768+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var86)
15621769 if templ_7745c5c3_Err != nil {
15631770 return templ_7745c5c3_Err
15641771 }
1565- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "\" name=\"text\" rows=\"3\" maxlength=\"8000\" required placeholder=\"plain text, 8000 characters at most\"")
1772+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "\" name=\"text\" rows=\"3\" maxlength=\"8000\"")
15661773 if templ_7745c5c3_Err != nil {
15671774 return templ_7745c5c3_Err
15681775 }
1776+ if f.Plain {
1777+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, " required placeholder=\"plain text, 8000 characters at most\"")
1778+ if templ_7745c5c3_Err != nil {
1779+ return templ_7745c5c3_Err
1780+ }
1781+ } else {
1782+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, " placeholder=\"Markdown, 8000 characters at most\"")
1783+ if templ_7745c5c3_Err != nil {
1784+ return templ_7745c5c3_Err
1785+ }
1786+ }
15691787 if f.Focus {
1570- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, " autofocus")
1788+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, " autofocus")
15711789 if templ_7745c5c3_Err != nil {
15721790 return templ_7745c5c3_Err
15731791 }
15741792 }
1575- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "></textarea> ")
1793+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "></textarea> ")
15761794 if templ_7745c5c3_Err != nil {
15771795 return templ_7745c5c3_Err
15781796 }
15791797 if f.Path != "" {
1580- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "<input type=\"hidden\" name=\"path\" value=\"")
1798+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "<input type=\"hidden\" name=\"path\" value=\"")
15811799 if templ_7745c5c3_Err != nil {
15821800 return templ_7745c5c3_Err
15831801 }
1584- var templ_7745c5c3_Var79 string
1585- templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path)
1802+ var templ_7745c5c3_Var87 string
1803+ templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path)
15861804 if templ_7745c5c3_Err != nil {
1587- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 287, Col: 50}
1805+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 332, Col: 50}
15881806 }
1589- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var79)
1807+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var87)
15901808 if templ_7745c5c3_Err != nil {
15911809 return templ_7745c5c3_Err
15921810 }
1593- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "\"> <input type=\"hidden\" name=\"line\" value=\"")
1811+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "\"> <input type=\"hidden\" name=\"line\" value=\"")
15941812 if templ_7745c5c3_Err != nil {
15951813 return templ_7745c5c3_Err
15961814 }
1597- var templ_7745c5c3_Var80 string
1598- templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Line)
1815+ var templ_7745c5c3_Var88 string
1816+ templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Line)
15991817 if templ_7745c5c3_Err != nil {
1600- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 288, Col: 50}
1818+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 333, Col: 50}
16011819 }
1602- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
1820+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var88)
16031821 if templ_7745c5c3_Err != nil {
16041822 return templ_7745c5c3_Err
16051823 }
1606- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "\"> <input type=\"hidden\" name=\"side\" value=\"")
1824+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "\"> <input type=\"hidden\" name=\"side\" value=\"")
16071825 if templ_7745c5c3_Err != nil {
16081826 return templ_7745c5c3_Err
16091827 }
1610- var templ_7745c5c3_Var81 string
1611- templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Side)
1828+ var templ_7745c5c3_Var89 string
1829+ templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Side)
16121830 if templ_7745c5c3_Err != nil {
1613- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 289, Col: 50}
1831+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 334, Col: 50}
16141832 }
1615- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var81)
1833+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var89)
16161834 if templ_7745c5c3_Err != nil {
16171835 return templ_7745c5c3_Err
16181836 }
1619- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "\"> ")
1837+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "\"> <label>from line <input type=\"number\" name=\"start_line\" min=\"1\" value=\"")
1838+ if templ_7745c5c3_Err != nil {
1839+ return templ_7745c5c3_Err
1840+ }
1841+ var templ_7745c5c3_Var90 string
1842+ templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.StartLine)
1843+ if templ_7745c5c3_Err != nil {
1844+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 335, Col: 86}
1845+ }
1846+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var90)
1847+ if templ_7745c5c3_Err != nil {
1848+ return templ_7745c5c3_Err
1849+ }
1850+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "\"></label> ")
1851+ if templ_7745c5c3_Err != nil {
1852+ return templ_7745c5c3_Err
1853+ }
1854+ }
1855+ if f.ReplyTo != "" {
1856+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "<input type=\"hidden\" name=\"reply_to\" value=\"")
1857+ if templ_7745c5c3_Err != nil {
1858+ return templ_7745c5c3_Err
1859+ }
1860+ var templ_7745c5c3_Var91 string
1861+ templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ReplyTo)
1862+ if templ_7745c5c3_Err != nil {
1863+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 338, Col: 57}
1864+ }
1865+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var91)
1866+ if templ_7745c5c3_Err != nil {
1867+ return templ_7745c5c3_Err
1868+ }
1869+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "\"> ")
1870+ if templ_7745c5c3_Err != nil {
1871+ return templ_7745c5c3_Err
1872+ }
1873+ }
1874+ if f.Path == "" && f.ReplyTo == "" && !f.Plain {
1875+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "<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>")
16201876 if templ_7745c5c3_Err != nil {
16211877 return templ_7745c5c3_Err
16221878 }
16231879 }
16241880 if f.From > 0 {
1625- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<input type=\"hidden\" name=\"from\" value=\"")
1881+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "<input type=\"hidden\" name=\"from\" value=\"")
16261882 if templ_7745c5c3_Err != nil {
16271883 return templ_7745c5c3_Err
16281884 }
1629- var templ_7745c5c3_Var82 string
1630- templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(f.From))
1885+ var templ_7745c5c3_Var92 string
1886+ templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(f.From))
16311887 if templ_7745c5c3_Err != nil {
1632- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 292, Col: 56}
1888+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 344, Col: 56}
16331889 }
1634- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82)
1890+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var92)
16351891 if templ_7745c5c3_Err != nil {
16361892 return templ_7745c5c3_Err
16371893 }
1638- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\">")
1894+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "\">")
16391895 if templ_7745c5c3_Err != nil {
16401896 return templ_7745c5c3_Err
16411897 }
16421898 }
1643- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "<div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Comment</button> ")
1899+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "<div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Comment</button> ")
16441900 if templ_7745c5c3_Err != nil {
16451901 return templ_7745c5c3_Err
16461902 }
16471903 if f.Cancel != "" {
1648- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<a class=\"btn\" href=\"")
1904+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "<a class=\"btn\" href=\"")
16491905 if templ_7745c5c3_Err != nil {
16501906 return templ_7745c5c3_Err
16511907 }
1652- var templ_7745c5c3_Var83 templ.SafeURL
1653- templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Cancel))
1908+ var templ_7745c5c3_Var93 templ.SafeURL
1909+ templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Cancel))
16541910 if templ_7745c5c3_Err != nil {
1655- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 297, Col: 49}
1911+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 349, Col: 49}
16561912 }
1657- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83))
1913+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))
16581914 if templ_7745c5c3_Err != nil {
16591915 return templ_7745c5c3_Err
16601916 }
1661- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "\">Cancel</a>")
1917+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "\">Cancel</a>")
16621918 if templ_7745c5c3_Err != nil {
16631919 return templ_7745c5c3_Err
16641920 }
16651921 }
1666- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "</div></form>")
1922+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "</div></form>")
16671923 if templ_7745c5c3_Err != nil {
16681924 return templ_7745c5c3_Err
16691925 }
@@ -1687,25 +1943,25 @@ func mrRail(p MRPage) templ.Component {
16871943 }()
16881944 }
16891945 ctx = templ.InitializeContext(ctx)
1690- templ_7745c5c3_Var84 := templ.GetChildren(ctx)
1691- if templ_7745c5c3_Var84 == nil {
1692- templ_7745c5c3_Var84 = templ.NopComponent
1946+ templ_7745c5c3_Var94 := templ.GetChildren(ctx)
1947+ if templ_7745c5c3_Var94 == nil {
1948+ templ_7745c5c3_Var94 = templ.NopComponent
16931949 }
16941950 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\">")
1951+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "<section aria-labelledby=\"r-files\" class=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
16961952 if templ_7745c5c3_Err != nil {
16971953 return templ_7745c5c3_Err
16981954 }
1699- var templ_7745c5c3_Var85 string
1700- templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
1955+ var templ_7745c5c3_Var95 string
1956+ templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
17011957 if templ_7745c5c3_Err != nil {
1702- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 306, Col: 49}
1958+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 358, Col: 49}
17031959 }
1704- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85))
1960+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))
17051961 if templ_7745c5c3_Err != nil {
17061962 return templ_7745c5c3_Err
17071963 }
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>")
1964+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "</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>")
17091965 if templ_7745c5c3_Err != nil {
17101966 return templ_7745c5c3_Err
17111967 }
@@ -1713,82 +1969,147 @@ func mrRail(p MRPage) templ.Component {
17131969 if templ_7745c5c3_Err != nil {
17141970 return templ_7745c5c3_Err
17151971 }
1716- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</section>")
1972+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "</section>")
17171973 if templ_7745c5c3_Err != nil {
17181974 return templ_7745c5c3_Err
17191975 }
1976+ if len(p.Verdicts) > 0 {
1977+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "<section><h2>Verdicts</h2><ul>")
1978+ if templ_7745c5c3_Err != nil {
1979+ return templ_7745c5c3_Err
1980+ }
1981+ for _, v := range p.Verdicts {
1982+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "<li><a href=\"")
1983+ if templ_7745c5c3_Err != nil {
1984+ return templ_7745c5c3_Err
1985+ }
1986+ var templ_7745c5c3_Var96 templ.SafeURL
1987+ templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(v.Href))
1988+ if templ_7745c5c3_Err != nil {
1989+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 371, Col: 37}
1990+ }
1991+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))
1992+ if templ_7745c5c3_Err != nil {
1993+ return templ_7745c5c3_Err
1994+ }
1995+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "\">")
1996+ if templ_7745c5c3_Err != nil {
1997+ return templ_7745c5c3_Err
1998+ }
1999+ var templ_7745c5c3_Var97 string
2000+ templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(v.Author)
2001+ if templ_7745c5c3_Err != nil {
2002+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 371, Col: 50}
2003+ }
2004+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))
2005+ if templ_7745c5c3_Err != nil {
2006+ return templ_7745c5c3_Err
2007+ }
2008+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, ": ")
2009+ if templ_7745c5c3_Err != nil {
2010+ return templ_7745c5c3_Err
2011+ }
2012+ var templ_7745c5c3_Var98 string
2013+ templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(v.Label)
2014+ if templ_7745c5c3_Err != nil {
2015+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 371, Col: 63}
2016+ }
2017+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98))
2018+ if templ_7745c5c3_Err != nil {
2019+ return templ_7745c5c3_Err
2020+ }
2021+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "</a> ")
2022+ if templ_7745c5c3_Err != nil {
2023+ return templ_7745c5c3_Err
2024+ }
2025+ if v.Stale {
2026+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "<span class=\"mute\">(stale)</span>")
2027+ if templ_7745c5c3_Err != nil {
2028+ return templ_7745c5c3_Err
2029+ }
2030+ }
2031+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "</li>")
2032+ if templ_7745c5c3_Err != nil {
2033+ return templ_7745c5c3_Err
2034+ }
2035+ }
2036+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "</ul></section>")
2037+ if templ_7745c5c3_Err != nil {
2038+ return templ_7745c5c3_Err
2039+ }
2040+ }
17202041 templ_7745c5c3_Err = MRChecks(p.Checks).Render(ctx, templ_7745c5c3_Buffer)
17212042 if templ_7745c5c3_Err != nil {
17222043 return templ_7745c5c3_Err
17232044 }
1724- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "<section aria-labelledby=\"r-commits\"><h2 id=\"r-commits\">Commits <span class=\"count\">")
2045+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "<section aria-labelledby=\"r-commits\"><h2 id=\"r-commits\">Commits <span class=\"count\">")
17252046 if templ_7745c5c3_Err != nil {
17262047 return templ_7745c5c3_Err
17272048 }
1728- var templ_7745c5c3_Var86 string
1729- templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Commits)))
2049+ var templ_7745c5c3_Var99 string
2050+ templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Commits)))
17302051 if templ_7745c5c3_Err != nil {
1731- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 315, Col: 71}
2052+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 382, Col: 71}
17322053 }
1733- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
2054+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))
17342055 if templ_7745c5c3_Err != nil {
17352056 return templ_7745c5c3_Err
17362057 }
1737- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "</span></h2><ul class=\"pcommits\">")
2058+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "</span></h2><ul class=\"pcommits\">")
17382059 if templ_7745c5c3_Err != nil {
17392060 return templ_7745c5c3_Err
17402061 }
17412062 for _, c := range p.Commits {
1742- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "<li")
2063+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "<li")
17432064 if templ_7745c5c3_Err != nil {
17442065 return templ_7745c5c3_Err
17452066 }
17462067 if c.New {
1747- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, " data-new")
2068+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, " data-new")
17482069 if templ_7745c5c3_Err != nil {
17492070 return templ_7745c5c3_Err
17502071 }
17512072 }
1752- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "><a href=\"")
2073+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "><a href=\"")
17532074 if templ_7745c5c3_Err != nil {
17542075 return templ_7745c5c3_Err
17552076 }
1756- var templ_7745c5c3_Var87 templ.SafeURL
1757- templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
2077+ var templ_7745c5c3_Var100 templ.SafeURL
2078+ templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
17582079 if templ_7745c5c3_Err != nil {
1759- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 319, Col: 36}
2080+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 386, Col: 36}
17602081 }
1761- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87))
2082+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100))
17622083 if templ_7745c5c3_Err != nil {
17632084 return templ_7745c5c3_Err
17642085 }
1765- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "\">")
2086+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "\">")
17662087 if templ_7745c5c3_Err != nil {
17672088 return templ_7745c5c3_Err
17682089 }
1769- var templ_7745c5c3_Var88 string
1770- templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
2090+ var templ_7745c5c3_Var101 string
2091+ templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
17712092 if templ_7745c5c3_Err != nil {
1772- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 319, Col: 48}
2093+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 386, Col: 48}
17732094 }
1774- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88))
2095+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101))
17752096 if templ_7745c5c3_Err != nil {
17762097 return templ_7745c5c3_Err
17772098 }
1778- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "</a> <span class=\"s\">")
2099+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "</a> <span class=\"s\">")
17792100 if templ_7745c5c3_Err != nil {
17802101 return templ_7745c5c3_Err
17812102 }
1782- var templ_7745c5c3_Var89 string
1783- templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
2103+ var templ_7745c5c3_Var102 string
2104+ templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
17842105 if templ_7745c5c3_Err != nil {
1785- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 320, Col: 32}
2106+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 387, Col: 32}
17862107 }
1787- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))
2108+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102))
17882109 if templ_7745c5c3_Err != nil {
17892110 return templ_7745c5c3_Err
17902111 }
1791- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</span> ")
2112+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "</span> ")
17922113 if templ_7745c5c3_Err != nil {
17932114 return templ_7745c5c3_Err
17942115 }
@@ -1798,138 +2119,138 @@ func mrRail(p MRPage) templ.Component {
17982119 return templ_7745c5c3_Err
17992120 }
18002121 }
1801- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<span class=\"mute\">")
2122+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "<span class=\"mute\">")
18022123 if templ_7745c5c3_Err != nil {
18032124 return templ_7745c5c3_Err
18042125 }
1805- var templ_7745c5c3_Var90 string
1806- templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
2126+ var templ_7745c5c3_Var103 string
2127+ templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
18072128 if templ_7745c5c3_Err != nil {
1808- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 325, Col: 16}
2129+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 392, Col: 16}
18092130 }
1810- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90))
2131+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
18112132 if templ_7745c5c3_Err != nil {
18122133 return templ_7745c5c3_Err
18132134 }
1814- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, " ")
2135+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, " ")
18152136 if templ_7745c5c3_Err != nil {
18162137 return templ_7745c5c3_Err
18172138 }
18182139 if c.WhenAt != "" {
1819- var templ_7745c5c3_Var91 string
1820- templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
2140+ var templ_7745c5c3_Var104 string
2141+ templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
18212142 if templ_7745c5c3_Err != nil {
1822- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 327, Col: 15}
2143+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 394, Col: 15}
18232144 }
1824- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91))
2145+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))
18252146 if templ_7745c5c3_Err != nil {
18262147 return templ_7745c5c3_Err
18272148 }
1828- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "<time datetime=\"")
2149+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "<time datetime=\"")
18292150 if templ_7745c5c3_Err != nil {
18302151 return templ_7745c5c3_Err
18312152 }
1832- var templ_7745c5c3_Var92 string
1833- templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
2153+ var templ_7745c5c3_Var105 string
2154+ templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
18342155 if templ_7745c5c3_Err != nil {
1835- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 327, Col: 42}
2156+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 394, Col: 42}
18362157 }
1837- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var92)
2158+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var105)
18382159 if templ_7745c5c3_Err != nil {
18392160 return templ_7745c5c3_Err
18402161 }
1841- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "\">")
2162+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "\">")
18422163 if templ_7745c5c3_Err != nil {
18432164 return templ_7745c5c3_Err
18442165 }
1845- var templ_7745c5c3_Var93 string
1846- templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
2166+ var templ_7745c5c3_Var106 string
2167+ templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
18472168 if templ_7745c5c3_Err != nil {
1848- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 327, Col: 53}
2169+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 394, Col: 53}
18492170 }
1850- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))
2171+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106))
18512172 if templ_7745c5c3_Err != nil {
18522173 return templ_7745c5c3_Err
18532174 }
1854- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "</time> ")
2175+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "</time> ")
18552176 if templ_7745c5c3_Err != nil {
18562177 return templ_7745c5c3_Err
18572178 }
18582179 }
18592180 if c.New {
1860- var templ_7745c5c3_Var94 string
1861- templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.JoinStringErrs(" · new")
2181+ var templ_7745c5c3_Var107 string
2182+ templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinStringErrs(" · new")
18622183 if templ_7745c5c3_Err != nil {
1863- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 330, Col: 18}
2184+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 397, Col: 18}
18642185 }
1865- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var94))
2186+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107))
18662187 if templ_7745c5c3_Err != nil {
18672188 return templ_7745c5c3_Err
18682189 }
18692190 }
1870- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "</span></li>")
2191+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "</span></li>")
18712192 if templ_7745c5c3_Err != nil {
18722193 return templ_7745c5c3_Err
18732194 }
18742195 }
1875- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "</ul>")
2196+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "</ul>")
18762197 if templ_7745c5c3_Err != nil {
18772198 return templ_7745c5c3_Err
18782199 }
18792200 if len(p.Commits) == 0 {
1880- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "<p class=\"dim\">no commit to merge.</p>")
2201+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "<p class=\"dim\">no commit to merge.</p>")
18812202 if templ_7745c5c3_Err != nil {
18822203 return templ_7745c5c3_Err
18832204 }
18842205 }
18852206 if p.CommitsNote != "" {
1886- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "<p class=\"note\">")
2207+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "<p class=\"note\">")
18872208 if templ_7745c5c3_Err != nil {
18882209 return templ_7745c5c3_Err
18892210 }
1890- var templ_7745c5c3_Var95 string
1891- templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(p.CommitsNote)
2211+ var templ_7745c5c3_Var108 string
2212+ templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(p.CommitsNote)
18922213 if templ_7745c5c3_Err != nil {
1893- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 340, Col: 34}
2214+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 407, Col: 34}
18942215 }
1895- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))
2216+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
18962217 if templ_7745c5c3_Err != nil {
18972218 return templ_7745c5c3_Err
18982219 }
1899- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "</p>")
2220+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "</p>")
19002221 if templ_7745c5c3_Err != nil {
19012222 return templ_7745c5c3_Err
19022223 }
19032224 }
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>")
2225+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "</section><section aria-labelledby=\"r-settings\"><h2 id=\"r-settings\">Settings</h2><dl class=\"kv\"><dt>modes</dt><dd>")
19052226 if templ_7745c5c3_Err != nil {
19062227 return templ_7745c5c3_Err
19072228 }
1908- var templ_7745c5c3_Var96 string
1909- templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(p.Modes, ", "))
2229+ var templ_7745c5c3_Var109 string
2230+ templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(p.Modes, ", "))
19102231 if templ_7745c5c3_Err != nil {
1911- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 347, Col: 36}
2232+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 414, Col: 36}
19122233 }
1913- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))
2234+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109))
19142235 if templ_7745c5c3_Err != nil {
19152236 return templ_7745c5c3_Err
19162237 }
1917- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "</dd><dt>checks</dt><dd>")
2238+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "</dd><dt>checks</dt><dd>")
19182239 if templ_7745c5c3_Err != nil {
19192240 return templ_7745c5c3_Err
19202241 }
19212242 if p.ChecksBlock {
1922- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "failing ones block the merge")
2243+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "failing ones block the merge")
19232244 if templ_7745c5c3_Err != nil {
19242245 return templ_7745c5c3_Err
19252246 }
19262247 } else {
1927- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "do not block the merge")
2248+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, "do not block the merge")
19282249 if templ_7745c5c3_Err != nil {
19292250 return templ_7745c5c3_Err
19302251 }
19312252 }
1932- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "</dd></dl></section>")
2253+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "</dd></dl></section>")
19332254 if templ_7745c5c3_Err != nil {
19342255 return templ_7745c5c3_Err
19352256 }
@@ -1957,51 +2278,51 @@ func mrBar(p MRPage) templ.Component {
19572278 }()
19582279 }
19592280 ctx = templ.InitializeContext(ctx)
1960- templ_7745c5c3_Var97 := templ.GetChildren(ctx)
1961- if templ_7745c5c3_Var97 == nil {
1962- templ_7745c5c3_Var97 = templ.NopComponent
2281+ templ_7745c5c3_Var110 := templ.GetChildren(ctx)
2282+ if templ_7745c5c3_Var110 == nil {
2283+ templ_7745c5c3_Var110 = templ.NopComponent
19632284 }
19642285 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\">")
2286+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "<div class=\"diff-tools mr-bar\" role=\"group\" aria-label=\"Diff view and merge\"><span class=\"dim\">")
19662287 if templ_7745c5c3_Err != nil {
19672288 return templ_7745c5c3_Err
19682289 }
1969- var templ_7745c5c3_Var98 string
1970- templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
2290+ var templ_7745c5c3_Var111 string
2291+ templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
19712292 if templ_7745c5c3_Err != nil {
1972- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 366, Col: 55}
2293+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 433, Col: 55}
19732294 }
1974- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98))
2295+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111))
19752296 if templ_7745c5c3_Err != nil {
19762297 return templ_7745c5c3_Err
19772298 }
1978- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, " · <span class=\"plus\">+")
2299+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, " · <span class=\"plus\">+")
19792300 if templ_7745c5c3_Err != nil {
19802301 return templ_7745c5c3_Err
19812302 }
1982- var templ_7745c5c3_Var99 string
1983- templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
2303+ var templ_7745c5c3_Var112 string
2304+ templ_7745c5c3_Var112, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
19842305 if templ_7745c5c3_Err != nil {
1985- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 366, Col: 95}
2306+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 433, Col: 95}
19862307 }
1987- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))
2308+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var112))
19882309 if templ_7745c5c3_Err != nil {
19892310 return templ_7745c5c3_Err
19902311 }
1991- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "</span> <span class=\"minus\">-")
2312+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "</span> <span class=\"minus\">-")
19922313 if templ_7745c5c3_Err != nil {
19932314 return templ_7745c5c3_Err
19942315 }
1995- var templ_7745c5c3_Var100 string
1996- templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
2316+ var templ_7745c5c3_Var113 string
2317+ templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
19972318 if templ_7745c5c3_Err != nil {
1998- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 366, Col: 140}
2319+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 433, Col: 140}
19992320 }
2000- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100))
2321+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var113))
20012322 if templ_7745c5c3_Err != nil {
20022323 return templ_7745c5c3_Err
20032324 }
2004- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "</span></span>")
2325+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "</span></span>")
20052326 if templ_7745c5c3_Err != nil {
20062327 return templ_7745c5c3_Err
20072328 }
@@ -2017,7 +2338,7 @@ func mrBar(p MRPage) templ.Component {
20172338 if templ_7745c5c3_Err != nil {
20182339 return templ_7745c5c3_Err
20192340 }
2020- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "<span class=\"grow\"></span>")
2341+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "<span class=\"grow\"></span>")
20212342 if templ_7745c5c3_Err != nil {
20222343 return templ_7745c5c3_Err
20232344 }
@@ -2030,7 +2351,7 @@ func mrBar(p MRPage) templ.Component {
20302351 if templ_7745c5c3_Err != nil {
20312352 return templ_7745c5c3_Err
20322353 }
2033- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, " ")
2354+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, " ")
20342355 if templ_7745c5c3_Err != nil {
20352356 return templ_7745c5c3_Err
20362357 }
@@ -2039,7 +2360,7 @@ func mrBar(p MRPage) templ.Component {
20392360 return templ_7745c5c3_Err
20402361 }
20412362 }
2042- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "</div>")
2363+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "</div>")
20432364 if templ_7745c5c3_Err != nil {
20442365 return templ_7745c5c3_Err
20452366 }
@@ -2066,219 +2387,219 @@ func mergeMenu(p MRPage) templ.Component {
20662387 }()
20672388 }
20682389 ctx = templ.InitializeContext(ctx)
2069- templ_7745c5c3_Var101 := templ.GetChildren(ctx)
2070- if templ_7745c5c3_Var101 == nil {
2071- templ_7745c5c3_Var101 = templ.NopComponent
2390+ templ_7745c5c3_Var114 := templ.GetChildren(ctx)
2391+ if templ_7745c5c3_Var114 == nil {
2392+ templ_7745c5c3_Var114 = templ.NopComponent
20722393 }
20732394 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\">")
2395+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, "<details class=\"menu mmenu\" id=\"merge-menu\" data-state=\"open\"><summary class=\"btn primary\" aria-keyshortcuts=\"Shift+M\">")
20752396 if templ_7745c5c3_Err != nil {
20762397 return templ_7745c5c3_Err
20772398 }
20782399 if p.Box.ReadyAction != "" {
2079- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "Draft ")
2400+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, "Draft ")
20802401 if templ_7745c5c3_Err != nil {
20812402 return templ_7745c5c3_Err
20822403 }
20832404 } else {
2084- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "Merge ")
2405+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, "Merge ")
20852406 if templ_7745c5c3_Err != nil {
20862407 return templ_7745c5c3_Err
20872408 }
20882409 }
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>")
2410+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, "<kbd aria-hidden=\"true\">M</kbd></summary><div class=\"menu-pop\" role=\"group\" aria-label=\"Merge\"><dl class=\"kv\"><dt>head</dt><dd>")
20902411 if templ_7745c5c3_Err != nil {
20912412 return templ_7745c5c3_Err
20922413 }
20932414 if p.Box.HeadGone {
2094- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "gone: the source branch was deleted")
2415+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, "gone: the source branch was deleted")
20952416 if templ_7745c5c3_Err != nil {
20962417 return templ_7745c5c3_Err
20972418 }
20982419 } else {
2099- var templ_7745c5c3_Var102 string
2100- templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(p.HeadShort)
2420+ var templ_7745c5c3_Var115 string
2421+ templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.JoinStringErrs(p.HeadShort)
21012422 if templ_7745c5c3_Err != nil {
2102- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 399, Col: 19}
2423+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 466, Col: 19}
21032424 }
2104- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102))
2425+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var115))
21052426 if templ_7745c5c3_Err != nil {
21062427 return templ_7745c5c3_Err
21072428 }
21082429 }
2109- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "</dd><dt>into</dt><dd>")
2430+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, "</dd><dt>into</dt><dd>")
21102431 if templ_7745c5c3_Err != nil {
21112432 return templ_7745c5c3_Err
21122433 }
2113- var templ_7745c5c3_Var103 string
2114- templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2434+ var templ_7745c5c3_Var116 string
2435+ templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
21152436 if templ_7745c5c3_Err != nil {
2116- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 403, Col: 18}
2437+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 470, Col: 18}
21172438 }
2118- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
2439+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var116))
21192440 if templ_7745c5c3_Err != nil {
21202441 return templ_7745c5c3_Err
21212442 }
2122- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "</dd></dl>")
2443+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, "</dd></dl>")
21232444 if templ_7745c5c3_Err != nil {
21242445 return templ_7745c5c3_Err
21252446 }
21262447 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=\"")
2448+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, "<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=\"")
21282449 if templ_7745c5c3_Err != nil {
21292450 return templ_7745c5c3_Err
21302451 }
2131- var templ_7745c5c3_Var104 templ.SafeURL
2132- templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadyAction))
2452+ var templ_7745c5c3_Var117 templ.SafeURL
2453+ templ_7745c5c3_Var117, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadyAction))
21332454 if templ_7745c5c3_Err != nil {
2134- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 407, Col: 80}
2455+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 474, Col: 80}
21352456 }
2136- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))
2457+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var117))
21372458 if templ_7745c5c3_Err != nil {
21382459 return templ_7745c5c3_Err
21392460 }
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>")
2461+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, "\"><button type=\"submit\" class=\"btn primary\" data-default title=\"remove the draft prefix from the title\">Ready for review</button></form>")
21412462 if templ_7745c5c3_Err != nil {
21422463 return templ_7745c5c3_Err
21432464 }
21442465 } else if !p.Box.HeadGone {
2145- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "<form class=\"d-acts\" method=\"post\" action=\"")
2466+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, "<form class=\"d-acts\" method=\"post\" action=\"")
21462467 if templ_7745c5c3_Err != nil {
21472468 return templ_7745c5c3_Err
21482469 }
2149- var templ_7745c5c3_Var105 templ.SafeURL
2150- templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.MergeAction))
2470+ var templ_7745c5c3_Var118 templ.SafeURL
2471+ templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.MergeAction))
21512472 if templ_7745c5c3_Err != nil {
2152- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 411, Col: 80}
2473+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 478, Col: 80}
21532474 }
2154- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))
2475+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118))
21552476 if templ_7745c5c3_Err != nil {
21562477 return templ_7745c5c3_Err
21572478 }
2158- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
2479+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
21592480 if templ_7745c5c3_Err != nil {
21602481 return templ_7745c5c3_Err
21612482 }
2162- var templ_7745c5c3_Var106 string
2163- templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
2483+ var templ_7745c5c3_Var119 string
2484+ templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
21642485 if templ_7745c5c3_Err != nil {
2165- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 412, Col: 59}
2486+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 479, Col: 59}
21662487 }
2167- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var106)
2488+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var119)
21682489 if templ_7745c5c3_Err != nil {
21692490 return templ_7745c5c3_Err
21702491 }
2171- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "\"><fieldset class=\"d-modes\"><legend class=\"sr\">merge mode</legend> ")
2492+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 229, "\"><fieldset class=\"d-modes\"><legend class=\"sr\">merge mode</legend> ")
21722493 if templ_7745c5c3_Err != nil {
21732494 return templ_7745c5c3_Err
21742495 }
21752496 for i, m := range p.Box.Modes {
2176- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "<label><input type=\"radio\" name=\"mode\" value=\"")
2497+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, "<label><input type=\"radio\" name=\"mode\" value=\"")
21772498 if templ_7745c5c3_Err != nil {
21782499 return templ_7745c5c3_Err
21792500 }
2180- var templ_7745c5c3_Var107 string
2181- templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)
2501+ var templ_7745c5c3_Var120 string
2502+ templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)
21822503 if templ_7745c5c3_Err != nil {
2183- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 417, Col: 55}
2504+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 55}
21842505 }
2185- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var107)
2506+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var120)
21862507 if templ_7745c5c3_Err != nil {
21872508 return templ_7745c5c3_Err
21882509 }
2189- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "\"")
2510+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "\"")
21902511 if templ_7745c5c3_Err != nil {
21912512 return templ_7745c5c3_Err
21922513 }
21932514 if i == 0 {
2194- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, " checked")
2515+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, " checked")
21952516 if templ_7745c5c3_Err != nil {
21962517 return templ_7745c5c3_Err
21972518 }
21982519 }
2199- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "> ")
2520+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, "> ")
22002521 if templ_7745c5c3_Err != nil {
22012522 return templ_7745c5c3_Err
22022523 }
2203- var templ_7745c5c3_Var108 string
2204- templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)
2524+ var templ_7745c5c3_Var121 string
2525+ templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)
22052526 if templ_7745c5c3_Err != nil {
2206- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 418, Col: 17}
2527+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 485, Col: 17}
22072528 }
2208- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
2529+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var121))
22092530 if templ_7745c5c3_Err != nil {
22102531 return templ_7745c5c3_Err
22112532 }
2212- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, " ")
2533+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, " ")
22132534 if templ_7745c5c3_Err != nil {
22142535 return templ_7745c5c3_Err
22152536 }
22162537 if i < 9 {
2217- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "<kbd aria-hidden=\"true\">")
2538+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "<kbd aria-hidden=\"true\">")
22182539 if templ_7745c5c3_Err != nil {
22192540 return templ_7745c5c3_Err
22202541 }
2221- var templ_7745c5c3_Var109 string
2222- templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(i + 1))
2542+ var templ_7745c5c3_Var122 string
2543+ templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(i + 1))
22232544 if templ_7745c5c3_Err != nil {
2224- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 420, Col: 46}
2545+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 487, Col: 46}
22252546 }
2226- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109))
2547+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var122))
22272548 if templ_7745c5c3_Err != nil {
22282549 return templ_7745c5c3_Err
22292550 }
2230- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "</kbd>")
2551+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, "</kbd>")
22312552 if templ_7745c5c3_Err != nil {
22322553 return templ_7745c5c3_Err
22332554 }
22342555 }
2235- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "</label>")
2556+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, "</label>")
22362557 if templ_7745c5c3_Err != nil {
22372558 return templ_7745c5c3_Err
22382559 }
22392560 }
2240- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "</fieldset>")
2561+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, "</fieldset>")
22412562 if templ_7745c5c3_Err != nil {
22422563 return templ_7745c5c3_Err
22432564 }
22442565 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 ")
2566+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, "<label class=\"d-check\"><input type=\"checkbox\" name=\"delete_branch\" value=\"1\"> delete ")
22462567 if templ_7745c5c3_Err != nil {
22472568 return templ_7745c5c3_Err
22482569 }
2249- var templ_7745c5c3_Var110 string
2250- templ_7745c5c3_Var110, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
2570+ var templ_7745c5c3_Var123 string
2571+ templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
22512572 if templ_7745c5c3_Err != nil {
2252- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 426, Col: 102}
2573+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 493, Col: 102}
22532574 }
2254- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var110))
2575+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var123))
22552576 if templ_7745c5c3_Err != nil {
22562577 return templ_7745c5c3_Err
22572578 }
2258- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, " after the merge <kbd aria-hidden=\"true\">D</kbd></label>")
2579+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, " after the merge <kbd aria-hidden=\"true\">D</kbd></label>")
22592580 if templ_7745c5c3_Err != nil {
22602581 return templ_7745c5c3_Err
22612582 }
22622583 }
2263- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "<div class=\"d-btns\"><button type=\"submit\" class=\"btn primary\" data-default>Merge into ")
2584+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "<div class=\"d-btns\"><button type=\"submit\" class=\"btn primary\" data-default>Merge into ")
22642585 if templ_7745c5c3_Err != nil {
22652586 return templ_7745c5c3_Err
22662587 }
2267- var templ_7745c5c3_Var111 string
2268- templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2588+ var templ_7745c5c3_Var124 string
2589+ templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
22692590 if templ_7745c5c3_Err != nil {
2270- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 429, Col: 82}
2591+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 496, Col: 82}
22712592 }
2272- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111))
2593+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var124))
22732594 if templ_7745c5c3_Err != nil {
22742595 return templ_7745c5c3_Err
22752596 }
2276- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "</button></div></form>")
2597+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, "</button></div></form>")
22772598 if templ_7745c5c3_Err != nil {
22782599 return templ_7745c5c3_Err
22792600 }
22802601 }
2281- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "</div></details>")
2602+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, "</div></details>")
22822603 if templ_7745c5c3_Err != nil {
22832604 return templ_7745c5c3_Err
22842605 }
@@ -2304,66 +2625,66 @@ func moreMenu(p MRPage) templ.Component {
23042625 }()
23052626 }
23062627 ctx = templ.InitializeContext(ctx)
2307- templ_7745c5c3_Var112 := templ.GetChildren(ctx)
2308- if templ_7745c5c3_Var112 == nil {
2309- templ_7745c5c3_Var112 = templ.NopComponent
2628+ templ_7745c5c3_Var125 := templ.GetChildren(ctx)
2629+ if templ_7745c5c3_Var125 == nil {
2630+ templ_7745c5c3_Var125 = templ.NopComponent
23102631 }
23112632 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\">")
2633+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 244, "<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\">")
23132634 if templ_7745c5c3_Err != nil {
23142635 return templ_7745c5c3_Err
23152636 }
23162637 if p.Box.CanMarkRead {
2317- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "<form method=\"post\" action=\"")
2638+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, "<form method=\"post\" action=\"")
23182639 if templ_7745c5c3_Err != nil {
23192640 return templ_7745c5c3_Err
23202641 }
2321- var templ_7745c5c3_Var113 templ.SafeURL
2322- templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadAction))
2642+ var templ_7745c5c3_Var126 templ.SafeURL
2643+ templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadAction))
23232644 if templ_7745c5c3_Err != nil {
2324- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 444, Col: 64}
2645+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 511, Col: 64}
23252646 }
2326- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var113))
2647+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))
23272648 if templ_7745c5c3_Err != nil {
23282649 return templ_7745c5c3_Err
23292650 }
2330- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
2651+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 246, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
23312652 if templ_7745c5c3_Err != nil {
23322653 return templ_7745c5c3_Err
23332654 }
2334- var templ_7745c5c3_Var114 string
2335- templ_7745c5c3_Var114, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
2655+ var templ_7745c5c3_Var127 string
2656+ templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
23362657 if templ_7745c5c3_Err != nil {
2337- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 445, Col: 59}
2658+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 512, Col: 59}
23382659 }
2339- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var114)
2660+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var127)
23402661 if templ_7745c5c3_Err != nil {
23412662 return templ_7745c5c3_Err
23422663 }
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>")
2664+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 247, "\"> <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>")
23442665 if templ_7745c5c3_Err != nil {
23452666 return templ_7745c5c3_Err
23462667 }
23472668 }
2348- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "<form method=\"post\" action=\"")
2669+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 248, "<form method=\"post\" action=\"")
23492670 if templ_7745c5c3_Err != nil {
23502671 return templ_7745c5c3_Err
23512672 }
2352- var templ_7745c5c3_Var115 templ.SafeURL
2353- templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.CloseAction))
2673+ var templ_7745c5c3_Var128 templ.SafeURL
2674+ templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.CloseAction))
23542675 if templ_7745c5c3_Err != nil {
2355- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 449, Col: 64}
2676+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 516, Col: 64}
23562677 }
2357- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var115))
2678+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var128))
23582679 if templ_7745c5c3_Err != nil {
23592680 return templ_7745c5c3_Err
23602681 }
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>")
2682+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 249, "\"><button type=\"submit\" title=\"close without merging; the branch is kept\" class=\"btn danger\">Close</button></form>")
23622683 if templ_7745c5c3_Err != nil {
23632684 return templ_7745c5c3_Err
23642685 }
23652686 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>")
2687+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 250, "<details class=\"d-or\"><summary class=\"mute\">or from a terminal</summary>")
23672688 if templ_7745c5c3_Err != nil {
23682689 return templ_7745c5c3_Err
23692690 }
@@ -2371,12 +2692,12 @@ func moreMenu(p MRPage) templ.Component {
23712692 if templ_7745c5c3_Err != nil {
23722693 return templ_7745c5c3_Err
23732694 }
2374- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "</details>")
2695+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 251, "</details>")
23752696 if templ_7745c5c3_Err != nil {
23762697 return templ_7745c5c3_Err
23772698 }
23782699 }
2379- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "</div></details>")
2700+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 252, "</div></details>")
23802701 if templ_7745c5c3_Err != nil {
23812702 return templ_7745c5c3_Err
23822703 }
@@ -2400,12 +2721,12 @@ func Compare(l Layout, p ComparePage) templ.Component {
24002721 }()
24012722 }
24022723 ctx = templ.InitializeContext(ctx)
2403- templ_7745c5c3_Var116 := templ.GetChildren(ctx)
2404- if templ_7745c5c3_Var116 == nil {
2405- templ_7745c5c3_Var116 = templ.NopComponent
2724+ templ_7745c5c3_Var129 := templ.GetChildren(ctx)
2725+ if templ_7745c5c3_Var129 == nil {
2726+ templ_7745c5c3_Var129 = templ.NopComponent
24062727 }
24072728 ctx = templ.ClearChildren(ctx)
2408- templ_7745c5c3_Var117 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
2729+ templ_7745c5c3_Var130 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
24092730 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
24102731 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
24112732 if !templ_7745c5c3_IsBuffer {
@@ -2417,263 +2738,263 @@ func Compare(l Layout, p ComparePage) templ.Component {
24172738 }()
24182739 }
24192740 ctx = templ.InitializeContext(ctx)
2420- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, "<header class=\"show mr-h\"><h1>")
2741+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 253, "<header class=\"show mr-h\"><h1>")
24212742 if templ_7745c5c3_Err != nil {
24222743 return templ_7745c5c3_Err
24232744 }
2424- var templ_7745c5c3_Var118 string
2425- templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
2745+ var templ_7745c5c3_Var131 string
2746+ templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
24262747 if templ_7745c5c3_Err != nil {
2427- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 465, Col: 17}
2748+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 532, Col: 17}
24282749 }
2429- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118))
2750+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131))
24302751 if templ_7745c5c3_Err != nil {
24312752 return templ_7745c5c3_Err
24322753 }
2433- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, " → ")
2754+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 254, " → ")
24342755 if templ_7745c5c3_Err != nil {
24352756 return templ_7745c5c3_Err
24362757 }
2437- var templ_7745c5c3_Var119 string
2438- templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2758+ var templ_7745c5c3_Var132 string
2759+ templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
24392760 if templ_7745c5c3_Err != nil {
2440- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 465, Col: 34}
2761+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 532, Col: 34}
24412762 }
2442- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var119))
2763+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132))
24432764 if templ_7745c5c3_Err != nil {
24442765 return templ_7745c5c3_Err
24452766 }
2446- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, "</h1><dl class=\"kv show-kv\"><dt>branch</dt><dd><a href=\"")
2767+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 255, "</h1><dl class=\"kv show-kv\"><dt>branch</dt><dd><a href=\"")
24472768 if templ_7745c5c3_Err != nil {
24482769 return templ_7745c5c3_Err
24492770 }
2450- var templ_7745c5c3_Var120 templ.SafeURL
2451- templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))
2771+ var templ_7745c5c3_Var133 templ.SafeURL
2772+ templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))
24522773 if templ_7745c5c3_Err != nil {
2453- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 468, Col: 45}
2774+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 535, Col: 45}
24542775 }
2455- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var120))
2776+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133))
24562777 if templ_7745c5c3_Err != nil {
24572778 return templ_7745c5c3_Err
24582779 }
2459- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, "\">")
2780+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 256, "\">")
24602781 if templ_7745c5c3_Err != nil {
24612782 return templ_7745c5c3_Err
24622783 }
2463- var templ_7745c5c3_Var121 string
2464- templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
2784+ var templ_7745c5c3_Var134 string
2785+ templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
24652786 if templ_7745c5c3_Err != nil {
2466- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 468, Col: 58}
2787+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 535, Col: 58}
24672788 }
2468- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var121))
2789+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134))
24692790 if templ_7745c5c3_Err != nil {
24702791 return templ_7745c5c3_Err
24712792 }
2472- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, "</a></dd><dt>target</dt><dd><a href=\"")
2793+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 257, "</a></dd><dt>target</dt><dd><a href=\"")
24732794 if templ_7745c5c3_Err != nil {
24742795 return templ_7745c5c3_Err
24752796 }
2476- var templ_7745c5c3_Var122 templ.SafeURL
2477- templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.TargetHref))
2797+ var templ_7745c5c3_Var135 templ.SafeURL
2798+ templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.TargetHref))
24782799 if templ_7745c5c3_Err != nil {
2479- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 470, Col: 45}
2800+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 537, Col: 45}
24802801 }
2481- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var122))
2802+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var135))
24822803 if templ_7745c5c3_Err != nil {
24832804 return templ_7745c5c3_Err
24842805 }
2485- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, "\">")
2806+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 258, "\">")
24862807 if templ_7745c5c3_Err != nil {
24872808 return templ_7745c5c3_Err
24882809 }
2489- var templ_7745c5c3_Var123 string
2490- templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2810+ var templ_7745c5c3_Var136 string
2811+ templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
24912812 if templ_7745c5c3_Err != nil {
2492- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 470, Col: 58}
2813+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 537, Col: 58}
24932814 }
2494- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var123))
2815+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var136))
24952816 if templ_7745c5c3_Err != nil {
24962817 return templ_7745c5c3_Err
24972818 }
2498- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, "</a></dd><dt>ahead</dt><dd>")
2819+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 259, "</a></dd><dt>ahead</dt><dd>")
24992820 if templ_7745c5c3_Err != nil {
25002821 return templ_7745c5c3_Err
25012822 }
2502- var templ_7745c5c3_Var124 string
2503- templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.JoinStringErrs(p.AheadText)
2823+ var templ_7745c5c3_Var137 string
2824+ templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.JoinStringErrs(p.AheadText)
25042825 if templ_7745c5c3_Err != nil {
2505- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 472, Col: 21}
2826+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 539, Col: 21}
25062827 }
2507- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var124))
2828+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var137))
25082829 if templ_7745c5c3_Err != nil {
25092830 return templ_7745c5c3_Err
25102831 }
2511- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, "</dd></dl></header>")
2832+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 260, "</dd></dl></header>")
25122833 if templ_7745c5c3_Err != nil {
25132834 return templ_7745c5c3_Err
25142835 }
25152836 if p.Note != "" {
2516- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, "<p class=\"notice\">")
2837+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 261, "<p class=\"notice\">")
25172838 if templ_7745c5c3_Err != nil {
25182839 return templ_7745c5c3_Err
25192840 }
2520- var templ_7745c5c3_Var125 string
2521- templ_7745c5c3_Var125, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
2841+ var templ_7745c5c3_Var138 string
2842+ templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
25222843 if templ_7745c5c3_Err != nil {
2523- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 476, Col: 29}
2844+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 543, Col: 29}
25242845 }
2525- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var125))
2846+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138))
25262847 if templ_7745c5c3_Err != nil {
25272848 return templ_7745c5c3_Err
25282849 }
2529- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, "</p>")
2850+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 262, "</p>")
25302851 if templ_7745c5c3_Err != nil {
25312852 return templ_7745c5c3_Err
25322853 }
25332854 }
2534- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, " ")
2855+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 263, " ")
25352856 if templ_7745c5c3_Err != nil {
25362857 return templ_7745c5c3_Err
25372858 }
25382859 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=\"")
2860+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 264, "<p class=\"notice\">an open merge request already covers these branches: <a href=\"")
25402861 if templ_7745c5c3_Err != nil {
25412862 return templ_7745c5c3_Err
25422863 }
2543- var templ_7745c5c3_Var126 templ.SafeURL
2544- templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Existing.Href))
2864+ var templ_7745c5c3_Var139 templ.SafeURL
2865+ templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Existing.Href))
25452866 if templ_7745c5c3_Err != nil {
2546- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 479, Col: 114}
2867+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 546, Col: 114}
25472868 }
2548- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))
2869+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139))
25492870 if templ_7745c5c3_Err != nil {
25502871 return templ_7745c5c3_Err
25512872 }
2552- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, "\">")
2873+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 265, "\">")
25532874 if templ_7745c5c3_Err != nil {
25542875 return templ_7745c5c3_Err
25552876 }
2556- var templ_7745c5c3_Var127 string
2557- templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.JoinStringErrs(p.Existing.Label)
2877+ var templ_7745c5c3_Var140 string
2878+ templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(p.Existing.Label)
25582879 if templ_7745c5c3_Err != nil {
2559- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 479, Col: 135}
2880+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 546, Col: 135}
25602881 }
2561- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var127))
2882+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140))
25622883 if templ_7745c5c3_Err != nil {
25632884 return templ_7745c5c3_Err
25642885 }
2565- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 229, "</a></p>")
2886+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 266, "</a></p>")
25662887 if templ_7745c5c3_Err != nil {
25672888 return templ_7745c5c3_Err
25682889 }
25692890 }
2570- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, " ")
2891+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 267, " ")
25712892 if templ_7745c5c3_Err != nil {
25722893 return templ_7745c5c3_Err
25732894 }
25742895 if p.Create != nil {
2575- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "<form class=\"cform create\" method=\"post\" action=\"")
2896+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 268, "<form class=\"cform create\" method=\"post\" action=\"")
25762897 if templ_7745c5c3_Err != nil {
25772898 return templ_7745c5c3_Err
25782899 }
2579- var templ_7745c5c3_Var128 templ.SafeURL
2580- templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Create.Action))
2900+ var templ_7745c5c3_Var141 templ.SafeURL
2901+ templ_7745c5c3_Var141, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Create.Action))
25812902 if templ_7745c5c3_Err != nil {
2582- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 482, Col: 83}
2903+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 549, Col: 83}
25832904 }
2584- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var128))
2905+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var141))
25852906 if templ_7745c5c3_Err != nil {
25862907 return templ_7745c5c3_Err
25872908 }
2588- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, "\"><input type=\"hidden\" name=\"source\" value=\"")
2909+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 269, "\"><input type=\"hidden\" name=\"source\" value=\"")
25892910 if templ_7745c5c3_Err != nil {
25902911 return templ_7745c5c3_Err
25912912 }
2592- var templ_7745c5c3_Var129 string
2593- templ_7745c5c3_Var129, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Source)
2913+ var templ_7745c5c3_Var142 string
2914+ templ_7745c5c3_Var142, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Source)
25942915 if templ_7745c5c3_Err != nil {
2595- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 483, Col: 62}
2916+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 550, Col: 62}
25962917 }
2597- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var129)
2918+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var142)
25982919 if templ_7745c5c3_Err != nil {
25992920 return templ_7745c5c3_Err
26002921 }
2601- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, "\"> <input type=\"hidden\" name=\"target\" value=\"")
2922+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 270, "\"> <input type=\"hidden\" name=\"target\" value=\"")
26022923 if templ_7745c5c3_Err != nil {
26032924 return templ_7745c5c3_Err
26042925 }
2605- var templ_7745c5c3_Var130 string
2606- templ_7745c5c3_Var130, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Target)
2926+ var templ_7745c5c3_Var143 string
2927+ templ_7745c5c3_Var143, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Target)
26072928 if templ_7745c5c3_Err != nil {
2608- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 62}
2929+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 551, Col: 62}
26092930 }
2610- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var130)
2931+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var143)
26112932 if templ_7745c5c3_Err != nil {
26122933 return templ_7745c5c3_Err
26132934 }
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=\"")
2935+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 271, "\"> <label for=\"mr-title\">title</label> <input id=\"mr-title\" name=\"title\" type=\"text\" maxlength=\"120\" required value=\"")
26152936 if templ_7745c5c3_Err != nil {
26162937 return templ_7745c5c3_Err
26172938 }
2618- var templ_7745c5c3_Var131 string
2619- templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Title)
2939+ var templ_7745c5c3_Var144 string
2940+ templ_7745c5c3_Var144, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Title)
26202941 if templ_7745c5c3_Err != nil {
2621- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 486, Col: 97}
2942+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 553, Col: 97}
26222943 }
2623- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var131)
2944+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var144)
26242945 if templ_7745c5c3_Err != nil {
26252946 return templ_7745c5c3_Err
26262947 }
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>")
2948+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 272, "\" 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>")
26282949 if templ_7745c5c3_Err != nil {
26292950 return templ_7745c5c3_Err
26302951 }
26312952 }
2632- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, " ")
2953+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 273, " ")
26332954 if templ_7745c5c3_Err != nil {
26342955 return templ_7745c5c3_Err
26352956 }
26362957 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\">")
2958+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 274, "<div class=\"diff-tools\" role=\"group\" aria-label=\"Diff view\"><span class=\"dim\">")
26382959 if templ_7745c5c3_Err != nil {
26392960 return templ_7745c5c3_Err
26402961 }
2641- var templ_7745c5c3_Var132 string
2642- templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
2962+ var templ_7745c5c3_Var145 string
2963+ templ_7745c5c3_Var145, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
26432964 if templ_7745c5c3_Err != nil {
2644- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 496, Col: 57}
2965+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 563, Col: 57}
26452966 }
2646- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132))
2967+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var145))
26472968 if templ_7745c5c3_Err != nil {
26482969 return templ_7745c5c3_Err
26492970 }
2650- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, " · <span class=\"plus\">+")
2971+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 275, " · <span class=\"plus\">+")
26512972 if templ_7745c5c3_Err != nil {
26522973 return templ_7745c5c3_Err
26532974 }
2654- var templ_7745c5c3_Var133 string
2655- templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
2975+ var templ_7745c5c3_Var146 string
2976+ templ_7745c5c3_Var146, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
26562977 if templ_7745c5c3_Err != nil {
2657- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 496, Col: 97}
2978+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 563, Col: 97}
26582979 }
2659- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133))
2980+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var146))
26602981 if templ_7745c5c3_Err != nil {
26612982 return templ_7745c5c3_Err
26622983 }
2663- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, "</span> <span class=\"minus\">-")
2984+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 276, "</span> <span class=\"minus\">-")
26642985 if templ_7745c5c3_Err != nil {
26652986 return templ_7745c5c3_Err
26662987 }
2667- var templ_7745c5c3_Var134 string
2668- templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
2988+ var templ_7745c5c3_Var147 string
2989+ templ_7745c5c3_Var147, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
26692990 if templ_7745c5c3_Err != nil {
2670- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 496, Col: 142}
2991+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 563, Col: 142}
26712992 }
2672- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134))
2993+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var147))
26732994 if templ_7745c5c3_Err != nil {
26742995 return templ_7745c5c3_Err
26752996 }
2676- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, "</span></span> <span class=\"grow\"></span>")
2997+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 277, "</span></span> <span class=\"grow\"></span>")
26772998 if templ_7745c5c3_Err != nil {
26782999 return templ_7745c5c3_Err
26793000 }
@@ -2689,64 +3010,64 @@ func Compare(l Layout, p ComparePage) templ.Component {
26893010 if templ_7745c5c3_Err != nil {
26903011 return templ_7745c5c3_Err
26913012 }
2692- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "</div>")
3013+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 278, "</div>")
26933014 if templ_7745c5c3_Err != nil {
26943015 return templ_7745c5c3_Err
26953016 }
26963017 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>")
3018+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 279, "<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>")
26983019 if templ_7745c5c3_Err != nil {
26993020 return templ_7745c5c3_Err
27003021 }
27013022 }
2702- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, " <div id=\"diffs\"")
3023+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 280, " <div id=\"diffs\"")
27033024 if templ_7745c5c3_Err != nil {
27043025 return templ_7745c5c3_Err
27053026 }
27063027 if p.Both {
2707- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 244, " data-diff=\"")
3028+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 281, " data-diff=\"")
27083029 if templ_7745c5c3_Err != nil {
27093030 return templ_7745c5c3_Err
27103031 }
2711- var templ_7745c5c3_Var135 string
2712- templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
3032+ var templ_7745c5c3_Var148 string
3033+ templ_7745c5c3_Var148, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
27133034 if templ_7745c5c3_Err != nil {
2714- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 511, Col: 23}
3035+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 578, Col: 23}
27153036 }
2716- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var135)
3037+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var148)
27173038 if templ_7745c5c3_Err != nil {
27183039 return templ_7745c5c3_Err
27193040 }
2720- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, "\"")
3041+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 282, "\"")
27213042 if templ_7745c5c3_Err != nil {
27223043 return templ_7745c5c3_Err
27233044 }
27243045 } else {
2725- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 246, " data-view=\"")
3046+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 283, " data-view=\"")
27263047 if templ_7745c5c3_Err != nil {
27273048 return templ_7745c5c3_Err
27283049 }
2729- var templ_7745c5c3_Var136 string
2730- templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
3050+ var templ_7745c5c3_Var149 string
3051+ templ_7745c5c3_Var149, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
27313052 if templ_7745c5c3_Err != nil {
2732- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 513, Col: 23}
3053+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 580, Col: 23}
27333054 }
2734- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var136)
3055+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var149)
27353056 if templ_7745c5c3_Err != nil {
27363057 return templ_7745c5c3_Err
27373058 }
2738- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 247, "\"")
3059+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 284, "\"")
27393060 if templ_7745c5c3_Err != nil {
27403061 return templ_7745c5c3_Err
27413062 }
27423063 }
27433064 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\"")
3065+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 285, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"")
27453066 if templ_7745c5c3_Err != nil {
27463067 return templ_7745c5c3_Err
27473068 }
27483069 }
2749- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 249, ">")
3070+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 286, ">")
27503071 if templ_7745c5c3_Err != nil {
27513072 return templ_7745c5c3_Err
27523073 }
@@ -2756,14 +3077,14 @@ func Compare(l Layout, p ComparePage) templ.Component {
27563077 return templ_7745c5c3_Err
27573078 }
27583079 }
2759- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 250, "</div>")
3080+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 287, "</div>")
27603081 if templ_7745c5c3_Err != nil {
27613082 return templ_7745c5c3_Err
27623083 }
27633084 }
27643085 return nil
27653086 })
2766- templ_7745c5c3_Err = Shell(l, compareRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var117), templ_7745c5c3_Buffer)
3087+ templ_7745c5c3_Err = Shell(l, compareRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var130), templ_7745c5c3_Buffer)
27673088 if templ_7745c5c3_Err != nil {
27683089 return templ_7745c5c3_Err
27693090 }
@@ -2787,173 +3108,173 @@ func compareRail(p ComparePage) templ.Component {
27873108 }()
27883109 }
27893110 ctx = templ.InitializeContext(ctx)
2790- templ_7745c5c3_Var137 := templ.GetChildren(ctx)
2791- if templ_7745c5c3_Var137 == nil {
2792- templ_7745c5c3_Var137 = templ.NopComponent
3111+ templ_7745c5c3_Var150 := templ.GetChildren(ctx)
3112+ if templ_7745c5c3_Var150 == nil {
3113+ templ_7745c5c3_Var150 = templ.NopComponent
27933114 }
27943115 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\">")
3116+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 288, "<section aria-labelledby=\"r-ahead\"><h2 id=\"r-ahead\">Commits <span class=\"count\">")
27963117 if templ_7745c5c3_Err != nil {
27973118 return templ_7745c5c3_Err
27983119 }
2799- var templ_7745c5c3_Var138 string
2800- templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Ahead)))
3120+ var templ_7745c5c3_Var151 string
3121+ templ_7745c5c3_Var151, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Ahead)))
28013122 if templ_7745c5c3_Err != nil {
2802- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 529, Col: 67}
3123+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 596, Col: 67}
28033124 }
2804- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138))
3125+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var151))
28053126 if templ_7745c5c3_Err != nil {
28063127 return templ_7745c5c3_Err
28073128 }
2808- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 252, "</span></h2><ul class=\"pcommits\">")
3129+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 289, "</span></h2><ul class=\"pcommits\">")
28093130 if templ_7745c5c3_Err != nil {
28103131 return templ_7745c5c3_Err
28113132 }
28123133 for _, c := range p.Ahead {
2813- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 253, "<li><a href=\"")
3134+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 290, "<li><a href=\"")
28143135 if templ_7745c5c3_Err != nil {
28153136 return templ_7745c5c3_Err
28163137 }
2817- var templ_7745c5c3_Var139 templ.SafeURL
2818- templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
3138+ var templ_7745c5c3_Var152 templ.SafeURL
3139+ templ_7745c5c3_Var152, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
28193140 if templ_7745c5c3_Err != nil {
2820- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 533, Col: 37}
3141+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 600, Col: 37}
28213142 }
2822- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139))
3143+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var152))
28233144 if templ_7745c5c3_Err != nil {
28243145 return templ_7745c5c3_Err
28253146 }
2826- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 254, "\">")
3147+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 291, "\">")
28273148 if templ_7745c5c3_Err != nil {
28283149 return templ_7745c5c3_Err
28293150 }
2830- var templ_7745c5c3_Var140 string
2831- templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
3151+ var templ_7745c5c3_Var153 string
3152+ templ_7745c5c3_Var153, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
28323153 if templ_7745c5c3_Err != nil {
2833- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 533, Col: 49}
3154+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 600, Col: 49}
28343155 }
2835- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140))
3156+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var153))
28363157 if templ_7745c5c3_Err != nil {
28373158 return templ_7745c5c3_Err
28383159 }
2839- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 255, "</a> <span class=\"s\">")
3160+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 292, "</a> <span class=\"s\">")
28403161 if templ_7745c5c3_Err != nil {
28413162 return templ_7745c5c3_Err
28423163 }
2843- var templ_7745c5c3_Var141 string
2844- templ_7745c5c3_Var141, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
3164+ var templ_7745c5c3_Var154 string
3165+ templ_7745c5c3_Var154, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
28453166 if templ_7745c5c3_Err != nil {
2846- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 534, Col: 32}
3167+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 601, Col: 32}
28473168 }
2848- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var141))
3169+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var154))
28493170 if templ_7745c5c3_Err != nil {
28503171 return templ_7745c5c3_Err
28513172 }
2852- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 256, "</span> <span class=\"mute\">")
3173+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 293, "</span> <span class=\"mute\">")
28533174 if templ_7745c5c3_Err != nil {
28543175 return templ_7745c5c3_Err
28553176 }
2856- var templ_7745c5c3_Var142 string
2857- templ_7745c5c3_Var142, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
3177+ var templ_7745c5c3_Var155 string
3178+ templ_7745c5c3_Var155, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
28583179 if templ_7745c5c3_Err != nil {
2859- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 536, Col: 16}
3180+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 603, Col: 16}
28603181 }
2861- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var142))
3182+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var155))
28623183 if templ_7745c5c3_Err != nil {
28633184 return templ_7745c5c3_Err
28643185 }
2865- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 257, " ")
3186+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 294, " ")
28663187 if templ_7745c5c3_Err != nil {
28673188 return templ_7745c5c3_Err
28683189 }
28693190 if c.WhenAt != "" {
2870- var templ_7745c5c3_Var143 string
2871- templ_7745c5c3_Var143, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
3191+ var templ_7745c5c3_Var156 string
3192+ templ_7745c5c3_Var156, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
28723193 if templ_7745c5c3_Err != nil {
2873- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 538, Col: 15}
3194+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 605, Col: 15}
28743195 }
2875- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var143))
3196+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var156))
28763197 if templ_7745c5c3_Err != nil {
28773198 return templ_7745c5c3_Err
28783199 }
2879- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 258, "<time datetime=\"")
3200+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 295, "<time datetime=\"")
28803201 if templ_7745c5c3_Err != nil {
28813202 return templ_7745c5c3_Err
28823203 }
2883- var templ_7745c5c3_Var144 string
2884- templ_7745c5c3_Var144, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
3204+ var templ_7745c5c3_Var157 string
3205+ templ_7745c5c3_Var157, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
28853206 if templ_7745c5c3_Err != nil {
2886- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 538, Col: 42}
3207+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 605, Col: 42}
28873208 }
2888- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var144)
3209+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var157)
28893210 if templ_7745c5c3_Err != nil {
28903211 return templ_7745c5c3_Err
28913212 }
2892- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 259, "\">")
3213+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 296, "\">")
28933214 if templ_7745c5c3_Err != nil {
28943215 return templ_7745c5c3_Err
28953216 }
2896- var templ_7745c5c3_Var145 string
2897- templ_7745c5c3_Var145, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
3217+ var templ_7745c5c3_Var158 string
3218+ templ_7745c5c3_Var158, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
28983219 if templ_7745c5c3_Err != nil {
2899- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 538, Col: 53}
3220+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 605, Col: 53}
29003221 }
2901- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var145))
3222+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var158))
29023223 if templ_7745c5c3_Err != nil {
29033224 return templ_7745c5c3_Err
29043225 }
2905- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 260, "</time>")
3226+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 297, "</time>")
29063227 if templ_7745c5c3_Err != nil {
29073228 return templ_7745c5c3_Err
29083229 }
29093230 }
2910- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 261, "</span></li>")
3231+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 298, "</span></li>")
29113232 if templ_7745c5c3_Err != nil {
29123233 return templ_7745c5c3_Err
29133234 }
29143235 }
2915- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 262, "</ul>")
3236+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 299, "</ul>")
29163237 if templ_7745c5c3_Err != nil {
29173238 return templ_7745c5c3_Err
29183239 }
29193240 if len(p.Ahead) == 0 {
2920- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 263, "<p class=\"dim\">no commit ahead of ")
3241+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 300, "<p class=\"dim\">no commit ahead of ")
29213242 if templ_7745c5c3_Err != nil {
29223243 return templ_7745c5c3_Err
29233244 }
2924- var templ_7745c5c3_Var146 string
2925- templ_7745c5c3_Var146, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
3245+ var templ_7745c5c3_Var159 string
3246+ templ_7745c5c3_Var159, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
29263247 if templ_7745c5c3_Err != nil {
2927- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 545, Col: 47}
3248+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 612, Col: 47}
29283249 }
2929- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var146))
3250+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var159))
29303251 if templ_7745c5c3_Err != nil {
29313252 return templ_7745c5c3_Err
29323253 }
2933- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 264, ".</p>")
3254+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 301, ".</p>")
29343255 if templ_7745c5c3_Err != nil {
29353256 return templ_7745c5c3_Err
29363257 }
29373258 }
2938- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 265, "</section>")
3259+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 302, "</section>")
29393260 if templ_7745c5c3_Err != nil {
29403261 return templ_7745c5c3_Err
29413262 }
29423263 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\">")
3264+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 303, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
29443265 if templ_7745c5c3_Err != nil {
29453266 return templ_7745c5c3_Err
29463267 }
2947- var templ_7745c5c3_Var147 string
2948- templ_7745c5c3_Var147, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
3268+ var templ_7745c5c3_Var160 string
3269+ templ_7745c5c3_Var160, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
29493270 if templ_7745c5c3_Err != nil {
2950- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 550, Col: 66}
3271+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 617, Col: 66}
29513272 }
2952- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var147))
3273+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var160))
29533274 if templ_7745c5c3_Err != nil {
29543275 return templ_7745c5c3_Err
29553276 }
2956- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 267, "</span></h2>")
3277+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 304, "</span></h2>")
29573278 if templ_7745c5c3_Err != nil {
29583279 return templ_7745c5c3_Err
29593280 }
@@ -2961,12 +3282,12 @@ func compareRail(p ComparePage) templ.Component {
29613282 if templ_7745c5c3_Err != nil {
29623283 return templ_7745c5c3_Err
29633284 }
2964- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 268, "</section>")
3285+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 305, "</section>")
29653286 if templ_7745c5c3_Err != nil {
29663287 return templ_7745c5c3_Err
29673288 }
29683289 }
2969- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 269, "<section aria-labelledby=\"r-cli\"><h2 id=\"r-cli\">From a terminal</h2>")
3290+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 306, "<section aria-labelledby=\"r-cli\"><h2 id=\"r-cli\">From a terminal</h2>")
29703291 if templ_7745c5c3_Err != nil {
29713292 return templ_7745c5c3_Err
29723293 }
@@ -2974,7 +3295,7 @@ func compareRail(p ComparePage) templ.Component {
29743295 if templ_7745c5c3_Err != nil {
29753296 return templ_7745c5c3_Err
29763297 }
2977- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 270, "</section>")
3298+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 307, "</section>")
29783299 if templ_7745c5c3_Err != nil {
29793300 return templ_7745c5c3_Err
29803301 }
M internal/adapters/web/static/app/apple-touch-icon.png +0 -0 binary file: no text diff

binary file: no text diff

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

binary file: no text diff

M internal/adapters/web/static/css/55-diff.css +4 -0
@@ -6,3 +6,7 @@
66 }
77 .eol { color: var(--ink-mute); padding-left: 1ch; user-select: none; }
88 }
9+
10+@layer components {
11+ .sel { outline: 1px solid var(--ink-dim); outline-offset: -1px; }
12+}
M internal/adapters/web/static/css/60-mr.css +8 -3
@@ -11,7 +11,6 @@
1111 .mr-desc.prose { max-width: none; padding: var(--s4) var(--s5); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); }
1212 .mr-desc.prose > * { max-width: var(--measure); }
1313 .mr-desc.prose > pre, .mr-desc.prose > table { max-width: 100%; }
14- .c-text a { text-decoration: underline; }
1514
1615 .since {
1716 display: inline-flex; flex-wrap: wrap; gap: var(--s1); padding: var(--s1);
@@ -147,12 +146,11 @@
147146 & p { white-space: normal; }
148147 & .c-who { font-weight: bold; }
149148 & .c-state { color: var(--ink-dim); }
150- & .c-text { white-space: pre-wrap; overflow-wrap: anywhere; }
151149 & .c-where { font-size: var(--fs-s); overflow-wrap: anywhere; }
152150 & .c-act { margin-top: var(--s2); }
153151 & .c-act button { height: 26px; font-size: var(--fs-xs); }
154152 &[data-resolved="true"] { border-left-color: var(--border-strong); }
155- &[data-resolved="true"] .c-text { color: var(--ink-dim); }
153+ &[data-resolved="true"] .c-body { color: var(--ink-dim); }
156154 }
157155 .dt .fnote td { background: var(--bg-canvas); padding: var(--s2) var(--s3); white-space: normal; }
158156 .dt .fnote .cmt, .dt .fnote .cform { margin: 0; }
@@ -215,3 +213,10 @@
215213 .list.mrs .c-who { display: none; }
216214 }
217215 }
216+
217+@layer components {
218+ .c-body { white-space: normal; overflow-wrap: anywhere; }
219+ .c-body.prose > * { margin-block: .5em; }
220+ .c-body .ref { font-variant-numeric: tabular-nums; text-decoration: underline; }
221+ .cmt > .cmt { margin-inline-start: 2ch; }
222+}
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
@@ -220,6 +220,8 @@ func (s *Server) routes() *http.ServeMux {
220220 mux.HandleFunc("POST /{repo}/merge-requests", s.mrAction(s.mrCreate))
221221 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments", s.mrAction(s.mrComment))
222222 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/resolve", s.mrAction(s.mrResolve))
223+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/edit", s.mrAction(s.mrEditComment))
224+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/delete", s.mrAction(s.mrDeleteComment))
223225 mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))
224226 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
225227 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
M internal/app/cli_mr.go +252 -41
@@ -7,6 +7,7 @@ import (
77 "errors"
88 "fmt"
99 "io"
10+ "net/url"
1011 "strconv"
1112 "strings"
1213
@@ -101,6 +102,7 @@ func parseSide(s string) (ermite.DiffSide, error) {
101102 // jsonMergeRequest is the stable JSON shape of a merge request.
102103 type jsonMergeRequest struct {
103104 Repo string `json:"repo"`
105+ URL string `json:"url"`
104106 Number int64 `json:"number"`
105107 Title string `json:"title"`
106108 Description string `json:"description"`
@@ -121,8 +123,9 @@ type jsonMerged struct {
121123 SourceBranchDeleted bool `json:"source_branch_deleted"`
122124 }
123125
124-func toJSONMergeRequest(m *ermite.MergeRequest) jsonMergeRequest {
126+func toJSONMergeRequest(m *ermite.MergeRequest, baseURL string) jsonMergeRequest {
125127 return jsonMergeRequest{
128+ URL: mrWebURL(baseURL, m.GetRepo(), m.GetNumber()),
126129 Repo: m.GetRepo(), Number: m.GetNumber(), Title: m.GetTitle(), Description: m.GetDescription(),
127130 SourceBranch: m.GetSourceBranch(), TargetBranch: m.GetTargetBranch(), State: stateName(m.GetState()),
128131 Author: m.GetAuthor(), CreatedAt: jsonTime(m.GetCreatedAt()), UpdatedAt: jsonTime(m.GetUpdatedAt()),
@@ -141,17 +144,33 @@ type jsonComment struct {
141144 Text string `json:"text"`
142145 Resolved bool `json:"resolved"`
143146 CreatedAt *string `json:"created_at"`
147+ ReplyTo string `json:"reply_to"`
148+ StartLine int32 `json:"start_line"`
149+ Verdict string `json:"verdict"`
150+ EditedAt *string `json:"edited_at"`
151+ Deleted bool `json:"deleted"`
152+}
153+
154+func verdictName(v ermite.Verdict) string {
155+ switch v {
156+ case ermite.Verdict_VERDICT_APPROVE:
157+ return "approve"
158+ case ermite.Verdict_VERDICT_CHANGES_REQUESTED:
159+ return "changes_requested"
160+ }
161+ return ""
144162 }
145163
146164 func toJSONComment(c *ermite.Comment) jsonComment {
147165 return jsonComment{
148166 ID: c.GetId(), Author: c.GetAuthor(), Path: c.GetPath(), Line: c.GetLine(), Side: sideName(c.GetSide()),
149167 CommitID: c.GetCommitId(), Text: c.GetText(), Resolved: c.GetResolved(), CreatedAt: jsonTime(c.GetCreatedAt()),
168+ ReplyTo: c.GetReplyTo(), StartLine: c.GetStartLine(), Verdict: verdictName(c.GetVerdict()), EditedAt: jsonTime(c.GetEditedAt()), Deleted: c.GetDeleted(),
150169 }
151170 }
152171
153172 const jsonMRHelp = `
154-JSON fields of a merge request (--json): repo, number, title, description, source_branch,
173+JSON fields of a merge request (--json): repo, url (full web URL), number, title, description, source_branch,
155174 target_branch, state ("open", "merged" or "closed"), author (token label), created_at and
156175 updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the
157176 branch is gone), merged_commit_id and merge_mode ("fast-forward" or "squash"; both empty
@@ -160,7 +179,8 @@ unless merged).`
160179 const jsonCommentHelp = `
161180 JSON fields of a comment: id, author, path, line and side ("old" or "new"; empty path, line 0
162181 and empty side for a general comment), commit_id (the head when it was written), text,
163-resolved, created_at.`
182+resolved, created_at, reply_to (empty for roots), start_line (0 for a single line),
183+verdict ("", "approve", "changes_requested"), edited_at (nullable RFC 3339), deleted.`
164184
165185 func newMRCmd(env *cliEnv) *cobra.Command {
166186 mr := &cobra.Command{
@@ -172,7 +192,7 @@ func newMRCmd(env *cliEnv) *cobra.Command {
172192 }
173193 mr.AddCommand(
174194 newMRCreateCmd(env), newMRListCmd(env), newMRShowCmd(env), newMRCommentCmd(env),
175- newMRResolveCmd(env), newMRMergeCmd(env), newMRCloseCmd(env),
195+ newMRResolveCmd(env), newMREditCommentCmd(env), newMRDeleteCommentCmd(env), newMRMergeCmd(env), newMRCloseCmd(env),
176196 )
177197 return mr
178198 }
@@ -245,7 +265,8 @@ func newMRCreateCmd(env *cliEnv) *cobra.Command {
245265 "The title is one line of at most 120 characters, the description plain text of at most 8000;\n" +
246266 "neither may hold control characters (a description may hold line breaks) or bidirectional\n" +
247267 "override characters. --description - reads it from standard input. Fails if an open merge request\n" +
248- "already has the same source and target. Needs the mr:write scope." + jsonMRHelp,
268+ "already has the same source and target. Prints a confirmation, then the full web URL on its own line.\n" +
269+ "Needs the mr:write scope." + jsonMRHelp,
249270 Args: cobra.ExactArgs(1),
250271 ValidArgsFunction: completeRepoNames(env),
251272 }
@@ -273,20 +294,26 @@ func newMRCreateCmd(env *cliEnv) *cobra.Command {
273294 if err != nil {
274295 return c.Describe(err)
275296 }
276- return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "opened")
297+ return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "opened", c.URL())
277298 }
278299 return cmd
279300 }
280301
281302 func trimBranch(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "refs/heads/") }
282303
283-// outputMR prints a merge request as JSON, or as one confirmation line.
284-func outputMR(cmd *cobra.Command, m *ermite.MergeRequest, asJSON bool, verb string) error {
304+// mrWebURL builds the web route with the repository as a single path segment.
305+// Repository names currently forbid slashes, but escape all path delimiters defensively.
306+func mrWebURL(baseURL, repo string, number int64) string {
307+ return strings.TrimRight(baseURL, "/") + "/" + url.PathEscape(repo) + "/merge-requests/" + strconv.FormatInt(number, 10)
308+}
309+
310+// outputMR prints a merge request as JSON, or a confirmation followed by its URL.
311+func outputMR(cmd *cobra.Command, m *ermite.MergeRequest, asJSON bool, verb, baseURL string) error {
285312 if asJSON {
286- return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m))
313+ return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, baseURL))
287314 }
288- _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s!%d (%s into %s)\n", verb, clean(m.GetRepo()), m.GetNumber(),
289- clean(m.GetSourceBranch()), clean(m.GetTargetBranch()))
315+ _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s!%d (%s into %s)\n%s\n", verb, clean(m.GetRepo()), m.GetNumber(),
316+ clean(m.GetSourceBranch()), clean(m.GetTargetBranch()), mrWebURL(baseURL, m.GetRepo(), m.GetNumber()))
290317 return err
291318 }
292319
@@ -321,7 +348,7 @@ func newMRListCmd(env *cliEnv) *cobra.Command {
321348 if *asJSON {
322349 out := make([]jsonMergeRequest, 0, len(mrs))
323350 for _, m := range mrs {
324- out = append(out, toJSONMergeRequest(m))
351+ out = append(out, toJSONMergeRequest(m, c.URL()))
325352 }
326353 return writeJSON(cmd.OutOrStdout(), out)
327354 }
@@ -385,15 +412,25 @@ type jsonCommit struct {
385412 // jsonMRDetail is the stable JSON shape of `mr show --json`.
386413 type jsonMRDetail struct {
387414 jsonMergeRequest
388- Commits []jsonCommit `json:"commits"`
389- CommitsTruncated bool `json:"commits_truncated"`
390- DiffBaseID string `json:"diff_base_id"`
391- Stats jsonStats `json:"stats"`
392- DiffTruncated bool `json:"diff_truncated"`
393- Files []jsonFile `json:"files"`
394- Comments []jsonComment `json:"comments"`
395- LastReadID string `json:"last_read_id"`
396- LastReadLost bool `json:"last_read_lost"`
415+ Commits []jsonCommit `json:"commits"`
416+ CommitsTruncated bool `json:"commits_truncated"`
417+ DiffBaseID string `json:"diff_base_id"`
418+ Stats jsonStats `json:"stats"`
419+ DiffTruncated bool `json:"diff_truncated"`
420+ Files []jsonFile `json:"files"`
421+ Comments []jsonComment `json:"comments"`
422+ Verdicts []jsonAuthorVerdict `json:"verdicts"`
423+ LastReadID string `json:"last_read_id"`
424+ LastReadLost bool `json:"last_read_lost"`
425+}
426+
427+type jsonAuthorVerdict struct {
428+ Author string `json:"author"`
429+ Verdict string `json:"verdict"`
430+ CommentID string `json:"comment_id"`
431+ CommitID string `json:"commit_id"`
432+ CreatedAt *string `json:"created_at"`
433+ Stale bool `json:"stale"`
397434 }
398435
399436 func fileStatusName(s ermite.FileStatus) string {
@@ -404,16 +441,16 @@ func lineKindName(k ermite.LineKind) string {
404441 return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_"))
405442 }
406443
407-func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool) jsonMRDetail {
444+func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail {
408445 out := jsonMRDetail{
409- jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest()),
446+ jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL),
410447 Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(),
411448 DiffBaseID: d.GetDiffBaseId(),
412449 Stats: jsonStats{
413450 FilesChanged: d.GetStats().GetFilesChanged(), Additions: d.GetStats().GetAdditions(), Deletions: d.GetStats().GetDeletions(),
414451 },
415452 DiffTruncated: d.GetDiff().GetTruncated(),
416- Files: []jsonFile{}, Comments: []jsonComment{},
453+ Files: []jsonFile{}, Comments: []jsonComment{}, Verdicts: []jsonAuthorVerdict{},
417454 LastReadID: d.GetLastReadId(), LastReadLost: d.GetLastReadLost(),
418455 }
419456 for _, c := range d.GetCommits() {
@@ -443,6 +480,9 @@ func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool) jsonMRDetail
443480 for _, c := range d.GetComments() {
444481 out.Comments = append(out.Comments, toJSONComment(c))
445482 }
483+ for _, v := range d.GetVerdicts() {
484+ out.Verdicts = append(out.Verdicts, jsonAuthorVerdict{Author: v.GetAuthor(), Verdict: verdictName(v.GetVerdict()), CommentID: v.GetCommentId(), CommitID: v.GetCommitId(), CreatedAt: jsonTime(v.GetCreatedAt()), Stale: v.GetStale()})
485+ }
446486 return out
447487 }
448488
@@ -461,7 +501,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
461501 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +
462502 "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" +
463503 "with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" +
464- "and last_read_lost.",
504+ "and last_read_lost; verdicts (author, verdict, comment_id, commit_id, created_at, stale).",
465505 Args: cobra.ExactArgs(2),
466506 ValidArgsFunction: completeMRRepoThenNumber(env),
467507 }
@@ -487,7 +527,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
487527 }
488528 d := res.Msg
489529 if *asJSON {
490- err = writeJSON(cmd.OutOrStdout(), toJSONDetail(d, withDiff))
530+ err = writeJSON(cmd.OutOrStdout(), toJSONDetail(d, withDiff, c.URL()))
491531 } else {
492532 err = printMRDetail(cmd.OutOrStdout(), d, withDiff, sinceLastRead)
493533 }
@@ -529,6 +569,25 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
529569 if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED {
530570 pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"})
531571 }
572+ if len(d.GetVerdicts()) > 0 {
573+ var verdicts []string
574+ for _, v := range d.GetVerdicts() {
575+ var label string
576+ switch v.GetVerdict() {
577+ case ermite.Verdict_VERDICT_APPROVE:
578+ label = "approved"
579+ case ermite.Verdict_VERDICT_CHANGES_REQUESTED:
580+ label = "changes requested"
581+ default:
582+ label = fmt.Sprintf("verdict %d", v.GetVerdict())
583+ }
584+ if v.GetStale() {
585+ label += " (stale)"
586+ }
587+ verdicts = append(verdicts, v.GetAuthor()+" "+label)
588+ }
589+ pairs = append(pairs, [2]string{"verdicts", strings.Join(verdicts, ", ")})
590+ }
532591 if err := fields(w, pairs); err != nil {
533592 return err
534593 }
@@ -561,16 +620,46 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
561620 }
562621
563622 _, _ = fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments()))
564- for _, c := range d.GetComments() {
623+ var ordered []*ermite.Comment
624+ for _, root := range d.GetComments() {
625+ if root.GetReplyTo() != "" {
626+ continue
627+ }
628+ ordered = append(ordered, root)
629+ for _, reply := range d.GetComments() {
630+ if reply.GetReplyTo() == root.GetId() {
631+ ordered = append(ordered, reply)
632+ }
633+ }
634+ }
635+ for _, c := range ordered {
565636 where := "general"
566637 if c.GetPath() != "" {
567638 where = fmt.Sprintf("%s:%d (%s)", clean(c.GetPath()), c.GetLine(), sideName(c.GetSide()))
568639 }
640+ if c.GetStartLine() > 0 {
641+ where = fmt.Sprintf("%s:lines %d-%d (%s)", clean(c.GetPath()), c.GetStartLine(), c.GetLine(), sideName(c.GetSide()))
642+ }
569643 mark := ""
644+ if c.GetEditedAt() != nil {
645+ mark += " (edited)"
646+ }
647+ if c.GetDeleted() {
648+ mark += " [deleted]"
649+ }
650+ if c.GetVerdict() != ermite.Verdict_VERDICT_UNSPECIFIED {
651+ mark += " [" + verdictName(c.GetVerdict()) + "]"
652+ }
570653 if c.GetResolved() {
571- mark = " [resolved]"
654+ mark += " [resolved]"
655+ }
656+ prefix := " "
657+ body := indentText(c.GetText())
658+ if c.GetReplyTo() != "" {
659+ prefix += " "
660+ body = " " + strings.ReplaceAll(body, "\n", "\n ")
572661 }
573- _, _ = fmt.Fprintf(w, " #%s %s, %s, %s%s\n%s\n", c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), where, mark, indentText(c.GetText()))
662+ _, _ = fmt.Fprintf(w, "%s#%s %s, %s, %s%s\n%s\n", prefix, c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), where, mark, body)
574663 }
575664 if withDiff {
576665 printDiff(w, d.GetDiff())
@@ -643,12 +732,14 @@ func printDiff(w io.Writer, d *ermite.Diff) {
643732 }
644733
645734 func newMRCommentCmd(env *cliEnv) *cobra.Command {
646- var message, path, side string
647- var line int
735+ var message, path, side, line, reply string
736+ var approve, requestChanges bool
648737 cmd := &cobra.Command{
649738 Use: "comment <repo> <number>",
650739 Short: "Comment on a merge request",
651- Long: "Add a plain-text comment (at most 8000 characters, no control characters except line breaks).\n" +
740+ Long: "Add a Markdown note (at most 8000 characters); ^N refers to a note in this merge request.\n" +
741+ "--reply replies to a root note. --line accepts N or A-B. --approve and --request-changes\n" +
742+ "set a verdict (needs mr:review) and allow an empty message.\n" +
652743 "-m - reads it from standard input. Without --path it is a general comment. With --path, --line and\n" +
653744 "--side (default new) it is attached to a line of the current diff: the path as in `mr show --diff`\n" +
654745 "(the new path, the old one for a deleted file), the line number on the old or new side; a line\n" +
@@ -656,11 +747,18 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command {
656747 Args: cobra.ExactArgs(2),
657748 ValidArgsFunction: completeMRRepoThenNumber(env),
658749 }
659- cmd.Flags().StringVarP(&message, "message", "m", "", "text of the comment, or - to read standard input (required)")
750+ cmd.Flags().StringVarP(&message, "message", "m", "", "Markdown text, or - to read standard input (required unless setting a verdict)")
660751 cmd.Flags().StringVar(&path, "path", "", "file the comment is attached to")
661- cmd.Flags().IntVar(&line, "line", 0, "1-based line number on --side")
752+ cmd.Flags().StringVar(&line, "line", "", "1-based line N or range A-B on --side")
753+ cmd.Flags().StringVar(&reply, "reply", "", "reply to root note (^12, #12 or 12)")
754+ cmd.Flags().BoolVar(&approve, "approve", false, "approve (needs mr:review)")
755+ cmd.Flags().BoolVar(&requestChanges, "request-changes", false, "request changes (needs mr:review)")
662756 cmd.Flags().StringVar(&side, "side", sideNew, "side the line counts on: old or new")
663- _ = cmd.MarkFlagRequired("message")
757+ cmd.MarkFlagsMutuallyExclusive("approve", "request-changes")
758+ for _, flag := range []string{"path", "line", "side", "approve", "request-changes"} {
759+ cmd.MarkFlagsMutuallyExclusive("reply", flag)
760+ }
761+ // A verdict note may omit its message.
664762 _ = cmd.RegisterFlagCompletionFunc("side", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
665763 return []string{sideOld, sideNew}, cobra.ShellCompDirectiveNoFileComp
666764 })
@@ -674,17 +772,36 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command {
674772 if err != nil {
675773 return err
676774 }
775+ if !cmd.Flags().Changed("message") && !approve && !requestChanges {
776+ return errors.New("--message is required unless --approve or --request-changes is set")
777+ }
677778 req := &ermite.AddCommentRequest{Repo: args[0], Number: n, Text: text}
779+ if approve {
780+ req.Verdict = ermite.Verdict_VERDICT_APPROVE
781+ }
782+ if requestChanges {
783+ req.Verdict = ermite.Verdict_VERDICT_CHANGES_REQUESTED
784+ }
785+ if cmd.Flags().Changed("reply") {
786+ req.ReplyTo, err = parseNoteID(reply)
787+ if err != nil {
788+ return err
789+ }
790+ }
678791 switch {
679792 case path != "":
680- if line < 1 {
793+ if line == "" {
681794 return errors.New("--line is required with --path: give the line number")
682795 }
796+ req.StartLine, req.Line, err = parseLineRange(line)
797+ if err != nil {
798+ return err
799+ }
683800 if req.Side, err = parseSide(side); err != nil {
684801 return err
685802 }
686- req.Path, req.Line = path, int32(line) //nolint:gosec // a line number typed by a person
687- case line != 0 || cmd.Flags().Changed("side"):
803+ req.Path = path
804+ case cmd.Flags().Changed("line") || cmd.Flags().Changed("side"):
688805 return errors.New("--line and --side need --path")
689806 }
690807 c, err := env.client(cmd)
@@ -704,6 +821,100 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command {
704821 return cmd
705822 }
706823
824+func parseNoteID(s string) (string, error) {
825+ id := strings.TrimLeft(strings.TrimSpace(s), "^#")
826+ n, err := strconv.ParseInt(id, 10, 64)
827+ if err != nil || n < 1 {
828+ return "", fmt.Errorf("invalid note id %q: use ^12, #12 or 12", s)
829+ }
830+ return strconv.FormatInt(n, 10), nil
831+}
832+
833+func parseLineRange(s string) (start, end int32, err error) {
834+ parts := strings.Split(s, "-")
835+ invalid := fmt.Errorf("invalid --line %q: use N or A-B with 1 <= A < B", s)
836+ if len(parts) < 1 || len(parts) > 2 {
837+ return 0, 0, invalid
838+ }
839+ parse := func(v string) (int32, error) {
840+ n, e := strconv.ParseInt(v, 10, 32)
841+ if e != nil || n < 1 {
842+ return 0, invalid
843+ }
844+ return int32(n), nil
845+ }
846+ end, err = parse(parts[len(parts)-1])
847+ if err != nil {
848+ return 0, 0, err
849+ }
850+ if len(parts) == 2 {
851+ start, err = parse(parts[0])
852+ if err != nil || start >= end {
853+ return 0, 0, invalid
854+ }
855+ }
856+ return start, end, nil
857+}
858+
859+func newMREditCommentCmd(env *cliEnv) *cobra.Command { return newMRNoteMutationCmd(env, true) }
860+func newMRDeleteCommentCmd(env *cliEnv) *cobra.Command { return newMRNoteMutationCmd(env, false) }
861+
862+func newMRNoteMutationCmd(env *cliEnv, edit bool) *cobra.Command {
863+ verb := "delete"
864+ if edit {
865+ verb = "edit"
866+ }
867+ var message string
868+ cmd := &cobra.Command{Use: verb + "-comment <repo> <number> <comment-id>", Short: verb + " your own merge request note", Long: "Edit or delete your own note on an open merge request. Needs mr:write. Verdicts are unchanged." + jsonCommentHelp, Args: cobra.ExactArgs(3), ValidArgsFunction: completeMRRepoThenNumber(env)}
869+ if edit {
870+ cmd.Flags().StringVarP(&message, "message", "m", "", "Markdown text, or - to read standard input (required)")
871+ _ = cmd.MarkFlagRequired("message")
872+ }
873+ asJSON := addJSONFlag(cmd)
874+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
875+ n, err := parseNumber(args[1])
876+ if err != nil {
877+ return err
878+ }
879+ id, err := parseNoteID(args[2])
880+ if err != nil {
881+ return err
882+ }
883+ text, err := readText(cmd, message)
884+ if err != nil {
885+ return err
886+ }
887+ c, err := env.client(cmd)
888+ if err != nil {
889+ return err
890+ }
891+ var note *ermite.Comment
892+ if edit {
893+ res, e := c.MergeRequests().EditComment(env.ctx(cmd), connect.NewRequest(&ermite.EditCommentRequest{Repo: args[0], Number: n, CommentId: id, Text: text}))
894+ if e != nil {
895+ return c.Describe(e)
896+ }
897+ note = res.Msg.GetComment()
898+ } else {
899+ res, e := c.MergeRequests().DeleteComment(env.ctx(cmd), connect.NewRequest(&ermite.DeleteCommentRequest{Repo: args[0], Number: n, CommentId: id}))
900+ if e != nil {
901+ return c.Describe(e)
902+ }
903+ note = res.Msg.GetComment()
904+ }
905+ if *asJSON {
906+ return writeJSON(cmd.OutOrStdout(), toJSONComment(note))
907+ }
908+ past := "deleted"
909+ if edit {
910+ past = "edited"
911+ }
912+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "comment %s of %s!%d %s\n", clean(id), clean(args[0]), n, past)
913+ return err
914+ }
915+ return cmd
916+}
917+
707918 func newMRResolveCmd(env *cliEnv) *cobra.Command {
708919 var undo bool
709920 cmd := &cobra.Command{
@@ -803,7 +1014,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
8031014 clean(m.GetSourceBranch()))
8041015 }
8051016 if *asJSON {
806- return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m), SourceBranchDeleted: deleted})
1017+ return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m, c.URL()), SourceBranchDeleted: deleted})
8071018 }
8081019 _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(),
8091020 clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId()))
@@ -820,7 +1031,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command {
8201031 Use: "close <repo> <number>",
8211032 Short: "Close a merge request without merging it",
8221033 Long: "Close an open merge request without merging it. A closed merge request cannot be reopened: open a\n" +
823- "new one. Needs the mr:write scope." + jsonMRHelp,
1034+ "new one. Prints a confirmation, then the full web URL on its own line. Needs the mr:write scope." + jsonMRHelp,
8241035 Args: cobra.ExactArgs(2),
8251036 ValidArgsFunction: completeMRRepoThenNumber(env),
8261037 }
@@ -838,7 +1049,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command {
8381049 if err != nil {
8391050 return c.Describe(err)
8401051 }
841- return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "closed")
1052+ return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "closed", c.URL())
8421053 }
8431054 return cmd
8441055 }
M internal/app/cli_mr_e2e_test.go +74 -1
@@ -18,6 +18,7 @@ import (
1818
1919 type mrOut struct {
2020 Repo string `json:"repo"`
21+ URL string `json:"url"`
2122 Number int64 `json:"number"`
2223 Title string `json:"title"`
2324 Description string `json:"description"`
@@ -32,7 +33,7 @@ type mrOut struct {
3233 MergeMode string `json:"merge_mode"`
3334 }
3435
35-var mrKeys = []string{"author", "created_at", "description", "head_id", "merge_mode", "merged_commit_id", "number", "repo", "source_branch", "state", "target_branch", "title", "updated_at"}
36+var mrKeys = []string{"author", "created_at", "description", "head_id", "merge_mode", "merged_commit_id", "number", "repo", "source_branch", "state", "target_branch", "title", "updated_at", "url"}
3637
3738 type commentOut struct {
3839 ID string `json:"id"`
@@ -420,3 +421,75 @@ func (w *watchProc) nextOfType(t *testing.T, typ string) eventOut {
420421 t.Fatalf("no %s event", typ)
421422 return eventOut{}
422423 }
424+
425+func TestMergeRequestNotesCLIEndToEnd(t *testing.T) {
426+ c := newCLIHarness(t)
427+ c.cli(t, adminToken, "repo", "create", "notes", "--visibility", "public").ok(t)
428+ wt := c.workTree("main", "README.md")
429+ c.mustGit(wt, "checkout", "-q", "-b", "feat/notes")
430+ c.writeFile(wt, "notes.txt", []byte("one\ntwo\nthree\nfour\n"))
431+ c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/notes.git"), "main", "feat/notes")
432+ c.cli(t, adminToken, "mr", "create", "notes", "--source", "feat/notes", "--title", "Notes").ok(t)
433+ w := c.startWatch(t, adminToken)
434+ res := c.cli(t, adminToken, "mr", "comment", "notes", "1", "--path", "notes.txt", "--line", "1-3", "-m", "**range**", "--json").ok(t)
435+ root := decode[jsonComment](t, res.stdout)
436+ assert.EqualValues(t, 1, root.StartLine)
437+ assert.EqualValues(t, 3, root.Line)
438+ w.nextOfType(t, "comment_added")
439+ res = c.cli(t, adminToken, "mr", "comment", "notes", "1", "--reply", "^"+root.ID, "-m", "reply to ^"+root.ID, "--json").ok(t)
440+ reply := decode[jsonComment](t, res.stdout)
441+ assert.Equal(t, root.ID, reply.ReplyTo)
442+ assert.Equal(t, root.Path, reply.Path)
443+ assert.Equal(t, root.StartLine, reply.StartLine)
444+ assert.Equal(t, root.Line, reply.Line)
445+ w.nextOfType(t, "comment_added")
446+ require.Error(t, c.cli(t, adminToken, "mr", "comment", "notes", "1", "--reply", reply.ID, "-m", "nested").err)
447+ require.Error(t, c.cli(t, adminToken, "mr", "resolve", "notes", "1", reply.ID).err)
448+ writer := c.token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite})
449+ denied := c.cli(t, writer, "mr", "comment", "notes", "1", "--approve")
450+ require.Error(t, denied.err)
451+ assert.Contains(t, denied.err.Error(), "mr:review")
452+ res = c.cli(t, adminToken, "mr", "comment", "notes", "1", "--approve", "--json").ok(t)
453+ verdict := decode[jsonComment](t, res.stdout)
454+ assert.Equal(t, "approve", verdict.Verdict)
455+ assert.True(t, verdict.Resolved)
456+ assert.Empty(t, verdict.Text)
457+ w.nextOfType(t, "comment_added")
458+ require.Error(t, c.cli(t, writer, "mr", "edit-comment", "notes", "1", verdict.ID, "-m", "not mine").err)
459+ res = runCLI(t, c.vars(adminToken), "edited from stdin\n", "mr", "edit-comment", "notes", "1", "#"+reply.ID, "-m", "-", "--json").ok(t)
460+ edited := decode[jsonComment](t, res.stdout)
461+ assert.Equal(t, "edited from stdin", edited.Text)
462+ require.NotNil(t, edited.EditedAt)
463+ w.nextOfType(t, "comment_edited")
464+ res = c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", verdict.ID, "--json").ok(t)
465+ deleted := decode[jsonComment](t, res.stdout)
466+ assert.True(t, deleted.Deleted)
467+ assert.Equal(t, "approve", deleted.Verdict)
468+ assert.Empty(t, deleted.Text)
469+ require.NotNil(t, deleted.EditedAt)
470+ w.nextOfType(t, "comment_deleted")
471+ c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", verdict.ID).ok(t)
472+ c.cli(t, adminToken, "mr", "resolve", "notes", "1", root.ID).ok(t)
473+ w.nextOfType(t, "comment_resolved")
474+ res = c.cli(t, "", "mr", "show", "notes", "1", "--json").ok(t)
475+ detail := decode[jsonMRDetail](t, res.stdout)
476+ require.Len(t, detail.Comments, 3)
477+ require.Len(t, detail.Verdicts, 1)
478+ assert.Equal(t, verdict.ID, detail.Verdicts[0].CommentID)
479+ assert.Equal(t, "approve", detail.Verdicts[0].Verdict)
480+ assert.False(t, detail.Verdicts[0].Stale)
481+ res = c.cli(t, "", "mr", "show", "notes", "1").ok(t)
482+ assert.Contains(t, res.stdout, "lines 1-3")
483+ assert.Contains(t, res.stdout, "(edited)")
484+ assert.Contains(t, res.stdout, "[deleted]")
485+ assert.Contains(t, res.stdout, "approved")
486+ assert.Contains(t, res.stdout, " #"+reply.ID)
487+ c.cli(t, adminToken, "mr", "comment", "notes", "1", "--request-changes", "-m", "needs work").ok(t)
488+ res = c.cli(t, "", "mr", "show", "notes", "1", "--json").ok(t)
489+ detail = decode[jsonMRDetail](t, res.stdout)
490+ require.Len(t, detail.Verdicts, 1)
491+ assert.Equal(t, "changes_requested", detail.Verdicts[0].Verdict)
492+ c.cli(t, adminToken, "mr", "close", "notes", "1").ok(t)
493+ require.Error(t, c.cli(t, adminToken, "mr", "edit-comment", "notes", "1", root.ID, "-m", "late").err)
494+ require.Error(t, c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", root.ID).err)
495+}
M internal/app/cli_mr_test.go +109 -3
@@ -30,7 +30,7 @@ func TestMRCreateMapsFlags(t *testing.T) {
3030 url := f.serve(t)
3131 res := runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "Add search", "--description", "Finds things.",
3232 "--source", "refs/heads/feat/x", "--target", " main ").ok(t)
33- assert.Equal(t, "opened alpha!1 (feat/x into main)\n", res.stdout)
33+ assert.Equal(t, "opened alpha!1 (feat/x into main)\n"+url+"/alpha/merge-requests/1\n", res.stdout)
3434 _, reqs := f.snapshot()
3535 got := reqs[0].(*ermite.CreateMergeRequestRequest)
3636 assert.Equal(t, "alpha", got.GetRepo())
@@ -49,7 +49,7 @@ func TestMRCreateMapsFlags(t *testing.T) {
4949 _ = res
5050
5151 res = runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "t", "--source", "x", "--json").ok(t)
52- assert.JSONEq(t, `{"repo":"alpha","number":1,"title":"Add search","description":"Finds things.","source_branch":"feat/x","target_branch":"main","state":"open","author":"agent-7","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","head_id":"`+headA+`","merged_commit_id":"","merge_mode":""}`, res.stdout)
52+ assert.JSONEq(t, `{"url":"`+url+`/alpha/merge-requests/1","repo":"alpha","number":1,"title":"Add search","description":"Finds things.","source_branch":"feat/x","target_branch":"main","state":"open","author":"agent-7","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","head_id":"`+headA+`","merged_commit_id":"","merge_mode":""}`, res.stdout)
5353
5454 for _, args := range [][]string{{"mr", "create", "alpha", "--source", "x"}, {"mr", "create", "alpha", "--title", "t"}, {"mr", "create", "--title", "t", "--source", "x"}} {
5555 require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args)
@@ -85,6 +85,9 @@ func TestMRListTableJSONAndStates(t *testing.T) {
8585 res = runCLI(t, envFor(url), "", "mr", "list", "alpha", "--json").ok(t)
8686 assert.Equal(t, []string{"alpha", "alpha"}, []string{decode[[]mrOut](t, res.stdout)[0].Repo, decode[[]mrOut](t, res.stdout)[1].Repo})
8787 assert.Equal(t, "squash", decode[[]mrOut](t, res.stdout)[0].MergeMode)
88+ for i, m := range decode[[]mrOut](t, res.stdout) {
89+ assert.Equal(t, mrWebURL(url, "alpha", int64(2-i)), m.URL)
90+ }
8891 }
8992
9093 func TestMRListFollowsPages(t *testing.T) {
@@ -153,6 +156,7 @@ func TestMRShow(t *testing.T) {
153156 assert.NotContains(t, js.stdout, `"hunks"`)
154157 js = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json", "--diff").ok(t)
155158 show := decode[showOut](t, js.stdout)
159+ assert.Equal(t, url+"/alpha/merge-requests/1", decode[mrOut](t, js.stdout).URL)
156160 require.Len(t, show.Files, 1)
157161 require.Len(t, show.Files[0].Hunks, 1)
158162 assert.Equal(t, "addition", show.Files[0].Hunks[0].Lines[2].Kind)
@@ -274,6 +278,7 @@ func TestMRResolveMergeAndClose(t *testing.T) {
274278 // The default mode is a fast-forward.
275279 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t)
276280 assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State)
281+ assert.Equal(t, url+"/alpha/merge-requests/1", decode[mrOut](t, res.stdout).URL)
277282 calls, reqs = f.snapshot()
278283 assert.Equal(t, "MergeMergeRequest", calls[len(calls)-1])
279284 m = reqs[len(reqs)-1].(*ermite.MergeMergeRequestRequest)
@@ -281,9 +286,10 @@ func TestMRResolveMergeAndClose(t *testing.T) {
281286 assert.Equal(t, headA, m.GetHeadId())
282287
283288 res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1").ok(t)
284- assert.Equal(t, "closed alpha!1 (feat/x into main)\n", res.stdout)
289+ assert.Equal(t, "closed alpha!1 (feat/x into main)\n"+url+"/alpha/merge-requests/1\n", res.stdout)
285290 res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1", "--json").ok(t)
286291 assert.Equal(t, "closed", decode[mrOut](t, res.stdout).State)
292+ assert.Equal(t, url+"/alpha/merge-requests/1", decode[mrOut](t, res.stdout).URL)
287293 }
288294
289295 // ADR 0017: the merge is of the commit that was reviewed, so --head is
@@ -381,3 +387,103 @@ func TestSigningKeyShowOutput(t *testing.T) {
381387 res = runCLI(t, envFor(url), "", "signing-key", "show", "--json").ok(t)
382388 assert.JSONEq(t, `{"public_key":"ssh-ed25519 AAAAC3Nz","fingerprint":"SHA256:abc","committer_name":"ermite","committer_email":"ermite@git.example.org","allowed_signers":"ermite@git.example.org ssh-ed25519 AAAAC3Nz"}`, res.stdout)
383389 }
390+
391+func TestNoteRangesAndIDs(t *testing.T) {
392+ for _, tc := range []struct {
393+ input string
394+ start, end int32
395+ }{{"1", 0, 1}, {"10-24", 10, 24}, {"2147483647", 0, 2147483647}} {
396+ start, end, err := parseLineRange(tc.input)
397+ require.NoError(t, err)
398+ assert.Equal(t, tc.start, start)
399+ assert.Equal(t, tc.end, end)
400+ }
401+ for _, input := range []string{"", "0", "-1", "1-1", "24-10", "0-2", "1-2-3", "2147483648", "a", "1-"} {
402+ _, _, err := parseLineRange(input)
403+ require.Error(t, err, input)
404+ }
405+ for _, input := range []string{"12", "^12", "#12"} {
406+ got, err := parseNoteID(input)
407+ require.NoError(t, err)
408+ assert.Equal(t, "12", got)
409+ }
410+}
411+
412+func TestNoteFlags(t *testing.T) {
413+ f := &fakeAPI{addComment: func(r *ermite.AddCommentRequest) (*ermite.AddCommentResponse, error) {
414+ return &ermite.AddCommentResponse{Comment: &ermite.Comment{Id: "1", ReplyTo: r.GetReplyTo(), StartLine: r.GetStartLine(), Line: r.GetLine(), Verdict: r.GetVerdict()}}, nil
415+ }}
416+ url := f.serve(t)
417+ for _, tc := range []struct {
418+ flags []string
419+ reply string
420+ start int32
421+ verdict string
422+ }{
423+ {[]string{"--reply", "^12", "-m", "reply"}, "12", 0, ""},
424+ {[]string{"--path", "x", "--line", "10-24", "-m", "range"}, "", 10, ""},
425+ {[]string{"--approve"}, "", 0, "approve"},
426+ {[]string{"--request-changes"}, "", 0, "changes_requested"},
427+ } {
428+ res := runCLI(t, envFor(url), "", append([]string{"mr", "comment", "alpha", "1", "--json"}, tc.flags...)...).ok(t)
429+ got := decode[jsonComment](t, res.stdout)
430+ assert.Equal(t, tc.reply, got.ReplyTo)
431+ assert.Equal(t, tc.start, got.StartLine)
432+ assert.Equal(t, tc.verdict, got.Verdict)
433+ }
434+ for _, flags := range [][]string{{"--approve", "--request-changes"}, {"--reply", "1", "--path", "x"}, {"--reply", "1", "--line", "2"}, {"--reply", "1", "--side", "new"}, {"--reply", "1", "--approve"}, {"--reply", "1", "--request-changes"}} {
435+ require.Error(t, runCLI(t, envFor(url), "", append([]string{"mr", "comment", "alpha", "1", "-m", "x"}, flags...)...).err)
436+ }
437+}
438+
439+func TestNoteDetailOutput(t *testing.T) {
440+ d := &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Comments: []*ermite.Comment{
441+ {Id: "1", Path: "x", StartLine: 10, Line: 24, Text: "root"},
442+ {Id: "3", Text: "other", Deleted: true},
443+ {Id: "2", ReplyTo: "1", Text: "reply", EditedAt: ts("2026-10-10T12:00:00Z")},
444+ }, Verdicts: []*ermite.AuthorVerdict{
445+ {Author: "owner", Verdict: ermite.Verdict_VERDICT_APPROVE, CommentId: "1", Stale: true},
446+ {Author: "bot", Verdict: ermite.Verdict_VERDICT_CHANGES_REQUESTED, CommentId: "3"},
447+ }}
448+ var out strings.Builder
449+ require.NoError(t, printMRDetail(&out, d, false, false))
450+ text := out.String()
451+ assert.Contains(t, text, "owner approved (stale), bot changes requested")
452+ assert.Contains(t, text, "lines 10-24")
453+ assert.Contains(t, text, "(edited)")
454+ assert.Contains(t, text, "[deleted]")
455+ assert.Less(t, strings.Index(text, " #2"), strings.Index(text, " #3"))
456+ got := toJSONDetail(d, false, "https://git.example.org")
457+ require.Len(t, got.Verdicts, 2)
458+ assert.True(t, got.Verdicts[0].Stale)
459+ assert.Equal(t, "changes_requested", got.Verdicts[1].Verdict)
460+ assert.Equal(t, "1", got.Comments[2].ReplyTo)
461+ assert.NotNil(t, got.Comments[2].EditedAt)
462+}
463+
464+func TestUnknownVerdictDetailOutput(t *testing.T) {
465+ var out strings.Builder
466+ require.NoError(t, printMRDetail(&out, &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Verdicts: []*ermite.AuthorVerdict{{Author: "future", Verdict: ermite.Verdict(99)}}}, false, false))
467+ assert.Contains(t, out.String(), "future verdict 99")
468+ assert.NotContains(t, out.String(), "changes requested")
469+ assert.NotContains(t, out.String(), "approved")
470+}
471+
472+func TestMRReviewScopeCompletion(t *testing.T) {
473+ cmd := newTokenCreateCmd(&cliEnv{})
474+ complete, ok := cmd.GetFlagCompletionFunc("scope")
475+ require.True(t, ok)
476+ values, _ := complete(cmd, nil, "")
477+ assert.Contains(t, values, "mr:review")
478+}
479+
480+func TestMRWebURL(t *testing.T) {
481+ for _, tt := range []struct{ name, base, repo, want string }{
482+ {"plain", "https://git.example.org", "my-repo", "https://git.example.org/my-repo/merge-requests/42"},
483+ {"trailing slash", "https://git.example.org/", "alpha", "https://git.example.org/alpha/merge-requests/42"},
484+ {"base path", "https://git.example.org/forge///", "alpha", "https://git.example.org/forge/alpha/merge-requests/42"},
485+ {"escaped segment", "https://git.example.org", "team/repo #?%", "https://git.example.org/team%2Frepo%20%23%3F%25/merge-requests/42"},
486+ } {
487+ t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, mrWebURL(tt.base, tt.repo, 42)) })
488+ }
489+}
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 +26 -13
@@ -45,15 +45,18 @@ const (
4545 eventPushed = "pushed"
4646 eventGap = "gap"
4747
48- eventMROpened = "merge_request_opened"
49- eventMRMerged = "merge_request_merged"
50- eventMRClosed = "merge_request_closed"
51- eventCommentAdded = "comment_added"
48+ eventMROpened = "merge_request_opened"
49+ eventMRMerged = "merge_request_merged"
50+ eventMRClosed = "merge_request_closed"
51+ eventCommentAdded = "comment_added"
52+ eventCommentEdited = "comment_edited"
53+ eventCommentDeleted = "comment_deleted"
54+ eventCommentResolved = "comment_resolved"
5255 )
5356
5457 // toJSONEvent maps an event; ok is false for kinds this client does not know,
5558 // which the contract says to ignore.
56-func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) {
59+func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) {
5760 out := jsonEvent{ID: ev.GetId(), Time: jsonTime(ev.GetTime())}
5861 switch k := ev.GetKind().(type) {
5962 case *ermite.Event_RepoCreated:
@@ -71,17 +74,26 @@ func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) {
7174 out.RefUpdates = append(out.RefUpdates, jsonRefUpdate{Ref: u.GetRef(), OldID: u.GetOldId(), NewID: u.GetNewId()})
7275 }
7376 case *ermite.Event_MergeRequestOpened:
74- m := toJSONMergeRequest(k.MergeRequestOpened.GetMergeRequest())
77+ m := toJSONMergeRequest(k.MergeRequestOpened.GetMergeRequest(), baseURL)
7578 out.Type, out.Repo, out.MergeRequest = eventMROpened, m.Repo, &m
7679 case *ermite.Event_MergeRequestMerged:
77- m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest())
80+ m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest(), baseURL)
7881 out.Type, out.Repo, out.MergeRequest = eventMRMerged, m.Repo, &m
7982 case *ermite.Event_MergeRequestClosed:
80- m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest())
83+ m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest(), baseURL)
8184 out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m
8285 case *ermite.Event_CommentAdded:
8386 c := toJSONComment(k.CommentAdded.GetComment())
8487 out.Type, out.Repo, out.Number, out.Comment = eventCommentAdded, k.CommentAdded.GetRepo(), k.CommentAdded.GetNumber(), &c
88+ case *ermite.Event_CommentEdited:
89+ c := toJSONComment(k.CommentEdited.GetComment())
90+ out.Type, out.Repo, out.Number, out.Comment = eventCommentEdited, k.CommentEdited.GetRepo(), k.CommentEdited.GetNumber(), &c
91+ case *ermite.Event_CommentDeleted:
92+ c := toJSONComment(k.CommentDeleted.GetComment())
93+ out.Type, out.Repo, out.Number, out.Comment = eventCommentDeleted, k.CommentDeleted.GetRepo(), k.CommentDeleted.GetNumber(), &c
94+ case *ermite.Event_CommentResolved:
95+ c := toJSONComment(k.CommentResolved.GetComment())
96+ out.Type, out.Repo, out.Number, out.Comment = eventCommentResolved, k.CommentResolved.GetRepo(), k.CommentResolved.GetNumber(), &c
8597 case *ermite.Event_Gap:
8698 out.Type = eventGap
8799 default:
@@ -112,7 +124,7 @@ func eventLine(e jsonEvent) string {
112124 detail = e.Repo + " " + strings.Join(parts, ", ")
113125 case eventMROpened, eventMRMerged, eventMRClosed:
114126 detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch)
115- case eventCommentAdded:
127+ case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved:
116128 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)
117129 case eventGap:
118130 detail = "events were lost, reload what you display"
@@ -142,17 +154,18 @@ func newWatchCmd(env *cliEnv) *cobra.Command {
142154 Use: "watch [repo]",
143155 Short: "Print repository events as they happen",
144156 Long: "Print one line per event (repository created, updated or deleted, refs pushed, merge requests\n" +
145- "opened, merged or closed, comments added) for every\n" +
157+ "opened, merged or closed, notes added, edited, deleted or resolved) for every\n" +
146158 "repository you can see, or only for [repo]. It runs until interrupted. When the connection drops it\n" +
147159 "reconnects with a growing delay and resumes after the last event it printed; if the server no longer\n" +
148160 "has that event it prints a gap event: reload what you display. Connection notices go to standard\n" +
149161 "error.\n\n" +
150162 "--json prints one JSON object per line (NDJSON). Fields: id, time (RFC 3339 UTC), type\n" +
151163 "(repo_created, repo_updated, repo_deleted, pushed, merge_request_opened, merge_request_merged,\n" +
152- "merge_request_closed, comment_added or gap) and, depending on the type, repo (name),\n" +
164+ "merge_request_closed, comment_added, comment_edited, comment_deleted, comment_resolved or gap)\n" +
165+ "and, depending on the type, repo (name),\n" +
153166 "repository (the repository fields of `repo show --json`), ref_updates (array of ref, old_id,\n" +
154167 "new_id; an empty id means the ref was created or deleted), merge_request (the fields of\n" +
155- "`mr show --json`) and, for comment_added, number (of the merge request) and comment. Unknown\n" +
168+ "`mr show --json`) and, for comment events, number (of the merge request) and comment. Unknown\n" +
156169 "event types are skipped.",
157170 Args: cobra.MaximumNArgs(1),
158171 ValidArgsFunction: completeRepoNames(env),
@@ -177,7 +190,7 @@ func newWatchCmd(env *cliEnv) *cobra.Command {
177190 OnNotice: func(msg string) { noticef(cmd, "%s", clean(msg)) },
178191 }
179192 return c.Watch(ctx, opts, func(ev *ermite.Event) error {
180- e, ok := toJSONEvent(ev)
193+ e, ok := toJSONEvent(ev, c.URL())
181194 if !ok {
182195 return nil
183196 }
M internal/app/cli_watch_test.go +24 -0
@@ -199,3 +199,27 @@ func TestWatchMergeRequestEvents(t *testing.T) {
199199 assert.Equal(t, "merge_request_merged", decode[eventOut](t, lines[2]).Type)
200200 assert.Equal(t, "merge_request_closed", decode[eventOut](t, lines[3]).Type)
201201 }
202+
203+func TestNoteWatchEvents(t *testing.T) {
204+ note := &ermite.Comment{Id: "4", ReplyTo: "2", StartLine: 10, Line: 24, Verdict: ermite.Verdict_VERDICT_APPROVE, EditedAt: ts("2026-10-10T12:00:00Z"), Deleted: true, Author: "owner"}
205+ events := []*ermite.Event{
206+ {Kind: &ermite.Event_CommentEdited{CommentEdited: &ermite.CommentEdited{Repo: "alpha", Number: 1, Comment: note}}},
207+ {Kind: &ermite.Event_CommentDeleted{CommentDeleted: &ermite.CommentDeleted{Repo: "alpha", Number: 1, Comment: note}}},
208+ {Kind: &ermite.Event_CommentResolved{CommentResolved: &ermite.CommentResolved{Repo: "alpha", Number: 1, Comment: note}}},
209+ }
210+ for i, ev := range events {
211+ got, ok := toJSONEvent(ev, "https://git.example.org")
212+ require.True(t, ok)
213+ assert.Equal(t, []string{"comment_edited", "comment_deleted", "comment_resolved"}[i], got.Type)
214+ assert.Equal(t, "alpha", got.Repo)
215+ assert.EqualValues(t, 1, got.Number)
216+ require.NotNil(t, got.Comment)
217+ assert.True(t, got.Comment.Deleted)
218+ assert.Equal(t, "2", got.Comment.ReplyTo)
219+ assert.Equal(t, "approve", got.Comment.Verdict)
220+ assert.Contains(t, eventLine(got), got.Type+" alpha!1 by owner")
221+ raw, err := json.Marshal(got)
222+ require.NoError(t, err)
223+ assert.Contains(t, string(raw), `"edited_at":"2026-10-10T12:00:00Z"`)
224+ }
225+}
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
@@ -245,6 +245,45 @@ type IssuePendingRemoved struct {
245245 // EventKind implements Event.
246246 func (IssuePendingRemoved) EventKind() string { return "issue.pending_removed" }
247247
248+// CommentEdited is published when the text of a note was edited.
249+type CommentEdited struct {
250+ Repo RepoName
251+ Number int64
252+ Comment Comment
253+ At time.Time
254+ Visibility Visibility
255+ CreatedAt time.Time
256+}
257+
258+// EventKind implements Event.
259+func (CommentEdited) EventKind() string { return "mr.comment_edited" }
260+
261+// CommentDeleted is published when the text of a note was deleted.
262+type CommentDeleted struct {
263+ Repo RepoName
264+ Number int64
265+ Comment Comment
266+ At time.Time
267+ Visibility Visibility
268+ CreatedAt time.Time
269+}
270+
271+// EventKind implements Event.
272+func (CommentDeleted) EventKind() string { return "mr.comment_deleted" }
273+
274+// CommentResolved is published when a thread was resolved or reopened.
275+type CommentResolved struct {
276+ Repo RepoName
277+ Number int64
278+ Comment Comment
279+ At time.Time
280+ Visibility Visibility
281+ CreatedAt time.Time
282+}
283+
284+// EventKind implements Event.
285+func (CommentResolved) EventKind() string { return "mr.comment_resolved" }
286+
248287 // EventGap tells a subscriber that it missed events because it was too slow;
249288 // it must refresh its whole view.
250289 type EventGap struct{}
M internal/domain/mergerequest.go +80 -0
@@ -188,6 +188,11 @@ func (m MergeRequest) IsOpen() bool { return m.State == MRStateOpen }
188188
189189 // Comment is a note on a merge request, general or on a line of its diff.
190190 type Comment struct {
191+ ReplyTo int64
192+ StartLine int
193+ Verdict Verdict
194+ EditedAt time.Time
195+ Deleted bool
191196 // ID is unique within the merge request, in order of creation.
192197 ID int64
193198 Author string
@@ -350,3 +355,78 @@ func singleLine(s string) string {
350355 return r
351356 }, s)
352357 }
358+
359+// Verdict is an immutable review decision on a root note.
360+type Verdict uint8
361+
362+// Review verdicts.
363+const (
364+ VerdictNone Verdict = iota
365+ VerdictApprove
366+ VerdictChangesRequested
367+)
368+
369+// String returns the wire name of a verdict.
370+func (v Verdict) String() string {
371+ switch v {
372+ case VerdictApprove:
373+ return "approve"
374+ case VerdictChangesRequested:
375+ return "changes_requested"
376+ }
377+ return ""
378+}
379+
380+// ParseVerdict parses a review decision.
381+func ParseVerdict(s string) (Verdict, error) {
382+ switch s {
383+ case "":
384+ return VerdictNone, nil
385+ case "approve":
386+ return VerdictApprove, nil
387+ case "changes_requested":
388+ return VerdictChangesRequested, nil
389+ }
390+ return VerdictNone, fmt.Errorf("%w: verdict %q", ErrInvalid, s)
391+}
392+
393+// IsReply reports whether this note replies to a root.
394+func (c Comment) IsReply() bool { return c.ReplyTo != 0 }
395+
396+// IsRange reports whether this note anchors a range.
397+func (c Comment) IsRange() bool { return c.StartLine != 0 }
398+
399+// AuthorVerdict is an author's latest review decision.
400+type AuthorVerdict struct {
401+ Author string
402+ Verdict Verdict
403+ CommentID int64
404+ CommitID ObjectID
405+ At time.Time
406+ Stale bool
407+}
408+
409+// CurrentVerdicts returns the latest verdict per author, including deleted notes,
410+// in note ID order. Only approvals become stale when the head changes.
411+func CurrentVerdicts(comments []Comment, head ObjectID) []AuthorVerdict {
412+ latest := make(map[string]Comment)
413+ for _, c := range comments {
414+ if c.Verdict != VerdictNone && c.ID > latest[c.Author].ID {
415+ latest[c.Author] = c
416+ }
417+ }
418+ out := make([]AuthorVerdict, 0, len(latest))
419+ for _, c := range latest {
420+ out = append(out, AuthorVerdict{Author: c.Author, Verdict: c.Verdict, CommentID: c.ID, CommitID: c.CommitID, At: c.CreatedAt, Stale: c.Verdict == VerdictApprove && (head == (ObjectID{}) || c.CommitID != head)})
421+ }
422+ slices.SortFunc(out, func(a, b AuthorVerdict) int {
423+ if a.CommentID < b.CommentID {
424+ return -1
425+ }
426+ if a.CommentID > b.CommentID {
427+ return 1
428+ }
429+ return 0
430+ })
431+ return out
432+}
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.
@@ -43,11 +45,11 @@ const (
4345 ScopeIssueWrite Scope = "issue:write"
4446 )
4547
46-// AllScopes returns the ten scopes in a stable order.
48+// AllScopes returns the eleven scopes in a stable order.
4749 func AllScopes() []Scope {
4850 return []Scope{
4951 ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,
50- ScopeMRWrite, ScopeMRMerge,
52+ ScopeMRWrite, ScopeMRMerge, ScopeMRReview,
5153 ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,
5254 ScopeTokenAdmin,
5355 ScopeIssueWrite,
M internal/domain/token_test.go +1 -1
@@ -16,7 +16,7 @@ import (
1616 func TestScopes(t *testing.T) {
1717 t.Parallel()
1818 all := domain.AllScopes()
19- assert.Len(t, all, 10)
19+ assert.Len(t, all, 11)
2020 assert.Contains(t, all, domain.ScopeIssueWrite)
2121 got, err := domain.ParseScope("issue:write")
2222 require.NoError(t, err)
M internal/ports/dto.go +8 -0
@@ -260,6 +260,8 @@ type MergeRequestDetail struct {
260260 Diff domain.Diff
261261 // Comments are the comments, oldest first.
262262 Comments []domain.Comment
263+ // Verdicts are the latest verdict per author in note ID order.
264+ Verdicts []domain.AuthorVerdict
263265 // LastReadID is the head recorded by MarkRead; zero when none.
264266 LastReadID domain.ObjectID
265267 // LastReadLost tells that since-last-read was asked for, a head was
@@ -289,6 +291,12 @@ type FileWindow struct{ From, To int }
289291
290292 // AddCommentParams describes the comment to add.
291293 type AddCommentParams struct {
294+ // ReplyTo names a non-deleted root; replies cannot specify an anchor or verdict.
295+ ReplyTo int64
296+ // StartLine starts an inclusive range, requiring a path and 1 <= StartLine < Line.
297+ StartLine int
298+ // Verdict requires mr:review and is only permitted on roots.
299+ Verdict domain.Verdict
292300 // Path, Line and Side designate a line of the diff; all empty for a
293301 // general comment.
294302 Path string
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 +14 -1
@@ -192,6 +192,12 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
192192 return repoSnapshot(ev.Visibility, ev.CreatedAt)
193193 case domain.IssueSubmitted, domain.IssuePendingRemoved:
194194 return bufferedEvent{known: true} // owner topic only: no repository snapshot
195+ case domain.CommentEdited:
196+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
197+ case domain.CommentDeleted:
198+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
199+ case domain.CommentResolved:
200+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
195201 case domain.RunUpdated:
196202 return repoSnapshot(ev.Visibility, ev.CreatedAt)
197203 case domain.JobUpdated:
@@ -529,6 +535,12 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
529535 return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
530536 case domain.IssueCommented:
531537 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
538+ case domain.CommentEdited:
539+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
540+ case domain.CommentDeleted:
541+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
542+ case domain.CommentResolved:
543+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
532544 case domain.RunUpdated:
533545 // Home shows the CI state of each repository.
534546 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)
@@ -633,7 +645,8 @@ func isRepoLifecycleEvent(ev domain.Event) bool {
633645 func isItemEvent(ev domain.Event) bool {
634646 switch ev.(type) {
635647 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
636- domain.MergeRequestReady, domain.CommentAdded:
648+ domain.MergeRequestReady, domain.CommentAdded,
649+ domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
637650 return true
638651 }
639652 return isIssueEvent(ev)
M internal/usecase/events_test.go +32 -0
@@ -965,3 +965,35 @@ func TestWatchHomeGetsRunUpdates(t *testing.T) {
965965 root.quiet(t, r, pub)
966966 })
967967 }
968+
969+func TestNoteMutationEventVisibility(t *testing.T) {
970+ synctest.Test(t, func(t *testing.T) {
971+ r := newEventRig(t, 0)
972+ pub := r.repo("pub", domain.VisibilityPublic)
973+ priv := r.repo("priv", domain.VisibilityPrivate)
974+ ctx, cancel := context.WithCancel(context.Background())
975+ defer cancel()
976+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{})
977+ owner := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:priv"}})
978+ synctest.Wait()
979+ for _, kind := range []string{"edited", "deleted", "resolved"} {
980+ for _, n := range []domain.RepoName{priv, pub} {
981+ vis := domain.VisibilityPublic
982+ if n == priv {
983+ vis = domain.VisibilityPrivate
984+ }
985+ switch kind {
986+ case "edited":
987+ r.feed <- domain.CommentEdited{Repo: n, Visibility: vis, CreatedAt: now, At: now}
988+ case "deleted":
989+ r.feed <- domain.CommentDeleted{Repo: n, Visibility: vis, CreatedAt: now, At: now}
990+ case "resolved":
991+ r.feed <- domain.CommentResolved{Repo: n, Visibility: vis, CreatedAt: now, At: now}
992+ }
993+ }
994+ assert.Equal(t, "mr.comment_"+kind, anon.next(t).Event.EventKind())
995+ assert.Equal(t, "mr.comment_"+kind, owner.next(t).Event.EventKind())
996+ anon.quiet(t, r, pub)
997+ }
998+ })
999+}
M internal/usecase/export_test.go +0 -3
@@ -16,9 +16,6 @@ import (
1616 "bnema.dev/ermite/internal/ports"
1717 )
1818
19-// AuthorOf exposes authorOf to the tests.
20-func AuthorOf(p domain.Principal) string { return authorOf(p) }
21-
2219 // Sweep exposes the daily sweep to the tests.
2320 func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }
2421
M internal/usecase/issues.go +2 -2
@@ -116,7 +116,7 @@ func (i *Issues) Create(ctx context.Context, p domain.Principal, name domain.Rep
116116 now := i.clock.Now().UTC()
117117 issue, err := i.store.Create(ctx, domain.Issue{
118118 Repo: name, Title: title, Body: body, State: domain.IssueStateOpen,
119- Author: authorOf(p), CreatedAt: now, UpdatedAt: now,
119+ Author: domain.AuthorName(p), CreatedAt: now, UpdatedAt: now,
120120 })
121121 if err != nil {
122122 return domain.Issue{}, fmt.Errorf("create issue of %q: %w", name, err)
@@ -191,7 +191,7 @@ func (i *Issues) AddComment(ctx context.Context, p domain.Principal, name domain
191191 if issue.CommentSeq >= MaxCommentsPerIssue {
192192 return domain.IssueComment{}, fmt.Errorf("%w: #%d already has %d comments", domain.ErrResourceExhausted, number, MaxCommentsPerIssue)
193193 }
194- c = domain.IssueComment{ID: issue.CommentSeq + 1, Author: authorOf(p), Text: body, CreatedAt: i.clock.Now().UTC()}
194+ c = domain.IssueComment{ID: issue.CommentSeq + 1, Author: domain.AuthorName(p), Text: body, CreatedAt: i.clock.Now().UTC()}
195195 issue.CommentSeq, issue.UpdatedAt = c.ID, c.CreatedAt
196196 err = i.store.SaveComment(ctx, issue, c)
197197 if err == nil {
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
@@ -74,6 +74,13 @@ message MergeRequest {
7474 int64 comment_seq = 14;
7575 }
7676
77+// Verdict is the immutable review decision of a note.
78+enum Verdict {
79+ VERDICT_UNSPECIFIED = 0;
80+ VERDICT_APPROVE = 1;
81+ VERDICT_CHANGES_REQUESTED = 2;
82+}
83+
7784 // Comment is stored under the key "mrc/<repo>/<number:010d>/<id:010d>".
7885 message Comment {
7986 int64 id = 1;
@@ -85,6 +92,11 @@ message Comment {
8592 string text = 7;
8693 bool resolved = 8;
8794 google.protobuf.Timestamp created_at = 9;
95+ int64 reply_to = 10;
96+ int32 start_line = 11;
97+ Verdict verdict = 12;
98+ google.protobuf.Timestamp edited_at = 13;
99+ bool deleted = 14;
88100 }
89101
90102 // Sequence is stored under the key "mrseq/<repo>": the last merge request
M skills/ermite/CLI.md +15 -6
@@ -28,7 +28,7 @@ Presets add up with `--scope` (repeatable or comma-separated):
2828
2929 | Preset | Scopes |
3030 |---|---|
31-| `cli` | every scope, for the owner's daily use |
31+| `cli` | every scope (including `mr:review`), for the owner's daily use; a token created before `mr:review` existed lacks it and loses owner rights: recreate it with `--preset cli` using the root token or from a signed-in owner web session (an old token lacking `mr:review` cannot grant it), then replace the stored CLI secret and revoke the old token |
3232 | `agent` | `repo:write`, `mr:write` |
3333 | `ci` | `repo:read` |
3434
@@ -39,7 +39,8 @@ Scopes:
3939 | `repo:read` | read private repositories |
4040 | `repo:write` | push, create a repository |
4141 | `repo:admin` | delete a repository, change visibility, description, default branch, merge settings |
42-| `mr:write` | open, comment on, resolve, close, mark read |
42+| `mr:write` | open, comment on, reply, edit and delete own notes, resolve, close, mark read |
43+| `mr:review` | give a verdict (`--approve`, `--request-changes`) |
4344 | `mr:merge` | merge, and delete the merged source branch with `--delete-branch` |
4445 | `issue:write` | open, comment on, close and reopen issues |
4546 | `token:admin` | create, list and revoke tokens |
@@ -56,12 +57,20 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
5657 | `mr create <repo> --source B --title T [--target B] [--description TEXT\|-]` | `mr:write` | target defaults to the default branch; title of 120 characters max; `-` reads standard input |
5758 | `mr list <repo> [--state open\|merged\|closed\|all] [--json]` | none | default `open` |
5859 | `mr show <repo> <n> [--diff] [--since-last-read] [--mark-read] [--json]` | `mr:write` for `--mark-read` | commits, changed files and comments; `--diff` adds hunks with line numbers |
59-| `mr comment <repo> <n> -m TEXT\|- [--path P --line N --side old\|new]` | `mr:write` | the line must be in the current diff; a deleted file uses its old path |
60-| `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | doing it twice is not an error |
60+| `mr comment <repo> <n> -m TEXT\|- [--path P --line N\|A-B --side old\|new] [--reply ID] [--approve\|--request-changes]` | `mr:write`; verdicts `mr:review` | Markdown note (8000 characters max); the lines must be in the current diff on one side; a deleted file uses its old path; `--reply` takes a root note (`12`, `#12` or `^12`), inherits its anchor and cannot carry a verdict; a verdict note may have no message |
61+| `mr edit-comment <repo> <n> <comment-id> -m TEXT\|-` | `mr:write` | author only, open merge request; the verdict is unchanged |
62+| `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept |
63+| `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | on the thread root, covers its replies; doing it twice is not an error |
6164 | `mr merge <repo> <n> --head <full-id> [--squash] [--delete-branch]` | `mr:merge` | refused if the branch moved or the target is not an ancestor of the head; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |
6265 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |
6366
64-Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, `target_branch`, `state`, `author` (token label), `created_at`, `updated_at`, `head_id`, `merged_commit_id`, `merge_mode`. `mr show --json` adds `commits`, `diff_base_id`, `stats`, `files` (with `hunks` under `--diff`: `kind`, `old_number`, `new_number`, `text`), `comments`, `last_read_id`, `last_read_lost`.
67+`ermite mr create` and `ermite mr close` print a confirmation followed by the full
68+merge request web URL on its own line. With `--json`, merge request objects
69+(create, show, list, close and merge) include `url` alongside the other fields.
70+
71+Merge request JSON: `repo`, `url` (full web URL), `number`, `title`, `description`, `source_branch`, `target_branch`, `state`, `author` (token label), `created_at`, `updated_at`, `head_id`, `merged_commit_id`, `merge_mode`. `mr show --json` adds `commits`, `diff_base_id`, `stats`, `files` (with `hunks` under `--diff`: `kind`, `old_number`, `new_number`, `text`), `comments`, `verdicts`, `last_read_id`, `last_read_lost`.
72+
73+Comment JSON: `id`, `author`, `path`, `line`, `side`, `commit_id`, `text`, `resolved`, `created_at`, `reply_to` (empty for roots), `start_line` (0 for a single line), `verdict` (`""`, `approve`, `changes_requested`), `edited_at`, `deleted`. Verdict JSON (latest per author): `author`, `verdict`, `comment_id`, `commit_id`, `created_at`, `stale` (an approval given on another head; changes requested are never stale). Verdicts are informative and never block a merge. In notes, `^N` refers to note N.
6574
6675 ## Issues
6776
@@ -97,7 +106,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the `
97106 | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` |
98107 | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) |
99108 | `merge_request_opened`, `merge_request_merged`, `merge_request_closed` | `repo`, `merge_request` |
100-| `comment_added` | `repo`, `number`, `comment` |
109+| `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) |
101110 | `gap` | none: events were lost, reload the state |
102111
103112 ## Server and web
M skills/ermite/SKILL.md +17 -4
@@ -35,11 +35,24 @@ git push https://git.example.com/<repo>.git main # password = token,
3535
3636 1. List: `ermite mr list <repo> --json`.
3737 2. Read: `ermite mr show <repo> <n> --diff --json`. Keep `head_id`; take line numbers from the hunks.
38-3. Comment on a line: `ermite mr comment <repo> <n> -m "text" --path <file> --line <n> --side new`. Without `--path` the comment is general. The line must appear in the diff.
39-4. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--squash` for one commit signed by the server and `--delete-branch` to delete the source branch afterwards.
38+3. Comment on a line: `ermite mr comment <repo> <n> -m "text" --path <file> --line <n> --side new`; `--line 10-14` covers a range on one side. Without `--path` the comment is general. The line must appear in the diff. Notes are Markdown; `^N` refers to note N.
39+4. End a review with a verdict (needs `mr:review`): `--approve` or `--request-changes`, alone or with a message. Verdicts are informative and cannot be edited; a new verdict replaces the previous one.
40+5. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--squash` for one commit signed by the server and `--delete-branch` to delete the source branch afterwards.
4041
4142 The merge is refused when the branch moved since `head_id` (review again) or when the target is not an ancestor of the head (rebase and push again). The repository may allow only one merge mode.
4243
44+### Act on a review
45+
46+Notes are visible as soon as they are written, so a review is not finished until its verdict. Act on a verdict, not on each note (`ermite watch --json` shows `verdict` on the `comment_added` event).
47+
48+On `changes_requested`: run `ermite mr show <repo> <n> --json`, read the open threads (roots with `resolved` false and their replies), fix the code, push, then answer each thread with `ermite mr comment <repo> <n> --reply <id> -m "Fixed in ^N"`. Leave resolving to the reviewer: do not run `mr resolve` on their threads. Edit or delete only your own notes (`mr edit-comment`, `mr delete-comment`).
49+
50+An approval is stale after a push; changes requested stay until the reviewer gives a new verdict.
51+
52+`ermite mr create` and `ermite mr close` print a confirmation followed by the full
53+merge request web URL on its own line. With `--json`, merge request objects
54+(create, show, list, close and merge) include `url` alongside the other fields.
55+
4356 Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new pushes, `mr show --since-last-read` shows only what changed since `--mark-read`.
4457
4558 ## Tokens
@@ -48,11 +61,11 @@ Open one: `ermite mr create <repo> --source <branch> --title "..."`. After new p
4861 ermite token create my-agent --preset agent --repo <repo> --expires 30d
4962 ```
5063
51-The secret is printed once. Presets: `agent` (push, open and comment on merge requests), `ci` (read), `cli` (everything). Add `--scope mr:merge` to let an agent merge. A token can never grant more than the token that creates it. Full scope list: [CLI.md](CLI.md).
64+The secret is printed once. Presets: `agent` (push, open and comment on merge requests; no verdicts), `ci` (read), `cli` (everything). Add `--scope mr:review` to let an agent give verdicts, `--scope mr:merge` to let it merge. A token can never grant more than the token that creates it. Full scope list: [CLI.md](CLI.md).
5265
5366 ## Follow events
5467
55-`ermite watch [repo] --json` prints one JSON object per event (pushes, repositories, merge requests, comments). It reconnects and resumes on its own; a `gap` event means some events were lost, so reload the state.
68+`ermite watch [repo] --json` prints one JSON object per event (pushes, repositories, merge requests, notes: added, edited, deleted, resolved). It reconnects and resumes on its own; a `gap` event means some events were lost, so reload the state.
5669
5770 ## Run a server
5871

Keyboard shortcuts

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

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

Branches 2

Tags 43

↑↓ moveenter openesc close