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_RESOLVED,
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+resolved\n.*resolved by\s+!5\n`, res.stdout)
284 assert.Contains(t, res.stdout, "\nmerge requests (2)\n !5 open Fix the parser · resolves\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":"resolved","author":"","created_at":null,"updated_at":null,"comment_count":0,"closed_by_merge_request":5,"comments":[],"events":[],"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, "resolved by")
297 res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
298 assert.Contains(t, res.stdout, `"references": []`)
299}
300
301func TestIssueResolveAndHistory(t *testing.T) {
302 events := []*ermite.IssueEvent{
303 {Id: "1", Kind: ermite.IssueEventKind_ISSUE_EVENT_KIND_REFERENCED, Issue: 2, MergeRequest: 5, MergeRequestTitle: "Fix", Closes: true, Actor: "owner", CreatedAt: ts("2026-10-01T10:00:00Z")},
304 {Id: "2", Kind: ermite.IssueEventKind_ISSUE_EVENT_KIND_RESOLVED, Issue: 2, MergeRequest: 5, Actor: "merger", CreatedAt: ts("2026-10-03T10:00:00Z")},
305 }
306 f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
307 return &ermite.GetVersionResponse{Features: []string{"issues", "issues.resolve"}}, nil
308 },
309 resolveIssue: func(r *ermite.ResolveIssueRequest) (*ermite.ResolveIssueResponse, error) {
310 assert.Equal(t, "alpha", r.GetRepo())
311 assert.EqualValues(t, 2, r.GetNumber())
312 return &ermite.ResolveIssueResponse{Issue: &ermite.Issue{Repo: "alpha", Number: 2, Title: "Fix", State: ermite.IssueState_ISSUE_STATE_RESOLVED}}, nil
313 },
314 getIssue: func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) {
315 return &ermite.GetIssueResponse{Issue: issueOne, Events: events, Comments: []*ermite.IssueComment{{Id: "1", Author: "reviewer", Text: "checking", CreatedAt: ts("2026-10-02T10:00:00Z")}}}, nil
316 },
317 listIssues: func(r *ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error) {
318 assert.Equal(t, ermite.IssueState_ISSUE_STATE_RESOLVED, r.GetState())
319 return &ermite.ListIssuesResponse{}, nil
320 },
321 }
322 url := f.serve(t)
323 res := runCLI(t, envFor(url), "", "issue", "resolve", "alpha", "#2").ok(t)
324 assert.Equal(t, "resolved alpha#2 (Fix)\n", res.stdout)
325 res = runCLI(t, envFor(url), "", "issue", "resolve", "alpha", "2", "--json").ok(t)
326 assert.Contains(t, res.stdout, `"state": "resolved"`)
327 runCLI(t, envFor(url), "", "issue", "list", "alpha", "--state", "resolved").ok(t)
328 res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2").ok(t)
329 assert.Contains(t, res.stdout, "owner referenced this in !5 (will resolve)")
330 assert.Contains(t, res.stdout, "merger resolved this by merging !5")
331 assert.Less(t, strings.Index(res.stdout, "owner referenced"), strings.Index(res.stdout, "reviewer"))
332 assert.Less(t, strings.Index(res.stdout, "reviewer"), strings.Index(res.stdout, "merger resolved"))
333 res = runCLI(t, envFor(url), "", "issue", "show", "alpha", "2", "--json").ok(t)
334 assert.Contains(t, res.stdout, `"events": [`)
335 assert.Contains(t, res.stdout, `"merge_request_title": "Fix"`)
336 assert.Contains(t, res.stdout, `"kind": "resolved"`)
337}
338
339func TestIssueResolveRequiresFeature(t *testing.T) {
340 f := &fakeAPI{getVersion: withIssues, resolveIssue: func(*ermite.ResolveIssueRequest) (*ermite.ResolveIssueResponse, error) {
341 t.Error("ResolveIssue must not be called")
342 return nil, errors.New("unexpected ResolveIssue")
343 }}
344 res := runCLI(t, envFor(f.serve(t)), "", "issue", "resolve", "alpha", "2")
345 require.Error(t, res.err)
346 assert.Contains(t, res.err.Error(), "the server does not support issues.resolve")
347 assert.Contains(t, res.err.Error(), "cannot resolve issues")
348}