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