detached 4c8ce48

feat(usecase): add the issue use cases and the issues setting

commit
4c8ce482d580fa86f1b585fbc1e60fdc44e20d17
author
brice · · 2026-10-19 05:40 UTC
parent
c52c40f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +1402 -3
M internal/domain/issue.go +14 -0
@@ -89,3 +89,17 @@ type IssueComment struct {
8989 // Version is set by the store on read, like Issue's.
9090 Version uint64
9191 }
92+
93+// NumberKind says what a number of a repository names. Issues and merge
94+// requests share one sequence, so a number names at most one of them.
95+type NumberKind int
96+
97+// Number kinds.
98+const (
99+ // NumberNone: no issue or merge request has this number.
100+ NumberNone NumberKind = iota
101+ // NumberIssue: the number names an issue.
102+ NumberIssue
103+ // NumberMergeRequest: the number names a merge request.
104+ NumberMergeRequest
105+)
@@ -89,3 +89,17 @@ type IssueComment struct {
89 // Version is set by the store on read, like Issue's.89 // Version is set by the store on read, like Issue's.
90 Version uint6490 Version uint64
91 }91 }
92+
93+// NumberKind says what a number of a repository names. Issues and merge
94+// requests share one sequence, so a number names at most one of them.
95+type NumberKind int
96+
97+// Number kinds.
98+const (
99+ // NumberNone: no issue or merge request has this number.
100+ NumberNone NumberKind = iota
101+ // NumberIssue: the number names an issue.
102+ NumberIssue
103+ // NumberMergeRequest: the number names a merge request.
104+ NumberMergeRequest
105+)
M internal/ports/dto.go +15 -0
@@ -49,6 +49,8 @@ type UpdateRepoParams struct {
4949 AllowedMergeModes *[]domain.MergeMode
5050 // FailingChecksBlock replaces the setting of that name.
5151 FailingChecksBlock *bool
52+ // Issues replaces the issues mode. IssuesPublic is refused.
53+ Issues *domain.IssuesMode
5254 }
5355
5456 // TokenPage is a page of stored tokens, newest first.
@@ -216,6 +218,19 @@ type IssuePage struct {
216218 NextPageToken string
217219 }
218220
221+// CreateIssueParams describes the issue to open.
222+type CreateIssueParams struct {
223+ Title string
224+ Body string
225+}
226+
227+// IssueDetail is an issue with its comments.
228+type IssueDetail struct {
229+ Issue domain.Issue
230+ // Comments are oldest first.
231+ Comments []domain.IssueComment
232+}
233+
219234 // MergeRequestDetail is a merge request with its review data.
220235 type MergeRequestDetail struct {
221236 MergeRequest domain.MergeRequest
@@ -49,6 +49,8 @@ type UpdateRepoParams struct {
49 AllowedMergeModes *[]domain.MergeMode49 AllowedMergeModes *[]domain.MergeMode
50 // FailingChecksBlock replaces the setting of that name.50 // FailingChecksBlock replaces the setting of that name.
51 FailingChecksBlock *bool51 FailingChecksBlock *bool
52+ // Issues replaces the issues mode. IssuesPublic is refused.
53+ Issues *domain.IssuesMode
52 }54 }
53 55
54 // TokenPage is a page of stored tokens, newest first.56 // TokenPage is a page of stored tokens, newest first.
@@ -216,6 +218,19 @@ type IssuePage struct {
216 NextPageToken string218 NextPageToken string
217 }219 }
218 220
221+// CreateIssueParams describes the issue to open.
222+type CreateIssueParams struct {
223+ Title string
224+ Body string
225+}
226+
227+// IssueDetail is an issue with its comments.
228+type IssueDetail struct {
229+ Issue domain.Issue
230+ // Comments are oldest first.
231+ Comments []domain.IssueComment
232+}
233+
219 // MergeRequestDetail is a merge request with its review data.234 // MergeRequestDetail is a merge request with its review data.
220 type MergeRequestDetail struct {235 type MergeRequestDetail struct {
221 MergeRequest domain.MergeRequest236 MergeRequest domain.MergeRequest
A internal/ports/mocks/IssueNumberResolver.go +126 -0
@@ -0,0 +1,126 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewIssueNumberResolver creates a new instance of IssueNumberResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewIssueNumberResolver(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *IssueNumberResolver {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &IssueNumberResolver{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// IssueNumberResolver is an autogenerated mock type for the IssueNumberResolver type
38+type IssueNumberResolver struct {
39+ mock.Mock
40+}
41+
42+type IssueNumberResolver_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *IssueNumberResolver) EXPECT() *IssueNumberResolver_Expecter {
47+ return &IssueNumberResolver_Expecter{mock: &_m.Mock}
48+}
49+
50+// Kind provides a mock function for the type IssueNumberResolver
51+func (_mock *IssueNumberResolver) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) {
52+ ret := _mock.Called(ctx, p, name, n)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Kind")
56+ }
57+
58+ var r0 domain.NumberKind
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.NumberKind, error)); ok {
61+ return returnFunc(ctx, p, name, n)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.NumberKind); ok {
64+ r0 = returnFunc(ctx, p, name, n)
65+ } else {
66+ r0 = ret.Get(0).(domain.NumberKind)
67+ }
68+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
69+ r1 = returnFunc(ctx, p, name, n)
70+ } else {
71+ r1 = ret.Error(1)
72+ }
73+ return r0, r1
74+}
75+
76+// IssueNumberResolver_Kind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Kind'
77+type IssueNumberResolver_Kind_Call struct {
78+ *mock.Call
79+}
80+
81+// Kind is a helper method to define mock.On call
82+// - ctx context.Context
83+// - p domain.Principal
84+// - name domain.RepoName
85+// - n int64
86+func (_e *IssueNumberResolver_Expecter) Kind(ctx any, p any, name any, n any) *IssueNumberResolver_Kind_Call {
87+ return &IssueNumberResolver_Kind_Call{Call: _e.mock.On("Kind", ctx, p, name, n)}
88+}
89+
90+func (_c *IssueNumberResolver_Kind_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, n int64)) *IssueNumberResolver_Kind_Call {
91+ _c.Call.Run(func(args mock.Arguments) {
92+ var arg0 context.Context
93+ if args[0] != nil {
94+ arg0 = args[0].(context.Context)
95+ }
96+ var arg1 domain.Principal
97+ if args[1] != nil {
98+ arg1 = args[1].(domain.Principal)
99+ }
100+ var arg2 domain.RepoName
101+ if args[2] != nil {
102+ arg2 = args[2].(domain.RepoName)
103+ }
104+ var arg3 int64
105+ if args[3] != nil {
106+ arg3 = args[3].(int64)
107+ }
108+ run(
109+ arg0,
110+ arg1,
111+ arg2,
112+ arg3,
113+ )
114+ })
115+ return _c
116+}
117+
118+func (_c *IssueNumberResolver_Kind_Call) Return(numberKind domain.NumberKind, err error) *IssueNumberResolver_Kind_Call {
119+ _c.Call.Return(numberKind, err)
120+ return _c
121+}
122+
123+func (_c *IssueNumberResolver_Kind_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error)) *IssueNumberResolver_Kind_Call {
124+ _c.Call.Return(run)
125+ return _c
126+}
@@ -0,0 +1,126 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewIssueNumberResolver creates a new instance of IssueNumberResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewIssueNumberResolver(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *IssueNumberResolver {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &IssueNumberResolver{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// IssueNumberResolver is an autogenerated mock type for the IssueNumberResolver type
38+type IssueNumberResolver struct {
39+ mock.Mock
40+}
41+
42+type IssueNumberResolver_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *IssueNumberResolver) EXPECT() *IssueNumberResolver_Expecter {
47+ return &IssueNumberResolver_Expecter{mock: &_m.Mock}
48+}
49+
50+// Kind provides a mock function for the type IssueNumberResolver
51+func (_mock *IssueNumberResolver) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) {
52+ ret := _mock.Called(ctx, p, name, n)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Kind")
56+ }
57+
58+ var r0 domain.NumberKind
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.NumberKind, error)); ok {
61+ return returnFunc(ctx, p, name, n)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.NumberKind); ok {
64+ r0 = returnFunc(ctx, p, name, n)
65+ } else {
66+ r0 = ret.Get(0).(domain.NumberKind)
67+ }
68+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
69+ r1 = returnFunc(ctx, p, name, n)
70+ } else {
71+ r1 = ret.Error(1)
72+ }
73+ return r0, r1
74+}
75+
76+// IssueNumberResolver_Kind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Kind'
77+type IssueNumberResolver_Kind_Call struct {
78+ *mock.Call
79+}
80+
81+// Kind is a helper method to define mock.On call
82+// - ctx context.Context
83+// - p domain.Principal
84+// - name domain.RepoName
85+// - n int64
86+func (_e *IssueNumberResolver_Expecter) Kind(ctx any, p any, name any, n any) *IssueNumberResolver_Kind_Call {
87+ return &IssueNumberResolver_Kind_Call{Call: _e.mock.On("Kind", ctx, p, name, n)}
88+}
89+
90+func (_c *IssueNumberResolver_Kind_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, n int64)) *IssueNumberResolver_Kind_Call {
91+ _c.Call.Run(func(args mock.Arguments) {
92+ var arg0 context.Context
93+ if args[0] != nil {
94+ arg0 = args[0].(context.Context)
95+ }
96+ var arg1 domain.Principal
97+ if args[1] != nil {
98+ arg1 = args[1].(domain.Principal)
99+ }
100+ var arg2 domain.RepoName
101+ if args[2] != nil {
102+ arg2 = args[2].(domain.RepoName)
103+ }
104+ var arg3 int64
105+ if args[3] != nil {
106+ arg3 = args[3].(int64)
107+ }
108+ run(
109+ arg0,
110+ arg1,
111+ arg2,
112+ arg3,
113+ )
114+ })
115+ return _c
116+}
117+
118+func (_c *IssueNumberResolver_Kind_Call) Return(numberKind domain.NumberKind, err error) *IssueNumberResolver_Kind_Call {
119+ _c.Call.Return(numberKind, err)
120+ return _c
121+}
122+
123+func (_c *IssueNumberResolver_Kind_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error)) *IssueNumberResolver_Kind_Call {
124+ _c.Call.Return(run)
125+ return _c
126+}
A internal/ports/mocks/IssueService.go +523 -0
@@ -0,0 +1,523 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewIssueService creates a new instance of IssueService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewIssueService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *IssueService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &IssueService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// IssueService is an autogenerated mock type for the IssueService type
39+type IssueService struct {
40+ mock.Mock
41+}
42+
43+type IssueService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *IssueService) EXPECT() *IssueService_Expecter {
48+ return &IssueService_Expecter{mock: &_m.Mock}
49+}
50+
51+// AddComment provides a mock function for the type IssueService
52+func (_mock *IssueService) AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error) {
53+ ret := _mock.Called(ctx, p, name, number, text)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for AddComment")
57+ }
58+
59+ var r0 domain.IssueComment
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, string) (domain.IssueComment, error)); ok {
62+ return returnFunc(ctx, p, name, number, text)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, string) domain.IssueComment); ok {
65+ r0 = returnFunc(ctx, p, name, number, text)
66+ } else {
67+ r0 = ret.Get(0).(domain.IssueComment)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, string) error); ok {
70+ r1 = returnFunc(ctx, p, name, number, text)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// IssueService_AddComment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddComment'
78+type IssueService_AddComment_Call struct {
79+ *mock.Call
80+}
81+
82+// AddComment is a helper method to define mock.On call
83+// - ctx context.Context
84+// - p domain.Principal
85+// - name domain.RepoName
86+// - number int64
87+// - text string
88+func (_e *IssueService_Expecter) AddComment(ctx any, p any, name any, number any, text any) *IssueService_AddComment_Call {
89+ return &IssueService_AddComment_Call{Call: _e.mock.On("AddComment", ctx, p, name, number, text)}
90+}
91+
92+func (_c *IssueService_AddComment_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string)) *IssueService_AddComment_Call {
93+ _c.Call.Run(func(args mock.Arguments) {
94+ var arg0 context.Context
95+ if args[0] != nil {
96+ arg0 = args[0].(context.Context)
97+ }
98+ var arg1 domain.Principal
99+ if args[1] != nil {
100+ arg1 = args[1].(domain.Principal)
101+ }
102+ var arg2 domain.RepoName
103+ if args[2] != nil {
104+ arg2 = args[2].(domain.RepoName)
105+ }
106+ var arg3 int64
107+ if args[3] != nil {
108+ arg3 = args[3].(int64)
109+ }
110+ var arg4 string
111+ if args[4] != nil {
112+ arg4 = args[4].(string)
113+ }
114+ run(
115+ arg0,
116+ arg1,
117+ arg2,
118+ arg3,
119+ arg4,
120+ )
121+ })
122+ return _c
123+}
124+
125+func (_c *IssueService_AddComment_Call) Return(issueComment domain.IssueComment, err error) *IssueService_AddComment_Call {
126+ _c.Call.Return(issueComment, err)
127+ return _c
128+}
129+
130+func (_c *IssueService_AddComment_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error)) *IssueService_AddComment_Call {
131+ _c.Call.Return(run)
132+ return _c
133+}
134+
135+// Close provides a mock function for the type IssueService
136+func (_mock *IssueService) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
137+ ret := _mock.Called(ctx, p, name, number)
138+
139+ if len(ret) == 0 {
140+ panic("no return value specified for Close")
141+ }
142+
143+ var r0 domain.Issue
144+ var r1 error
145+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Issue, error)); ok {
146+ return returnFunc(ctx, p, name, number)
147+ }
148+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.Issue); ok {
149+ r0 = returnFunc(ctx, p, name, number)
150+ } else {
151+ r0 = ret.Get(0).(domain.Issue)
152+ }
153+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
154+ r1 = returnFunc(ctx, p, name, number)
155+ } else {
156+ r1 = ret.Error(1)
157+ }
158+ return r0, r1
159+}
160+
161+// IssueService_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
162+type IssueService_Close_Call struct {
163+ *mock.Call
164+}
165+
166+// Close is a helper method to define mock.On call
167+// - ctx context.Context
168+// - p domain.Principal
169+// - name domain.RepoName
170+// - number int64
171+func (_e *IssueService_Expecter) Close(ctx any, p any, name any, number any) *IssueService_Close_Call {
172+ return &IssueService_Close_Call{Call: _e.mock.On("Close", ctx, p, name, number)}
173+}
174+
175+func (_c *IssueService_Close_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *IssueService_Close_Call {
176+ _c.Call.Run(func(args mock.Arguments) {
177+ var arg0 context.Context
178+ if args[0] != nil {
179+ arg0 = args[0].(context.Context)
180+ }
181+ var arg1 domain.Principal
182+ if args[1] != nil {
183+ arg1 = args[1].(domain.Principal)
184+ }
185+ var arg2 domain.RepoName
186+ if args[2] != nil {
187+ arg2 = args[2].(domain.RepoName)
188+ }
189+ var arg3 int64
190+ if args[3] != nil {
191+ arg3 = args[3].(int64)
192+ }
193+ run(
194+ arg0,
195+ arg1,
196+ arg2,
197+ arg3,
198+ )
199+ })
200+ return _c
201+}
202+
203+func (_c *IssueService_Close_Call) Return(issue domain.Issue, err error) *IssueService_Close_Call {
204+ _c.Call.Return(issue, err)
205+ return _c
206+}
207+
208+func (_c *IssueService_Close_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error)) *IssueService_Close_Call {
209+ _c.Call.Return(run)
210+ return _c
211+}
212+
213+// Create provides a mock function for the type IssueService
214+func (_mock *IssueService) Create(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateIssueParams) (domain.Issue, error) {
215+ ret := _mock.Called(ctx, p, name, params)
216+
217+ if len(ret) == 0 {
218+ panic("no return value specified for Create")
219+ }
220+
221+ var r0 domain.Issue
222+ var r1 error
223+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateIssueParams) (domain.Issue, error)); ok {
224+ return returnFunc(ctx, p, name, params)
225+ }
226+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateIssueParams) domain.Issue); ok {
227+ r0 = returnFunc(ctx, p, name, params)
228+ } else {
229+ r0 = ret.Get(0).(domain.Issue)
230+ }
231+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateIssueParams) error); ok {
232+ r1 = returnFunc(ctx, p, name, params)
233+ } else {
234+ r1 = ret.Error(1)
235+ }
236+ return r0, r1
237+}
238+
239+// IssueService_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create'
240+type IssueService_Create_Call struct {
241+ *mock.Call
242+}
243+
244+// Create is a helper method to define mock.On call
245+// - ctx context.Context
246+// - p domain.Principal
247+// - name domain.RepoName
248+// - params ports.CreateIssueParams
249+func (_e *IssueService_Expecter) Create(ctx any, p any, name any, params any) *IssueService_Create_Call {
250+ return &IssueService_Create_Call{Call: _e.mock.On("Create", ctx, p, name, params)}
251+}
252+
253+func (_c *IssueService_Create_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateIssueParams)) *IssueService_Create_Call {
254+ _c.Call.Run(func(args mock.Arguments) {
255+ var arg0 context.Context
256+ if args[0] != nil {
257+ arg0 = args[0].(context.Context)
258+ }
259+ var arg1 domain.Principal
260+ if args[1] != nil {
261+ arg1 = args[1].(domain.Principal)
262+ }
263+ var arg2 domain.RepoName
264+ if args[2] != nil {
265+ arg2 = args[2].(domain.RepoName)
266+ }
267+ var arg3 ports.CreateIssueParams
268+ if args[3] != nil {
269+ arg3 = args[3].(ports.CreateIssueParams)
270+ }
271+ run(
272+ arg0,
273+ arg1,
274+ arg2,
275+ arg3,
276+ )
277+ })
278+ return _c
279+}
280+
281+func (_c *IssueService_Create_Call) Return(issue domain.Issue, err error) *IssueService_Create_Call {
282+ _c.Call.Return(issue, err)
283+ return _c
284+}
285+
286+func (_c *IssueService_Create_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateIssueParams) (domain.Issue, error)) *IssueService_Create_Call {
287+ _c.Call.Return(run)
288+ return _c
289+}
290+
291+// Get provides a mock function for the type IssueService
292+func (_mock *IssueService) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (ports.IssueDetail, error) {
293+ ret := _mock.Called(ctx, p, name, number)
294+
295+ if len(ret) == 0 {
296+ panic("no return value specified for Get")
297+ }
298+
299+ var r0 ports.IssueDetail
300+ var r1 error
301+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (ports.IssueDetail, error)); ok {
302+ return returnFunc(ctx, p, name, number)
303+ }
304+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) ports.IssueDetail); ok {
305+ r0 = returnFunc(ctx, p, name, number)
306+ } else {
307+ r0 = ret.Get(0).(ports.IssueDetail)
308+ }
309+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
310+ r1 = returnFunc(ctx, p, name, number)
311+ } else {
312+ r1 = ret.Error(1)
313+ }
314+ return r0, r1
315+}
316+
317+// IssueService_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
318+type IssueService_Get_Call struct {
319+ *mock.Call
320+}
321+
322+// Get is a helper method to define mock.On call
323+// - ctx context.Context
324+// - p domain.Principal
325+// - name domain.RepoName
326+// - number int64
327+func (_e *IssueService_Expecter) Get(ctx any, p any, name any, number any) *IssueService_Get_Call {
328+ return &IssueService_Get_Call{Call: _e.mock.On("Get", ctx, p, name, number)}
329+}
330+
331+func (_c *IssueService_Get_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *IssueService_Get_Call {
332+ _c.Call.Run(func(args mock.Arguments) {
333+ var arg0 context.Context
334+ if args[0] != nil {
335+ arg0 = args[0].(context.Context)
336+ }
337+ var arg1 domain.Principal
338+ if args[1] != nil {
339+ arg1 = args[1].(domain.Principal)
340+ }
341+ var arg2 domain.RepoName
342+ if args[2] != nil {
343+ arg2 = args[2].(domain.RepoName)
344+ }
345+ var arg3 int64
346+ if args[3] != nil {
347+ arg3 = args[3].(int64)
348+ }
349+ run(
350+ arg0,
351+ arg1,
352+ arg2,
353+ arg3,
354+ )
355+ })
356+ return _c
357+}
358+
359+func (_c *IssueService_Get_Call) Return(issueDetail ports.IssueDetail, err error) *IssueService_Get_Call {
360+ _c.Call.Return(issueDetail, err)
361+ return _c
362+}
363+
364+func (_c *IssueService_Get_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (ports.IssueDetail, error)) *IssueService_Get_Call {
365+ _c.Call.Return(run)
366+ return _c
367+}
368+
369+// List provides a mock function for the type IssueService
370+func (_mock *IssueService) List(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
371+ ret := _mock.Called(ctx, p, name, params)
372+
373+ if len(ret) == 0 {
374+ panic("no return value specified for List")
375+ }
376+
377+ var r0 ports.IssuePage
378+ var r1 error
379+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListIssuesParams) (ports.IssuePage, error)); ok {
380+ return returnFunc(ctx, p, name, params)
381+ }
382+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListIssuesParams) ports.IssuePage); ok {
383+ r0 = returnFunc(ctx, p, name, params)
384+ } else {
385+ r0 = ret.Get(0).(ports.IssuePage)
386+ }
387+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.ListIssuesParams) error); ok {
388+ r1 = returnFunc(ctx, p, name, params)
389+ } else {
390+ r1 = ret.Error(1)
391+ }
392+ return r0, r1
393+}
394+
395+// IssueService_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
396+type IssueService_List_Call struct {
397+ *mock.Call
398+}
399+
400+// List is a helper method to define mock.On call
401+// - ctx context.Context
402+// - p domain.Principal
403+// - name domain.RepoName
404+// - params ports.ListIssuesParams
405+func (_e *IssueService_Expecter) List(ctx any, p any, name any, params any) *IssueService_List_Call {
406+ return &IssueService_List_Call{Call: _e.mock.On("List", ctx, p, name, params)}
407+}
408+
409+func (_c *IssueService_List_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListIssuesParams)) *IssueService_List_Call {
410+ _c.Call.Run(func(args mock.Arguments) {
411+ var arg0 context.Context
412+ if args[0] != nil {
413+ arg0 = args[0].(context.Context)
414+ }
415+ var arg1 domain.Principal
416+ if args[1] != nil {
417+ arg1 = args[1].(domain.Principal)
418+ }
419+ var arg2 domain.RepoName
420+ if args[2] != nil {
421+ arg2 = args[2].(domain.RepoName)
422+ }
423+ var arg3 ports.ListIssuesParams
424+ if args[3] != nil {
425+ arg3 = args[3].(ports.ListIssuesParams)
426+ }
427+ run(
428+ arg0,
429+ arg1,
430+ arg2,
431+ arg3,
432+ )
433+ })
434+ return _c
435+}
436+
437+func (_c *IssueService_List_Call) Return(issuePage ports.IssuePage, err error) *IssueService_List_Call {
438+ _c.Call.Return(issuePage, err)
439+ return _c
440+}
441+
442+func (_c *IssueService_List_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error)) *IssueService_List_Call {
443+ _c.Call.Return(run)
444+ return _c
445+}
446+
447+// Reopen provides a mock function for the type IssueService
448+func (_mock *IssueService) Reopen(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
449+ ret := _mock.Called(ctx, p, name, number)
450+
451+ if len(ret) == 0 {
452+ panic("no return value specified for Reopen")
453+ }
454+
455+ var r0 domain.Issue
456+ var r1 error
457+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Issue, error)); ok {
458+ return returnFunc(ctx, p, name, number)
459+ }
460+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.Issue); ok {
461+ r0 = returnFunc(ctx, p, name, number)
462+ } else {
463+ r0 = ret.Get(0).(domain.Issue)
464+ }
465+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
466+ r1 = returnFunc(ctx, p, name, number)
467+ } else {
468+ r1 = ret.Error(1)
469+ }
470+ return r0, r1
471+}
472+
473+// IssueService_Reopen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reopen'
474+type IssueService_Reopen_Call struct {
475+ *mock.Call
476+}
477+
478+// Reopen is a helper method to define mock.On call
479+// - ctx context.Context
480+// - p domain.Principal
481+// - name domain.RepoName
482+// - number int64
483+func (_e *IssueService_Expecter) Reopen(ctx any, p any, name any, number any) *IssueService_Reopen_Call {
484+ return &IssueService_Reopen_Call{Call: _e.mock.On("Reopen", ctx, p, name, number)}
485+}
486+
487+func (_c *IssueService_Reopen_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *IssueService_Reopen_Call {
488+ _c.Call.Run(func(args mock.Arguments) {
489+ var arg0 context.Context
490+ if args[0] != nil {
491+ arg0 = args[0].(context.Context)
492+ }
493+ var arg1 domain.Principal
494+ if args[1] != nil {
495+ arg1 = args[1].(domain.Principal)
496+ }
497+ var arg2 domain.RepoName
498+ if args[2] != nil {
499+ arg2 = args[2].(domain.RepoName)
500+ }
501+ var arg3 int64
502+ if args[3] != nil {
503+ arg3 = args[3].(int64)
504+ }
505+ run(
506+ arg0,
507+ arg1,
508+ arg2,
509+ arg3,
510+ )
511+ })
512+ return _c
513+}
514+
515+func (_c *IssueService_Reopen_Call) Return(issue domain.Issue, err error) *IssueService_Reopen_Call {
516+ _c.Call.Return(issue, err)
517+ return _c
518+}
519+
520+func (_c *IssueService_Reopen_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error)) *IssueService_Reopen_Call {
521+ _c.Call.Return(run)
522+ return _c
523+}
@@ -0,0 +1,523 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewIssueService creates a new instance of IssueService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewIssueService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *IssueService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &IssueService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// IssueService is an autogenerated mock type for the IssueService type
39+type IssueService struct {
40+ mock.Mock
41+}
42+
43+type IssueService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *IssueService) EXPECT() *IssueService_Expecter {
48+ return &IssueService_Expecter{mock: &_m.Mock}
49+}
50+
51+// AddComment provides a mock function for the type IssueService
52+func (_mock *IssueService) AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error) {
53+ ret := _mock.Called(ctx, p, name, number, text)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for AddComment")
57+ }
58+
59+ var r0 domain.IssueComment
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, string) (domain.IssueComment, error)); ok {
62+ return returnFunc(ctx, p, name, number, text)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, string) domain.IssueComment); ok {
65+ r0 = returnFunc(ctx, p, name, number, text)
66+ } else {
67+ r0 = ret.Get(0).(domain.IssueComment)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, string) error); ok {
70+ r1 = returnFunc(ctx, p, name, number, text)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// IssueService_AddComment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddComment'
78+type IssueService_AddComment_Call struct {
79+ *mock.Call
80+}
81+
82+// AddComment is a helper method to define mock.On call
83+// - ctx context.Context
84+// - p domain.Principal
85+// - name domain.RepoName
86+// - number int64
87+// - text string
88+func (_e *IssueService_Expecter) AddComment(ctx any, p any, name any, number any, text any) *IssueService_AddComment_Call {
89+ return &IssueService_AddComment_Call{Call: _e.mock.On("AddComment", ctx, p, name, number, text)}
90+}
91+
92+func (_c *IssueService_AddComment_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string)) *IssueService_AddComment_Call {
93+ _c.Call.Run(func(args mock.Arguments) {
94+ var arg0 context.Context
95+ if args[0] != nil {
96+ arg0 = args[0].(context.Context)
97+ }
98+ var arg1 domain.Principal
99+ if args[1] != nil {
100+ arg1 = args[1].(domain.Principal)
101+ }
102+ var arg2 domain.RepoName
103+ if args[2] != nil {
104+ arg2 = args[2].(domain.RepoName)
105+ }
106+ var arg3 int64
107+ if args[3] != nil {
108+ arg3 = args[3].(int64)
109+ }
110+ var arg4 string
111+ if args[4] != nil {
112+ arg4 = args[4].(string)
113+ }
114+ run(
115+ arg0,
116+ arg1,
117+ arg2,
118+ arg3,
119+ arg4,
120+ )
121+ })
122+ return _c
123+}
124+
125+func (_c *IssueService_AddComment_Call) Return(issueComment domain.IssueComment, err error) *IssueService_AddComment_Call {
126+ _c.Call.Return(issueComment, err)
127+ return _c
128+}
129+
130+func (_c *IssueService_AddComment_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error)) *IssueService_AddComment_Call {
131+ _c.Call.Return(run)
132+ return _c
133+}
134+
135+// Close provides a mock function for the type IssueService
136+func (_mock *IssueService) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
137+ ret := _mock.Called(ctx, p, name, number)
138+
139+ if len(ret) == 0 {
140+ panic("no return value specified for Close")
141+ }
142+
143+ var r0 domain.Issue
144+ var r1 error
145+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Issue, error)); ok {
146+ return returnFunc(ctx, p, name, number)
147+ }
148+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.Issue); ok {
149+ r0 = returnFunc(ctx, p, name, number)
150+ } else {
151+ r0 = ret.Get(0).(domain.Issue)
152+ }
153+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
154+ r1 = returnFunc(ctx, p, name, number)
155+ } else {
156+ r1 = ret.Error(1)
157+ }
158+ return r0, r1
159+}
160+
161+// IssueService_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
162+type IssueService_Close_Call struct {
163+ *mock.Call
164+}
165+
166+// Close is a helper method to define mock.On call
167+// - ctx context.Context
168+// - p domain.Principal
169+// - name domain.RepoName
170+// - number int64
171+func (_e *IssueService_Expecter) Close(ctx any, p any, name any, number any) *IssueService_Close_Call {
172+ return &IssueService_Close_Call{Call: _e.mock.On("Close", ctx, p, name, number)}
173+}
174+
175+func (_c *IssueService_Close_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *IssueService_Close_Call {
176+ _c.Call.Run(func(args mock.Arguments) {
177+ var arg0 context.Context
178+ if args[0] != nil {
179+ arg0 = args[0].(context.Context)
180+ }
181+ var arg1 domain.Principal
182+ if args[1] != nil {
183+ arg1 = args[1].(domain.Principal)
184+ }
185+ var arg2 domain.RepoName
186+ if args[2] != nil {
187+ arg2 = args[2].(domain.RepoName)
188+ }
189+ var arg3 int64
190+ if args[3] != nil {
191+ arg3 = args[3].(int64)
192+ }
193+ run(
194+ arg0,
195+ arg1,
196+ arg2,
197+ arg3,
198+ )
199+ })
200+ return _c
201+}
202+
203+func (_c *IssueService_Close_Call) Return(issue domain.Issue, err error) *IssueService_Close_Call {
204+ _c.Call.Return(issue, err)
205+ return _c
206+}
207+
208+func (_c *IssueService_Close_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error)) *IssueService_Close_Call {
209+ _c.Call.Return(run)
210+ return _c
211+}
212+
213+// Create provides a mock function for the type IssueService
214+func (_mock *IssueService) Create(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateIssueParams) (domain.Issue, error) {
215+ ret := _mock.Called(ctx, p, name, params)
216+
217+ if len(ret) == 0 {
218+ panic("no return value specified for Create")
219+ }
220+
221+ var r0 domain.Issue
222+ var r1 error
223+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateIssueParams) (domain.Issue, error)); ok {
224+ return returnFunc(ctx, p, name, params)
225+ }
226+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateIssueParams) domain.Issue); ok {
227+ r0 = returnFunc(ctx, p, name, params)
228+ } else {
229+ r0 = ret.Get(0).(domain.Issue)
230+ }
231+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateIssueParams) error); ok {
232+ r1 = returnFunc(ctx, p, name, params)
233+ } else {
234+ r1 = ret.Error(1)
235+ }
236+ return r0, r1
237+}
238+
239+// IssueService_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create'
240+type IssueService_Create_Call struct {
241+ *mock.Call
242+}
243+
244+// Create is a helper method to define mock.On call
245+// - ctx context.Context
246+// - p domain.Principal
247+// - name domain.RepoName
248+// - params ports.CreateIssueParams
249+func (_e *IssueService_Expecter) Create(ctx any, p any, name any, params any) *IssueService_Create_Call {
250+ return &IssueService_Create_Call{Call: _e.mock.On("Create", ctx, p, name, params)}
251+}
252+
253+func (_c *IssueService_Create_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateIssueParams)) *IssueService_Create_Call {
254+ _c.Call.Run(func(args mock.Arguments) {
255+ var arg0 context.Context
256+ if args[0] != nil {
257+ arg0 = args[0].(context.Context)
258+ }
259+ var arg1 domain.Principal
260+ if args[1] != nil {
261+ arg1 = args[1].(domain.Principal)
262+ }
263+ var arg2 domain.RepoName
264+ if args[2] != nil {
265+ arg2 = args[2].(domain.RepoName)
266+ }
267+ var arg3 ports.CreateIssueParams
268+ if args[3] != nil {
269+ arg3 = args[3].(ports.CreateIssueParams)
270+ }
271+ run(
272+ arg0,
273+ arg1,
274+ arg2,
275+ arg3,
276+ )
277+ })
278+ return _c
279+}
280+
281+func (_c *IssueService_Create_Call) Return(issue domain.Issue, err error) *IssueService_Create_Call {
282+ _c.Call.Return(issue, err)
283+ return _c
284+}
285+
286+func (_c *IssueService_Create_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateIssueParams) (domain.Issue, error)) *IssueService_Create_Call {
287+ _c.Call.Return(run)
288+ return _c
289+}
290+
291+// Get provides a mock function for the type IssueService
292+func (_mock *IssueService) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (ports.IssueDetail, error) {
293+ ret := _mock.Called(ctx, p, name, number)
294+
295+ if len(ret) == 0 {
296+ panic("no return value specified for Get")
297+ }
298+
299+ var r0 ports.IssueDetail
300+ var r1 error
301+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (ports.IssueDetail, error)); ok {
302+ return returnFunc(ctx, p, name, number)
303+ }
304+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) ports.IssueDetail); ok {
305+ r0 = returnFunc(ctx, p, name, number)
306+ } else {
307+ r0 = ret.Get(0).(ports.IssueDetail)
308+ }
309+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
310+ r1 = returnFunc(ctx, p, name, number)
311+ } else {
312+ r1 = ret.Error(1)
313+ }
314+ return r0, r1
315+}
316+
317+// IssueService_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
318+type IssueService_Get_Call struct {
319+ *mock.Call
320+}
321+
322+// Get is a helper method to define mock.On call
323+// - ctx context.Context
324+// - p domain.Principal
325+// - name domain.RepoName
326+// - number int64
327+func (_e *IssueService_Expecter) Get(ctx any, p any, name any, number any) *IssueService_Get_Call {
328+ return &IssueService_Get_Call{Call: _e.mock.On("Get", ctx, p, name, number)}
329+}
330+
331+func (_c *IssueService_Get_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *IssueService_Get_Call {
332+ _c.Call.Run(func(args mock.Arguments) {
333+ var arg0 context.Context
334+ if args[0] != nil {
335+ arg0 = args[0].(context.Context)
336+ }
337+ var arg1 domain.Principal
338+ if args[1] != nil {
339+ arg1 = args[1].(domain.Principal)
340+ }
341+ var arg2 domain.RepoName
342+ if args[2] != nil {
343+ arg2 = args[2].(domain.RepoName)
344+ }
345+ var arg3 int64
346+ if args[3] != nil {
347+ arg3 = args[3].(int64)
348+ }
349+ run(
350+ arg0,
351+ arg1,
352+ arg2,
353+ arg3,
354+ )
355+ })
356+ return _c
357+}
358+
359+func (_c *IssueService_Get_Call) Return(issueDetail ports.IssueDetail, err error) *IssueService_Get_Call {
360+ _c.Call.Return(issueDetail, err)
361+ return _c
362+}
363+
364+func (_c *IssueService_Get_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (ports.IssueDetail, error)) *IssueService_Get_Call {
365+ _c.Call.Return(run)
366+ return _c
367+}
368+
369+// List provides a mock function for the type IssueService
370+func (_mock *IssueService) List(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
371+ ret := _mock.Called(ctx, p, name, params)
372+
373+ if len(ret) == 0 {
374+ panic("no return value specified for List")
375+ }
376+
377+ var r0 ports.IssuePage
378+ var r1 error
379+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListIssuesParams) (ports.IssuePage, error)); ok {
380+ return returnFunc(ctx, p, name, params)
381+ }
382+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListIssuesParams) ports.IssuePage); ok {
383+ r0 = returnFunc(ctx, p, name, params)
384+ } else {
385+ r0 = ret.Get(0).(ports.IssuePage)
386+ }
387+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.ListIssuesParams) error); ok {
388+ r1 = returnFunc(ctx, p, name, params)
389+ } else {
390+ r1 = ret.Error(1)
391+ }
392+ return r0, r1
393+}
394+
395+// IssueService_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
396+type IssueService_List_Call struct {
397+ *mock.Call
398+}
399+
400+// List is a helper method to define mock.On call
401+// - ctx context.Context
402+// - p domain.Principal
403+// - name domain.RepoName
404+// - params ports.ListIssuesParams
405+func (_e *IssueService_Expecter) List(ctx any, p any, name any, params any) *IssueService_List_Call {
406+ return &IssueService_List_Call{Call: _e.mock.On("List", ctx, p, name, params)}
407+}
408+
409+func (_c *IssueService_List_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListIssuesParams)) *IssueService_List_Call {
410+ _c.Call.Run(func(args mock.Arguments) {
411+ var arg0 context.Context
412+ if args[0] != nil {
413+ arg0 = args[0].(context.Context)
414+ }
415+ var arg1 domain.Principal
416+ if args[1] != nil {
417+ arg1 = args[1].(domain.Principal)
418+ }
419+ var arg2 domain.RepoName
420+ if args[2] != nil {
421+ arg2 = args[2].(domain.RepoName)
422+ }
423+ var arg3 ports.ListIssuesParams
424+ if args[3] != nil {
425+ arg3 = args[3].(ports.ListIssuesParams)
426+ }
427+ run(
428+ arg0,
429+ arg1,
430+ arg2,
431+ arg3,
432+ )
433+ })
434+ return _c
435+}
436+
437+func (_c *IssueService_List_Call) Return(issuePage ports.IssuePage, err error) *IssueService_List_Call {
438+ _c.Call.Return(issuePage, err)
439+ return _c
440+}
441+
442+func (_c *IssueService_List_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error)) *IssueService_List_Call {
443+ _c.Call.Return(run)
444+ return _c
445+}
446+
447+// Reopen provides a mock function for the type IssueService
448+func (_mock *IssueService) Reopen(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
449+ ret := _mock.Called(ctx, p, name, number)
450+
451+ if len(ret) == 0 {
452+ panic("no return value specified for Reopen")
453+ }
454+
455+ var r0 domain.Issue
456+ var r1 error
457+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Issue, error)); ok {
458+ return returnFunc(ctx, p, name, number)
459+ }
460+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.Issue); ok {
461+ r0 = returnFunc(ctx, p, name, number)
462+ } else {
463+ r0 = ret.Get(0).(domain.Issue)
464+ }
465+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
466+ r1 = returnFunc(ctx, p, name, number)
467+ } else {
468+ r1 = ret.Error(1)
469+ }
470+ return r0, r1
471+}
472+
473+// IssueService_Reopen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reopen'
474+type IssueService_Reopen_Call struct {
475+ *mock.Call
476+}
477+
478+// Reopen is a helper method to define mock.On call
479+// - ctx context.Context
480+// - p domain.Principal
481+// - name domain.RepoName
482+// - number int64
483+func (_e *IssueService_Expecter) Reopen(ctx any, p any, name any, number any) *IssueService_Reopen_Call {
484+ return &IssueService_Reopen_Call{Call: _e.mock.On("Reopen", ctx, p, name, number)}
485+}
486+
487+func (_c *IssueService_Reopen_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *IssueService_Reopen_Call {
488+ _c.Call.Run(func(args mock.Arguments) {
489+ var arg0 context.Context
490+ if args[0] != nil {
491+ arg0 = args[0].(context.Context)
492+ }
493+ var arg1 domain.Principal
494+ if args[1] != nil {
495+ arg1 = args[1].(domain.Principal)
496+ }
497+ var arg2 domain.RepoName
498+ if args[2] != nil {
499+ arg2 = args[2].(domain.RepoName)
500+ }
501+ var arg3 int64
502+ if args[3] != nil {
503+ arg3 = args[3].(int64)
504+ }
505+ run(
506+ arg0,
507+ arg1,
508+ arg2,
509+ arg3,
510+ )
511+ })
512+ return _c
513+}
514+
515+func (_c *IssueService_Reopen_Call) Return(issue domain.Issue, err error) *IssueService_Reopen_Call {
516+ _c.Call.Return(issue, err)
517+ return _c
518+}
519+
520+func (_c *IssueService_Reopen_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error)) *IssueService_Reopen_Call {
521+ _c.Call.Return(run)
522+ return _c
523+}
M internal/ports/services.go +20 -0
@@ -124,6 +124,26 @@ type MergeRequestService interface {
124124 CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error)
125125 }
126126
127+// IssueService is the issue use cases. *usecase.Issues implements it.
128+type IssueService interface {
129+ Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateIssueParams) (domain.Issue, error)
130+ List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListIssuesParams) (IssuePage, error)
131+ // Get returns the issue with its comments, oldest first.
132+ Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (IssueDetail, error)
133+ AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error)
134+ Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error)
135+ Reopen(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error)
136+}
137+
138+// IssueNumberResolver tells whether a number of a repository names an issue or
139+// a merge request, for the callers that follow a "#n" reference. *usecase.Issues
140+// implements it.
141+type IssueNumberResolver interface {
142+ // Kind needs the access of reading the repository and answers
143+ // domain.ErrNotFound otherwise, also when issues are off.
144+ Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error)
145+}
146+
127147 // SigningKeyService tells which key signs the commits of the forge.
128148 // *usecase.SigningKey implements it.
129149 type SigningKeyService interface {
@@ -124,6 +124,26 @@ type MergeRequestService interface {
124 CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error)124 CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error)
125 }125 }
126 126
127+// IssueService is the issue use cases. *usecase.Issues implements it.
128+type IssueService interface {
129+ Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateIssueParams) (domain.Issue, error)
130+ List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListIssuesParams) (IssuePage, error)
131+ // Get returns the issue with its comments, oldest first.
132+ Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (IssueDetail, error)
133+ AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error)
134+ Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error)
135+ Reopen(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error)
136+}
137+
138+// IssueNumberResolver tells whether a number of a repository names an issue or
139+// a merge request, for the callers that follow a "#n" reference. *usecase.Issues
140+// implements it.
141+type IssueNumberResolver interface {
142+ // Kind needs the access of reading the repository and answers
143+ // domain.ErrNotFound otherwise, also when issues are off.
144+ Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error)
145+}
146+
127 // SigningKeyService tells which key signs the commits of the forge.147 // SigningKeyService tells which key signs the commits of the forge.
128 // *usecase.SigningKey implements it.148 // *usecase.SigningKey implements it.
129 type SigningKeyService interface {149 type SigningKeyService interface {
M internal/usecase/events.go +16 -0
@@ -178,6 +178,14 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
178178 return repoSnapshot(ev.Visibility, ev.CreatedAt)
179179 case domain.CommentAdded:
180180 return repoSnapshot(ev.Visibility, ev.CreatedAt)
181+ case domain.IssueOpened:
182+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
183+ case domain.IssueClosed:
184+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
185+ case domain.IssueReopened:
186+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
187+ case domain.IssueCommented:
188+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
181189 case domain.RunUpdated:
182190 return repoSnapshot(ev.Visibility, ev.CreatedAt)
183191 case domain.JobUpdated:
@@ -487,6 +495,14 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
487495 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
488496 case domain.CommentAdded:
489497 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
498+ case domain.IssueOpened:
499+ return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
500+ case domain.IssueClosed:
501+ return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
502+ case domain.IssueReopened:
503+ return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
504+ case domain.IssueCommented:
505+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
490506 case domain.RunUpdated:
491507 // Home shows the CI state of each repository.
492508 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)
@@ -178,6 +178,14 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
178 return repoSnapshot(ev.Visibility, ev.CreatedAt)178 return repoSnapshot(ev.Visibility, ev.CreatedAt)
179 case domain.CommentAdded:179 case domain.CommentAdded:
180 return repoSnapshot(ev.Visibility, ev.CreatedAt)180 return repoSnapshot(ev.Visibility, ev.CreatedAt)
181+ case domain.IssueOpened:
182+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
183+ case domain.IssueClosed:
184+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
185+ case domain.IssueReopened:
186+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
187+ case domain.IssueCommented:
188+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
181 case domain.RunUpdated:189 case domain.RunUpdated:
182 return repoSnapshot(ev.Visibility, ev.CreatedAt)190 return repoSnapshot(ev.Visibility, ev.CreatedAt)
183 case domain.JobUpdated:191 case domain.JobUpdated:
@@ -487,6 +495,14 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
487 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)495 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
488 case domain.CommentAdded:496 case domain.CommentAdded:
489 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)497 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
498+ case domain.IssueOpened:
499+ return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
500+ case domain.IssueClosed:
501+ return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
502+ case domain.IssueReopened:
503+ return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
504+ case domain.IssueCommented:
505+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
490 case domain.RunUpdated:506 case domain.RunUpdated:
491 // Home shows the CI state of each repository.507 // Home shows the CI state of each repository.
492 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)508 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)
A internal/usecase/issues.go +289 -0
@@ -0,0 +1,289 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ "github.com/bnema/zerowrap"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// MaxCommentsPerIssue is the most comments an issue holds. Past it AddComment
17+// fails with domain.ErrResourceExhausted. Comments are never deleted, so the
18+// limit counts the ones ever added.
19+const MaxCommentsPerIssue = 1000
20+
21+// Issues are the issue use cases (ADR 0031): open, comment on, close and
22+// reopen an issue. Issues share their numbers with the merge requests of the
23+// repository. A repository whose issues mode is off answers domain.ErrNotFound
24+// to everyone, the owner included, like a repository that does not exist.
25+// Reading needs the access of reading the repository (canRead), writing needs
26+// issue:write on it, which the branch prefixes of a token do not restrict:
27+// an issue writes no ref.
28+type Issues struct {
29+ meta ports.RepoMetaStore
30+ store ports.IssueStore
31+ mrs ports.MergeRequestStore
32+ bus ports.EventBus
33+ clock ports.Clock
34+}
35+
36+var (
37+ _ ports.IssueService = (*Issues)(nil)
38+ _ ports.IssueNumberResolver = (*Issues)(nil)
39+)
40+
41+// IssuesConfig holds what the issue use cases need. Every field is required.
42+type IssuesConfig struct {
43+ Meta ports.RepoMetaStore
44+ Store ports.IssueStore
45+ // MergeRequests tells which numbers name a merge request (Kind).
46+ MergeRequests ports.MergeRequestStore
47+ Bus ports.EventBus
48+ Clock ports.Clock
49+}
50+
51+// NewIssues builds the issue use cases.
52+func NewIssues(cfg IssuesConfig) *Issues {
53+ return &Issues{meta: cfg.Meta, store: cfg.Store, mrs: cfg.MergeRequests, bus: cfg.Bus, clock: cfg.Clock}
54+}
55+
56+// enabled looks the repository up and hides it when issues are off.
57+func (i *Issues) enabled(ctx context.Context, name domain.RepoName) (domain.Repo, error) {
58+ repo, err := lookupRepo(ctx, i.meta, name)
59+ if err != nil {
60+ return domain.Repo{}, err
61+ }
62+ if repo.Settings.Issues.OrDefault() == domain.IssuesOff {
63+ return domain.Repo{}, domain.ErrNotFound
64+ }
65+ return repo, nil
66+}
67+
68+// readable applies the read rule: the access of reading the repository.
69+func (i *Issues) readable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
70+ repo, err := i.enabled(ctx, name)
71+ if err != nil {
72+ return domain.Repo{}, err
73+ }
74+ if !canRead(p, repo) {
75+ return domain.Repo{}, domain.ErrNotFound
76+ }
77+ return repo, nil
78+}
79+
80+// writable is the write rule, as MergeRequests.writable: a principal that can
81+// neither read the repository nor holds the scope on it gets
82+// domain.ErrNotFound, one that cannot write the error of scopeAccess.
83+func (i *Issues) writable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
84+ repo, err := i.enabled(ctx, name)
85+ if err != nil {
86+ return domain.Repo{}, err
87+ }
88+ if !canRead(p, repo) && !p.Allows(domain.ScopeIssueWrite, name) {
89+ return domain.Repo{}, domain.ErrNotFound
90+ }
91+ if err := scopeAccess(p, repo, domain.ScopeIssueWrite); err != nil {
92+ return domain.Repo{}, err
93+ }
94+ return repo, nil
95+}
96+
97+// Create opens an issue and publishes IssueOpened. The principal needs
98+// issue:write. Invalid texts give an *domain.InvalidFieldsError.
99+func (i *Issues) Create(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateIssueParams) (domain.Issue, error) {
100+ repo, err := i.writable(ctx, p, name)
101+ if err != nil {
102+ return domain.Issue{}, err
103+ }
104+ var v domain.Violations
105+ title, err := domain.ParseIssueTitle(params.Title)
106+ if err != nil {
107+ v.AddErr("title", err)
108+ }
109+ body, err := domain.ParseIssueBody(params.Body)
110+ if err != nil {
111+ v.AddErr("body", err)
112+ }
113+ if err := v.Err(); err != nil {
114+ return domain.Issue{}, err
115+ }
116+ now := i.clock.Now().UTC()
117+ issue, err := i.store.Create(ctx, domain.Issue{
118+ Repo: name, Title: title, Body: body, State: domain.IssueStateOpen,
119+ Author: authorOf(p), CreatedAt: now, UpdatedAt: now,
120+ })
121+ if err != nil {
122+ return domain.Issue{}, fmt.Errorf("create issue of %q: %w", name, err)
123+ }
124+ log := zerowrap.FromCtx(ctx)
125+ log.Info().Str("repo", name.String()).Int64("number", issue.Number).
126+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue opened")
127+ i.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
128+ return issue, nil
129+}
130+
131+// List returns a page of the issues of a repository, highest number first.
132+func (i *Issues) List(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
133+ size, err := pageSize(params.PageSize)
134+ if err != nil {
135+ return ports.IssuePage{}, err
136+ }
137+ if _, err := i.readable(ctx, p, name); err != nil {
138+ return ports.IssuePage{}, err
139+ }
140+ params.PageSize = size
141+ page, err := i.store.List(ctx, name, params)
142+ if err != nil {
143+ return ports.IssuePage{}, fmt.Errorf("list issues of %q: %w", name, err)
144+ }
145+ return page, nil
146+}
147+
148+// Get returns an issue with its comments.
149+func (i *Issues) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (ports.IssueDetail, error) {
150+ if _, err := i.readable(ctx, p, name); err != nil {
151+ return ports.IssueDetail{}, err
152+ }
153+ issue, err := i.store.Get(ctx, name, number)
154+ if err != nil {
155+ return ports.IssueDetail{}, err
156+ }
157+ comments, err := i.store.ListComments(ctx, name, number)
158+ if err != nil {
159+ return ports.IssueDetail{}, fmt.Errorf("list comments of #%d of %q: %w", number, name, err)
160+ }
161+ return ports.IssueDetail{Issue: issue, Comments: comments}, nil
162+}
163+
164+// AddComment adds a comment and publishes IssueCommented. The principal needs
165+// issue:write. The text must not be blank. A comment may follow a closed
166+// issue; one that already holds MaxCommentsPerIssue comments gives
167+// domain.ErrResourceExhausted.
168+func (i *Issues) AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error) {
169+ repo, err := i.writable(ctx, p, name)
170+ if err != nil {
171+ return domain.IssueComment{}, err
172+ }
173+ issue, err := i.store.Get(ctx, name, number)
174+ if err != nil {
175+ return domain.IssueComment{}, err
176+ }
177+ var v domain.Violations
178+ body, err := domain.ParseIssueBody(text)
179+ switch {
180+ case err != nil:
181+ v.AddErr("text", err)
182+ case body.IsBlank():
183+ v.Add("text", "is required")
184+ }
185+ if err := v.Err(); err != nil {
186+ return domain.IssueComment{}, err
187+ }
188+ var c domain.IssueComment
189+ for range maxWriteRetries {
190+ if issue.State == domain.IssueStatePending {
191+ return domain.IssueComment{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State)
192+ }
193+ if issue.CommentSeq >= MaxCommentsPerIssue {
194+ return domain.IssueComment{}, fmt.Errorf("%w: #%d already has %d comments", domain.ErrResourceExhausted, number, MaxCommentsPerIssue)
195+ }
196+ c = domain.IssueComment{ID: issue.CommentSeq + 1, Author: authorOf(p), Text: body, CreatedAt: i.clock.Now().UTC()}
197+ issue.CommentSeq, issue.UpdatedAt = c.ID, c.CreatedAt
198+ err = i.store.SaveComment(ctx, issue, c)
199+ if !errors.Is(err, domain.ErrConflict) {
200+ break
201+ }
202+ if issue, err = i.store.Get(ctx, name, number); err != nil {
203+ return domain.IssueComment{}, err
204+ }
205+ }
206+ if err != nil {
207+ return domain.IssueComment{}, fmt.Errorf("save comment on #%d of %q: %w", number, name, err)
208+ }
209+ log := zerowrap.FromCtx(ctx)
210+ log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
211+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue comment added")
212+ i.bus.Publish(ctx, domain.IssueCommented{
213+ Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
214+ })
215+ return c, nil
216+}
217+
218+// Close closes an open issue and publishes IssueClosed. The principal needs
219+// issue:write. An issue that is not open gives domain.ErrPreconditionFailed.
220+func (i *Issues) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
221+ return i.transition(ctx, p, name, number, domain.IssueStateOpen, domain.IssueStateClosed)
222+}
223+
224+// Reopen reopens a closed issue and publishes IssueReopened. The principal
225+// needs issue:write. An issue that is not closed gives
226+// domain.ErrPreconditionFailed.
227+func (i *Issues) Reopen(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
228+ return i.transition(ctx, p, name, number, domain.IssueStateClosed, domain.IssueStateOpen)
229+}
230+
231+// transition moves an issue from one state to the other, retrying when another
232+// write changes it in between.
233+func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, from, to domain.IssueState) (domain.Issue, error) {
234+ repo, err := i.writable(ctx, p, name)
235+ if err != nil {
236+ return domain.Issue{}, err
237+ }
238+ verb := "close"
239+ if to == domain.IssueStateOpen {
240+ verb = "reopen"
241+ }
242+ for range maxWriteRetries {
243+ issue, err := i.store.Get(ctx, name, number)
244+ if err != nil {
245+ return domain.Issue{}, err
246+ }
247+ if issue.State != from {
248+ return domain.Issue{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State)
249+ }
250+ issue.State, issue.UpdatedAt = to, i.clock.Now().UTC()
251+ err = i.store.Update(ctx, issue)
252+ if errors.Is(err, domain.ErrConflict) {
253+ continue
254+ }
255+ if err != nil {
256+ return domain.Issue{}, fmt.Errorf("%s #%d of %q: %w", verb, number, name, err)
257+ }
258+ log := zerowrap.FromCtx(ctx)
259+ log.Info().Str("repo", name.String()).Int64("number", number).
260+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue " + verb + "d")
261+ if to == domain.IssueStateOpen {
262+ i.bus.Publish(ctx, domain.IssueReopened{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
263+ } else {
264+ i.bus.Publish(ctx, domain.IssueClosed{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
265+ }
266+ return issue, nil
267+ }
268+ return domain.Issue{}, fmt.Errorf("%s #%d of %q: %w", verb, number, name, domain.ErrConflict)
269+}
270+
271+// Kind tells what number n of the repository names. The principal needs the
272+// access of reading the repository, and issues must be on there: otherwise it
273+// is domain.ErrNotFound.
274+func (i *Issues) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) {
275+ if _, err := i.readable(ctx, p, name); err != nil {
276+ return domain.NumberNone, err
277+ }
278+ if _, err := i.store.Get(ctx, name, n); err == nil {
279+ return domain.NumberIssue, nil
280+ } else if !errors.Is(err, domain.ErrNotFound) {
281+ return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err)
282+ }
283+ if _, err := i.mrs.Get(ctx, name, n); err == nil {
284+ return domain.NumberMergeRequest, nil
285+ } else if !errors.Is(err, domain.ErrNotFound) {
286+ return domain.NumberNone, fmt.Errorf("read !%d of %q: %w", n, name, err)
287+ }
288+ return domain.NumberNone, nil
289+}
@@ -0,0 +1,289 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ "github.com/bnema/zerowrap"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// MaxCommentsPerIssue is the most comments an issue holds. Past it AddComment
17+// fails with domain.ErrResourceExhausted. Comments are never deleted, so the
18+// limit counts the ones ever added.
19+const MaxCommentsPerIssue = 1000
20+
21+// Issues are the issue use cases (ADR 0031): open, comment on, close and
22+// reopen an issue. Issues share their numbers with the merge requests of the
23+// repository. A repository whose issues mode is off answers domain.ErrNotFound
24+// to everyone, the owner included, like a repository that does not exist.
25+// Reading needs the access of reading the repository (canRead), writing needs
26+// issue:write on it, which the branch prefixes of a token do not restrict:
27+// an issue writes no ref.
28+type Issues struct {
29+ meta ports.RepoMetaStore
30+ store ports.IssueStore
31+ mrs ports.MergeRequestStore
32+ bus ports.EventBus
33+ clock ports.Clock
34+}
35+
36+var (
37+ _ ports.IssueService = (*Issues)(nil)
38+ _ ports.IssueNumberResolver = (*Issues)(nil)
39+)
40+
41+// IssuesConfig holds what the issue use cases need. Every field is required.
42+type IssuesConfig struct {
43+ Meta ports.RepoMetaStore
44+ Store ports.IssueStore
45+ // MergeRequests tells which numbers name a merge request (Kind).
46+ MergeRequests ports.MergeRequestStore
47+ Bus ports.EventBus
48+ Clock ports.Clock
49+}
50+
51+// NewIssues builds the issue use cases.
52+func NewIssues(cfg IssuesConfig) *Issues {
53+ return &Issues{meta: cfg.Meta, store: cfg.Store, mrs: cfg.MergeRequests, bus: cfg.Bus, clock: cfg.Clock}
54+}
55+
56+// enabled looks the repository up and hides it when issues are off.
57+func (i *Issues) enabled(ctx context.Context, name domain.RepoName) (domain.Repo, error) {
58+ repo, err := lookupRepo(ctx, i.meta, name)
59+ if err != nil {
60+ return domain.Repo{}, err
61+ }
62+ if repo.Settings.Issues.OrDefault() == domain.IssuesOff {
63+ return domain.Repo{}, domain.ErrNotFound
64+ }
65+ return repo, nil
66+}
67+
68+// readable applies the read rule: the access of reading the repository.
69+func (i *Issues) readable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
70+ repo, err := i.enabled(ctx, name)
71+ if err != nil {
72+ return domain.Repo{}, err
73+ }
74+ if !canRead(p, repo) {
75+ return domain.Repo{}, domain.ErrNotFound
76+ }
77+ return repo, nil
78+}
79+
80+// writable is the write rule, as MergeRequests.writable: a principal that can
81+// neither read the repository nor holds the scope on it gets
82+// domain.ErrNotFound, one that cannot write the error of scopeAccess.
83+func (i *Issues) writable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
84+ repo, err := i.enabled(ctx, name)
85+ if err != nil {
86+ return domain.Repo{}, err
87+ }
88+ if !canRead(p, repo) && !p.Allows(domain.ScopeIssueWrite, name) {
89+ return domain.Repo{}, domain.ErrNotFound
90+ }
91+ if err := scopeAccess(p, repo, domain.ScopeIssueWrite); err != nil {
92+ return domain.Repo{}, err
93+ }
94+ return repo, nil
95+}
96+
97+// Create opens an issue and publishes IssueOpened. The principal needs
98+// issue:write. Invalid texts give an *domain.InvalidFieldsError.
99+func (i *Issues) Create(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateIssueParams) (domain.Issue, error) {
100+ repo, err := i.writable(ctx, p, name)
101+ if err != nil {
102+ return domain.Issue{}, err
103+ }
104+ var v domain.Violations
105+ title, err := domain.ParseIssueTitle(params.Title)
106+ if err != nil {
107+ v.AddErr("title", err)
108+ }
109+ body, err := domain.ParseIssueBody(params.Body)
110+ if err != nil {
111+ v.AddErr("body", err)
112+ }
113+ if err := v.Err(); err != nil {
114+ return domain.Issue{}, err
115+ }
116+ now := i.clock.Now().UTC()
117+ issue, err := i.store.Create(ctx, domain.Issue{
118+ Repo: name, Title: title, Body: body, State: domain.IssueStateOpen,
119+ Author: authorOf(p), CreatedAt: now, UpdatedAt: now,
120+ })
121+ if err != nil {
122+ return domain.Issue{}, fmt.Errorf("create issue of %q: %w", name, err)
123+ }
124+ log := zerowrap.FromCtx(ctx)
125+ log.Info().Str("repo", name.String()).Int64("number", issue.Number).
126+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue opened")
127+ i.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
128+ return issue, nil
129+}
130+
131+// List returns a page of the issues of a repository, highest number first.
132+func (i *Issues) List(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
133+ size, err := pageSize(params.PageSize)
134+ if err != nil {
135+ return ports.IssuePage{}, err
136+ }
137+ if _, err := i.readable(ctx, p, name); err != nil {
138+ return ports.IssuePage{}, err
139+ }
140+ params.PageSize = size
141+ page, err := i.store.List(ctx, name, params)
142+ if err != nil {
143+ return ports.IssuePage{}, fmt.Errorf("list issues of %q: %w", name, err)
144+ }
145+ return page, nil
146+}
147+
148+// Get returns an issue with its comments.
149+func (i *Issues) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (ports.IssueDetail, error) {
150+ if _, err := i.readable(ctx, p, name); err != nil {
151+ return ports.IssueDetail{}, err
152+ }
153+ issue, err := i.store.Get(ctx, name, number)
154+ if err != nil {
155+ return ports.IssueDetail{}, err
156+ }
157+ comments, err := i.store.ListComments(ctx, name, number)
158+ if err != nil {
159+ return ports.IssueDetail{}, fmt.Errorf("list comments of #%d of %q: %w", number, name, err)
160+ }
161+ return ports.IssueDetail{Issue: issue, Comments: comments}, nil
162+}
163+
164+// AddComment adds a comment and publishes IssueCommented. The principal needs
165+// issue:write. The text must not be blank. A comment may follow a closed
166+// issue; one that already holds MaxCommentsPerIssue comments gives
167+// domain.ErrResourceExhausted.
168+func (i *Issues) AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, text string) (domain.IssueComment, error) {
169+ repo, err := i.writable(ctx, p, name)
170+ if err != nil {
171+ return domain.IssueComment{}, err
172+ }
173+ issue, err := i.store.Get(ctx, name, number)
174+ if err != nil {
175+ return domain.IssueComment{}, err
176+ }
177+ var v domain.Violations
178+ body, err := domain.ParseIssueBody(text)
179+ switch {
180+ case err != nil:
181+ v.AddErr("text", err)
182+ case body.IsBlank():
183+ v.Add("text", "is required")
184+ }
185+ if err := v.Err(); err != nil {
186+ return domain.IssueComment{}, err
187+ }
188+ var c domain.IssueComment
189+ for range maxWriteRetries {
190+ if issue.State == domain.IssueStatePending {
191+ return domain.IssueComment{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State)
192+ }
193+ if issue.CommentSeq >= MaxCommentsPerIssue {
194+ return domain.IssueComment{}, fmt.Errorf("%w: #%d already has %d comments", domain.ErrResourceExhausted, number, MaxCommentsPerIssue)
195+ }
196+ c = domain.IssueComment{ID: issue.CommentSeq + 1, Author: authorOf(p), Text: body, CreatedAt: i.clock.Now().UTC()}
197+ issue.CommentSeq, issue.UpdatedAt = c.ID, c.CreatedAt
198+ err = i.store.SaveComment(ctx, issue, c)
199+ if !errors.Is(err, domain.ErrConflict) {
200+ break
201+ }
202+ if issue, err = i.store.Get(ctx, name, number); err != nil {
203+ return domain.IssueComment{}, err
204+ }
205+ }
206+ if err != nil {
207+ return domain.IssueComment{}, fmt.Errorf("save comment on #%d of %q: %w", number, name, err)
208+ }
209+ log := zerowrap.FromCtx(ctx)
210+ log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
211+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue comment added")
212+ i.bus.Publish(ctx, domain.IssueCommented{
213+ Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
214+ })
215+ return c, nil
216+}
217+
218+// Close closes an open issue and publishes IssueClosed. The principal needs
219+// issue:write. An issue that is not open gives domain.ErrPreconditionFailed.
220+func (i *Issues) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
221+ return i.transition(ctx, p, name, number, domain.IssueStateOpen, domain.IssueStateClosed)
222+}
223+
224+// Reopen reopens a closed issue and publishes IssueReopened. The principal
225+// needs issue:write. An issue that is not closed gives
226+// domain.ErrPreconditionFailed.
227+func (i *Issues) Reopen(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Issue, error) {
228+ return i.transition(ctx, p, name, number, domain.IssueStateClosed, domain.IssueStateOpen)
229+}
230+
231+// transition moves an issue from one state to the other, retrying when another
232+// write changes it in between.
233+func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, from, to domain.IssueState) (domain.Issue, error) {
234+ repo, err := i.writable(ctx, p, name)
235+ if err != nil {
236+ return domain.Issue{}, err
237+ }
238+ verb := "close"
239+ if to == domain.IssueStateOpen {
240+ verb = "reopen"
241+ }
242+ for range maxWriteRetries {
243+ issue, err := i.store.Get(ctx, name, number)
244+ if err != nil {
245+ return domain.Issue{}, err
246+ }
247+ if issue.State != from {
248+ return domain.Issue{}, fmt.Errorf("%w: #%d is %s", domain.ErrPreconditionFailed, number, issue.State)
249+ }
250+ issue.State, issue.UpdatedAt = to, i.clock.Now().UTC()
251+ err = i.store.Update(ctx, issue)
252+ if errors.Is(err, domain.ErrConflict) {
253+ continue
254+ }
255+ if err != nil {
256+ return domain.Issue{}, fmt.Errorf("%s #%d of %q: %w", verb, number, name, err)
257+ }
258+ log := zerowrap.FromCtx(ctx)
259+ log.Info().Str("repo", name.String()).Int64("number", number).
260+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue " + verb + "d")
261+ if to == domain.IssueStateOpen {
262+ i.bus.Publish(ctx, domain.IssueReopened{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
263+ } else {
264+ i.bus.Publish(ctx, domain.IssueClosed{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
265+ }
266+ return issue, nil
267+ }
268+ return domain.Issue{}, fmt.Errorf("%s #%d of %q: %w", verb, number, name, domain.ErrConflict)
269+}
270+
271+// Kind tells what number n of the repository names. The principal needs the
272+// access of reading the repository, and issues must be on there: otherwise it
273+// is domain.ErrNotFound.
274+func (i *Issues) Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) {
275+ if _, err := i.readable(ctx, p, name); err != nil {
276+ return domain.NumberNone, err
277+ }
278+ if _, err := i.store.Get(ctx, name, n); err == nil {
279+ return domain.NumberIssue, nil
280+ } else if !errors.Is(err, domain.ErrNotFound) {
281+ return domain.NumberNone, fmt.Errorf("read #%d of %q: %w", n, name, err)
282+ }
283+ if _, err := i.mrs.Get(ctx, name, n); err == nil {
284+ return domain.NumberMergeRequest, nil
285+ } else if !errors.Is(err, domain.ErrNotFound) {
286+ return domain.NumberNone, fmt.Errorf("read !%d of %q: %w", n, name, err)
287+ }
288+ return domain.NumberNone, nil
289+}
A internal/usecase/issues_integration_test.go +87 -0
@@ -0,0 +1,87 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "path/filepath"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/adapters/eventbus"
14+ "bnema.dev/ermite/internal/adapters/kvstore"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+ "bnema.dev/ermite/internal/usecase"
18+)
19+
20+// Issues over the real store: shared numbers with merge requests, the life of
21+// an issue and its events.
22+func TestIssuesOverTheStore(t *testing.T) {
23+ ctx := context.Background()
24+ store, err := kvstore.Open(ctx, filepath.Join(t.TempDir(), "kv"))
25+ require.NoError(t, err)
26+ t.Cleanup(func() { require.NoError(t, store.Close(context.Background())) })
27+ bus := eventbus.NewWithBuffer(64)
28+ events := bus.Subscribe(ctx)
29+ n := name(t, "proj")
30+ settings := domain.DefaultRepoSettings()
31+ settings.Issues = domain.IssuesOwner
32+ require.NoError(t, store.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: settings}))
33+
34+ issues := usecase.NewIssues(usecase.IssuesConfig{Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: bus, Clock: fixedClock{}})
35+ agent := domain.TokenPrincipal(domain.Token{ID: "t", Label: "bot", Scopes: []domain.Scope{domain.ScopeIssueWrite}, BranchPrefixes: []string{"x/"}})
36+
37+ mrTitle, err := domain.ParseMRTitle("Change")
38+ require.NoError(t, err)
39+ mr, err := store.MergeRequests().Create(ctx, domain.MergeRequest{
40+ Repo: n, Title: mrTitle, State: domain.MRStateOpen, Source: "feat", Target: "main", Author: "a", CreatedAt: now, UpdatedAt: now,
41+ })
42+ require.NoError(t, err)
43+ assert.EqualValues(t, 1, mr.Number)
44+
45+ issue, err := issues.Create(ctx, agent, n, ports.CreateIssueParams{Title: "Crash", Body: "details"})
46+ require.NoError(t, err)
47+ assert.EqualValues(t, 2, issue.Number, "the number follows the merge request")
48+ assert.Equal(t, "bot", issue.Author)
49+
50+ kind, err := issues.Kind(ctx, domain.Anonymous(), n, 1)
51+ require.NoError(t, err)
52+ assert.Equal(t, domain.NumberMergeRequest, kind)
53+ kind, err = issues.Kind(ctx, domain.Anonymous(), n, 2)
54+ require.NoError(t, err)
55+ assert.Equal(t, domain.NumberIssue, kind)
56+ kind, err = issues.Kind(ctx, domain.Anonymous(), n, 3)
57+ require.NoError(t, err)
58+ assert.Equal(t, domain.NumberNone, kind)
59+
60+ _, err = issues.AddComment(ctx, agent, n, 2, "me too")
61+ require.NoError(t, err)
62+ _, err = issues.Close(ctx, agent, n, 2)
63+ require.NoError(t, err)
64+ _, err = issues.AddComment(ctx, agent, n, 2, "after close")
65+ require.NoError(t, err)
66+ _, err = issues.Close(ctx, agent, n, 2)
67+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
68+ _, err = issues.Reopen(ctx, agent, n, 2)
69+ require.NoError(t, err)
70+
71+ d, err := issues.Get(ctx, domain.Anonymous(), n, 2)
72+ require.NoError(t, err)
73+ assert.Equal(t, domain.IssueStateOpen, d.Issue.State)
74+ require.Len(t, d.Comments, 2)
75+ assert.Equal(t, "me too", d.Comments[0].Text.String())
76+ assert.EqualValues(t, 2, d.Issue.CommentSeq)
77+
78+ page, err := issues.List(ctx, domain.Anonymous(), n, ports.ListIssuesParams{State: domain.IssueStateClosed})
79+ require.NoError(t, err)
80+ assert.Empty(t, page.Issues)
81+
82+ var kinds []string
83+ for len(events) > 0 {
84+ kinds = append(kinds, (<-events).EventKind())
85+ }
86+ assert.Equal(t, []string{"issue.opened", "issue.commented", "issue.closed", "issue.commented", "issue.reopened"}, kinds)
87+}
@@ -0,0 +1,87 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "path/filepath"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/adapters/eventbus"
14+ "bnema.dev/ermite/internal/adapters/kvstore"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+ "bnema.dev/ermite/internal/usecase"
18+)
19+
20+// Issues over the real store: shared numbers with merge requests, the life of
21+// an issue and its events.
22+func TestIssuesOverTheStore(t *testing.T) {
23+ ctx := context.Background()
24+ store, err := kvstore.Open(ctx, filepath.Join(t.TempDir(), "kv"))
25+ require.NoError(t, err)
26+ t.Cleanup(func() { require.NoError(t, store.Close(context.Background())) })
27+ bus := eventbus.NewWithBuffer(64)
28+ events := bus.Subscribe(ctx)
29+ n := name(t, "proj")
30+ settings := domain.DefaultRepoSettings()
31+ settings.Issues = domain.IssuesOwner
32+ require.NoError(t, store.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: settings}))
33+
34+ issues := usecase.NewIssues(usecase.IssuesConfig{Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: bus, Clock: fixedClock{}})
35+ agent := domain.TokenPrincipal(domain.Token{ID: "t", Label: "bot", Scopes: []domain.Scope{domain.ScopeIssueWrite}, BranchPrefixes: []string{"x/"}})
36+
37+ mrTitle, err := domain.ParseMRTitle("Change")
38+ require.NoError(t, err)
39+ mr, err := store.MergeRequests().Create(ctx, domain.MergeRequest{
40+ Repo: n, Title: mrTitle, State: domain.MRStateOpen, Source: "feat", Target: "main", Author: "a", CreatedAt: now, UpdatedAt: now,
41+ })
42+ require.NoError(t, err)
43+ assert.EqualValues(t, 1, mr.Number)
44+
45+ issue, err := issues.Create(ctx, agent, n, ports.CreateIssueParams{Title: "Crash", Body: "details"})
46+ require.NoError(t, err)
47+ assert.EqualValues(t, 2, issue.Number, "the number follows the merge request")
48+ assert.Equal(t, "bot", issue.Author)
49+
50+ kind, err := issues.Kind(ctx, domain.Anonymous(), n, 1)
51+ require.NoError(t, err)
52+ assert.Equal(t, domain.NumberMergeRequest, kind)
53+ kind, err = issues.Kind(ctx, domain.Anonymous(), n, 2)
54+ require.NoError(t, err)
55+ assert.Equal(t, domain.NumberIssue, kind)
56+ kind, err = issues.Kind(ctx, domain.Anonymous(), n, 3)
57+ require.NoError(t, err)
58+ assert.Equal(t, domain.NumberNone, kind)
59+
60+ _, err = issues.AddComment(ctx, agent, n, 2, "me too")
61+ require.NoError(t, err)
62+ _, err = issues.Close(ctx, agent, n, 2)
63+ require.NoError(t, err)
64+ _, err = issues.AddComment(ctx, agent, n, 2, "after close")
65+ require.NoError(t, err)
66+ _, err = issues.Close(ctx, agent, n, 2)
67+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
68+ _, err = issues.Reopen(ctx, agent, n, 2)
69+ require.NoError(t, err)
70+
71+ d, err := issues.Get(ctx, domain.Anonymous(), n, 2)
72+ require.NoError(t, err)
73+ assert.Equal(t, domain.IssueStateOpen, d.Issue.State)
74+ require.Len(t, d.Comments, 2)
75+ assert.Equal(t, "me too", d.Comments[0].Text.String())
76+ assert.EqualValues(t, 2, d.Issue.CommentSeq)
77+
78+ page, err := issues.List(ctx, domain.Anonymous(), n, ports.ListIssuesParams{State: domain.IssueStateClosed})
79+ require.NoError(t, err)
80+ assert.Empty(t, page.Issues)
81+
82+ var kinds []string
83+ for len(events) > 0 {
84+ kinds = append(kinds, (<-events).EventKind())
85+ }
86+ assert.Equal(t, []string{"issue.opened", "issue.commented", "issue.closed", "issue.commented", "issue.reopened"}, kinds)
87+}
A internal/usecase/issues_test.go +258 -0
@@ -0,0 +1,258 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+ "bnema.dev/ermite/internal/ports/mocks"
16+ "bnema.dev/ermite/internal/usecase"
17+)
18+
19+type issueUnit struct {
20+ meta *mocks.RepoMetaStore
21+ store *mocks.IssueStore
22+ mrs *mocks.MergeRequestStore
23+ bus *mocks.EventBus
24+ issues *usecase.Issues
25+ repo domain.Repo
26+}
27+
28+func newIssueUnit(t *testing.T, vis domain.Visibility, mode domain.IssuesMode) *issueUnit {
29+ t.Helper()
30+ settings := domain.DefaultRepoSettings()
31+ settings.Issues = mode
32+ u := &issueUnit{
33+ meta: mocks.NewRepoMetaStore(t),
34+ store: mocks.NewIssueStore(t),
35+ mrs: mocks.NewMergeRequestStore(t),
36+ bus: mocks.NewEventBus(t),
37+ repo: domain.Repo{Name: name(t, "proj"), Visibility: vis, Settings: settings},
38+ }
39+ clock := mocks.NewClock(t)
40+ clock.EXPECT().Now().Return(now).Maybe()
41+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Maybe()
42+ u.issues = usecase.NewIssues(usecase.IssuesConfig{Meta: u.meta, Store: u.store, MergeRequests: u.mrs, Bus: u.bus, Clock: clock})
43+ return u
44+}
45+
46+func issuePrincipal(scopes ...domain.Scope) domain.Principal {
47+ return domain.TokenPrincipal(domain.Token{ID: "t", Label: "agent-1", Scopes: scopes, BranchPrefixes: []string{"agent/"}})
48+}
49+
50+func TestIssuesOffHidesEverything(t *testing.T) {
51+ t.Parallel()
52+ for _, mode := range []domain.IssuesMode{domain.IssuesOff, ""} {
53+ u := newIssueUnit(t, domain.VisibilityPublic, mode)
54+ ctx, n := context.Background(), u.repo.Name
55+ _, err := u.issues.Create(ctx, domain.Root(), n, ports.CreateIssueParams{Title: "t"})
56+ assert.ErrorIs(t, err, domain.ErrNotFound)
57+ _, err = u.issues.List(ctx, domain.Root(), n, ports.ListIssuesParams{})
58+ assert.ErrorIs(t, err, domain.ErrNotFound)
59+ _, err = u.issues.Get(ctx, domain.Owner(), n, 1)
60+ assert.ErrorIs(t, err, domain.ErrNotFound)
61+ _, err = u.issues.AddComment(ctx, domain.Root(), n, 1, "x")
62+ assert.ErrorIs(t, err, domain.ErrNotFound)
63+ _, err = u.issues.Close(ctx, domain.Root(), n, 1)
64+ assert.ErrorIs(t, err, domain.ErrNotFound)
65+ _, err = u.issues.Reopen(ctx, domain.Root(), n, 1)
66+ assert.ErrorIs(t, err, domain.ErrNotFound)
67+ _, err = u.issues.Kind(ctx, domain.Root(), n, 1)
68+ assert.ErrorIs(t, err, domain.ErrNotFound)
69+ }
70+}
71+
72+func TestIssueAccessRules(t *testing.T) {
73+ t.Parallel()
74+ ctx := context.Background()
75+ t.Run("private repository hides reads", func(t *testing.T) {
76+ t.Parallel()
77+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
78+ _, err := u.issues.List(ctx, domain.Anonymous(), u.repo.Name, ports.ListIssuesParams{})
79+ assert.ErrorIs(t, err, domain.ErrNotFound)
80+ _, err = u.issues.Get(ctx, issuePrincipal(domain.ScopeIssueWrite), u.repo.Name, 1)
81+ assert.ErrorIs(t, err, domain.ErrNotFound, "issue:write does not read")
82+ })
83+ t.Run("writes", func(t *testing.T) {
84+ t.Parallel()
85+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
86+ p := ports.CreateIssueParams{Title: "t"}
87+ _, err := u.issues.Create(ctx, domain.Anonymous(), u.repo.Name, p)
88+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
89+ _, err = u.issues.Create(ctx, issuePrincipal(domain.ScopeRepoRead), u.repo.Name, p)
90+ assert.ErrorIs(t, err, domain.ErrForbidden)
91+ })
92+ t.Run("private write without the scope", func(t *testing.T) {
93+ t.Parallel()
94+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
95+ _, err := u.issues.Create(ctx, issuePrincipal(domain.ScopeRepoWrite), u.repo.Name, ports.CreateIssueParams{Title: "t"})
96+ assert.ErrorIs(t, err, domain.ErrNotFound)
97+ })
98+}
99+
100+func TestIssueCreateAndTransitions(t *testing.T) {
101+ t.Parallel()
102+ ctx := context.Background()
103+ p := issuePrincipal(domain.ScopeIssueWrite) // branch-limited: issues write no ref
104+
105+ t.Run("create", func(t *testing.T) {
106+ t.Parallel()
107+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
108+ u.store.EXPECT().Create(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool {
109+ return i.Repo == u.repo.Name && i.Title.String() == "Crash" && i.Body.String() == "why" &&
110+ i.State == domain.IssueStateOpen && i.Author == "agent-1" && i.CreatedAt.Equal(now)
111+ })).RunAndReturn(func(_ context.Context, i domain.Issue) (domain.Issue, error) { i.Number = 7; return i, nil })
112+ u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
113+ o, ok := ev.(domain.IssueOpened)
114+ return ok && o.Issue.Number == 7 && o.Visibility == domain.VisibilityPrivate
115+ })).Return()
116+ got, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: " Crash ", Body: "why"})
117+ require.NoError(t, err)
118+ assert.EqualValues(t, 7, got.Number)
119+ })
120+
121+ t.Run("invalid text stores nothing", func(t *testing.T) {
122+ t.Parallel()
123+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
124+ _, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: ""})
125+ var fe *domain.InvalidFieldsError
126+ require.ErrorAs(t, err, &fe)
127+ })
128+
129+ open := domain.Issue{Repo: name(t, "proj"), Number: 3, State: domain.IssueStateOpen, Version: 1}
130+
131+ t.Run("close retries a conflict then publishes", func(t *testing.T) {
132+ t.Parallel()
133+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
134+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(open, nil).Twice()
135+ u.store.EXPECT().Update(mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
136+ u.store.EXPECT().Update(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool { return i.State == domain.IssueStateClosed })).Return(nil).Once()
137+ u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
138+ c, ok := ev.(domain.IssueClosed)
139+ return ok && c.Issue.State == domain.IssueStateClosed
140+ })).Return()
141+ got, err := u.issues.Close(ctx, p, u.repo.Name, 3)
142+ require.NoError(t, err)
143+ assert.Equal(t, domain.IssueStateClosed, got.State)
144+ })
145+
146+ t.Run("close of a closed issue", func(t *testing.T) {
147+ t.Parallel()
148+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
149+ closed := open
150+ closed.State = domain.IssueStateClosed
151+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(closed, nil)
152+ _, err := u.issues.Close(ctx, p, u.repo.Name, 3)
153+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
154+ })
155+
156+ t.Run("reopen", func(t *testing.T) {
157+ t.Parallel()
158+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
159+ closed := open
160+ closed.State = domain.IssueStateClosed
161+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(closed, nil)
162+ u.store.EXPECT().Update(mock.Anything, mock.Anything).Return(nil)
163+ u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool { _, ok := ev.(domain.IssueReopened); return ok })).Return()
164+ got, err := u.issues.Reopen(ctx, p, u.repo.Name, 3)
165+ require.NoError(t, err)
166+ assert.Equal(t, domain.IssueStateOpen, got.State)
167+ })
168+
169+ t.Run("reopen of an open issue", func(t *testing.T) {
170+ t.Parallel()
171+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
172+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(open, nil)
173+ _, err := u.issues.Reopen(ctx, p, u.repo.Name, 3)
174+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
175+ })
176+}
177+
178+func TestIssueAddComment(t *testing.T) {
179+ t.Parallel()
180+ ctx := context.Background()
181+ p := issuePrincipal(domain.ScopeIssueWrite)
182+ issue := domain.Issue{Repo: name(t, "proj"), Number: 3, State: domain.IssueStateClosed, CommentSeq: 4, Version: 1}
183+
184+ t.Run("on a closed issue, with a retry", func(t *testing.T) {
185+ t.Parallel()
186+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
187+ fresh := issue
188+ fresh.CommentSeq = 5
189+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(issue, nil).Once()
190+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(fresh, nil).Once()
191+ u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
192+ u.store.EXPECT().SaveComment(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool { return i.CommentSeq == 6 }),
193+ mock.MatchedBy(func(c domain.IssueComment) bool { return c.ID == 6 && c.Author == "agent-1" && c.Text.String() == "hi" })).Return(nil).Once()
194+ u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
195+ c, ok := ev.(domain.IssueCommented)
196+ return ok && c.Number == 3 && c.Comment.ID == 6
197+ })).Return()
198+ c, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, "hi")
199+ require.NoError(t, err)
200+ assert.EqualValues(t, 6, c.ID)
201+ })
202+
203+ t.Run("blank", func(t *testing.T) {
204+ t.Parallel()
205+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
206+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(issue, nil)
207+ _, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, " \n ")
208+ var fe *domain.InvalidFieldsError
209+ require.ErrorAs(t, err, &fe)
210+ })
211+
212+ t.Run("full", func(t *testing.T) {
213+ t.Parallel()
214+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
215+ full := issue
216+ full.CommentSeq = usecase.MaxCommentsPerIssue
217+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(full, nil)
218+ _, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, "x")
219+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
220+ })
221+}
222+
223+func TestIssueKind(t *testing.T) {
224+ t.Parallel()
225+ ctx := context.Background()
226+ t.Run("issue", func(t *testing.T) {
227+ t.Parallel()
228+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
229+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{Number: 1}, nil)
230+ k, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
231+ require.NoError(t, err)
232+ assert.Equal(t, domain.NumberIssue, k)
233+ })
234+ t.Run("merge request", func(t *testing.T) {
235+ t.Parallel()
236+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
237+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{}, domain.ErrNotFound)
238+ u.mrs.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{Number: 1}, nil)
239+ k, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
240+ require.NoError(t, err)
241+ assert.Equal(t, domain.NumberMergeRequest, k)
242+ })
243+ t.Run("none", func(t *testing.T) {
244+ t.Parallel()
245+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
246+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{}, domain.ErrNotFound)
247+ u.mrs.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{}, domain.ErrNotFound)
248+ k, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
249+ require.NoError(t, err)
250+ assert.Equal(t, domain.NumberNone, k)
251+ })
252+ t.Run("private repository", func(t *testing.T) {
253+ t.Parallel()
254+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
255+ _, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
256+ assert.ErrorIs(t, err, domain.ErrNotFound)
257+ })
258+}
@@ -0,0 +1,258 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+ "bnema.dev/ermite/internal/ports/mocks"
16+ "bnema.dev/ermite/internal/usecase"
17+)
18+
19+type issueUnit struct {
20+ meta *mocks.RepoMetaStore
21+ store *mocks.IssueStore
22+ mrs *mocks.MergeRequestStore
23+ bus *mocks.EventBus
24+ issues *usecase.Issues
25+ repo domain.Repo
26+}
27+
28+func newIssueUnit(t *testing.T, vis domain.Visibility, mode domain.IssuesMode) *issueUnit {
29+ t.Helper()
30+ settings := domain.DefaultRepoSettings()
31+ settings.Issues = mode
32+ u := &issueUnit{
33+ meta: mocks.NewRepoMetaStore(t),
34+ store: mocks.NewIssueStore(t),
35+ mrs: mocks.NewMergeRequestStore(t),
36+ bus: mocks.NewEventBus(t),
37+ repo: domain.Repo{Name: name(t, "proj"), Visibility: vis, Settings: settings},
38+ }
39+ clock := mocks.NewClock(t)
40+ clock.EXPECT().Now().Return(now).Maybe()
41+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Maybe()
42+ u.issues = usecase.NewIssues(usecase.IssuesConfig{Meta: u.meta, Store: u.store, MergeRequests: u.mrs, Bus: u.bus, Clock: clock})
43+ return u
44+}
45+
46+func issuePrincipal(scopes ...domain.Scope) domain.Principal {
47+ return domain.TokenPrincipal(domain.Token{ID: "t", Label: "agent-1", Scopes: scopes, BranchPrefixes: []string{"agent/"}})
48+}
49+
50+func TestIssuesOffHidesEverything(t *testing.T) {
51+ t.Parallel()
52+ for _, mode := range []domain.IssuesMode{domain.IssuesOff, ""} {
53+ u := newIssueUnit(t, domain.VisibilityPublic, mode)
54+ ctx, n := context.Background(), u.repo.Name
55+ _, err := u.issues.Create(ctx, domain.Root(), n, ports.CreateIssueParams{Title: "t"})
56+ assert.ErrorIs(t, err, domain.ErrNotFound)
57+ _, err = u.issues.List(ctx, domain.Root(), n, ports.ListIssuesParams{})
58+ assert.ErrorIs(t, err, domain.ErrNotFound)
59+ _, err = u.issues.Get(ctx, domain.Owner(), n, 1)
60+ assert.ErrorIs(t, err, domain.ErrNotFound)
61+ _, err = u.issues.AddComment(ctx, domain.Root(), n, 1, "x")
62+ assert.ErrorIs(t, err, domain.ErrNotFound)
63+ _, err = u.issues.Close(ctx, domain.Root(), n, 1)
64+ assert.ErrorIs(t, err, domain.ErrNotFound)
65+ _, err = u.issues.Reopen(ctx, domain.Root(), n, 1)
66+ assert.ErrorIs(t, err, domain.ErrNotFound)
67+ _, err = u.issues.Kind(ctx, domain.Root(), n, 1)
68+ assert.ErrorIs(t, err, domain.ErrNotFound)
69+ }
70+}
71+
72+func TestIssueAccessRules(t *testing.T) {
73+ t.Parallel()
74+ ctx := context.Background()
75+ t.Run("private repository hides reads", func(t *testing.T) {
76+ t.Parallel()
77+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
78+ _, err := u.issues.List(ctx, domain.Anonymous(), u.repo.Name, ports.ListIssuesParams{})
79+ assert.ErrorIs(t, err, domain.ErrNotFound)
80+ _, err = u.issues.Get(ctx, issuePrincipal(domain.ScopeIssueWrite), u.repo.Name, 1)
81+ assert.ErrorIs(t, err, domain.ErrNotFound, "issue:write does not read")
82+ })
83+ t.Run("writes", func(t *testing.T) {
84+ t.Parallel()
85+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
86+ p := ports.CreateIssueParams{Title: "t"}
87+ _, err := u.issues.Create(ctx, domain.Anonymous(), u.repo.Name, p)
88+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
89+ _, err = u.issues.Create(ctx, issuePrincipal(domain.ScopeRepoRead), u.repo.Name, p)
90+ assert.ErrorIs(t, err, domain.ErrForbidden)
91+ })
92+ t.Run("private write without the scope", func(t *testing.T) {
93+ t.Parallel()
94+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
95+ _, err := u.issues.Create(ctx, issuePrincipal(domain.ScopeRepoWrite), u.repo.Name, ports.CreateIssueParams{Title: "t"})
96+ assert.ErrorIs(t, err, domain.ErrNotFound)
97+ })
98+}
99+
100+func TestIssueCreateAndTransitions(t *testing.T) {
101+ t.Parallel()
102+ ctx := context.Background()
103+ p := issuePrincipal(domain.ScopeIssueWrite) // branch-limited: issues write no ref
104+
105+ t.Run("create", func(t *testing.T) {
106+ t.Parallel()
107+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
108+ u.store.EXPECT().Create(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool {
109+ return i.Repo == u.repo.Name && i.Title.String() == "Crash" && i.Body.String() == "why" &&
110+ i.State == domain.IssueStateOpen && i.Author == "agent-1" && i.CreatedAt.Equal(now)
111+ })).RunAndReturn(func(_ context.Context, i domain.Issue) (domain.Issue, error) { i.Number = 7; return i, nil })
112+ u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
113+ o, ok := ev.(domain.IssueOpened)
114+ return ok && o.Issue.Number == 7 && o.Visibility == domain.VisibilityPrivate
115+ })).Return()
116+ got, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: " Crash ", Body: "why"})
117+ require.NoError(t, err)
118+ assert.EqualValues(t, 7, got.Number)
119+ })
120+
121+ t.Run("invalid text stores nothing", func(t *testing.T) {
122+ t.Parallel()
123+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
124+ _, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: ""})
125+ var fe *domain.InvalidFieldsError
126+ require.ErrorAs(t, err, &fe)
127+ })
128+
129+ open := domain.Issue{Repo: name(t, "proj"), Number: 3, State: domain.IssueStateOpen, Version: 1}
130+
131+ t.Run("close retries a conflict then publishes", func(t *testing.T) {
132+ t.Parallel()
133+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
134+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(open, nil).Twice()
135+ u.store.EXPECT().Update(mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
136+ u.store.EXPECT().Update(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool { return i.State == domain.IssueStateClosed })).Return(nil).Once()
137+ u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
138+ c, ok := ev.(domain.IssueClosed)
139+ return ok && c.Issue.State == domain.IssueStateClosed
140+ })).Return()
141+ got, err := u.issues.Close(ctx, p, u.repo.Name, 3)
142+ require.NoError(t, err)
143+ assert.Equal(t, domain.IssueStateClosed, got.State)
144+ })
145+
146+ t.Run("close of a closed issue", func(t *testing.T) {
147+ t.Parallel()
148+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
149+ closed := open
150+ closed.State = domain.IssueStateClosed
151+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(closed, nil)
152+ _, err := u.issues.Close(ctx, p, u.repo.Name, 3)
153+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
154+ })
155+
156+ t.Run("reopen", func(t *testing.T) {
157+ t.Parallel()
158+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
159+ closed := open
160+ closed.State = domain.IssueStateClosed
161+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(closed, nil)
162+ u.store.EXPECT().Update(mock.Anything, mock.Anything).Return(nil)
163+ u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool { _, ok := ev.(domain.IssueReopened); return ok })).Return()
164+ got, err := u.issues.Reopen(ctx, p, u.repo.Name, 3)
165+ require.NoError(t, err)
166+ assert.Equal(t, domain.IssueStateOpen, got.State)
167+ })
168+
169+ t.Run("reopen of an open issue", func(t *testing.T) {
170+ t.Parallel()
171+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
172+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(open, nil)
173+ _, err := u.issues.Reopen(ctx, p, u.repo.Name, 3)
174+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
175+ })
176+}
177+
178+func TestIssueAddComment(t *testing.T) {
179+ t.Parallel()
180+ ctx := context.Background()
181+ p := issuePrincipal(domain.ScopeIssueWrite)
182+ issue := domain.Issue{Repo: name(t, "proj"), Number: 3, State: domain.IssueStateClosed, CommentSeq: 4, Version: 1}
183+
184+ t.Run("on a closed issue, with a retry", func(t *testing.T) {
185+ t.Parallel()
186+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
187+ fresh := issue
188+ fresh.CommentSeq = 5
189+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(issue, nil).Once()
190+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(fresh, nil).Once()
191+ u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
192+ u.store.EXPECT().SaveComment(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool { return i.CommentSeq == 6 }),
193+ mock.MatchedBy(func(c domain.IssueComment) bool { return c.ID == 6 && c.Author == "agent-1" && c.Text.String() == "hi" })).Return(nil).Once()
194+ u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
195+ c, ok := ev.(domain.IssueCommented)
196+ return ok && c.Number == 3 && c.Comment.ID == 6
197+ })).Return()
198+ c, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, "hi")
199+ require.NoError(t, err)
200+ assert.EqualValues(t, 6, c.ID)
201+ })
202+
203+ t.Run("blank", func(t *testing.T) {
204+ t.Parallel()
205+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
206+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(issue, nil)
207+ _, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, " \n ")
208+ var fe *domain.InvalidFieldsError
209+ require.ErrorAs(t, err, &fe)
210+ })
211+
212+ t.Run("full", func(t *testing.T) {
213+ t.Parallel()
214+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
215+ full := issue
216+ full.CommentSeq = usecase.MaxCommentsPerIssue
217+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(full, nil)
218+ _, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, "x")
219+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
220+ })
221+}
222+
223+func TestIssueKind(t *testing.T) {
224+ t.Parallel()
225+ ctx := context.Background()
226+ t.Run("issue", func(t *testing.T) {
227+ t.Parallel()
228+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
229+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{Number: 1}, nil)
230+ k, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
231+ require.NoError(t, err)
232+ assert.Equal(t, domain.NumberIssue, k)
233+ })
234+ t.Run("merge request", func(t *testing.T) {
235+ t.Parallel()
236+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
237+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{}, domain.ErrNotFound)
238+ u.mrs.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{Number: 1}, nil)
239+ k, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
240+ require.NoError(t, err)
241+ assert.Equal(t, domain.NumberMergeRequest, k)
242+ })
243+ t.Run("none", func(t *testing.T) {
244+ t.Parallel()
245+ u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
246+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{}, domain.ErrNotFound)
247+ u.mrs.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{}, domain.ErrNotFound)
248+ k, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
249+ require.NoError(t, err)
250+ assert.Equal(t, domain.NumberNone, k)
251+ })
252+ t.Run("private repository", func(t *testing.T) {
253+ t.Parallel()
254+ u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
255+ _, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
256+ assert.ErrorIs(t, err, domain.ErrNotFound)
257+ })
258+}
M internal/usecase/repos_write.go +16 -3
@@ -62,7 +62,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
6262 }
6363 before := repo
6464 if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&
65- params.AllowedMergeModes == nil && params.FailingChecksBlock == nil {
65+ params.AllowedMergeModes == nil && params.FailingChecksBlock == nil && params.Issues == nil {
6666 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
6767 }
6868 if params.AllowedMergeModes != nil {
@@ -72,6 +72,16 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
7272 }
7373 params.AllowedMergeModes = &modes
7474 }
75+ if params.Issues != nil {
76+ mode, err := domain.ParseIssuesMode(params.Issues.String())
77+ if err != nil {
78+ return domain.Repo{}, err
79+ }
80+ if mode == domain.IssuesPublic {
81+ return domain.Repo{}, fmt.Errorf("%w: public issues are not available yet", domain.ErrInvalid)
82+ }
83+ params.Issues = &mode
84+ }
7585 if params.Description != nil {
7686 if err := validateDescription(*params.Description); err != nil {
7787 return domain.Repo{}, err
@@ -109,7 +119,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
109119 // branch was checked above, so the remaining causes are a vanished
110120 // repository or an I/O failure.
111121 metaDone := false
112- if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil {
122+ if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil {
113123 if repo, err = r.updateMeta(ctx, name, params); err != nil {
114124 return domain.Repo{}, err
115125 }
@@ -132,7 +142,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
132142 Bool("topics", params.Topics != nil).
133143 Bool("visibility", params.Visibility != nil).
134144 Bool("default_branch", params.DefaultBranch != nil).
135- Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil).
145+ Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil).
136146 Str("principal", p.Label()).Str("token_id", p.TokenID()).
137147 Msg("repository updated")
138148 r.bus.Publish(ctx, domain.RepoUpdated{
@@ -171,6 +181,9 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
171181 if params.FailingChecksBlock != nil {
172182 repo.Settings.FailingChecksBlock = *params.FailingChecksBlock
173183 }
184+ if params.Issues != nil {
185+ repo.Settings.Issues = *params.Issues
186+ }
174187 err = r.meta.Update(ctx, repo)
175188 if errors.Is(err, domain.ErrConflict) {
176189 continue
@@ -62,7 +62,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
62 }62 }
63 before := repo63 before := repo
64 if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&64 if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&
65- params.AllowedMergeModes == nil && params.FailingChecksBlock == nil {65+ params.AllowedMergeModes == nil && params.FailingChecksBlock == nil && params.Issues == nil {
66 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)66 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
67 }67 }
68 if params.AllowedMergeModes != nil {68 if params.AllowedMergeModes != nil {
@@ -72,6 +72,16 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
72 }72 }
73 params.AllowedMergeModes = &modes73 params.AllowedMergeModes = &modes
74 }74 }
75+ if params.Issues != nil {
76+ mode, err := domain.ParseIssuesMode(params.Issues.String())
77+ if err != nil {
78+ return domain.Repo{}, err
79+ }
80+ if mode == domain.IssuesPublic {
81+ return domain.Repo{}, fmt.Errorf("%w: public issues are not available yet", domain.ErrInvalid)
82+ }
83+ params.Issues = &mode
84+ }
75 if params.Description != nil {85 if params.Description != nil {
76 if err := validateDescription(*params.Description); err != nil {86 if err := validateDescription(*params.Description); err != nil {
77 return domain.Repo{}, err87 return domain.Repo{}, err
@@ -109,7 +119,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
109 // branch was checked above, so the remaining causes are a vanished119 // branch was checked above, so the remaining causes are a vanished
110 // repository or an I/O failure.120 // repository or an I/O failure.
111 metaDone := false121 metaDone := false
112- if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil {122+ if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil {
113 if repo, err = r.updateMeta(ctx, name, params); err != nil {123 if repo, err = r.updateMeta(ctx, name, params); err != nil {
114 return domain.Repo{}, err124 return domain.Repo{}, err
115 }125 }
@@ -132,7 +142,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
132 Bool("topics", params.Topics != nil).142 Bool("topics", params.Topics != nil).
133 Bool("visibility", params.Visibility != nil).143 Bool("visibility", params.Visibility != nil).
134 Bool("default_branch", params.DefaultBranch != nil).144 Bool("default_branch", params.DefaultBranch != nil).
135- Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil).145+ Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil).
136 Str("principal", p.Label()).Str("token_id", p.TokenID()).146 Str("principal", p.Label()).Str("token_id", p.TokenID()).
137 Msg("repository updated")147 Msg("repository updated")
138 r.bus.Publish(ctx, domain.RepoUpdated{148 r.bus.Publish(ctx, domain.RepoUpdated{
@@ -171,6 +181,9 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
171 if params.FailingChecksBlock != nil {181 if params.FailingChecksBlock != nil {
172 repo.Settings.FailingChecksBlock = *params.FailingChecksBlock182 repo.Settings.FailingChecksBlock = *params.FailingChecksBlock
173 }183 }
184+ if params.Issues != nil {
185+ repo.Settings.Issues = *params.Issues
186+ }
174 err = r.meta.Update(ctx, repo)187 err = r.meta.Update(ctx, repo)
175 if errors.Is(err, domain.ErrConflict) {188 if errors.Is(err, domain.ErrConflict) {
176 continue189 continue
M internal/usecase/repos_write_test.go +38 -0
@@ -215,6 +215,7 @@ func TestUpdate(t *testing.T) {
215215 "description": {Description: ptr("x")},
216216 "merge modes": {AllowedMergeModes: &modes},
217217 "checks rule": {FailingChecksBlock: &block},
218+ "issues": {Issues: ptr(domain.IssuesOwner)},
218219 } {
219220 f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish
220221 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
@@ -240,6 +241,43 @@ func TestUpdate(t *testing.T) {
240241 })
241242 }
242243
244+func TestUpdateIssuesMode(t *testing.T) {
245+ t.Parallel()
246+ ctx := context.Background()
247+ n := name(t, "tools")
248+ priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Settings: domain.DefaultRepoSettings()}
249+
250+ t.Run("public is refused before anything is written", func(t *testing.T) {
251+ t.Parallel()
252+ f := newFixture(t) // no Update, no Publish
253+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
254+ _, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesPublic)})
255+ assert.ErrorIs(t, err, domain.ErrInvalid)
256+ assert.Contains(t, err.Error(), "public issues are not available yet")
257+ })
258+
259+ t.Run("an unknown mode is invalid", func(t *testing.T) {
260+ t.Parallel()
261+ f := newFixture(t)
262+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
263+ _, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesMode("all"))})
264+ assert.ErrorIs(t, err, domain.ErrInvalid)
265+ })
266+
267+ t.Run("owner is stored and published", func(t *testing.T) {
268+ t.Parallel()
269+ f := newFixture(t)
270+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
271+ f.meta.EXPECT().Update(ctx, mock.MatchedBy(func(r domain.Repo) bool {
272+ return r.Settings.Issues == domain.IssuesOwner && r.Settings.FailingChecksBlock
273+ })).Return(nil)
274+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
275+ repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesOwner)})
276+ require.NoError(t, err)
277+ assert.Equal(t, domain.IssuesOwner, repo.Settings.Issues)
278+ })
279+}
280+
243281 func TestDelete(t *testing.T) {
244282 t.Parallel()
245283 ctx := context.Background()
@@ -215,6 +215,7 @@ func TestUpdate(t *testing.T) {
215 "description": {Description: ptr("x")},215 "description": {Description: ptr("x")},
216 "merge modes": {AllowedMergeModes: &modes},216 "merge modes": {AllowedMergeModes: &modes},
217 "checks rule": {FailingChecksBlock: &block},217 "checks rule": {FailingChecksBlock: &block},
218+ "issues": {Issues: ptr(domain.IssuesOwner)},
218 } {219 } {
219 f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish220 f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish
220 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)221 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
@@ -240,6 +241,43 @@ func TestUpdate(t *testing.T) {
240 })241 })
241 }242 }
242 243
244+func TestUpdateIssuesMode(t *testing.T) {
245+ t.Parallel()
246+ ctx := context.Background()
247+ n := name(t, "tools")
248+ priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Settings: domain.DefaultRepoSettings()}
249+
250+ t.Run("public is refused before anything is written", func(t *testing.T) {
251+ t.Parallel()
252+ f := newFixture(t) // no Update, no Publish
253+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
254+ _, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesPublic)})
255+ assert.ErrorIs(t, err, domain.ErrInvalid)
256+ assert.Contains(t, err.Error(), "public issues are not available yet")
257+ })
258+
259+ t.Run("an unknown mode is invalid", func(t *testing.T) {
260+ t.Parallel()
261+ f := newFixture(t)
262+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
263+ _, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesMode("all"))})
264+ assert.ErrorIs(t, err, domain.ErrInvalid)
265+ })
266+
267+ t.Run("owner is stored and published", func(t *testing.T) {
268+ t.Parallel()
269+ f := newFixture(t)
270+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
271+ f.meta.EXPECT().Update(ctx, mock.MatchedBy(func(r domain.Repo) bool {
272+ return r.Settings.Issues == domain.IssuesOwner && r.Settings.FailingChecksBlock
273+ })).Return(nil)
274+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
275+ repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesOwner)})
276+ require.NoError(t, err)
277+ assert.Equal(t, domain.IssuesOwner, repo.Settings.Issues)
278+ })
279+}
280+
243 func TestDelete(t *testing.T) {281 func TestDelete(t *testing.T) {
244 t.Parallel()282 t.Parallel()
245 ctx := context.Background()283 ctx := context.Background()

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 2

Tags 41

↑↓ moveenter openesc close