feat(usecase): owner inbox overview across repositories
- commit
- 974935d421642610cc4502207c253d9a4c068621
- author
- brice · · 2026-10-19 06:37 UTC
- parent
- e17a0e5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +384 -1
M internal/app/forge.go +4 -1
| @@ -116,7 +116,9 @@ type forge struct { | |||
| 116 | 116 | // ServerService.GetSigningKey. | |
| 117 | 117 | MergeRequests *usecase.MergeRequests | |
| 118 | 118 | // Issues serves IssueService, and tells what a number names. | |
| 119 | - | Issues *usecase.Issues | |
| 119 | + | Issues *usecase.Issues | |
| 120 | + | // Inbox serves the owner's inbox of the web. | |
| 121 | + | Inbox *usecase.Inbox | |
| 120 | 122 | SigningKey *usecase.SigningKey | |
| 121 | 123 | // Signatures are the trusted keys and the commit signature checks | |
| 122 | 124 | // (ADR 0030). | |
| @@ -321,6 +323,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 321 | 323 | f.Issues = usecase.NewIssues(usecase.IssuesConfig{ | |
| 322 | 324 | Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock, | |
| 323 | 325 | }) | |
| 326 | + | f.Inbox = usecase.NewInbox(usecase.InboxConfig{Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues()}) | |
| 324 | 327 | f.CI.SetPullGate(f.MergeRequests) | |
| 325 | 328 | log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready") | |
| 326 | 329 | ||
| @@ -116,7 +116,9 @@ type forge struct { | |||||
| 116 | // ServerService.GetSigningKey. | 116 | // ServerService.GetSigningKey. | ||
| 117 | MergeRequests *usecase.MergeRequests | 117 | MergeRequests *usecase.MergeRequests | ||
| 118 | // Issues serves IssueService, and tells what a number names. | 118 | // Issues serves IssueService, and tells what a number names. | ||
| 119 | - | Issues *usecase.Issues | 119 | + | Issues *usecase.Issues |
| 120 | + | // Inbox serves the owner's inbox of the web. | |||
| 121 | + | Inbox *usecase.Inbox | |||
| 120 | SigningKey *usecase.SigningKey | 122 | SigningKey *usecase.SigningKey | ||
| 121 | // Signatures are the trusted keys and the commit signature checks | 123 | // Signatures are the trusted keys and the commit signature checks | ||
| 122 | // (ADR 0030). | 124 | // (ADR 0030). | ||
| @@ -321,6 +323,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 321 | f.Issues = usecase.NewIssues(usecase.IssuesConfig{ | 323 | f.Issues = usecase.NewIssues(usecase.IssuesConfig{ | ||
| 322 | Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock, | 324 | Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock, | ||
| 323 | }) | 325 | }) | ||
| 326 | + | f.Inbox = usecase.NewInbox(usecase.InboxConfig{Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues()}) | |||
| 324 | f.CI.SetPullGate(f.MergeRequests) | 327 | f.CI.SetPullGate(f.MergeRequests) | ||
| 325 | log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready") | 328 | log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready") | ||
| 326 | 329 | ||||
M internal/ports/dto.go +24 -0
| @@ -307,3 +307,27 @@ type LoginLink struct { | |||
| 307 | 307 | URL string | |
| 308 | 308 | ExpiresAt time.Time | |
| 309 | 309 | } | |
| 310 | + | ||
| 311 | + | // InboxMergeRequest is an open merge request of the owner's inbox. The merge | |
| 312 | + | // request list of a repository shows no CI state, so none is carried here. | |
| 313 | + | type InboxMergeRequest struct { | |
| 314 | + | domain.MergeRequest | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | // InboxCounts are the numbers of open items across every repository, whatever | |
| 318 | + | // the filter. | |
| 319 | + | type InboxCounts struct{ MergeRequests, Issues int } | |
| 320 | + | ||
| 321 | + | // InboxOverview is what the owner has to look at across every repository: the | |
| 322 | + | // open merge requests and the open issues, most recently updated first. A | |
| 323 | + | // section for the items awaiting moderation can be added next to them. | |
| 324 | + | type InboxOverview struct { | |
| 325 | + | Counts InboxCounts | |
| 326 | + | // MergeRequests and Issues are those of the filtered repository, or of | |
| 327 | + | // every repository when there is no filter. Counts always counts all. | |
| 328 | + | MergeRequests []InboxMergeRequest | |
| 329 | + | Issues []domain.Issue | |
| 330 | + | // Repos are the repositories that have at least one open item, by name, | |
| 331 | + | // whatever the filter: they feed the filter itself. | |
| 332 | + | Repos []domain.RepoName | |
| 333 | + | } | |
| @@ -307,3 +307,27 @@ type LoginLink struct { | |||||
| 307 | URL string | 307 | URL string | ||
| 308 | ExpiresAt time.Time | 308 | ExpiresAt time.Time | ||
| 309 | } | 309 | } | ||
| 310 | + | ||||
| 311 | + | // InboxMergeRequest is an open merge request of the owner's inbox. The merge | |||
| 312 | + | // request list of a repository shows no CI state, so none is carried here. | |||
| 313 | + | type InboxMergeRequest struct { | |||
| 314 | + | domain.MergeRequest | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | // InboxCounts are the numbers of open items across every repository, whatever | |||
| 318 | + | // the filter. | |||
| 319 | + | type InboxCounts struct{ MergeRequests, Issues int } | |||
| 320 | + | ||||
| 321 | + | // InboxOverview is what the owner has to look at across every repository: the | |||
| 322 | + | // open merge requests and the open issues, most recently updated first. A | |||
| 323 | + | // section for the items awaiting moderation can be added next to them. | |||
| 324 | + | type InboxOverview struct { | |||
| 325 | + | Counts InboxCounts | |||
| 326 | + | // MergeRequests and Issues are those of the filtered repository, or of | |||
| 327 | + | // every repository when there is no filter. Counts always counts all. | |||
| 328 | + | MergeRequests []InboxMergeRequest | |||
| 329 | + | Issues []domain.Issue | |||
| 330 | + | // Repos are the repositories that have at least one open item, by name, | |||
| 331 | + | // whatever the filter: they feed the filter itself. | |||
| 332 | + | Repos []domain.RepoName | |||
| 333 | + | } | |||
A internal/ports/mocks/InboxService.go +121 -0
| @@ -0,0 +1,121 @@ | |||
| 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 | + | // NewInboxService creates a new instance of InboxService. 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 NewInboxService(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *InboxService { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &InboxService{} | |
| 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 | + | // InboxService is an autogenerated mock type for the InboxService type | |
| 39 | + | type InboxService struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type InboxService_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *InboxService) EXPECT() *InboxService_Expecter { | |
| 48 | + | return &InboxService_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Overview provides a mock function for the type InboxService | |
| 52 | + | func (_mock *InboxService) Overview(ctx context.Context, p domain.Principal, filter domain.RepoName) (ports.InboxOverview, error) { | |
| 53 | + | ret := _mock.Called(ctx, p, filter) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for Overview") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 ports.InboxOverview | |
| 60 | + | var r1 error | |
| 61 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) (ports.InboxOverview, error)); ok { | |
| 62 | + | return returnFunc(ctx, p, filter) | |
| 63 | + | } | |
| 64 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) ports.InboxOverview); ok { | |
| 65 | + | r0 = returnFunc(ctx, p, filter) | |
| 66 | + | } else { | |
| 67 | + | r0 = ret.Get(0).(ports.InboxOverview) | |
| 68 | + | } | |
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok { | |
| 70 | + | r1 = returnFunc(ctx, p, filter) | |
| 71 | + | } else { | |
| 72 | + | r1 = ret.Error(1) | |
| 73 | + | } | |
| 74 | + | return r0, r1 | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // InboxService_Overview_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Overview' | |
| 78 | + | type InboxService_Overview_Call struct { | |
| 79 | + | *mock.Call | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Overview is a helper method to define mock.On call | |
| 83 | + | // - ctx context.Context | |
| 84 | + | // - p domain.Principal | |
| 85 | + | // - filter domain.RepoName | |
| 86 | + | func (_e *InboxService_Expecter) Overview(ctx any, p any, filter any) *InboxService_Overview_Call { | |
| 87 | + | return &InboxService_Overview_Call{Call: _e.mock.On("Overview", ctx, p, filter)} | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (_c *InboxService_Overview_Call) Run(run func(ctx context.Context, p domain.Principal, filter domain.RepoName)) *InboxService_Overview_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 | + | run( | |
| 105 | + | arg0, | |
| 106 | + | arg1, | |
| 107 | + | arg2, | |
| 108 | + | ) | |
| 109 | + | }) | |
| 110 | + | return _c | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (_c *InboxService_Overview_Call) Return(inboxOverview ports.InboxOverview, err error) *InboxService_Overview_Call { | |
| 114 | + | _c.Call.Return(inboxOverview, err) | |
| 115 | + | return _c | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (_c *InboxService_Overview_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, filter domain.RepoName) (ports.InboxOverview, error)) *InboxService_Overview_Call { | |
| 119 | + | _c.Call.Return(run) | |
| 120 | + | return _c | |
| 121 | + | } | |
| @@ -0,0 +1,121 @@ | |||||
| 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 | + | // NewInboxService creates a new instance of InboxService. 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 NewInboxService(t interface { | |||
| 18 | + | mock.TestingT | |||
| 19 | + | Cleanup(func()) | |||
| 20 | + | }) *InboxService { | |||
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 22 | + | helper.Helper() | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | mock := &InboxService{} | |||
| 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 | + | // InboxService is an autogenerated mock type for the InboxService type | |||
| 39 | + | type InboxService struct { | |||
| 40 | + | mock.Mock | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | type InboxService_Expecter struct { | |||
| 44 | + | mock *mock.Mock | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (_m *InboxService) EXPECT() *InboxService_Expecter { | |||
| 48 | + | return &InboxService_Expecter{mock: &_m.Mock} | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // Overview provides a mock function for the type InboxService | |||
| 52 | + | func (_mock *InboxService) Overview(ctx context.Context, p domain.Principal, filter domain.RepoName) (ports.InboxOverview, error) { | |||
| 53 | + | ret := _mock.Called(ctx, p, filter) | |||
| 54 | + | ||||
| 55 | + | if len(ret) == 0 { | |||
| 56 | + | panic("no return value specified for Overview") | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | var r0 ports.InboxOverview | |||
| 60 | + | var r1 error | |||
| 61 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) (ports.InboxOverview, error)); ok { | |||
| 62 | + | return returnFunc(ctx, p, filter) | |||
| 63 | + | } | |||
| 64 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) ports.InboxOverview); ok { | |||
| 65 | + | r0 = returnFunc(ctx, p, filter) | |||
| 66 | + | } else { | |||
| 67 | + | r0 = ret.Get(0).(ports.InboxOverview) | |||
| 68 | + | } | |||
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok { | |||
| 70 | + | r1 = returnFunc(ctx, p, filter) | |||
| 71 | + | } else { | |||
| 72 | + | r1 = ret.Error(1) | |||
| 73 | + | } | |||
| 74 | + | return r0, r1 | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // InboxService_Overview_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Overview' | |||
| 78 | + | type InboxService_Overview_Call struct { | |||
| 79 | + | *mock.Call | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // Overview is a helper method to define mock.On call | |||
| 83 | + | // - ctx context.Context | |||
| 84 | + | // - p domain.Principal | |||
| 85 | + | // - filter domain.RepoName | |||
| 86 | + | func (_e *InboxService_Expecter) Overview(ctx any, p any, filter any) *InboxService_Overview_Call { | |||
| 87 | + | return &InboxService_Overview_Call{Call: _e.mock.On("Overview", ctx, p, filter)} | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func (_c *InboxService_Overview_Call) Run(run func(ctx context.Context, p domain.Principal, filter domain.RepoName)) *InboxService_Overview_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 | + | run( | |||
| 105 | + | arg0, | |||
| 106 | + | arg1, | |||
| 107 | + | arg2, | |||
| 108 | + | ) | |||
| 109 | + | }) | |||
| 110 | + | return _c | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func (_c *InboxService_Overview_Call) Return(inboxOverview ports.InboxOverview, err error) *InboxService_Overview_Call { | |||
| 114 | + | _c.Call.Return(inboxOverview, err) | |||
| 115 | + | return _c | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func (_c *InboxService_Overview_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, filter domain.RepoName) (ports.InboxOverview, error)) *InboxService_Overview_Call { | |||
| 119 | + | _c.Call.Return(run) | |||
| 120 | + | return _c | |||
| 121 | + | } | |||
M internal/ports/services.go +8 -0
| @@ -144,6 +144,14 @@ type IssueNumberResolver interface { | |||
| 144 | 144 | Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) | |
| 145 | 145 | } | |
| 146 | 146 | ||
| 147 | + | // InboxService is the owner's inbox: what is open across every repository. | |
| 148 | + | // *usecase.Inbox implements it. | |
| 149 | + | type InboxService interface { | |
| 150 | + | // Overview needs owner rights and answers domain.ErrNotFound to anyone | |
| 151 | + | // else. The filter, when not zero, restricts the lists to that repository. | |
| 152 | + | Overview(ctx context.Context, p domain.Principal, filter domain.RepoName) (InboxOverview, error) | |
| 153 | + | } | |
| 154 | + | ||
| 147 | 155 | // SigningKeyService tells which key signs the commits of the forge. | |
| 148 | 156 | // *usecase.SigningKey implements it. | |
| 149 | 157 | type SigningKeyService interface { | |
| @@ -144,6 +144,14 @@ type IssueNumberResolver interface { | |||||
| 144 | Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) | 144 | Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error) | ||
| 145 | } | 145 | } | ||
| 146 | 146 | ||||
| 147 | + | // InboxService is the owner's inbox: what is open across every repository. | |||
| 148 | + | // *usecase.Inbox implements it. | |||
| 149 | + | type InboxService interface { | |||
| 150 | + | // Overview needs owner rights and answers domain.ErrNotFound to anyone | |||
| 151 | + | // else. The filter, when not zero, restricts the lists to that repository. | |||
| 152 | + | Overview(ctx context.Context, p domain.Principal, filter domain.RepoName) (InboxOverview, error) | |||
| 153 | + | } | |||
| 154 | + | ||||
| 147 | // SigningKeyService tells which key signs the commits of the forge. | 155 | // SigningKeyService tells which key signs the commits of the forge. | ||
| 148 | // *usecase.SigningKey implements it. | 156 | // *usecase.SigningKey implements it. | ||
| 149 | type SigningKeyService interface { | 157 | type SigningKeyService interface { | ||
A internal/usecase/inbox.go +140 -0
| @@ -0,0 +1,140 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "slices" | |
| 10 | + | "strings" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | "bnema.dev/ermite/internal/ports" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // Inbox is the owner's inbox (ADR 0031): the open merge requests and issues of | |
| 18 | + | // every repository in one place. Only the owner may read it; anyone else | |
| 19 | + | // gets domain.ErrNotFound, as for a page that does not exist. The issues of a | |
| 20 | + | // repository whose issues mode is off are left out. | |
| 21 | + | type Inbox struct { | |
| 22 | + | meta ports.RepoMetaStore | |
| 23 | + | mrs ports.MergeRequestStore | |
| 24 | + | issues ports.IssueStore | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | var _ ports.InboxService = (*Inbox)(nil) | |
| 28 | + | ||
| 29 | + | // InboxConfig holds what the inbox needs. Every field is required. | |
| 30 | + | type InboxConfig struct { | |
| 31 | + | Meta ports.RepoMetaStore | |
| 32 | + | MergeRequests ports.MergeRequestStore | |
| 33 | + | Issues ports.IssueStore | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // NewInbox builds the inbox use case. | |
| 37 | + | func NewInbox(cfg InboxConfig) *Inbox { | |
| 38 | + | return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues} | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // Overview lists what is open, through the indexes of open items. The counts | |
| 42 | + | // and the list of repositories do not depend on the filter, the lists do. | |
| 43 | + | func (in *Inbox) Overview(ctx context.Context, p domain.Principal, filter domain.RepoName) (ports.InboxOverview, error) { | |
| 44 | + | var out ports.InboxOverview | |
| 45 | + | if !p.HasOwnerRights() { | |
| 46 | + | return out, domain.ErrNotFound | |
| 47 | + | } | |
| 48 | + | mrs, err := in.mrs.ListOpenAll(ctx) | |
| 49 | + | if err != nil { | |
| 50 | + | return out, fmt.Errorf("list open merge requests: %w", err) | |
| 51 | + | } | |
| 52 | + | issues, err := in.issues.ListOpenAll(ctx) | |
| 53 | + | if err != nil { | |
| 54 | + | return out, fmt.Errorf("list open issues: %w", err) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // exists and issuesOn cache what is read of each repository. | |
| 58 | + | issuesOn := make(map[domain.RepoName]bool) | |
| 59 | + | exists := make(map[domain.RepoName]bool) | |
| 60 | + | look := func(name domain.RepoName) error { | |
| 61 | + | if _, ok := exists[name]; ok { | |
| 62 | + | return nil | |
| 63 | + | } | |
| 64 | + | repo, err := lookupRepo(ctx, in.meta, name) | |
| 65 | + | switch { | |
| 66 | + | case errors.Is(err, domain.ErrNotFound): | |
| 67 | + | exists[name] = false | |
| 68 | + | case err != nil: | |
| 69 | + | return fmt.Errorf("read repository %q: %w", name, err) | |
| 70 | + | default: | |
| 71 | + | exists[name] = true | |
| 72 | + | issuesOn[name] = repo.Settings.Issues.OrDefault() != domain.IssuesOff | |
| 73 | + | } | |
| 74 | + | return nil | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | repos := make(map[domain.RepoName]bool) | |
| 78 | + | for _, m := range mrs { | |
| 79 | + | if err := look(m.Repo); err != nil { | |
| 80 | + | return out, err | |
| 81 | + | } | |
| 82 | + | if !exists[m.Repo] { | |
| 83 | + | continue | |
| 84 | + | } | |
| 85 | + | repos[m.Repo] = true | |
| 86 | + | out.Counts.MergeRequests++ | |
| 87 | + | if filter.IsZero() || m.Repo == filter { | |
| 88 | + | out.MergeRequests = append(out.MergeRequests, ports.InboxMergeRequest{MergeRequest: m}) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | for _, i := range issues { | |
| 92 | + | if err := look(i.Repo); err != nil { | |
| 93 | + | return out, err | |
| 94 | + | } | |
| 95 | + | if !exists[i.Repo] || !issuesOn[i.Repo] { | |
| 96 | + | continue | |
| 97 | + | } | |
| 98 | + | repos[i.Repo] = true | |
| 99 | + | out.Counts.Issues++ | |
| 100 | + | if filter.IsZero() || i.Repo == filter { | |
| 101 | + | out.Issues = append(out.Issues, i) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | slices.SortStableFunc(out.MergeRequests, func(a, b ports.InboxMergeRequest) int { | |
| 106 | + | return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number) | |
| 107 | + | }) | |
| 108 | + | slices.SortStableFunc(out.Issues, func(a, b domain.Issue) int { | |
| 109 | + | return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number) | |
| 110 | + | }) | |
| 111 | + | for r := range repos { | |
| 112 | + | out.Repos = append(out.Repos, r) | |
| 113 | + | } | |
| 114 | + | slices.SortFunc(out.Repos, func(a, b domain.RepoName) int { return strings.Compare(a.String(), b.String()) }) | |
| 115 | + | return out, nil | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // compareRecent orders by update time, most recent first (the creation time | |
| 119 | + | // stands in for a missing one), then by repository and number. | |
| 120 | + | func compareRecent(aUp, aCr time.Time, aRepo domain.RepoName, aN int64, bUp, bCr time.Time, bRepo domain.RepoName, bN int64) int { | |
| 121 | + | if aUp.IsZero() { | |
| 122 | + | aUp = aCr | |
| 123 | + | } | |
| 124 | + | if bUp.IsZero() { | |
| 125 | + | bUp = bCr | |
| 126 | + | } | |
| 127 | + | if c := bUp.Compare(aUp); c != 0 { | |
| 128 | + | return c | |
| 129 | + | } | |
| 130 | + | if c := strings.Compare(aRepo.String(), bRepo.String()); c != 0 { | |
| 131 | + | return c | |
| 132 | + | } | |
| 133 | + | switch { | |
| 134 | + | case aN > bN: | |
| 135 | + | return -1 | |
| 136 | + | case aN < bN: | |
| 137 | + | return 1 | |
| 138 | + | } | |
| 139 | + | return 0 | |
| 140 | + | } | |
| @@ -0,0 +1,140 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "slices" | |||
| 10 | + | "strings" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "bnema.dev/ermite/internal/domain" | |||
| 14 | + | "bnema.dev/ermite/internal/ports" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // Inbox is the owner's inbox (ADR 0031): the open merge requests and issues of | |||
| 18 | + | // every repository in one place. Only the owner may read it; anyone else | |||
| 19 | + | // gets domain.ErrNotFound, as for a page that does not exist. The issues of a | |||
| 20 | + | // repository whose issues mode is off are left out. | |||
| 21 | + | type Inbox struct { | |||
| 22 | + | meta ports.RepoMetaStore | |||
| 23 | + | mrs ports.MergeRequestStore | |||
| 24 | + | issues ports.IssueStore | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | var _ ports.InboxService = (*Inbox)(nil) | |||
| 28 | + | ||||
| 29 | + | // InboxConfig holds what the inbox needs. Every field is required. | |||
| 30 | + | type InboxConfig struct { | |||
| 31 | + | Meta ports.RepoMetaStore | |||
| 32 | + | MergeRequests ports.MergeRequestStore | |||
| 33 | + | Issues ports.IssueStore | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // NewInbox builds the inbox use case. | |||
| 37 | + | func NewInbox(cfg InboxConfig) *Inbox { | |||
| 38 | + | return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues} | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // Overview lists what is open, through the indexes of open items. The counts | |||
| 42 | + | // and the list of repositories do not depend on the filter, the lists do. | |||
| 43 | + | func (in *Inbox) Overview(ctx context.Context, p domain.Principal, filter domain.RepoName) (ports.InboxOverview, error) { | |||
| 44 | + | var out ports.InboxOverview | |||
| 45 | + | if !p.HasOwnerRights() { | |||
| 46 | + | return out, domain.ErrNotFound | |||
| 47 | + | } | |||
| 48 | + | mrs, err := in.mrs.ListOpenAll(ctx) | |||
| 49 | + | if err != nil { | |||
| 50 | + | return out, fmt.Errorf("list open merge requests: %w", err) | |||
| 51 | + | } | |||
| 52 | + | issues, err := in.issues.ListOpenAll(ctx) | |||
| 53 | + | if err != nil { | |||
| 54 | + | return out, fmt.Errorf("list open issues: %w", err) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // exists and issuesOn cache what is read of each repository. | |||
| 58 | + | issuesOn := make(map[domain.RepoName]bool) | |||
| 59 | + | exists := make(map[domain.RepoName]bool) | |||
| 60 | + | look := func(name domain.RepoName) error { | |||
| 61 | + | if _, ok := exists[name]; ok { | |||
| 62 | + | return nil | |||
| 63 | + | } | |||
| 64 | + | repo, err := lookupRepo(ctx, in.meta, name) | |||
| 65 | + | switch { | |||
| 66 | + | case errors.Is(err, domain.ErrNotFound): | |||
| 67 | + | exists[name] = false | |||
| 68 | + | case err != nil: | |||
| 69 | + | return fmt.Errorf("read repository %q: %w", name, err) | |||
| 70 | + | default: | |||
| 71 | + | exists[name] = true | |||
| 72 | + | issuesOn[name] = repo.Settings.Issues.OrDefault() != domain.IssuesOff | |||
| 73 | + | } | |||
| 74 | + | return nil | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | repos := make(map[domain.RepoName]bool) | |||
| 78 | + | for _, m := range mrs { | |||
| 79 | + | if err := look(m.Repo); err != nil { | |||
| 80 | + | return out, err | |||
| 81 | + | } | |||
| 82 | + | if !exists[m.Repo] { | |||
| 83 | + | continue | |||
| 84 | + | } | |||
| 85 | + | repos[m.Repo] = true | |||
| 86 | + | out.Counts.MergeRequests++ | |||
| 87 | + | if filter.IsZero() || m.Repo == filter { | |||
| 88 | + | out.MergeRequests = append(out.MergeRequests, ports.InboxMergeRequest{MergeRequest: m}) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | for _, i := range issues { | |||
| 92 | + | if err := look(i.Repo); err != nil { | |||
| 93 | + | return out, err | |||
| 94 | + | } | |||
| 95 | + | if !exists[i.Repo] || !issuesOn[i.Repo] { | |||
| 96 | + | continue | |||
| 97 | + | } | |||
| 98 | + | repos[i.Repo] = true | |||
| 99 | + | out.Counts.Issues++ | |||
| 100 | + | if filter.IsZero() || i.Repo == filter { | |||
| 101 | + | out.Issues = append(out.Issues, i) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | slices.SortStableFunc(out.MergeRequests, func(a, b ports.InboxMergeRequest) int { | |||
| 106 | + | return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number) | |||
| 107 | + | }) | |||
| 108 | + | slices.SortStableFunc(out.Issues, func(a, b domain.Issue) int { | |||
| 109 | + | return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number) | |||
| 110 | + | }) | |||
| 111 | + | for r := range repos { | |||
| 112 | + | out.Repos = append(out.Repos, r) | |||
| 113 | + | } | |||
| 114 | + | slices.SortFunc(out.Repos, func(a, b domain.RepoName) int { return strings.Compare(a.String(), b.String()) }) | |||
| 115 | + | return out, nil | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // compareRecent orders by update time, most recent first (the creation time | |||
| 119 | + | // stands in for a missing one), then by repository and number. | |||
| 120 | + | func compareRecent(aUp, aCr time.Time, aRepo domain.RepoName, aN int64, bUp, bCr time.Time, bRepo domain.RepoName, bN int64) int { | |||
| 121 | + | if aUp.IsZero() { | |||
| 122 | + | aUp = aCr | |||
| 123 | + | } | |||
| 124 | + | if bUp.IsZero() { | |||
| 125 | + | bUp = bCr | |||
| 126 | + | } | |||
| 127 | + | if c := bUp.Compare(aUp); c != 0 { | |||
| 128 | + | return c | |||
| 129 | + | } | |||
| 130 | + | if c := strings.Compare(aRepo.String(), bRepo.String()); c != 0 { | |||
| 131 | + | return c | |||
| 132 | + | } | |||
| 133 | + | switch { | |||
| 134 | + | case aN > bN: | |||
| 135 | + | return -1 | |||
| 136 | + | case aN < bN: | |||
| 137 | + | return 1 | |||
| 138 | + | } | |||
| 139 | + | return 0 | |||
| 140 | + | } | |||
A internal/usecase/inbox_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 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | "bnema.dev/ermite/internal/ports" | |
| 16 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 17 | + | "bnema.dev/ermite/internal/usecase" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func inboxMR(t *testing.T, repo string, n int64, up time.Time) domain.MergeRequest { | |
| 21 | + | t.Helper() | |
| 22 | + | ti, err := domain.ParseMRTitle("mr") | |
| 23 | + | require.NoError(t, err) | |
| 24 | + | return domain.MergeRequest{Repo: name(t, repo), Number: n, Title: ti, State: domain.MRStateOpen, UpdatedAt: up} | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func inboxIssue(t *testing.T, repo string, n int64, up time.Time) domain.Issue { | |
| 28 | + | t.Helper() | |
| 29 | + | ti, err := domain.ParseIssueTitle("issue") | |
| 30 | + | require.NoError(t, err) | |
| 31 | + | return domain.Issue{Repo: name(t, repo), Number: n, Title: ti, State: domain.IssueStateOpen, UpdatedAt: up} | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func newInboxUnit(t *testing.T) (*usecase.Inbox, *mocks.MergeRequestStore, *mocks.IssueStore, *mocks.RepoMetaStore) { | |
| 35 | + | t.Helper() | |
| 36 | + | mrs, is, meta := mocks.NewMergeRequestStore(t), mocks.NewIssueStore(t), mocks.NewRepoMetaStore(t) | |
| 37 | + | return usecase.NewInbox(usecase.InboxConfig{Meta: meta, MergeRequests: mrs, Issues: is}), mrs, is, meta | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func repoWithIssues(t *testing.T, n string, mode domain.IssuesMode) domain.Repo { | |
| 41 | + | s := domain.DefaultRepoSettings() | |
| 42 | + | s.Issues = mode | |
| 43 | + | return domain.Repo{Name: name(t, n), Visibility: domain.VisibilityPrivate, Settings: s} | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestInboxIsOwnerOnly(t *testing.T) { | |
| 47 | + | t.Parallel() | |
| 48 | + | in, _, _, _ := newInboxUnit(t) // the stores are never read | |
| 49 | + | for _, p := range []domain.Principal{domain.Anonymous(), issuePrincipal(domain.ScopeIssueWrite)} { | |
| 50 | + | _, err := in.Overview(context.Background(), p, domain.RepoName{}) | |
| 51 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestInboxOverviewSortsFiltersAndSkipsIssuesOff(t *testing.T) { | |
| 56 | + | t.Parallel() | |
| 57 | + | ctx := context.Background() | |
| 58 | + | t0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) | |
| 59 | + | in, mrs, is, meta := newInboxUnit(t) | |
| 60 | + | mrs.EXPECT().ListOpenAll(mock.Anything).Return([]domain.MergeRequest{ | |
| 61 | + | inboxMR(t, "alpha", 1, t0), inboxMR(t, "beta", 1, t0.Add(time.Hour)), inboxMR(t, "gone", 1, t0), | |
| 62 | + | }, nil) | |
| 63 | + | is.EXPECT().ListOpenAll(mock.Anything).Return([]domain.Issue{ | |
| 64 | + | inboxIssue(t, "alpha", 2, t0), inboxIssue(t, "beta", 2, t0.Add(2*time.Hour)), inboxIssue(t, "quiet", 2, t0), | |
| 65 | + | }, nil) | |
| 66 | + | meta.EXPECT().Get(mock.Anything, name(t, "alpha")).Return(repoWithIssues(t, "alpha", domain.IssuesOwner), nil) | |
| 67 | + | meta.EXPECT().Get(mock.Anything, name(t, "beta")).Return(repoWithIssues(t, "beta", domain.IssuesPublic), nil) | |
| 68 | + | meta.EXPECT().Get(mock.Anything, name(t, "quiet")).Return(repoWithIssues(t, "quiet", domain.IssuesOff), nil) | |
| 69 | + | meta.EXPECT().Get(mock.Anything, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound) | |
| 70 | + | ||
| 71 | + | got, err := in.Overview(ctx, domain.Owner(), domain.RepoName{}) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | assert.Equal(t, ports.InboxCounts{MergeRequests: 2, Issues: 2}, got.Counts) | |
| 74 | + | require.Len(t, got.MergeRequests, 2) | |
| 75 | + | assert.Equal(t, "beta", got.MergeRequests[0].Repo.String(), "most recently updated first") | |
| 76 | + | require.Len(t, got.Issues, 2) | |
| 77 | + | assert.Equal(t, "beta", got.Issues[0].Repo.String()) | |
| 78 | + | assert.Equal(t, []domain.RepoName{name(t, "alpha"), name(t, "beta")}, got.Repos) | |
| 79 | + | ||
| 80 | + | got, err = in.Overview(ctx, domain.Root(), name(t, "alpha")) | |
| 81 | + | require.NoError(t, err) | |
| 82 | + | assert.Equal(t, ports.InboxCounts{MergeRequests: 2, Issues: 2}, got.Counts, "counts ignore the filter") | |
| 83 | + | require.Len(t, got.MergeRequests, 1) | |
| 84 | + | require.Len(t, got.Issues, 1) | |
| 85 | + | assert.Equal(t, "alpha", got.Issues[0].Repo.String()) | |
| 86 | + | assert.Len(t, got.Repos, 2, "the filter list keeps every repository") | |
| 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 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/mock" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | "bnema.dev/ermite/internal/domain" | |||
| 15 | + | "bnema.dev/ermite/internal/ports" | |||
| 16 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 17 | + | "bnema.dev/ermite/internal/usecase" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | func inboxMR(t *testing.T, repo string, n int64, up time.Time) domain.MergeRequest { | |||
| 21 | + | t.Helper() | |||
| 22 | + | ti, err := domain.ParseMRTitle("mr") | |||
| 23 | + | require.NoError(t, err) | |||
| 24 | + | return domain.MergeRequest{Repo: name(t, repo), Number: n, Title: ti, State: domain.MRStateOpen, UpdatedAt: up} | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func inboxIssue(t *testing.T, repo string, n int64, up time.Time) domain.Issue { | |||
| 28 | + | t.Helper() | |||
| 29 | + | ti, err := domain.ParseIssueTitle("issue") | |||
| 30 | + | require.NoError(t, err) | |||
| 31 | + | return domain.Issue{Repo: name(t, repo), Number: n, Title: ti, State: domain.IssueStateOpen, UpdatedAt: up} | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func newInboxUnit(t *testing.T) (*usecase.Inbox, *mocks.MergeRequestStore, *mocks.IssueStore, *mocks.RepoMetaStore) { | |||
| 35 | + | t.Helper() | |||
| 36 | + | mrs, is, meta := mocks.NewMergeRequestStore(t), mocks.NewIssueStore(t), mocks.NewRepoMetaStore(t) | |||
| 37 | + | return usecase.NewInbox(usecase.InboxConfig{Meta: meta, MergeRequests: mrs, Issues: is}), mrs, is, meta | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func repoWithIssues(t *testing.T, n string, mode domain.IssuesMode) domain.Repo { | |||
| 41 | + | s := domain.DefaultRepoSettings() | |||
| 42 | + | s.Issues = mode | |||
| 43 | + | return domain.Repo{Name: name(t, n), Visibility: domain.VisibilityPrivate, Settings: s} | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestInboxIsOwnerOnly(t *testing.T) { | |||
| 47 | + | t.Parallel() | |||
| 48 | + | in, _, _, _ := newInboxUnit(t) // the stores are never read | |||
| 49 | + | for _, p := range []domain.Principal{domain.Anonymous(), issuePrincipal(domain.ScopeIssueWrite)} { | |||
| 50 | + | _, err := in.Overview(context.Background(), p, domain.RepoName{}) | |||
| 51 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestInboxOverviewSortsFiltersAndSkipsIssuesOff(t *testing.T) { | |||
| 56 | + | t.Parallel() | |||
| 57 | + | ctx := context.Background() | |||
| 58 | + | t0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) | |||
| 59 | + | in, mrs, is, meta := newInboxUnit(t) | |||
| 60 | + | mrs.EXPECT().ListOpenAll(mock.Anything).Return([]domain.MergeRequest{ | |||
| 61 | + | inboxMR(t, "alpha", 1, t0), inboxMR(t, "beta", 1, t0.Add(time.Hour)), inboxMR(t, "gone", 1, t0), | |||
| 62 | + | }, nil) | |||
| 63 | + | is.EXPECT().ListOpenAll(mock.Anything).Return([]domain.Issue{ | |||
| 64 | + | inboxIssue(t, "alpha", 2, t0), inboxIssue(t, "beta", 2, t0.Add(2*time.Hour)), inboxIssue(t, "quiet", 2, t0), | |||
| 65 | + | }, nil) | |||
| 66 | + | meta.EXPECT().Get(mock.Anything, name(t, "alpha")).Return(repoWithIssues(t, "alpha", domain.IssuesOwner), nil) | |||
| 67 | + | meta.EXPECT().Get(mock.Anything, name(t, "beta")).Return(repoWithIssues(t, "beta", domain.IssuesPublic), nil) | |||
| 68 | + | meta.EXPECT().Get(mock.Anything, name(t, "quiet")).Return(repoWithIssues(t, "quiet", domain.IssuesOff), nil) | |||
| 69 | + | meta.EXPECT().Get(mock.Anything, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound) | |||
| 70 | + | ||||
| 71 | + | got, err := in.Overview(ctx, domain.Owner(), domain.RepoName{}) | |||
| 72 | + | require.NoError(t, err) | |||
| 73 | + | assert.Equal(t, ports.InboxCounts{MergeRequests: 2, Issues: 2}, got.Counts) | |||
| 74 | + | require.Len(t, got.MergeRequests, 2) | |||
| 75 | + | assert.Equal(t, "beta", got.MergeRequests[0].Repo.String(), "most recently updated first") | |||
| 76 | + | require.Len(t, got.Issues, 2) | |||
| 77 | + | assert.Equal(t, "beta", got.Issues[0].Repo.String()) | |||
| 78 | + | assert.Equal(t, []domain.RepoName{name(t, "alpha"), name(t, "beta")}, got.Repos) | |||
| 79 | + | ||||
| 80 | + | got, err = in.Overview(ctx, domain.Root(), name(t, "alpha")) | |||
| 81 | + | require.NoError(t, err) | |||
| 82 | + | assert.Equal(t, ports.InboxCounts{MergeRequests: 2, Issues: 2}, got.Counts, "counts ignore the filter") | |||
| 83 | + | require.Len(t, got.MergeRequests, 1) | |||
| 84 | + | require.Len(t, got.Issues, 1) | |||
| 85 | + | assert.Equal(t, "alpha", got.Issues[0].Repo.String()) | |||
| 86 | + | assert.Len(t, got.Repos, 2, "the filter list keeps every repository") | |||
| 87 | + | } | |||