detached 973e682

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

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