1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "errors"
7 "os"
8 "path/filepath"
9 "testing"
10
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13
14 "bnema.dev/ermite-api/gen/ermite"
15)
16
17var issueOne = &ermite.Issue{
18 Repo: "alpha", Number: 2, Title: "Crash on start", Body: "It crashes.", State: ermite.IssueState_ISSUE_STATE_OPEN,
19 Author: "owner", CreatedAt: ts("2026-10-01T10:00:00Z"), UpdatedAt: ts("2026-10-02T11:30:00Z"), CommentCount: 1,
20}
21
22func withIssues(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
23 return &ermite.GetVersionResponse{Version: "v0.5.0", Features: []string{"issues"}}, nil
24}
25
26func TestIssueCreateMapsFlags(t *testing.T) {
27 f := &fakeAPI{getVersion: withIssues, createIssue: func(*ermite.CreateIssueRequest) (*ermite.CreateIssueResponse, error) {
28 return &ermite.CreateIssueResponse{Issue: issueOne}, nil
29 }}
30 url := f.serve(t)
31 res := runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "Crash on start", "--body", "It crashes.").ok(t)
32 assert.Equal(t, "opened alpha#2 (Crash on start)\n", res.stdout)
33 _, reqs := f.snapshot()
34 got := reqs[1].(*ermite.CreateIssueRequest)
35 assert.Equal(t, "alpha", got.GetRepo())
36 assert.Equal(t, "Crash on start", got.GetTitle())
37 assert.Equal(t, "It crashes.", got.GetBody())
38
39 runCLI(t, envFor(url), "from\nstdin\n", "issue", "create", "alpha", "--title", "t", "--body", "-").ok(t)
40 runCLI(t, envFor(url), "piped\n", "issue", "create", "alpha", "--title", "t", "--body-file", "-").ok(t)
41 file := filepath.Join(t.TempDir(), "body.txt")
42 require.NoError(t, os.WriteFile(file, []byte("from file\n"), 0o600))
43 runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--body-file", file).ok(t)
44 _, reqs = f.snapshot()
45 var bodies []string
46 for _, r := range reqs {
47 if c, ok := r.(*ermite.CreateIssueRequest); ok {
48 bodies = append(bodies, c.GetBody())
49 }
50 }
51 assert.Equal(t, []string{"It crashes.", "from\nstdin", "piped", "from file"}, bodies)
52
53 res = runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--json").ok(t)
54 assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"It crashes.","state":"open","author":"owner","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","comment_count":1}`, res.stdout)
55
56 for _, args := range [][]string{
57 {"issue", "create", "alpha"},
58 {"issue", "create", "alpha", "--title", "t", "--body", "a", "--body-file", file},
59 } {
60 require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args)
61 }
62}
63
64func TestIssueListTableJSONAndPages(t *testing.T) {
65 closed := &ermite.Issue{Repo: "alpha", Number: 1, Title: "Old", State: ermite.IssueState_ISSUE_STATE_CLOSED, Author: "bot", UpdatedAt: ts("2026-09-01T08:00:00Z")}
66 f := &fakeAPI{getVersion: withIssues, listIssues: func(r *ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error) {
67 if r.GetPageToken() == "" {
68 return &ermite.ListIssuesResponse{Issues: []*ermite.Issue{issueOne}, NextPageToken: "p2"}, nil
69 }
70 return &ermite.ListIssuesResponse{Issues: []*ermite.Issue{closed}}, nil
71 }}
72 url := f.serve(t)
73 res := runCLI(t, envFor(url), "", "issue", "list", "alpha", "--state", "all").ok(t)
74 assert.Regexp(t, `NUMBER\s+STATE\s+AUTHOR\s+COMMENTS\s+UPDATED\s+TITLE\n#2\s+open\s+owner\s+1\s+2026-10-02 11:30\s+Crash on start\n#1\s+closed\s+bot\s+0\s+2026-09-01 08:00\s+Old\n`, res.stdout)
75 _, reqs := f.snapshot()
76 require.Len(t, reqs, 3) // GetVersion, two pages
77 assert.Equal(t, ermite.IssueState_ISSUE_STATE_UNSPECIFIED, reqs[1].(*ermite.ListIssuesRequest).GetState())
78 assert.Equal(t, "p2", reqs[2].(*ermite.ListIssuesRequest).GetPageToken())
79
80 runCLI(t, envFor(url), "", "issue", "list", "alpha").ok(t)
81 _, reqs = f.snapshot()
82 assert.Equal(t, ermite.IssueState_ISSUE_STATE_OPEN, reqs[len(reqs)-2].(*ermite.ListIssuesRequest).GetState())
83
84 res = runCLI(t, envFor(url), "", "issue", "list", "alpha", "--json").ok(t)
85 assert.Contains(t, res.stdout, `"state": "closed"`)
86
87 require.Error(t, runCLI(t, envFor(url), "", "issue", "list", "alpha", "--state", "merged").err)
88
89 empty := &fakeAPI{getVersion: withIssues, listIssues: func(*ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error) {
90 return &ermite.ListIssuesResponse{}, nil
91 }}
92 res = runCLI(t, envFor(empty.serve(t)), "", "issue", "list", "alpha", "--json").ok(t)
93 assert.Equal(t, "[]\n", res.stdout)
94 res = runCLI(t, envFor(empty.serve(t)), "", "issue", "list", "alpha").ok(t)
95 assert.Equal(t, "ermite: no issues\n", res.stderr)
96}
97
98func TestIssueShowCommentCloseReopen(t *testing.T) {
99 cmt := &ermite.IssueComment{Id: "1", Author: "agent-7", Text: "On it.", CreatedAt: ts("2026-10-02T11:30:00Z")}
100 reopened := &ermite.Issue{Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_OPEN}
101 f := &fakeAPI{
102 getVersion: withIssues,
103 getIssue: func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
104 return &ermite.GetIssueResponse{Issue: issueOne, Comments: []*ermite.IssueComment{cmt}}, nil
105 },
106 addIssueCmt: func(*ermite.AddIssueCommentRequest) (*ermite.AddIssueCommentResponse, error) {
107 return &ermite.AddIssueCommentResponse{Comment: cmt}, nil
108 },
109 closeIssue: func(*ermite.CloseIssueRequest) (*ermite.CloseIssueResponse, error) {
110 c := &ermite.Issue{Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_CLOSED}
111 return &ermite.CloseIssueResponse{Issue: c}, nil
112 },
113 reopenIssue: func(*ermite.ReopenIssueRequest) (*ermite.ReopenIssueResponse, error) {
114 return &ermite.ReopenIssueResponse{Issue: reopened}, nil
115 },
116 }
117 url := f.serve(t)
118
119 res := runCLI(t, envFor(url), "", "issue", "show", "alpha", "#2").ok(t)
120 assert.Contains(t, res.stdout, "issue alpha#2")
121 assert.Contains(t, res.stdout, "It crashes.")
122 assert.Contains(t, res.stdout, "comments (1)")
123 assert.Contains(t, res.stdout, "#1 agent-7")
124 res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
125 assert.Contains(t, res.stdout, `"comments"`)
126 assert.Contains(t, res.stdout, `"text": "On it."`)
127
128 res = runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2", "-m", "On it.").ok(t)
129 assert.Equal(t, "comment 1 added to alpha#2\n", res.stdout)
130 runCLI(t, envFor(url), "multi\nline\n", "issue", "comment", "alpha", "2", "--text", "-").ok(t)
131 res = runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2", "-m", "x", "--json").ok(t)
132 assert.JSONEq(t, `{"id":"1","author":"agent-7","text":"On it.","created_at":"2026-10-02T11:30:00Z"}`, res.stdout)
133 _, reqs := f.snapshot()
134 var texts []string
135 for _, r := range reqs {
136 if c, ok := r.(*ermite.AddIssueCommentRequest); ok {
137 assert.EqualValues(t, 2, c.GetNumber())
138 texts = append(texts, c.GetText())
139 }
140 }
141 assert.Equal(t, []string{"On it.", "multi\nline", "x"}, texts)
142 require.Error(t, runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2").err)
143
144 res = runCLI(t, envFor(url), "", "issue", "close", "alpha", "2").ok(t)
145 assert.Equal(t, "closed alpha#2 (Crash on start)\n", res.stdout)
146 res = runCLI(t, envFor(url), "", "issue", "reopen", "alpha", "2", "--json").ok(t)
147 assert.Contains(t, res.stdout, `"state": "open"`)
148
149 for _, n := range []string{"0", "x", "-1"} {
150 require.Error(t, runCLI(t, envFor(url), "", "issue", "show", "alpha", n).err, n)
151 }
152}
153
154func TestIssueNeedsTheFeature(t *testing.T) {
155 f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
156 return &ermite.GetVersionResponse{Version: "v0.4.0", Features: []string{"merge_requests"}}, nil
157 }}
158 url := f.serve(t)
159 for _, args := range [][]string{
160 {"issue", "list", "alpha"}, {"issue", "show", "alpha", "1"}, {"issue", "create", "alpha", "--title", "t"},
161 {"issue", "comment", "alpha", "1", "-m", "x"}, {"issue", "close", "alpha", "1"}, {"issue", "reopen", "alpha", "1"},
162 {"repo", "edit", "alpha", "--issues", "owner"},
163 } {
164 res := runCLI(t, envFor(url), "", args...)
165 require.Error(t, res.err, "%v", args)
166 assert.Contains(t, res.err.Error(), "does not support issues", "%v", args)
167 }
168 calls, _ := f.snapshot()
169 for _, c := range calls {
170 assert.Equal(t, "GetVersion", c)
171 }
172}
173
174func TestRepoEditIssues(t *testing.T) {
175 updated := &ermite.Repo{Name: "alpha", Settings: &ermite.RepoSettings{Issues: ermite.IssuesMode_ISSUES_MODE_OWNER}}
176 f := &fakeAPI{getVersion: withIssues, updateRepo: func(*ermite.UpdateRepoRequest) (*ermite.UpdateRepoResponse, error) {
177 return &ermite.UpdateRepoResponse{Repo: updated}, nil
178 }}
179 url := f.serve(t)
180 for value, want := range map[string]ermite.IssuesMode{
181 "off": ermite.IssuesMode_ISSUES_MODE_OFF, "owner": ermite.IssuesMode_ISSUES_MODE_OWNER, "PUBLIC": ermite.IssuesMode_ISSUES_MODE_PUBLIC,
182 } {
183 res := runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--issues", value, "--json").ok(t)
184 assert.Contains(t, res.stdout, `"issues": "owner"`)
185 _, reqs := f.snapshot()
186 got := reqs[len(reqs)-1].(*ermite.UpdateRepoRequest)
187 require.NotNil(t, got.Issues, value)
188 assert.Equal(t, want, got.GetIssues())
189 }
190 res := runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--issues", "all")
191 require.Error(t, res.err)
192 assert.Contains(t, res.err.Error(), "off, owner or public")
193 res = runCLI(t, envFor(url), "", "repo", "show", "alpha")
194 require.Error(t, res.err) // getRepo not set: unimplemented
195}
196
197func TestRepoShowIssues(t *testing.T) {
198 r := &ermite.Repo{Name: "alpha", Settings: &ermite.RepoSettings{Issues: ermite.IssuesMode_ISSUES_MODE_OWNER}}
199 f := &fakeAPI{getRepo: func(*ermite.GetRepoRequest) (*ermite.GetRepoResponse, error) {
200 return &ermite.GetRepoResponse{Repo: r}, nil
201 }}
202 url := f.serve(t)
203 res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t)
204 assert.Contains(t, res.stdout, "issues owner\n")
205 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
206 assert.Contains(t, res.stdout, `"issues": "owner"`)
207}
208
209func TestTokenCreateIssueWriteNeedsTheFeature(t *testing.T) {
210 echo := func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
211 return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Label: r.GetLabel(), Scopes: r.GetScopes()}, Secret: "ermite_S"}, nil
212 }
213 f := &fakeAPI{createToken: echo, getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
214 return &ermite.GetVersionResponse{Version: "v0.4.0"}, nil
215 }}
216 res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--scope", "issue:write")
217 require.Error(t, res.err)
218 assert.Contains(t, res.err.Error(), "does not support issues")
219 calls, _ := f.snapshot()
220 assert.NotContains(t, calls, "CreateToken")
221
222 f = &fakeAPI{createToken: echo, getVersion: withIssues}
223 runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--scope", "issue:write").ok(t)
224 assert.Contains(t, lastCreateToken(t, f).GetScopes(), ermite.Scope_SCOPE_ISSUE_WRITE)
225}
226
227func 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
244type failingWriter struct{ after int }
245
246func (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
254func 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}