1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "errors"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"
14
15 "bnema.dev/ermite-api/gen/ermite"
16)
17
18var issueOne = &ermite.Issue{
19 Repo: "alpha", Number: 2, Title: "Crash on start", Body: "It crashes.", State: ermite.IssueState_ISSUE_STATE_OPEN,
20 Author: "owner", CreatedAt: ts("2026-10-01T10:00:00Z"), UpdatedAt: ts("2026-10-02T11:30:00Z"), CommentCount: 1,
21}
22
23func withIssues(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
24 return &ermite.GetVersionResponse{Version: "v0.5.0", Features: []string{"issues"}}, nil
25}
26
27func TestIssueCreateMapsFlags(t *testing.T) {
28 f := &fakeAPI{getVersion: withIssues, createIssue: func(*ermite.CreateIssueRequest) (*ermite.CreateIssueResponse, error) {
29 return &ermite.CreateIssueResponse{Issue: issueOne}, nil
30 }}
31 url := f.serve(t)
32 res := runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "Crash on start", "--body", "It crashes.").ok(t)
33 assert.Equal(t, "opened alpha#2 (Crash on start)\n", res.stdout)
34 _, reqs := f.snapshot()
35 got := reqs[1].(*ermite.CreateIssueRequest)
36 assert.Equal(t, "alpha", got.GetRepo())
37 assert.Equal(t, "Crash on start", got.GetTitle())
38 assert.Equal(t, "It crashes.", got.GetBody())
39
40 runCLI(t, envFor(url), "from\nstdin\n", "issue", "create", "alpha", "--title", "t", "--body", "-").ok(t)
41 runCLI(t, envFor(url), "piped\n", "issue", "create", "alpha", "--title", "t", "--body-file", "-").ok(t)
42 file := filepath.Join(t.TempDir(), "body.txt")
43 require.NoError(t, os.WriteFile(file, []byte("from file\n"), 0o600))
44 runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--body-file", file).ok(t)
45 _, reqs = f.snapshot()
46 var bodies []string
47 for _, r := range reqs {
48 if c, ok := r.(*ermite.CreateIssueRequest); ok {
49 bodies = append(bodies, c.GetBody())
50 }
51 }
52 assert.Equal(t, []string{"It crashes.", "from\nstdin", "piped", "from file"}, bodies)
53
54 res = runCLI(t, envFor(url), "", "issue", "create", "alpha", "--title", "t", "--json").ok(t)
55 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,"closed_by_merge_request":0}`, res.stdout)
56
57 for _, args := range [][]string{
58 {"issue", "create", "alpha"},
59 {"issue", "create", "alpha", "--title", "t", "--body", "a", "--body-file", file},
60 } {
61 require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args)
62 }
63}
64
65func TestIssueListTableJSONAndPages(t *testing.T) {
66 closed := &ermite.Issue{Repo: "alpha", Number: 1, Title: "Old", State: ermite.IssueState_ISSUE_STATE_CLOSED, Author: "bot", UpdatedAt: ts("2026-09-01T08:00:00Z")}
67 f := &fakeAPI{getVersion: withIssues, listIssues: func(r *ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error) {
68 if r.GetPageToken() == "" {
69 return &ermite.ListIssuesResponse{Issues: []*ermite.Issue{issueOne}, NextPageToken: "p2"}, nil
70 }
71 return &ermite.ListIssuesResponse{Issues: []*ermite.Issue{closed}}, nil
72 }}
73 url := f.serve(t)
74 res := runCLI(t, envFor(url), "", "issue", "list", "alpha", "--state", "all").ok(t)
75 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)
76 _, reqs := f.snapshot()
77 require.Len(t, reqs, 3) // GetVersion, two pages
78 assert.Equal(t, ermite.IssueState_ISSUE_STATE_UNSPECIFIED, reqs[1].(*ermite.ListIssuesRequest).GetState())
79 assert.Equal(t, "p2", reqs[2].(*ermite.ListIssuesRequest).GetPageToken())
80
81 runCLI(t, envFor(url), "", "issue", "list", "alpha").ok(t)
82 _, reqs = f.snapshot()
83 assert.Equal(t, ermite.IssueState_ISSUE_STATE_OPEN, reqs[len(reqs)-2].(*ermite.ListIssuesRequest).GetState())
84
85 res = runCLI(t, envFor(url), "", "issue", "list", "alpha", "--json").ok(t)
86 assert.Contains(t, res.stdout, `"state": "closed"`)
87
88 require.Error(t, runCLI(t, envFor(url), "", "issue", "list", "alpha", "--state", "merged").err)
89
90 empty := &fakeAPI{getVersion: withIssues, listIssues: func(*ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error) {
91 return &ermite.ListIssuesResponse{}, nil
92 }}
93 res = runCLI(t, envFor(empty.serve(t)), "", "issue", "list", "alpha", "--json").ok(t)
94 assert.Equal(t, "[]\n", res.stdout)
95 res = runCLI(t, envFor(empty.serve(t)), "", "issue", "list", "alpha").ok(t)
96 assert.Equal(t, "ermite: no issues\n", res.stderr)
97}
98
99func TestIssueShowCommentCloseReopen(t *testing.T) {
100 cmt := &ermite.IssueComment{Id: "1", Author: "agent-7", Text: "On it.", CreatedAt: ts("2026-10-02T11:30:00Z")}
101 reopened := &ermite.Issue{Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_OPEN}
102 f := &fakeAPI{
103 getVersion: withIssues,
104 getIssue: func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
105 return &ermite.GetIssueResponse{Issue: issueOne, Comments: []*ermite.IssueComment{cmt}}, nil
106 },
107 addIssueCmt: func(*ermite.AddIssueCommentRequest) (*ermite.AddIssueCommentResponse, error) {
108 return &ermite.AddIssueCommentResponse{Comment: cmt}, nil
109 },
110 closeIssue: func(*ermite.CloseIssueRequest) (*ermite.CloseIssueResponse, error) {
111 c := &ermite.Issue{Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_CLOSED}
112 return &ermite.CloseIssueResponse{Issue: c}, nil
113 },
114 reopenIssue: func(*ermite.ReopenIssueRequest) (*ermite.ReopenIssueResponse, error) {
115 return &ermite.ReopenIssueResponse{Issue: reopened}, nil
116 },
117 }
118 url := f.serve(t)
119
120 res := runCLI(t, envFor(url), "", "issue", "show", "alpha", "#2").ok(t)
121 assert.Contains(t, res.stdout, "issue alpha#2")
122 assert.Contains(t, res.stdout, "It crashes.")
123 assert.Contains(t, res.stdout, "comments (1)")
124 assert.Contains(t, res.stdout, "#1 agent-7")
125 res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
126 assert.Contains(t, res.stdout, `"comments"`)
127 assert.Contains(t, res.stdout, `"text": "On it."`)
128
129 res = runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2", "-m", "On it.").ok(t)
130 assert.Equal(t, "comment 1 added to alpha#2\n", res.stdout)
131 runCLI(t, envFor(url), "multi\nline\n", "issue", "comment", "alpha", "2", "--text", "-").ok(t)
132 res = runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2", "-m", "x", "--json").ok(t)
133 assert.JSONEq(t, `{"id":"1","author":"agent-7","text":"On it.","created_at":"2026-10-02T11:30:00Z"}`, res.stdout)
134 _, reqs := f.snapshot()
135 var texts []string
136 for _, r := range reqs {
137 if c, ok := r.(*ermite.AddIssueCommentRequest); ok {
138 assert.EqualValues(t, 2, c.GetNumber())
139 texts = append(texts, c.GetText())
140 }
141 }
142 assert.Equal(t, []string{"On it.", "multi\nline", "x"}, texts)
143 require.Error(t, runCLI(t, envFor(url), "", "issue", "comment", "alpha", "2").err)
144
145 res = runCLI(t, envFor(url), "", "issue", "close", "alpha", "2").ok(t)
146 assert.Equal(t, "closed alpha#2 (Crash on start)\n", res.stdout)
147 res = runCLI(t, envFor(url), "", "issue", "reopen", "alpha", "2", "--json").ok(t)
148 assert.Contains(t, res.stdout, `"state": "open"`)
149
150 for _, n := range []string{"0", "x", "-1"} {
151 require.Error(t, runCLI(t, envFor(url), "", "issue", "show", "alpha", n).err, n)
152 }
153}
154
155func TestIssueNeedsTheFeature(t *testing.T) {
156 f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
157 return &ermite.GetVersionResponse{Version: "v0.4.0", Features: []string{"merge_requests"}}, nil
158 }}
159 url := f.serve(t)
160 for _, args := range [][]string{
161 {"issue", "list", "alpha"}, {"issue", "show", "alpha", "1"}, {"issue", "create", "alpha", "--title", "t"},
162 {"issue", "comment", "alpha", "1", "-m", "x"}, {"issue", "close", "alpha", "1"}, {"issue", "reopen", "alpha", "1"},
163 {"repo", "edit", "alpha", "--issues", "owner"},
164 } {
165 res := runCLI(t, envFor(url), "", args...)
166 require.Error(t, res.err, "%v", args)
167 assert.Contains(t, res.err.Error(), "does not support issues", "%v", args)
168 }
169 calls, _ := f.snapshot()
170 for _, c := range calls {
171 assert.Equal(t, "GetVersion", c)
172 }
173}
174
175func TestRepoEditIssues(t *testing.T) {
176 updated := &ermite.Repo{Name: "alpha", Settings: &ermite.RepoSettings{Issues: ermite.IssuesMode_ISSUES_MODE_OWNER}}
177 f := &fakeAPI{getVersion: withIssues, updateRepo: func(*ermite.UpdateRepoRequest) (*ermite.UpdateRepoResponse, error) {
178 return &ermite.UpdateRepoResponse{Repo: updated}, nil
179 }}
180 url := f.serve(t)
181 for value, want := range map[string]ermite.IssuesMode{
182 "off": ermite.IssuesMode_ISSUES_MODE_OFF, "owner": ermite.IssuesMode_ISSUES_MODE_OWNER, "PUBLIC": ermite.IssuesMode_ISSUES_MODE_PUBLIC,
183 } {
184 res := runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--issues", value, "--json").ok(t)
185 assert.Contains(t, res.stdout, `"issues": "owner"`)
186 _, reqs := f.snapshot()
187 got := reqs[len(reqs)-1].(*ermite.UpdateRepoRequest)
188 require.NotNil(t, got.Issues, value)
189 assert.Equal(t, want, got.GetIssues())
190 }
191 res := runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--issues", "all")
192 require.Error(t, res.err)
193 assert.Contains(t, res.err.Error(), "off, owner or public")
194 res = runCLI(t, envFor(url), "", "repo", "show", "alpha")
195 require.Error(t, res.err) // getRepo not set: unimplemented
196}
197
198func TestRepoShowIssues(t *testing.T) {
199 r := &ermite.Repo{Name: "alpha", Settings: &ermite.RepoSettings{Issues: ermite.IssuesMode_ISSUES_MODE_OWNER}}
200 f := &fakeAPI{getRepo: func(*ermite.GetRepoRequest) (*ermite.GetRepoResponse, error) {
201 return &ermite.GetRepoResponse{Repo: r}, nil
202 }}
203 url := f.serve(t)
204 res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t)
205 assert.Contains(t, res.stdout, "issues owner\n")
206 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
207 assert.Contains(t, res.stdout, `"issues": "owner"`)
208}
209
210func TestTokenCreateIssueWriteNeedsTheFeature(t *testing.T) {
211 echo := func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
212 return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Label: r.GetLabel(), Scopes: r.GetScopes()}, Secret: "ermite_S"}, nil
213 }
214 f := &fakeAPI{createToken: echo, getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
215 return &ermite.GetVersionResponse{Version: "v0.4.0"}, nil
216 }}
217 res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--scope", "issue:write")
218 require.Error(t, res.err)
219 assert.Contains(t, res.err.Error(), "does not support issues")
220 calls, _ := f.snapshot()
221 assert.NotContains(t, calls, "CreateToken")
222
223 f = &fakeAPI{createToken: echo, getVersion: withIssues}
224 runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--scope", "issue:write").ok(t)
225 assert.Contains(t, lastCreateToken(t, f).GetScopes(), ermite.Scope_SCOPE_ISSUE_WRITE)
226}
227
228func TestTokenCreatePresetCLIOnAnOldServerDropsIssueWrite(t *testing.T) {
229 f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
230 return &ermite.GetVersionResponse{Version: "v0.4.0"}, nil
231 }, createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
232 return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Label: r.GetLabel(), Scopes: r.GetScopes()}, Secret: "ermite_S"}, nil
233 }}
234 res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "cli").ok(t)
235 got := lastCreateToken(t, f).GetScopes()
236 assert.NotContains(t, got, ermite.Scope_SCOPE_ISSUE_WRITE)
237 assert.NotEmpty(t, got)
238 assert.Contains(t, res.stderr, "no issue:write")
239
240 res = runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "cli", "--scope", "issue:write")
241 require.Error(t, res.err)
242 assert.Contains(t, res.err.Error(), "does not support issues")
243}
244
245type failingWriter struct{ after int }
246
247func (w *failingWriter) Write(p []byte) (int, error) {
248 if w.after <= 0 {
249 return 0, errors.New("write failed")
250 }
251 w.after--
252 return len(p), nil
253}
254
255func TestPrintIssueDetailReturnsWriteErrors(t *testing.T) {
256 d := &ermite.GetIssueResponse{Issue: &ermite.Issue{Repo: "alpha", Number: 1, Title: "t"}, Comments: []*ermite.IssueComment{{Id: "1", Text: "c"}}}
257 for after := range 60 {
258 err := printIssueDetail(&failingWriter{after: after}, d)
259 if err == nil {
260 return // all writes succeeded: every earlier failure was reported
261 }
262 assert.ErrorContains(t, err, "write failed", after)
263 }
264 t.Fatal("never completed")
265}
266
267func TestIssueShowMergeRequestLinks(t *testing.T) {
268 closed := &ermite.Issue{
269 Repo: "alpha", Number: 2, Title: "Crash on start", State: ermite.IssueState_ISSUE_STATE_CLOSED,
270 ClosedByMergeRequest: 5,
271 }
272 f := &fakeAPI{
273 getVersion: withIssues,
274 getIssue: func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
275 return &ermite.GetIssueResponse{Issue: closed, References: []*ermite.IssueReference{
276 {Number: 5, Title: "Fix the parser", State: ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN, Closes: true},
277 {Number: 7, Title: "Follow-up", State: ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED},
278 }}, nil
279 },
280 }
281 url := f.serve(t)
282 res := runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t)
283 assert.Regexp(t, `state\s+closed\n.*closed by\s+!5\n`, res.stdout)
284 assert.Contains(t, res.stdout, "\nmerge requests (2)\n !5 open Fix the parser · closes\n !7 merged Follow-up\n")
285 assert.Less(t, strings.Index(res.stdout, "merge requests (2)"), strings.Index(res.stdout, "comments (0)"))
286
287 res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
288 assert.JSONEq(t, `{"repo":"alpha","number":2,"title":"Crash on start","body":"","state":"closed","author":"","created_at":null,"updated_at":null,"comment_count":0,"closed_by_merge_request":5,"comments":[],"references":[{"number":5,"title":"Fix the parser","state":"open","closes":true},{"number":7,"title":"Follow-up","state":"merged","closes":false}]}`, res.stdout)
289
290 // No references: no block in text, an empty array in JSON.
291 f.getIssue = func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
292 return &ermite.GetIssueResponse{Issue: issueOne}, nil
293 }
294 res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t)
295 assert.NotContains(t, res.stdout, "merge requests")
296 assert.NotContains(t, res.stdout, "closed by")
297 res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
298 assert.Contains(t, res.stdout, `"references": []`)
299}