cli_issue_test.go RawHistoryFolder

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}

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 4

Tags 45

↑↓ moveenter openesc close