detached 4f0da00

fix(app): keep token preset cli working on servers without issues; return issue show write errors; correct issue help limits

commit
4f0da009b2c39ff30d4433ae2d428bdc3485b7ec
author
brice · · 2026-10-19 06:06 UTC
committer
brice · · 2026-10-19 06:07 UTC
parent
a4f8ebd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +68 -15
M internal/app/cli_issue.go +17 -8
@@ -110,13 +110,16 @@ func newIssueCmd(env *cliEnv) *cobra.Command {
110110
111111 // requireIssues refuses to go on with a server that does not list issues: it
112112 // has no IssueService. A server whose features cannot be read is refused too.
113+// errNoIssues marks a server that lacks the issues feature.
114+var errNoIssues = errors.New("issues are not available")
115+
113116 func requireIssues(ctx context.Context, c *client.Client) error {
114117 res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
115118 if err != nil {
116119 return c.Describe(err)
117120 }
118121 if !slices.Contains(res.Msg.GetFeatures(), client.FeatureIssues) {
119- return fmt.Errorf("the server does not support %s, so it has no issues (upgrade the server, see `ermite version`)", client.FeatureIssues)
122+ return fmt.Errorf("%w: the server does not support %s, so it has no issues (upgrade the server, see `ermite version`)", errNoIssues, client.FeatureIssues)
120123 }
121124 return nil
122125 }
@@ -194,9 +197,9 @@ func newIssueCreateCmd(env *cliEnv) *cobra.Command {
194197 cmd := &cobra.Command{
195198 Use: "create <repo>",
196199 Short: "Open an issue",
197- Long: "Open an issue. The title is one line of at most 120 characters, the body plain text of at most\n" +
198- "8000; neither may hold control characters (a body may hold line breaks) or bidirectional override\n" +
199- "characters. --body - and --body-file - read the body from standard input; --body-file reads it\n" +
200+ Long: "Open an issue. The title is one line of at most 120 characters, the body Markdown of at most\n" +
201+ "10,000 characters; disallowed control characters are stripped (a body may hold line breaks).\n" +
202+ "--body - and --body-file - read the body from standard input; --body-file reads it\n" +
200203 "from a file otherwise. Needs the issue:write scope." + jsonIssueHelp,
201204 Args: cobra.ExactArgs(1),
202205 ValidArgsFunction: completeRepoNames(env),
@@ -343,10 +346,16 @@ func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error {
343346 if err != nil {
344347 return err
345348 }
346- _, _ = fmt.Fprintf(w, "\n%s\n", indentText(body))
347- _, _ = fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments()))
349+ if _, err := fmt.Fprintf(w, "\n%s\n", indentText(body)); err != nil {
350+ return err
351+ }
352+ if _, err := fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())); err != nil {
353+ return err
354+ }
348355 for _, c := range d.GetComments() {
349- _, _ = fmt.Fprintf(w, " #%s %s, %s\n%s\n", c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), indentText(c.GetText()))
356+ if _, err := fmt.Fprintf(w, " #%s %s, %s\n%s\n", c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), indentText(c.GetText())); err != nil {
357+ return err
358+ }
350359 }
351360 return nil
352361 }
@@ -356,7 +365,7 @@ func newIssueCommentCmd(env *cliEnv) *cobra.Command {
356365 cmd := &cobra.Command{
357366 Use: "comment <repo> <number>",
358367 Short: "Comment on an issue",
359- Long: "Add a plain-text comment (at most 8000 characters, no control characters except line breaks).\n" +
368+ Long: "Add a Markdown comment (at most 10,000 characters; disallowed control characters are stripped).\n" +
360369 "-m - reads it from standard input. Needs the issue:write scope." + jsonIssueCommentHelp,
361370 Args: cobra.ExactArgs(2),
362371 ValidArgsFunction: completeIssueRepoThenNumber(env),
@@ -110,13 +110,16 @@ func newIssueCmd(env *cliEnv) *cobra.Command {
110 110
111 // requireIssues refuses to go on with a server that does not list issues: it111 // requireIssues refuses to go on with a server that does not list issues: it
112 // has no IssueService. A server whose features cannot be read is refused too.112 // has no IssueService. A server whose features cannot be read is refused too.
113+// errNoIssues marks a server that lacks the issues feature.
114+var errNoIssues = errors.New("issues are not available")
115+
113 func requireIssues(ctx context.Context, c *client.Client) error {116 func requireIssues(ctx context.Context, c *client.Client) error {
114 res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))117 res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
115 if err != nil {118 if err != nil {
116 return c.Describe(err)119 return c.Describe(err)
117 }120 }
118 if !slices.Contains(res.Msg.GetFeatures(), client.FeatureIssues) {121 if !slices.Contains(res.Msg.GetFeatures(), client.FeatureIssues) {
119- return fmt.Errorf("the server does not support %s, so it has no issues (upgrade the server, see `ermite version`)", client.FeatureIssues)122+ return fmt.Errorf("%w: the server does not support %s, so it has no issues (upgrade the server, see `ermite version`)", errNoIssues, client.FeatureIssues)
120 }123 }
121 return nil124 return nil
122 }125 }
@@ -194,9 +197,9 @@ func newIssueCreateCmd(env *cliEnv) *cobra.Command {
194 cmd := &cobra.Command{197 cmd := &cobra.Command{
195 Use: "create <repo>",198 Use: "create <repo>",
196 Short: "Open an issue",199 Short: "Open an issue",
197- Long: "Open an issue. The title is one line of at most 120 characters, the body plain text of at most\n" +200+ Long: "Open an issue. The title is one line of at most 120 characters, the body Markdown of at most\n" +
198- "8000; neither may hold control characters (a body may hold line breaks) or bidirectional override\n" +201+ "10,000 characters; disallowed control characters are stripped (a body may hold line breaks).\n" +
199- "characters. --body - and --body-file - read the body from standard input; --body-file reads it\n" +202+ "--body - and --body-file - read the body from standard input; --body-file reads it\n" +
200 "from a file otherwise. Needs the issue:write scope." + jsonIssueHelp,203 "from a file otherwise. Needs the issue:write scope." + jsonIssueHelp,
201 Args: cobra.ExactArgs(1),204 Args: cobra.ExactArgs(1),
202 ValidArgsFunction: completeRepoNames(env),205 ValidArgsFunction: completeRepoNames(env),
@@ -343,10 +346,16 @@ func printIssueDetail(w io.Writer, d *ermite.GetIssueResponse) error {
343 if err != nil {346 if err != nil {
344 return err347 return err
345 }348 }
346- _, _ = fmt.Fprintf(w, "\n%s\n", indentText(body))349+ if _, err := fmt.Fprintf(w, "\n%s\n", indentText(body)); err != nil {
347- _, _ = fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments()))350+ return err
351+ }
352+ if _, err := fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())); err != nil {
353+ return err
354+ }
348 for _, c := range d.GetComments() {355 for _, c := range d.GetComments() {
349- _, _ = fmt.Fprintf(w, " #%s %s, %s\n%s\n", c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), indentText(c.GetText()))356+ if _, err := fmt.Fprintf(w, " #%s %s, %s\n%s\n", c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), indentText(c.GetText())); err != nil {
357+ return err
358+ }
350 }359 }
351 return nil360 return nil
352 }361 }
@@ -356,7 +365,7 @@ func newIssueCommentCmd(env *cliEnv) *cobra.Command {
356 cmd := &cobra.Command{365 cmd := &cobra.Command{
357 Use: "comment <repo> <number>",366 Use: "comment <repo> <number>",
358 Short: "Comment on an issue",367 Short: "Comment on an issue",
359- Long: "Add a plain-text comment (at most 8000 characters, no control characters except line breaks).\n" +368+ Long: "Add a Markdown comment (at most 10,000 characters; disallowed control characters are stripped).\n" +
360 "-m - reads it from standard input. Needs the issue:write scope." + jsonIssueCommentHelp,369 "-m - reads it from standard input. Needs the issue:write scope." + jsonIssueCommentHelp,
361 Args: cobra.ExactArgs(2),370 Args: cobra.ExactArgs(2),
362 ValidArgsFunction: completeIssueRepoThenNumber(env),371 ValidArgsFunction: completeIssueRepoThenNumber(env),
M internal/app/cli_issue_test.go +40 -0
@@ -3,6 +3,7 @@
33 package app
44
55 import (
6+ "errors"
67 "os"
78 "path/filepath"
89 "testing"
@@ -222,3 +223,42 @@ func TestTokenCreateIssueWriteNeedsTheFeature(t *testing.T) {
222223 runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--scope", "issue:write").ok(t)
223224 assert.Contains(t, lastCreateToken(t, f).GetScopes(), ermite.Scope_SCOPE_ISSUE_WRITE)
224225 }
226+
227+func TestTokenCreatePresetCLIOnAnOldServerDropsIssueWrite(t *testing.T) {
228+ f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
229+ return &ermite.GetVersionResponse{Version: "v0.4.0"}, nil
230+ }, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
231+ return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Label: r.GetLabel(), Scopes: r.GetScopes()}, Secret: "ermite_S"}, nil
232+ }}
233+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "cli").ok(t)
234+ got := lastCreateToken(t, f).GetScopes()
235+ assert.NotContains(t, got, ermite.Scope_SCOPE_ISSUE_WRITE)
236+ assert.NotEmpty(t, got)
237+ assert.Contains(t, res.stderr, "no issue:write")
238+
239+ res = runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "cli", "--scope", "issue:write")
240+ require.Error(t, res.err)
241+ assert.Contains(t, res.err.Error(), "does not support issues")
242+}
243+
244+type failingWriter struct{ after int }
245+
246+func (w *failingWriter) Write(p []byte) (int, error) {
247+ if w.after <= 0 {
248+ return 0, errors.New("write failed")
249+ }
250+ w.after--
251+ return len(p), nil
252+}
253+
254+func TestPrintIssueDetailReturnsWriteErrors(t *testing.T) {
255+ d := &ermite.GetIssueResponse{Issue: &ermite.Issue{Repo: "alpha", Number: 1, Title: "t"}, Comments: []*ermite.IssueComment{{Id: "1", Text: "c"}}}
256+ for after := range 60 {
257+ err := printIssueDetail(&failingWriter{after: after}, d)
258+ if err == nil {
259+ return // all writes succeeded: every earlier failure was reported
260+ }
261+ assert.ErrorContains(t, err, "write failed", after)
262+ }
263+ t.Fatal("never completed")
264+}
@@ -3,6 +3,7 @@
3 package app3 package app
4 4
5 import (5 import (
6+ "errors"
6 "os"7 "os"
7 "path/filepath"8 "path/filepath"
8 "testing"9 "testing"
@@ -222,3 +223,42 @@ func TestTokenCreateIssueWriteNeedsTheFeature(t *testing.T) {
222 runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--scope", "issue:write").ok(t)223 runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--scope", "issue:write").ok(t)
223 assert.Contains(t, lastCreateToken(t, f).GetScopes(), ermite.Scope_SCOPE_ISSUE_WRITE)224 assert.Contains(t, lastCreateToken(t, f).GetScopes(), ermite.Scope_SCOPE_ISSUE_WRITE)
224 }225 }
226+
227+func TestTokenCreatePresetCLIOnAnOldServerDropsIssueWrite(t *testing.T) {
228+ f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
229+ return &ermite.GetVersionResponse{Version: "v0.4.0"}, nil
230+ }, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
231+ return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Label: r.GetLabel(), Scopes: r.GetScopes()}, Secret: "ermite_S"}, nil
232+ }}
233+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "cli").ok(t)
234+ got := lastCreateToken(t, f).GetScopes()
235+ assert.NotContains(t, got, ermite.Scope_SCOPE_ISSUE_WRITE)
236+ assert.NotEmpty(t, got)
237+ assert.Contains(t, res.stderr, "no issue:write")
238+
239+ res = runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "cli", "--scope", "issue:write")
240+ require.Error(t, res.err)
241+ assert.Contains(t, res.err.Error(), "does not support issues")
242+}
243+
244+type failingWriter struct{ after int }
245+
246+func (w *failingWriter) Write(p []byte) (int, error) {
247+ if w.after <= 0 {
248+ return 0, errors.New("write failed")
249+ }
250+ w.after--
251+ return len(p), nil
252+}
253+
254+func TestPrintIssueDetailReturnsWriteErrors(t *testing.T) {
255+ d := &ermite.GetIssueResponse{Issue: &ermite.Issue{Repo: "alpha", Number: 1, Title: "t"}, Comments: []*ermite.IssueComment{{Id: "1", Text: "c"}}}
256+ for after := range 60 {
257+ err := printIssueDetail(&failingWriter{after: after}, d)
258+ if err == nil {
259+ return // all writes succeeded: every earlier failure was reported
260+ }
261+ assert.ErrorContains(t, err, "write failed", after)
262+ }
263+ t.Fatal("never completed")
264+}
M internal/app/cli_token.go +9 -4
@@ -133,11 +133,16 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
133133 return err
134134 }
135135 }
136- // Only an explicit --scope issue:write is checked: a preset such as cli
137- // grows with the contract and must keep working on an older server.
138- if explicitScope(scopes, ermite.Scope_SCOPE_ISSUE_WRITE) {
136+ // A preset such as cli grows with the contract and must keep working on an
137+ // older server: issue:write from a preset alone is dropped there, while an
138+ // explicit --scope issue:write is refused.
139+ if slices.Contains(req.Scopes, ermite.Scope_SCOPE_ISSUE_WRITE) {
139140 if err := requireIssues(env.ctx(cmd), c); err != nil {
140- return fmt.Errorf("%w (needed for the issue:write scope; nothing was created)", err)
141+ if !errors.Is(err, errNoIssues) || explicitScope(scopes, ermite.Scope_SCOPE_ISSUE_WRITE) {
142+ return fmt.Errorf("%w (needed for the issue:write scope; nothing was created)", err)
143+ }
144+ req.Scopes = slices.DeleteFunc(req.Scopes, func(s ermite.Scope) bool { return s == ermite.Scope_SCOPE_ISSUE_WRITE })
145+ _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "note: the server has no issues feature, so the token has no issue:write scope")
141146 }
142147 }
143148 res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))
@@ -133,11 +133,16 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
133 return err133 return err
134 }134 }
135 }135 }
136- // Only an explicit --scope issue:write is checked: a preset such as cli136+ // A preset such as cli grows with the contract and must keep working on an
137- // grows with the contract and must keep working on an older server.137+ // older server: issue:write from a preset alone is dropped there, while an
138- if explicitScope(scopes, ermite.Scope_SCOPE_ISSUE_WRITE) {138+ // explicit --scope issue:write is refused.
139+ if slices.Contains(req.Scopes, ermite.Scope_SCOPE_ISSUE_WRITE) {
139 if err := requireIssues(env.ctx(cmd), c); err != nil {140 if err := requireIssues(env.ctx(cmd), c); err != nil {
140- return fmt.Errorf("%w (needed for the issue:write scope; nothing was created)", err)141+ if !errors.Is(err, errNoIssues) || explicitScope(scopes, ermite.Scope_SCOPE_ISSUE_WRITE) {
142+ return fmt.Errorf("%w (needed for the issue:write scope; nothing was created)", err)
143+ }
144+ req.Scopes = slices.DeleteFunc(req.Scopes, func(s ermite.Scope) bool { return s == ermite.Scope_SCOPE_ISSUE_WRITE })
145+ _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "note: the server has no issues feature, so the token has no issue:write scope")
141 }146 }
142 }147 }
143 res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))148 res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))
M internal/app/cli_token_test.go +2 -3
@@ -174,12 +174,11 @@ func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) {
174174 "cli": client.AllScopes(),
175175 } {
176176 t.Run(preset, func(t *testing.T) {
177- f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
177+ f := &fakeAPI{getVersion: withIssues, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
178178 return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Scopes: r.GetScopes()}, Secret: "s"}, nil
179179 }}
180180 runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", preset).ok(t)
181- _, reqs := f.snapshot()
182- got := reqs[0].(*ermite.CreateTokenRequest)
181+ got := lastCreateToken(t, f)
183182 assert.ElementsMatch(t, want, got.GetScopes())
184183 assert.Empty(t, got.GetRepos())
185184 assert.Nil(t, got.GetExpiresAt())
@@ -174,12 +174,11 @@ func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) {
174 "cli": client.AllScopes(),174 "cli": client.AllScopes(),
175 } {175 } {
176 t.Run(preset, func(t *testing.T) {176 t.Run(preset, func(t *testing.T) {
177- f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {177+ f := &fakeAPI{getVersion: withIssues, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
178 return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Scopes: r.GetScopes()}, Secret: "s"}, nil178 return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Scopes: r.GetScopes()}, Secret: "s"}, nil
179 }}179 }}
180 runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", preset).ok(t)180 runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", preset).ok(t)
181- _, reqs := f.snapshot()181+ got := lastCreateToken(t, f)
182- got := reqs[0].(*ermite.CreateTokenRequest)
183 assert.ElementsMatch(t, want, got.GetScopes())182 assert.ElementsMatch(t, want, got.GetScopes())
184 assert.Empty(t, got.GetRepos())183 assert.Empty(t, got.GetRepos())
185 assert.Nil(t, got.GetExpiresAt())184 assert.Nil(t, got.GetExpiresAt())

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 3

Tags 43

↑↓ moveenter openesc close