detached 974935d

feat(usecase): owner inbox overview across repositories

commit
974935d421642610cc4502207c253d9a4c068621
author
brice · · 2026-10-19 06:37 UTC
parent
e17a0e5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
116116 // ServerService.GetSigningKey.
117117 MergeRequests *usecase.MergeRequests
118118 // 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
120122 SigningKey *usecase.SigningKey
121123 // Signatures are the trusted keys and the commit signature checks
122124 // (ADR 0030).
@@ -321,6 +323,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
321323 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
322324 Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
323325 })
326+ f.Inbox = usecase.NewInbox(usecase.InboxConfig{Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues()})
324327 f.CI.SetPullGate(f.MergeRequests)
325328 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
326329
@@ -116,7 +116,9 @@ type forge struct {
116 // ServerService.GetSigningKey.116 // ServerService.GetSigningKey.
117 MergeRequests *usecase.MergeRequests117 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.Issues119+ Issues *usecase.Issues
120+ // Inbox serves the owner's inbox of the web.
121+ Inbox *usecase.Inbox
120 SigningKey *usecase.SigningKey122 SigningKey *usecase.SigningKey
121 // Signatures are the trusted keys and the commit signature checks123 // 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 {
307307 URL string
308308 ExpiresAt time.Time
309309 }
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 string307 URL string
308 ExpiresAt time.Time308 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 {
144144 Kind(ctx context.Context, p domain.Principal, name domain.RepoName, n int64) (domain.NumberKind, error)
145145 }
146146
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+
147155 // SigningKeyService tells which key signs the commits of the forge.
148156 // *usecase.SigningKey implements it.
149157 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+}

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