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