v0.31.0 d5e0505

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_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}

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 2

Tags 43

↑↓ moveenter openesc close