detached 06682af

fix(mr): deleted verdict notes, split-layout note links and verdict labels

commit
06682af77afcca45e84c6bb28b8592340fef3853
author
brice · · 2026-10-19 05:58 UTC
parent
3872b20
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +421 -292
M docs/tokens.md +1 -1
@@ -6,7 +6,7 @@ ermite token list
66 ermite token revoke <id>
77 ```
88
9-The secret is printed once. Presets and `--scope` add up. The full scope list is in [`skills/ermite/CLI.md`](../skills/ermite/CLI.md). `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` to keep the owner's rights.
9+The secret is printed once. Presets and `--scope` add up. The full scope list is in [`skills/ermite/CLI.md`](../skills/ermite/CLI.md). `mr:review` lets a token give a merge request verdict (`--approve`, `--request-changes`); the `cli` preset includes it, the `agent` preset does not. A token created before `mr:review` existed lacks it: recreate it with `--preset cli` using the root token or from a signed-in owner web session (an old token lacking `mr:review` cannot grant it), then replace the stored CLI secret and revoke the old token to keep the owner's rights.
1010
1111 | Preset | For |
1212 |---|---|
@@ -6,7 +6,7 @@ ermite token list
6 ermite token revoke <id>6 ermite token revoke <id>
7 ```7 ```
8 8
9-The secret is printed once. Presets and `--scope` add up. The full scope list is in [`skills/ermite/CLI.md`](../skills/ermite/CLI.md). `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` to keep the owner's rights.9+The secret is printed once. Presets and `--scope` add up. The full scope list is in [`skills/ermite/CLI.md`](../skills/ermite/CLI.md). `mr:review` lets a token give a merge request verdict (`--approve`, `--request-changes`); the `cli` preset includes it, the `agent` preset does not. A token created before `mr:review` existed lacks it: recreate it with `--preset cli` using the root token or from a signed-in owner web session (an old token lacking `mr:review` cannot grant it), then replace the stored CLI secret and revoke the old token to keep the owner's rights.
10 10
11 | Preset | For |11 | Preset | For |
12 |---|---|12 |---|---|
M internal/adapters/web/jstest/mr.test.mjs +30 -0
@@ -62,3 +62,33 @@ test('quote opens the thread reply and prefills and focuses its textarea', () =>
6262 assert.equal(field.value,'> source\n\n^2 ');
6363 assert.equal(focused,true);
6464 });
65+
66+test('reveal selects the split note, 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:{diff:'split'}} : 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+});
@@ -62,3 +62,33 @@ test('quote opens the thread reply and prefills and focuses its textarea', () =>
62 assert.equal(field.value,'> source\n\n^2 ');62 assert.equal(field.value,'> source\n\n^2 ');
63 assert.equal(focused,true);63 assert.equal(focused,true);
64 });64 });
65+
66+test('reveal selects the split note, 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:{diff:'split'}} : 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/mr.go +15 -11
@@ -106,7 +106,7 @@ type CommentView struct {
106106 When string
107107 WhenAt string
108108 Abs string
109- Body template.HTML
109+ Body, BodySplit template.HTML
110110 Replies []CommentView
111111 Edited, Deleted bool
112112 Verdict, Range, Quote string
@@ -796,7 +796,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
796796 exists := noteIDs(comments)
797797 children := map[int64][]domain.Comment{}
798798 for _, c := range comments {
799- if c.ReplyTo != 0 {
799+ if c.ReplyTo != 0 && !c.Deleted {
800800 children[c.ReplyTo] = append(children[c.ReplyTo], c)
801801 }
802802 if !c.Deleted {
@@ -806,14 +806,18 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
806806 p.NUnresolved++
807807 }
808808 }
809- var view func(domain.Comment) CommentView
810- view = func(c domain.Comment) CommentView {
809+ var view func(domain.Comment, bool) CommentView
810+ view = func(c domain.Comment, replyable bool) CommentView {
811811 cid := strconv.FormatInt(c.ID, 10)
812812 body, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"-", exists)
813813 if err != nil {
814814 body = ""
815815 }
816- v := CommentView{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}
816+ bodySplit, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"-s-", exists)
817+ if err != nil {
818+ bodySplit = ""
819+ }
820+ v := CommentView{BodySplit: bodySplit, ID: cid, Author: s.who(c.Author), Body: body, Resolved: c.Resolved, Deleted: c.Deleted, Edited: !c.EditedAt.IsZero(), Verdict: c.Verdict.String(), Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head}
817821 v.When, v.WhenAt = whenOf(now, c.CreatedAt)
818822 if !c.CreatedAt.IsZero() {
819823 v.Abs = absolute(c.CreatedAt)
@@ -827,7 +831,9 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
827831 if c.ReplyTo != 0 {
828832 root = strconv.FormatInt(c.ReplyTo, 10)
829833 }
830- v.Quote = "> " + strings.ReplaceAll(c.Text.String(), "\n", "\n> ") + "\n\n^" + cid + " "
834+ if replyable {
835+ v.Quote = "> " + strings.ReplaceAll(c.Text.String(), "\n", "\n> ") + "\n\n^" + cid + " "
836+ }
831837 if c.ReplyTo == 0 {
832838 v.Reply = &CommentForm{ID: "reply-" + cid, Action: mrHref(repo, n, "comments"), Title: "Reply", ReplyTo: root, From: from}
833839 }
@@ -837,18 +843,16 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
837843 }
838844 }
839845 for _, reply := range children[c.ID] {
840- if !reply.Deleted {
841- v.Replies = append(v.Replies, view(reply))
842- }
846+ v.Replies = append(v.Replies, view(reply, replyable))
843847 }
844848 return v
845849 }
846850 for _, c := range comments {
847- if c.ReplyTo != 0 || c.Deleted && len(children[c.ID]) == 0 {
851+ if c.ReplyTo != 0 || c.Deleted && c.Verdict == domain.VerdictNone && len(children[c.ID]) == 0 {
848852 continue
849853 }
850854 cid := strconv.FormatInt(c.ID, 10)
851- v := view(c)
855+ v := view(c, canWrite && !c.Deleted)
852856 general := c.Path == ""
853857 fi, listed := fileAt[c.Path]
854858 if !general && listed && p.Files[fi].Lazy {
@@ -106,7 +106,7 @@ type CommentView struct {
106 When string106 When string
107 WhenAt string107 WhenAt string
108 Abs string108 Abs string
109- Body template.HTML109+ Body, BodySplit template.HTML
110 Replies []CommentView110 Replies []CommentView
111 Edited, Deleted bool111 Edited, Deleted bool
112 Verdict, Range, Quote string112 Verdict, Range, Quote string
@@ -796,7 +796,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
796 exists := noteIDs(comments)796 exists := noteIDs(comments)
797 children := map[int64][]domain.Comment{}797 children := map[int64][]domain.Comment{}
798 for _, c := range comments {798 for _, c := range comments {
799- if c.ReplyTo != 0 {799+ if c.ReplyTo != 0 && !c.Deleted {
800 children[c.ReplyTo] = append(children[c.ReplyTo], c)800 children[c.ReplyTo] = append(children[c.ReplyTo], c)
801 }801 }
802 if !c.Deleted {802 if !c.Deleted {
@@ -806,14 +806,18 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
806 p.NUnresolved++806 p.NUnresolved++
807 }807 }
808 }808 }
809- var view func(domain.Comment) CommentView809+ var view func(domain.Comment, bool) CommentView
810- view = func(c domain.Comment) CommentView {810+ view = func(c domain.Comment, replyable bool) CommentView {
811 cid := strconv.FormatInt(c.ID, 10)811 cid := strconv.FormatInt(c.ID, 10)
812 body, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"-", exists)812 body, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"-", exists)
813 if err != nil {813 if err != nil {
814 body = ""814 body = ""
815 }815 }
816- v := CommentView{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}816+ bodySplit, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"-s-", exists)
817+ if err != nil {
818+ bodySplit = ""
819+ }
820+ v := CommentView{BodySplit: bodySplit, ID: cid, Author: s.who(c.Author), Body: body, Resolved: c.Resolved, Deleted: c.Deleted, Edited: !c.EditedAt.IsZero(), Verdict: c.Verdict.String(), Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head}
817 v.When, v.WhenAt = whenOf(now, c.CreatedAt)821 v.When, v.WhenAt = whenOf(now, c.CreatedAt)
818 if !c.CreatedAt.IsZero() {822 if !c.CreatedAt.IsZero() {
819 v.Abs = absolute(c.CreatedAt)823 v.Abs = absolute(c.CreatedAt)
@@ -827,7 +831,9 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
827 if c.ReplyTo != 0 {831 if c.ReplyTo != 0 {
828 root = strconv.FormatInt(c.ReplyTo, 10)832 root = strconv.FormatInt(c.ReplyTo, 10)
829 }833 }
830- v.Quote = "> " + strings.ReplaceAll(c.Text.String(), "\n", "\n> ") + "\n\n^" + cid + " "834+ if replyable {
835+ v.Quote = "> " + strings.ReplaceAll(c.Text.String(), "\n", "\n> ") + "\n\n^" + cid + " "
836+ }
831 if c.ReplyTo == 0 {837 if c.ReplyTo == 0 {
832 v.Reply = &CommentForm{ID: "reply-" + cid, Action: mrHref(repo, n, "comments"), Title: "Reply", ReplyTo: root, From: from}838 v.Reply = &CommentForm{ID: "reply-" + cid, Action: mrHref(repo, n, "comments"), Title: "Reply", ReplyTo: root, From: from}
833 }839 }
@@ -837,18 +843,16 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
837 }843 }
838 }844 }
839 for _, reply := range children[c.ID] {845 for _, reply := range children[c.ID] {
840- if !reply.Deleted {846+ v.Replies = append(v.Replies, view(reply, replyable))
841- v.Replies = append(v.Replies, view(reply))
842- }
843 }847 }
844 return v848 return v
845 }849 }
846 for _, c := range comments {850 for _, c := range comments {
847- if c.ReplyTo != 0 || c.Deleted && len(children[c.ID]) == 0 {851+ if c.ReplyTo != 0 || c.Deleted && c.Verdict == domain.VerdictNone && len(children[c.ID]) == 0 {
848 continue852 continue
849 }853 }
850 cid := strconv.FormatInt(c.ID, 10)854 cid := strconv.FormatInt(c.ID, 10)
851- v := view(c)855+ v := view(c, canWrite && !c.Deleted)
852 general := c.Path == ""856 general := c.Path == ""
853 fi, listed := fileAt[c.Path]857 fi, listed := fileAt[c.Path]
854 if !general && listed && p.Files[fi].Lazy {858 if !general && listed && p.Files[fi].Lazy {
M internal/adapters/web/mr.templ +10 -2
@@ -248,10 +248,18 @@ templ commentView(c CommentView, suffix string) {
248248 }
249249 </p>
250250 if c.Deleted {
251- <p class="mute">deleted note</p>
251+ if c.Verdict != "" {
252+ <p class="mute">text deleted</p>
253+ } else {
254+ <p class="mute">deleted note</p>
255+ }
252256 } else {
253257 <div class="c-body prose">
254- @templ.Raw(string(c.Body))
258+ if suffix == "-s" {
259+ @templ.Raw(string(c.BodySplit))
260+ } else {
261+ @templ.Raw(string(c.Body))
262+ }
255263 </div>
256264 }
257265 if c.Edited {
@@ -248,10 +248,18 @@ templ commentView(c CommentView, suffix string) {
248 }248 }
249 </p>249 </p>
250 if c.Deleted {250 if c.Deleted {
251- <p class="mute">deleted note</p>251+ if c.Verdict != "" {
252+ <p class="mute">text deleted</p>
253+ } else {
254+ <p class="mute">deleted note</p>
255+ }
252 } else {256 } else {
253 <div class="c-body prose">257 <div class="c-body prose">
254- @templ.Raw(string(c.Body))258+ if suffix == "-s" {
259+ @templ.Raw(string(c.BodySplit))
260+ } else {
261+ @templ.Raw(string(c.Body))
262+ }
255 </div>263 </div>
256 }264 }
257 if c.Edited {265 if c.Edited {
M internal/adapters/web/mr_notes_test.go +47 -0
@@ -110,3 +110,50 @@ func TestNotePosts(t *testing.T) {
110110 require.NoError(t, err)
111111 assert.Equal(t, "/demo/merge-requests/1#c2", to)
112112 }
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+ text, err := domain.ParsePlainText("# H\n[jump](#h)")
145+ require.NoError(t, err)
146+ s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
147+ p := MRPage{Files: []DiffFile{{Key: "a", ID: "f1", Hunks: []DiffHunk{{Lines: []DiffLine{{Kind: "add", newN: 1}}}}}}}
148+ s.placeComments(&p, "demo", 1, []domain.Comment{{ID: 12, Path: "a", Side: domain.SideNew, Line: 1, Text: text}}, "", true, true, nil)
149+ notes := p.Files[0].Hunks[0].Lines[0].Notes
150+ require.Len(t, notes, 1)
151+ var b bytes.Buffer
152+ require.NoError(t, commentView(notes[0], "").Render(context.Background(), &b))
153+ require.NoError(t, commentView(notes[0], "-s").Render(context.Background(), &b))
154+ html := b.String()
155+ assert.Equal(t, 1, strings.Count(html, `id="c12-h"`))
156+ assert.Equal(t, 1, strings.Count(html, `id="c12-s-h"`))
157+ assert.Contains(t, html, `href="#c12-h"`)
158+ assert.Contains(t, html, `href="#c12-s-h"`)
159+}
@@ -110,3 +110,50 @@ func TestNotePosts(t *testing.T) {
110 require.NoError(t, err)110 require.NoError(t, err)
111 assert.Equal(t, "/demo/merge-requests/1#c2", to)111 assert.Equal(t, "/demo/merge-requests/1#c2", to)
112 }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+ text, err := domain.ParsePlainText("# H\n[jump](#h)")
145+ require.NoError(t, err)
146+ s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
147+ p := MRPage{Files: []DiffFile{{Key: "a", ID: "f1", Hunks: []DiffHunk{{Lines: []DiffLine{{Kind: "add", newN: 1}}}}}}}
148+ s.placeComments(&p, "demo", 1, []domain.Comment{{ID: 12, Path: "a", Side: domain.SideNew, Line: 1, Text: text}}, "", true, true, nil)
149+ notes := p.Files[0].Hunks[0].Lines[0].Notes
150+ require.Len(t, notes, 1)
151+ var b bytes.Buffer
152+ require.NoError(t, commentView(notes[0], "").Render(context.Background(), &b))
153+ require.NoError(t, commentView(notes[0], "-s").Render(context.Background(), &b))
154+ html := b.String()
155+ assert.Equal(t, 1, strings.Count(html, `id="c12-h"`))
156+ assert.Equal(t, 1, strings.Count(html, `id="c12-s-h"`))
157+ assert.Contains(t, html, `href="#c12-h"`)
158+ assert.Contains(t, html, `href="#c12-s-h"`)
159+}
M internal/adapters/web/mr_templ.go +281 -267 generated file: collapsed
M internal/adapters/web/static/css/60-mr.css +1 -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; }
@@ -11,7 +11,6 @@
11 .mr-desc.prose { max-width: none; padding: var(--s4) var(--s5); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); }11 .mr-desc.prose { max-width: none; padding: var(--s4) var(--s5); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); }
12 .mr-desc.prose > * { max-width: var(--measure); }12 .mr-desc.prose > * { max-width: var(--measure); }
13 .mr-desc.prose > pre, .mr-desc.prose > table { max-width: 100%; }13 .mr-desc.prose > pre, .mr-desc.prose > table { max-width: 100%; }
14- .c-text a { text-decoration: underline; }
15 14
16 .since {15 .since {
17 display: inline-flex; flex-wrap: wrap; gap: var(--s1); padding: var(--s1);16 display: inline-flex; flex-wrap: wrap; gap: var(--s1); padding: var(--s1);
@@ -147,12 +146,11 @@
147 & p { white-space: normal; }146 & p { white-space: normal; }
148 & .c-who { font-weight: bold; }147 & .c-who { font-weight: bold; }
149 & .c-state { color: var(--ink-dim); }148 & .c-state { color: var(--ink-dim); }
150- & .c-text { white-space: pre-wrap; overflow-wrap: anywhere; }
151 & .c-where { font-size: var(--fs-s); overflow-wrap: anywhere; }149 & .c-where { font-size: var(--fs-s); overflow-wrap: anywhere; }
152 & .c-act { margin-top: var(--s2); }150 & .c-act { margin-top: var(--s2); }
153 & .c-act button { height: 26px; font-size: var(--fs-xs); }151 & .c-act button { height: 26px; font-size: var(--fs-xs); }
154 &[data-resolved="true"] { border-left-color: var(--border-strong); }152 &[data-resolved="true"] { border-left-color: var(--border-strong); }
155- &[data-resolved="true"] .c-text { color: var(--ink-dim); }153+ &[data-resolved="true"] .c-body { color: var(--ink-dim); }
156 }154 }
157 .dt .fnote td { background: var(--bg-canvas); padding: var(--s2) var(--s3); white-space: normal; }155 .dt .fnote td { background: var(--bg-canvas); padding: var(--s2) var(--s3); white-space: normal; }
158 .dt .fnote .cmt, .dt .fnote .cform { margin: 0; }156 .dt .fnote .cmt, .dt .fnote .cform { margin: 0; }
M internal/adapters/web/static/js/mr.js +17 -2
@@ -126,16 +126,31 @@ window.ermiteMR = (() => {
126126 function reveal(id, scroll = true) {
127127 // Any navigation, loaded or not, makes the reveals still loading stale.
128128 const gen = ++revealGen;
129+ const target = () => {
130+ if (/^c\d+$/.test(id) && $('#diffs')?.dataset.diff === 'split') {
131+ const split = document.getElementById(id + '-s');
132+ if (split) return split;
133+ }
134+ return document.getElementById(id);
135+ };
129136 let ph = document.getElementById(id)?.querySelector(':scope > .dlazy');
130137 if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {
131138 const base = id.replace(/-s$/, '');
132139 ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));
133140 }
134- if (!ph) return false;
141+ if (!ph) {
142+ const el = target();
143+ if (el && el.id !== id) {
144+ const d = el.closest('details');
145+ if (d) d.open = true;
146+ if (scroll) el.scrollIntoView({ block: 'start' });
147+ }
148+ return false;
149+ }
135150 load(ph).then(() => {
136151 // A later reveal (another file asked meanwhile) owns the view.
137152 if (gen !== revealGen) return;
138- const el = document.getElementById(id);
153+ const el = target();
139154 if (!el) return;
140155 const d = el.closest('details');
141156 if (d) d.open = true;
@@ -126,16 +126,31 @@ window.ermiteMR = (() => {
126 function reveal(id, scroll = true) {126 function reveal(id, scroll = true) {
127 // Any navigation, loaded or not, makes the reveals still loading stale.127 // Any navigation, loaded or not, makes the reveals still loading stale.
128 const gen = ++revealGen;128 const gen = ++revealGen;
129+ const target = () => {
130+ if (/^c\d+$/.test(id) && $('#diffs')?.dataset.diff === 'split') {
131+ const split = document.getElementById(id + '-s');
132+ if (split) return split;
133+ }
134+ return document.getElementById(id);
135+ };
129 let ph = document.getElementById(id)?.querySelector(':scope > .dlazy');136 let ph = document.getElementById(id)?.querySelector(':scope > .dlazy');
130 if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {137 if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) {
131 const base = id.replace(/-s$/, '');138 const base = id.replace(/-s$/, '');
132 ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));139 ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base));
133 }140 }
134- if (!ph) return false;141+ if (!ph) {
142+ const el = target();
143+ if (el && el.id !== id) {
144+ const d = el.closest('details');
145+ if (d) d.open = true;
146+ if (scroll) el.scrollIntoView({ block: 'start' });
147+ }
148+ return false;
149+ }
135 load(ph).then(() => {150 load(ph).then(() => {
136 // A later reveal (another file asked meanwhile) owns the view.151 // A later reveal (another file asked meanwhile) owns the view.
137 if (gen !== revealGen) return;152 if (gen !== revealGen) return;
138- const el = document.getElementById(id);153+ const el = target();
139 if (!el) return;154 if (!el) return;
140 const d = el.closest('details');155 const d = el.closest('details');
141 if (d) d.open = true;156 if (d) d.open = true;
M internal/app/cli_mr.go +7 -2
@@ -562,9 +562,14 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
562562 if len(d.GetVerdicts()) > 0 {
563563 var verdicts []string
564564 for _, v := range d.GetVerdicts() {
565- label := "changes requested"
566- if v.GetVerdict() == ermite.Verdict_VERDICT_APPROVE {
565+ var label string
566+ switch v.GetVerdict() {
567+ case ermite.Verdict_VERDICT_APPROVE:
567568 label = "approved"
569+ case ermite.Verdict_VERDICT_CHANGES_REQUESTED:
570+ label = "changes requested"
571+ default:
572+ label = fmt.Sprintf("verdict %d", v.GetVerdict())
568573 }
569574 if v.GetStale() {
570575 label += " (stale)"
@@ -562,9 +562,14 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
562 if len(d.GetVerdicts()) > 0 {562 if len(d.GetVerdicts()) > 0 {
563 var verdicts []string563 var verdicts []string
564 for _, v := range d.GetVerdicts() {564 for _, v := range d.GetVerdicts() {
565- label := "changes requested"565+ var label string
566- if v.GetVerdict() == ermite.Verdict_VERDICT_APPROVE {566+ switch v.GetVerdict() {
567+ case ermite.Verdict_VERDICT_APPROVE:
567 label = "approved"568 label = "approved"
569+ case ermite.Verdict_VERDICT_CHANGES_REQUESTED:
570+ label = "changes requested"
571+ default:
572+ label = fmt.Sprintf("verdict %d", v.GetVerdict())
568 }573 }
569 if v.GetStale() {574 if v.GetStale() {
570 label += " (stale)"575 label += " (stale)"
M internal/app/cli_mr_test.go +8 -0
@@ -455,6 +455,14 @@ func TestNoteDetailOutput(t *testing.T) {
455455 assert.NotNil(t, got.Comments[2].EditedAt)
456456 }
457457
458+func TestUnknownVerdictDetailOutput(t *testing.T) {
459+ var out strings.Builder
460+ require.NoError(t, printMRDetail(&out, &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Verdicts: []*ermite.AuthorVerdict{{Author: "future", Verdict: ermite.Verdict(99)}}}, false, false))
461+ assert.Contains(t, out.String(), "future verdict 99")
462+ assert.NotContains(t, out.String(), "changes requested")
463+ assert.NotContains(t, out.String(), "approved")
464+}
465+
458466 func TestMRReviewScopeCompletion(t *testing.T) {
459467 cmd := newTokenCreateCmd(&cliEnv{})
460468 complete, ok := cmd.GetFlagCompletionFunc("scope")
@@ -455,6 +455,14 @@ func TestNoteDetailOutput(t *testing.T) {
455 assert.NotNil(t, got.Comments[2].EditedAt)455 assert.NotNil(t, got.Comments[2].EditedAt)
456 }456 }
457 457
458+func TestUnknownVerdictDetailOutput(t *testing.T) {
459+ var out strings.Builder
460+ require.NoError(t, printMRDetail(&out, &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Verdicts: []*ermite.AuthorVerdict{{Author: "future", Verdict: ermite.Verdict(99)}}}, false, false))
461+ assert.Contains(t, out.String(), "future verdict 99")
462+ assert.NotContains(t, out.String(), "changes requested")
463+ assert.NotContains(t, out.String(), "approved")
464+}
465+
458 func TestMRReviewScopeCompletion(t *testing.T) {466 func TestMRReviewScopeCompletion(t *testing.T) {
459 cmd := newTokenCreateCmd(&cliEnv{})467 cmd := newTokenCreateCmd(&cliEnv{})
460 complete, ok := cmd.GetFlagCompletionFunc("scope")468 complete, ok := cmd.GetFlagCompletionFunc("scope")
M internal/domain/event.go +3 -3
@@ -173,7 +173,7 @@ type CommentAdded struct {
173173 // EventKind implements Event.
174174 func (CommentAdded) EventKind() string { return "mr.comment_added" }
175175
176-// CommentEdited is published when a comment was added to a merge request.
176+// CommentEdited is published when the text of a note was edited.
177177 type CommentEdited struct {
178178 Repo RepoName
179179 Number int64
@@ -186,7 +186,7 @@ type CommentEdited struct {
186186 // EventKind implements Event.
187187 func (CommentEdited) EventKind() string { return "mr.comment_edited" }
188188
189-// CommentDeleted is published when a comment was added to a merge request.
189+// CommentDeleted is published when the text of a note was deleted.
190190 type CommentDeleted struct {
191191 Repo RepoName
192192 Number int64
@@ -199,7 +199,7 @@ type CommentDeleted struct {
199199 // EventKind implements Event.
200200 func (CommentDeleted) EventKind() string { return "mr.comment_deleted" }
201201
202-// CommentResolved is published when a comment was added to a merge request.
202+// CommentResolved is published when a thread was resolved or reopened.
203203 type CommentResolved struct {
204204 Repo RepoName
205205 Number int64
@@ -173,7 +173,7 @@ type CommentAdded struct {
173 // EventKind implements Event.173 // EventKind implements Event.
174 func (CommentAdded) EventKind() string { return "mr.comment_added" }174 func (CommentAdded) EventKind() string { return "mr.comment_added" }
175 175
176-// CommentEdited is published when a comment was added to a merge request.176+// CommentEdited is published when the text of a note was edited.
177 type CommentEdited struct {177 type CommentEdited struct {
178 Repo RepoName178 Repo RepoName
179 Number int64179 Number int64
@@ -186,7 +186,7 @@ type CommentEdited struct {
186 // EventKind implements Event.186 // EventKind implements Event.
187 func (CommentEdited) EventKind() string { return "mr.comment_edited" }187 func (CommentEdited) EventKind() string { return "mr.comment_edited" }
188 188
189-// CommentDeleted is published when a comment was added to a merge request.189+// CommentDeleted is published when the text of a note was deleted.
190 type CommentDeleted struct {190 type CommentDeleted struct {
191 Repo RepoName191 Repo RepoName
192 Number int64192 Number int64
@@ -199,7 +199,7 @@ type CommentDeleted struct {
199 // EventKind implements Event.199 // EventKind implements Event.
200 func (CommentDeleted) EventKind() string { return "mr.comment_deleted" }200 func (CommentDeleted) EventKind() string { return "mr.comment_deleted" }
201 201
202-// CommentResolved is published when a comment was added to a merge request.202+// CommentResolved is published when a thread was resolved or reopened.
203 type CommentResolved struct {203 type CommentResolved struct {
204 Repo RepoName204 Repo RepoName
205 Number int64205 Number int64
M skills/ermite/CLI.md +1 -1
@@ -28,7 +28,7 @@ Presets add up with `--scope` (repeatable or comma-separated):
2828
2929 | Preset | Scopes |
3030 |---|---|
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` |
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
@@ -28,7 +28,7 @@ Presets add up with `--scope` (repeatable or comma-separated):
28 28
29 | Preset | Scopes |29 | Preset | Scopes |
30 |---|---|30 |---|---|
31-| `cli` | every scope (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` |31+| `cli` | every scope (including `mr:review`), for the owner's daily use; a token created before `mr:review` existed lacks it and loses owner rights: recreate it with `--preset cli` using the root token or from a signed-in owner web session (an old token lacking `mr:review` cannot grant it), then replace the stored CLI secret and revoke the old token |
32 | `agent` | `repo:write`, `mr:write` |32 | `agent` | `repo:write`, `mr:write` |
33 | `ci` | `repo:read` |33 | `ci` | `repo:read` |
34 34

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 44

↑↓ moveenter openesc close