1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "strings"
7 "testing"
8
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11
12 "bnema.dev/ermite-api/gen/ermite"
13)
14
15type issueOut struct {
16 Repo string `json:"repo"`
17 Number int64 `json:"number"`
18 Title string `json:"title"`
19 Body string `json:"body"`
20 State string `json:"state"`
21 Author string `json:"author"`
22 CreatedAt *string `json:"created_at"`
23 UpdatedAt *string `json:"updated_at"`
24 CommentCount int `json:"comment_count"`
25}
26
27var issueKeys = []string{"author", "body", "comment_count", "created_at", "number", "repo", "state", "title", "updated_at"}
28
29type issueCommentOut struct {
30 ID string `json:"id"`
31 Author string `json:"author"`
32 Text string `json:"text"`
33}
34
35// The issue commands against a real forge: issues share the numbering of the
36// merge requests.
37func TestIssuesCLIEndToEnd(t *testing.T) {
38 c := newCLIHarness(t)
39 agent := strings.TrimSpace(func() string {
40 out, errOut, code := c.bin(t, adminToken, "token", "create", "agent-7", "--preset", "agent", "--scope", "issue:write", "--repo", "proj")
41 require.Equal(t, 0, code, errOut)
42 return out
43 }())
44 c.cli(t, adminToken, "repo", "create", "proj").ok(t)
45
46 // Issues are off by default.
47 res := c.cli(t, adminToken, "repo", "show", "proj", "--json").ok(t)
48 assert.Equal(t, "off", decode[struct {
49 Issues string `json:"issues"`
50 }](t, res.stdout).Issues)
51 res = c.cli(t, adminToken, "issue", "create", "proj", "--title", "too early")
52 require.Error(t, res.err)
53
54 // repo edit --issues.
55 res = c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "owner", "--json").ok(t)
56 assert.Equal(t, "owner", decode[struct {
57 Issues string `json:"issues"`
58 }](t, res.stdout).Issues)
59 res = c.cli(t, adminToken, "repo", "show", "proj").ok(t)
60 assert.Regexp(t, `issues\s+owner`, res.stdout)
61 require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "everyone").err)
62 res = c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "public", "--json").ok(t)
63 assert.Equal(t, "public", decode[struct {
64 Issues string `json:"issues"`
65 }](t, res.stdout).Issues)
66 c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "owner").ok(t)
67
68 // A merge request first: the issue takes the next number.
69 wt := c.workTree("main", "README.md")
70 url := c.urlAuth(adminToken, "/proj.git")
71 c.mustGit(wt, "push", "-q", url, "main")
72 c.mustGit(wt, "checkout", "-q", "-b", "feat/x")
73 c.writeFile(wt, "x.txt", []byte("x\n"))
74 c.mustGit(wt, "push", "-q", url, "feat/x")
75 c.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/x", "--title", "First").ok(t)
76
77 res = c.cli(t, agent, "issue", "create", "proj", "--title", "Crash on start", "--body", "It crashes.\nSecond line.", "--json").ok(t)
78 assert.Equal(t, issueKeys, keys(t, res.stdout))
79 issue := decode[issueOut](t, res.stdout)
80 assert.EqualValues(t, 2, issue.Number, "shared numbering with merge requests")
81 assert.Equal(t, "open", issue.State)
82 assert.Equal(t, "agent-7", issue.Author)
83 assert.Equal(t, "It crashes.\nSecond line.", issue.Body)
84
85 res = runCLI(t, c.vars(agent), "from stdin\n", "issue", "create", "proj", "--title", "Second", "--body-file", "-").ok(t)
86 assert.Equal(t, "opened proj#3 (Second)\n", res.stdout)
87
88 // list.
89 res = c.cli(t, adminToken, "issue", "list", "proj", "--json").ok(t)
90 list := decode[[]issueOut](t, res.stdout)
91 require.Len(t, list, 2)
92 assert.EqualValues(t, 3, list[0].Number, "highest number first")
93 res = c.cli(t, adminToken, "issue", "list", "proj").ok(t)
94 lines := strings.Split(strings.TrimSpace(res.stdout), "\n")
95 require.Len(t, lines, 3)
96 assert.Regexp(t, `^NUMBER\s+STATE\s+AUTHOR\s+COMMENTS\s+UPDATED\s+TITLE$`, lines[0])
97 assert.Regexp(t, `^#3\s+open\s+agent-7\s+0\s+\d{4}-\d{2}-\d{2} \d{2}:\d{2}\s+Second$`, lines[1])
98
99 // comment, then show with it.
100 res = c.cli(t, agent, "issue", "comment", "proj", "2", "-m", "Reproduced.", "--json").ok(t)
101 cm := decode[issueCommentOut](t, res.stdout)
102 assert.Equal(t, "agent-7", cm.Author)
103 assert.Equal(t, "Reproduced.", cm.Text)
104 res = c.cli(t, adminToken, "issue", "comment", "proj", "#2", "-m", "Thanks.").ok(t)
105 assert.Regexp(t, `^comment \S+ added to proj#2\n$`, res.stdout)
106 res = c.cli(t, adminToken, "issue", "show", "proj", "2", "--json").ok(t)
107 type showOut struct {
108 issueOut
109 Comments []issueCommentOut `json:"comments"`
110 }
111 show := decode[showOut](t, res.stdout)
112 assert.Equal(t, 2, show.CommentCount)
113 require.Len(t, show.Comments, 2)
114 assert.Equal(t, "Reproduced.", show.Comments[0].Text, "oldest first")
115 res = c.cli(t, adminToken, "issue", "show", "proj", "2").ok(t)
116 assert.Contains(t, res.stdout, "Crash on start")
117 assert.Contains(t, res.stdout, "comments (2)")
118
119 // close and reopen.
120 res = c.cli(t, agent, "issue", "close", "proj", "2", "--json").ok(t)
121 assert.Equal(t, "closed", decode[issueOut](t, res.stdout).State)
122 res = c.cli(t, adminToken, "issue", "list", "proj", "--json").ok(t)
123 assert.Len(t, decode[[]issueOut](t, res.stdout), 1)
124 res = c.cli(t, adminToken, "issue", "list", "proj", "--state", "closed", "--json").ok(t)
125 assert.Len(t, decode[[]issueOut](t, res.stdout), 1)
126 res = c.cli(t, adminToken, "issue", "list", "proj", "--state", "all", "--json").ok(t)
127 assert.Len(t, decode[[]issueOut](t, res.stdout), 2)
128 res = c.cli(t, agent, "issue", "reopen", "proj", "2").ok(t)
129 assert.Equal(t, "reopened proj#2 (Crash on start)\n", res.stdout)
130
131 // An issue is not a merge request and the other way round.
132 _, stderr, code := c.bin(t, adminToken, "issue", "show", "proj", "1")
133 requireOneLineError(t, stderr, code, "not found")
134 _, stderr, code = c.bin(t, adminToken, "issue", "show", "proj", "9")
135 requireOneLineError(t, stderr, code, "not found")
136}
137
138// A server without the issues feature is refused before anything is created.
139func TestIssueWriteTokenRefusedWithoutTheFeature(t *testing.T) {
140 f := &fakeAPI{
141 getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
142 return &ermite.GetVersionResponse{Version: "v0.4.0", Features: []string{"merge_requests"}}, nil
143 },
144 createToken: func(*ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
145 return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t"}, Secret: "ermite_S"}, nil
146 },
147 }
148 res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "bot", "--preset", "agent", "--scope", "issue:write")
149 require.Error(t, res.err)
150 assert.Contains(t, res.err.Error(), "does not support issues")
151 assert.Empty(t, res.stdout)
152 calls, _ := f.snapshot()
153 assert.NotContains(t, calls, "CreateToken")
154}