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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +421 -292
M docs/tokens.md +1 -1
| @@ -6,7 +6,7 @@ ermite token list | |||
| 6 | 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 | 11 | | Preset | For | | |
| 12 | 12 | |---|---| | |
| @@ -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', () => | |||
| 62 | 62 | assert.equal(field.value,'> source\n\n^2 '); | |
| 63 | 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 | + | }); | |
| @@ -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 { | |||
| 106 | 106 | When string | |
| 107 | 107 | WhenAt string | |
| 108 | 108 | Abs string | |
| 109 | - | Body template.HTML | |
| 109 | + | Body, BodySplit template.HTML | |
| 110 | 110 | Replies []CommentView | |
| 111 | 111 | Edited, Deleted bool | |
| 112 | 112 | Verdict, Range, Quote string | |
| @@ -796,7 +796,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai | |||
| 796 | 796 | exists := noteIDs(comments) | |
| 797 | 797 | children := map[int64][]domain.Comment{} | |
| 798 | 798 | for _, c := range comments { | |
| 799 | - | if c.ReplyTo != 0 { | |
| 799 | + | if c.ReplyTo != 0 && !c.Deleted { | |
| 800 | 800 | children[c.ReplyTo] = append(children[c.ReplyTo], c) | |
| 801 | 801 | } | |
| 802 | 802 | if !c.Deleted { | |
| @@ -806,14 +806,18 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai | |||
| 806 | 806 | p.NUnresolved++ | |
| 807 | 807 | } | |
| 808 | 808 | } | |
| 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 { | |
| 811 | 811 | cid := strconv.FormatInt(c.ID, 10) | |
| 812 | 812 | body, err := s.md.renderNote([]byte(c.Text.String()), repo, head, "c"+cid+"-", exists) | |
| 813 | 813 | if err != nil { | |
| 814 | 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 | 821 | v.When, v.WhenAt = whenOf(now, c.CreatedAt) | |
| 818 | 822 | if !c.CreatedAt.IsZero() { | |
| 819 | 823 | v.Abs = absolute(c.CreatedAt) | |
| @@ -827,7 +831,9 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai | |||
| 827 | 831 | if c.ReplyTo != 0 { | |
| 828 | 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 | 837 | if c.ReplyTo == 0 { | |
| 832 | 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 | 845 | 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)) | |
| 843 | 847 | } | |
| 844 | 848 | return v | |
| 845 | 849 | } | |
| 846 | 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 | 852 | continue | |
| 849 | 853 | } | |
| 850 | 854 | cid := strconv.FormatInt(c.ID, 10) | |
| 851 | - | v := view(c) | |
| 855 | + | v := view(c, canWrite && !c.Deleted) | |
| 852 | 856 | general := c.Path == "" | |
| 853 | 857 | fi, listed := fileAt[c.Path] | |
| 854 | 858 | if !general && listed && p.Files[fi].Lazy { | |
| @@ -106,7 +106,7 @@ type CommentView struct { | |||||
| 106 | When string | 106 | When string | ||
| 107 | WhenAt string | 107 | WhenAt string | ||
| 108 | Abs string | 108 | Abs string | ||
| 109 | - | Body template.HTML | 109 | + | Body, BodySplit template.HTML |
| 110 | Replies []CommentView | 110 | Replies []CommentView | ||
| 111 | Edited, Deleted bool | 111 | Edited, Deleted bool | ||
| 112 | Verdict, Range, Quote string | 112 | 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) CommentView | 809 | + | 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 v | 848 | 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 | continue | 852 | 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) { | |||
| 248 | 248 | } | |
| 249 | 249 | </p> | |
| 250 | 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 | 256 | } else { | |
| 253 | 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 | 263 | </div> | |
| 256 | 264 | } | |
| 257 | 265 | 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) { | |||
| 110 | 110 | require.NoError(t, err) | |
| 111 | 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 | + | } | |
| @@ -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 @@ | |||
| 11 | 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 | 12 | .mr-desc.prose > * { max-width: var(--measure); } | |
| 13 | 13 | .mr-desc.prose > pre, .mr-desc.prose > table { max-width: 100%; } | |
| 14 | - | .c-text a { text-decoration: underline; } | |
| 15 | 14 | ||
| 16 | 15 | .since { | |
| 17 | 16 | display: inline-flex; flex-wrap: wrap; gap: var(--s1); padding: var(--s1); | |
| @@ -147,12 +146,11 @@ | |||
| 147 | 146 | & p { white-space: normal; } | |
| 148 | 147 | & .c-who { font-weight: bold; } | |
| 149 | 148 | & .c-state { color: var(--ink-dim); } | |
| 150 | - | & .c-text { white-space: pre-wrap; overflow-wrap: anywhere; } | |
| 151 | 149 | & .c-where { font-size: var(--fs-s); overflow-wrap: anywhere; } | |
| 152 | 150 | & .c-act { margin-top: var(--s2); } | |
| 153 | 151 | & .c-act button { height: 26px; font-size: var(--fs-xs); } | |
| 154 | 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 | 155 | .dt .fnote td { background: var(--bg-canvas); padding: var(--s2) var(--s3); white-space: normal; } | |
| 158 | 156 | .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 = (() => { | |||
| 126 | 126 | function reveal(id, scroll = true) { | |
| 127 | 127 | // Any navigation, loaded or not, makes the reveals still loading stale. | |
| 128 | 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 | 136 | let ph = document.getElementById(id)?.querySelector(':scope > .dlazy'); | |
| 130 | 137 | if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) { | |
| 131 | 138 | const base = id.replace(/-s$/, ''); | |
| 132 | 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 | 150 | load(ph).then(() => { | |
| 136 | 151 | // A later reveal (another file asked meanwhile) owns the view. | |
| 137 | 152 | if (gen !== revealGen) return; | |
| 138 | - | const el = document.getElementById(id); | |
| 153 | + | const el = target(); | |
| 139 | 154 | if (!el) return; | |
| 140 | 155 | const d = el.closest('details'); | |
| 141 | 156 | 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 | |||
| 562 | 562 | if len(d.GetVerdicts()) > 0 { | |
| 563 | 563 | var verdicts []string | |
| 564 | 564 | 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: | |
| 567 | 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 | 574 | if v.GetStale() { | |
| 570 | 575 | 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 []string | 563 | 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) { | |||
| 455 | 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 | 466 | func TestMRReviewScopeCompletion(t *testing.T) { | |
| 459 | 467 | cmd := newTokenCreateCmd(&cliEnv{}) | |
| 460 | 468 | 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 { | |||
| 173 | 173 | // EventKind implements Event. | |
| 174 | 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 | 177 | type CommentEdited struct { | |
| 178 | 178 | Repo RepoName | |
| 179 | 179 | Number int64 | |
| @@ -186,7 +186,7 @@ type CommentEdited struct { | |||
| 186 | 186 | // EventKind implements Event. | |
| 187 | 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 | 190 | type CommentDeleted struct { | |
| 191 | 191 | Repo RepoName | |
| 192 | 192 | Number int64 | |
| @@ -199,7 +199,7 @@ type CommentDeleted struct { | |||
| 199 | 199 | // EventKind implements Event. | |
| 200 | 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 | 203 | type CommentResolved struct { | |
| 204 | 204 | Repo RepoName | |
| 205 | 205 | 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 RepoName | 178 | Repo RepoName | ||
| 179 | Number int64 | 179 | 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 RepoName | 191 | Repo RepoName | ||
| 192 | Number int64 | 192 | 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 RepoName | 204 | Repo RepoName | ||
| 205 | Number int64 | 205 | Number int64 | ||
M skills/ermite/CLI.md +1 -1
| @@ -28,7 +28,7 @@ Presets add up with `--scope` (repeatable or comma-separated): | |||
| 28 | 28 | ||
| 29 | 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 | 32 | | `agent` | `repo:write`, `mr:write` | | |
| 33 | 33 | | `ci` | `repo:read` | | |
| 34 | 34 | ||
| @@ -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 | ||||