| @@ -0,0 +1,264 @@ |
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | + | |
| 3 | + | package app |
| 4 | + | |
| 5 | + | import ( |
| 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 | + | |
| 17 | + | var 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 | + | |
| 22 | + | func withIssues(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { |
| 23 | + | return &ermite.GetVersionResponse{Version: "v0.5.0", Features: []string{"issues"}}, nil |
| 24 | + | } |
| 25 | + | |
| 26 | + | func 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 | + | |
| 64 | + | func 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 | + | |
| 98 | + | func 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 | + | |
| 154 | + | func 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 | + | |
| 174 | + | func 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 | + | |
| 197 | + | func 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 | + | |
| 209 | + | func 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 | + | |
| 227 | + | func 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 | + | |
| 244 | + | type failingWriter struct{ after int } |
| 245 | + | |
| 246 | + | func (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 | + | |
| 254 | + | func 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 | + | } |