detached 16a8861

feat(app): open repositories for push, create on first push and fix the default branch

commit
16a8861be6ed0f789ec5403d39e72e521622af6d
author
brice · · 2026-10-10 07:10 UTC
parent
1ab78bb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +622 -0
A internal/app/pushes.go +192 -0
@@ -0,0 +1,192 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "strings"
10+ "time"
11+
12+ "github.com/bnema/zerowrap"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+)
17+
18+// pushRetries bounds the retries of a metadata update that loses a race.
19+const pushRetries = 5
20+
21+// MaintenanceScheduler queues repository maintenance. *Maintainer fits.
22+type MaintenanceScheduler interface {
23+ Schedule(name domain.RepoName)
24+}
25+
26+// Pushes are the use cases around a Git push.
27+type Pushes struct {
28+ meta ports.RepoMetaStore
29+ repos *Repos
30+ git ports.GitRepos
31+ bus ports.EventBus
32+ clock ports.Clock
33+ maint MaintenanceScheduler
34+}
35+
36+// NewPushes builds the push use cases. maint may be nil, which disables
37+// maintenance after a push.
38+func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus ports.EventBus, clock ports.Clock, maint MaintenanceScheduler) *Pushes {
39+ return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint}
40+}
41+
42+// Open decides whether the principal may push to the repository and, with
43+// create, creates it when it does not exist yet (private, published as
44+// RepoCreated). create is for the first request of a push only.
45+//
46+// Anonymous callers get domain.ErrUnauthenticated. A repository the caller
47+// cannot read and cannot write, or a missing one it may not create, answers
48+// domain.ErrNotFound, so that a private repository never shows itself. One it
49+// can read but not write answers domain.ErrForbidden.
50+func (u *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) (domain.Repo, bool, error) {
51+ if p.IsAnonymous() {
52+ return domain.Repo{}, false, domain.ErrUnauthenticated
53+ }
54+ repo, err := u.meta.Get(ctx, name)
55+ switch {
56+ case err == nil:
57+ return u.openExisting(p, repo)
58+ case !errors.Is(err, domain.ErrNotFound):
59+ return domain.Repo{}, false, fmt.Errorf("get repository %q: %w", name, err)
60+ }
61+
62+ if !create || !canWrite(p, name) {
63+ return domain.Repo{}, false, domain.ErrNotFound
64+ }
65+ repo, err = u.repos.Create(ctx, p, CreateParams{Name: name, Visibility: domain.VisibilityPrivate})
66+ switch {
67+ case err == nil:
68+ u.bus.Publish(ctx, domain.RepoCreated{Repo: repo.Name, Visibility: repo.Visibility, At: repo.CreatedAt})
69+ return repo, true, nil
70+ case errors.Is(err, domain.ErrAlreadyExists):
71+ // A concurrent first push won the race: this one pushes to its repository.
72+ repo, gerr := u.meta.Get(ctx, name)
73+ if gerr != nil {
74+ return domain.Repo{}, false, fmt.Errorf("get repository %q: %w", name, gerr)
75+ }
76+ return u.openExisting(p, repo)
77+ }
78+ return domain.Repo{}, false, err
79+}
80+
81+func (u *Pushes) openExisting(p domain.Principal, repo domain.Repo) (domain.Repo, bool, error) {
82+ switch {
83+ case canWrite(p, repo.Name):
84+ return repo, false, nil
85+ case canRead(p, repo):
86+ return domain.Repo{}, false, domain.ErrForbidden
87+ }
88+ return domain.Repo{}, false, domain.ErrNotFound
89+}
90+
91+// Check is the pre-receive decision. v1 accepts every push of a principal that
92+// may write the repository; protected branches and limits will refuse here by
93+// returning an error that matches domain.ErrRejected.
94+func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
95+ if !canWrite(p, name) {
96+ return fmt.Errorf("%w: no write access to %s", domain.ErrRejected, name)
97+ }
98+ log := zerowrap.FromCtx(ctx)
99+ log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked")
100+ return nil
101+}
102+
103+// Record is the post-receive step of an accepted push: it sets pushed_at,
104+// repairs HEAD when it points to a missing branch, publishes a PushEvent and
105+// schedules maintenance. Every step runs even when an earlier one fails; the
106+// failures are returned together and the push itself is not affected.
107+func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
108+ log := zerowrap.FromCtx(ctx)
109+ now := u.clock.Now().UTC()
110+ var errs []error
111+
112+ if err := u.touchPushedAt(ctx, name, now); err != nil {
113+ errs = append(errs, err)
114+ }
115+ if err := u.fixDefaultBranch(ctx, name, updates); err != nil {
116+ errs = append(errs, err)
117+ }
118+ u.bus.Publish(ctx, domain.PushEvent{Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now})
119+ if u.maint != nil {
120+ u.maint.Schedule(name)
121+ }
122+ log.Info().
123+ Str("repo", name.String()).Int("refs", len(updates)).
124+ Str("principal", p.Label()).Str("token_id", p.TokenID()).
125+ Msg("push received")
126+ return errors.Join(errs...)
127+}
128+
129+// touchPushedAt stores pushed_at, retrying when another write got in between.
130+// A repository deleted meanwhile is not an error.
131+func (u *Pushes) touchPushedAt(ctx context.Context, name domain.RepoName, now time.Time) error {
132+ for range pushRetries {
133+ repo, err := u.meta.Get(ctx, name)
134+ if errors.Is(err, domain.ErrNotFound) {
135+ return nil
136+ }
137+ if err != nil {
138+ return fmt.Errorf("get repository %q: %w", name, err)
139+ }
140+ if !now.After(repo.PushedAt) {
141+ return nil // a later push already recorded
142+ }
143+ repo.PushedAt = now
144+ err = u.meta.Update(ctx, repo)
145+ if errors.Is(err, domain.ErrConflict) {
146+ continue
147+ }
148+ if errors.Is(err, domain.ErrNotFound) {
149+ return nil
150+ }
151+ if err != nil {
152+ return fmt.Errorf("update repository %q: %w", name, err)
153+ }
154+ return nil
155+ }
156+ return fmt.Errorf("update pushed_at of %q: %w", name, domain.ErrConflict)
157+}
158+
159+// fixDefaultBranch points HEAD to a branch that exists when it points to a
160+// missing one: main when it was pushed, else the first pushed branch.
161+func (u *Pushes) fixDefaultBranch(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate) error {
162+ branch, exists, err := u.git.DefaultBranch(ctx, name)
163+ if err != nil {
164+ return fmt.Errorf("read default branch of %q: %w", name, err)
165+ }
166+ if exists {
167+ return nil
168+ }
169+ target := ""
170+ for _, up := range updates {
171+ b, ok := strings.CutPrefix(up.Ref, "refs/heads/")
172+ if !ok || up.IsDelete() {
173+ continue
174+ }
175+ if b == "main" {
176+ target = b
177+ break
178+ }
179+ if target == "" {
180+ target = b
181+ }
182+ }
183+ if target == "" || target == branch {
184+ return nil
185+ }
186+ if err := u.git.SetDefaultBranch(ctx, name, target); err != nil {
187+ return fmt.Errorf("set default branch of %q to %q: %w", name, target, err)
188+ }
189+ log := zerowrap.FromCtx(ctx)
190+ log.Info().Str("repo", name.String()).Str("branch", target).Msg("default branch set")
191+ return nil
192+}
@@ -0,0 +1,192 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "strings"
10+ "time"
11+
12+ "github.com/bnema/zerowrap"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+)
17+
18+// pushRetries bounds the retries of a metadata update that loses a race.
19+const pushRetries = 5
20+
21+// MaintenanceScheduler queues repository maintenance. *Maintainer fits.
22+type MaintenanceScheduler interface {
23+ Schedule(name domain.RepoName)
24+}
25+
26+// Pushes are the use cases around a Git push.
27+type Pushes struct {
28+ meta ports.RepoMetaStore
29+ repos *Repos
30+ git ports.GitRepos
31+ bus ports.EventBus
32+ clock ports.Clock
33+ maint MaintenanceScheduler
34+}
35+
36+// NewPushes builds the push use cases. maint may be nil, which disables
37+// maintenance after a push.
38+func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus ports.EventBus, clock ports.Clock, maint MaintenanceScheduler) *Pushes {
39+ return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint}
40+}
41+
42+// Open decides whether the principal may push to the repository and, with
43+// create, creates it when it does not exist yet (private, published as
44+// RepoCreated). create is for the first request of a push only.
45+//
46+// Anonymous callers get domain.ErrUnauthenticated. A repository the caller
47+// cannot read and cannot write, or a missing one it may not create, answers
48+// domain.ErrNotFound, so that a private repository never shows itself. One it
49+// can read but not write answers domain.ErrForbidden.
50+func (u *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) (domain.Repo, bool, error) {
51+ if p.IsAnonymous() {
52+ return domain.Repo{}, false, domain.ErrUnauthenticated
53+ }
54+ repo, err := u.meta.Get(ctx, name)
55+ switch {
56+ case err == nil:
57+ return u.openExisting(p, repo)
58+ case !errors.Is(err, domain.ErrNotFound):
59+ return domain.Repo{}, false, fmt.Errorf("get repository %q: %w", name, err)
60+ }
61+
62+ if !create || !canWrite(p, name) {
63+ return domain.Repo{}, false, domain.ErrNotFound
64+ }
65+ repo, err = u.repos.Create(ctx, p, CreateParams{Name: name, Visibility: domain.VisibilityPrivate})
66+ switch {
67+ case err == nil:
68+ u.bus.Publish(ctx, domain.RepoCreated{Repo: repo.Name, Visibility: repo.Visibility, At: repo.CreatedAt})
69+ return repo, true, nil
70+ case errors.Is(err, domain.ErrAlreadyExists):
71+ // A concurrent first push won the race: this one pushes to its repository.
72+ repo, gerr := u.meta.Get(ctx, name)
73+ if gerr != nil {
74+ return domain.Repo{}, false, fmt.Errorf("get repository %q: %w", name, gerr)
75+ }
76+ return u.openExisting(p, repo)
77+ }
78+ return domain.Repo{}, false, err
79+}
80+
81+func (u *Pushes) openExisting(p domain.Principal, repo domain.Repo) (domain.Repo, bool, error) {
82+ switch {
83+ case canWrite(p, repo.Name):
84+ return repo, false, nil
85+ case canRead(p, repo):
86+ return domain.Repo{}, false, domain.ErrForbidden
87+ }
88+ return domain.Repo{}, false, domain.ErrNotFound
89+}
90+
91+// Check is the pre-receive decision. v1 accepts every push of a principal that
92+// may write the repository; protected branches and limits will refuse here by
93+// returning an error that matches domain.ErrRejected.
94+func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
95+ if !canWrite(p, name) {
96+ return fmt.Errorf("%w: no write access to %s", domain.ErrRejected, name)
97+ }
98+ log := zerowrap.FromCtx(ctx)
99+ log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked")
100+ return nil
101+}
102+
103+// Record is the post-receive step of an accepted push: it sets pushed_at,
104+// repairs HEAD when it points to a missing branch, publishes a PushEvent and
105+// schedules maintenance. Every step runs even when an earlier one fails; the
106+// failures are returned together and the push itself is not affected.
107+func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
108+ log := zerowrap.FromCtx(ctx)
109+ now := u.clock.Now().UTC()
110+ var errs []error
111+
112+ if err := u.touchPushedAt(ctx, name, now); err != nil {
113+ errs = append(errs, err)
114+ }
115+ if err := u.fixDefaultBranch(ctx, name, updates); err != nil {
116+ errs = append(errs, err)
117+ }
118+ u.bus.Publish(ctx, domain.PushEvent{Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now})
119+ if u.maint != nil {
120+ u.maint.Schedule(name)
121+ }
122+ log.Info().
123+ Str("repo", name.String()).Int("refs", len(updates)).
124+ Str("principal", p.Label()).Str("token_id", p.TokenID()).
125+ Msg("push received")
126+ return errors.Join(errs...)
127+}
128+
129+// touchPushedAt stores pushed_at, retrying when another write got in between.
130+// A repository deleted meanwhile is not an error.
131+func (u *Pushes) touchPushedAt(ctx context.Context, name domain.RepoName, now time.Time) error {
132+ for range pushRetries {
133+ repo, err := u.meta.Get(ctx, name)
134+ if errors.Is(err, domain.ErrNotFound) {
135+ return nil
136+ }
137+ if err != nil {
138+ return fmt.Errorf("get repository %q: %w", name, err)
139+ }
140+ if !now.After(repo.PushedAt) {
141+ return nil // a later push already recorded
142+ }
143+ repo.PushedAt = now
144+ err = u.meta.Update(ctx, repo)
145+ if errors.Is(err, domain.ErrConflict) {
146+ continue
147+ }
148+ if errors.Is(err, domain.ErrNotFound) {
149+ return nil
150+ }
151+ if err != nil {
152+ return fmt.Errorf("update repository %q: %w", name, err)
153+ }
154+ return nil
155+ }
156+ return fmt.Errorf("update pushed_at of %q: %w", name, domain.ErrConflict)
157+}
158+
159+// fixDefaultBranch points HEAD to a branch that exists when it points to a
160+// missing one: main when it was pushed, else the first pushed branch.
161+func (u *Pushes) fixDefaultBranch(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate) error {
162+ branch, exists, err := u.git.DefaultBranch(ctx, name)
163+ if err != nil {
164+ return fmt.Errorf("read default branch of %q: %w", name, err)
165+ }
166+ if exists {
167+ return nil
168+ }
169+ target := ""
170+ for _, up := range updates {
171+ b, ok := strings.CutPrefix(up.Ref, "refs/heads/")
172+ if !ok || up.IsDelete() {
173+ continue
174+ }
175+ if b == "main" {
176+ target = b
177+ break
178+ }
179+ if target == "" {
180+ target = b
181+ }
182+ }
183+ if target == "" || target == branch {
184+ return nil
185+ }
186+ if err := u.git.SetDefaultBranch(ctx, name, target); err != nil {
187+ return fmt.Errorf("set default branch of %q to %q: %w", name, target, err)
188+ }
189+ log := zerowrap.FromCtx(ctx)
190+ log.Info().Str("repo", name.String()).Str("branch", target).Msg("default branch set")
191+ return nil
192+}
A internal/app/pushes_test.go +283 -0
@@ -0,0 +1,283 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app_test
4+
5+import (
6+ "context"
7+ "errors"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/app"
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
20+)
21+
22+type scheduler struct {
23+ mu sync.Mutex
24+ names []domain.RepoName
25+}
26+
27+func (s *scheduler) Schedule(n domain.RepoName) {
28+ s.mu.Lock()
29+ defer s.mu.Unlock()
30+ s.names = append(s.names, n)
31+}
32+
33+type pushFixture struct {
34+ meta *mocks.RepoMetaStore
35+ git *mocks.GitRepos
36+ bus *mocks.EventBus
37+ clock *mocks.Clock
38+ sched *scheduler
39+ pushes *app.Pushes
40+}
41+
42+func newPushFixture(t *testing.T) *pushFixture {
43+ t.Helper()
44+ f := &pushFixture{
45+ meta: mocks.NewRepoMetaStore(t),
46+ git: mocks.NewGitRepos(t),
47+ bus: mocks.NewEventBus(t),
48+ clock: mocks.NewClock(t),
49+ sched: &scheduler{},
50+ }
51+ f.clock.EXPECT().Now().Return(now).Maybe()
52+ repos := app.NewRepos(f.meta, f.git, f.clock)
53+ f.pushes = app.NewPushes(f.meta, repos, f.git, f.bus, f.clock, f.sched)
54+ return f
55+}
56+
57+const (
58+ oidA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
59+ oidB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
60+)
61+
62+func TestPushesOpen(t *testing.T) {
63+ ctx := context.Background()
64+ n := name(t, "tools")
65+ priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}
66+ pub := domain.Repo{Name: n, Visibility: domain.VisibilityPublic}
67+
68+ tests := []struct {
69+ name string
70+ p domain.Principal
71+ repo *domain.Repo // nil: missing
72+ create bool
73+ want error
74+ created bool
75+ }{
76+ {"anonymous", domain.Anonymous(), &pub, false, domain.ErrUnauthenticated, false},
77+ {"root, existing", domain.Root(), &priv, false, nil, false},
78+ {"writer, existing", tokenPrincipal(domain.ScopeRepoWrite), &priv, false, nil, false},
79+ {"writer limited to the repo", tokenPrincipal(domain.ScopeRepoWrite, "tools"), &priv, false, nil, false},
80+ {"writer limited to another repo, private", tokenPrincipal(domain.ScopeRepoWrite, "other"), &priv, false, domain.ErrNotFound, false},
81+ {"writer limited to another repo, public", tokenPrincipal(domain.ScopeRepoWrite, "other"), &pub, false, domain.ErrForbidden, false},
82+ {"reader of a private repo", tokenPrincipal(domain.ScopeRepoRead), &priv, false, domain.ErrForbidden, false},
83+ {"reader of a public repo", tokenPrincipal(domain.ScopeRepoRead), &pub, false, domain.ErrForbidden, false},
84+ {"token with no relevant scope, private", tokenPrincipal(domain.ScopeMRWrite), &priv, false, domain.ErrNotFound, false},
85+ {"missing, no create", domain.Root(), nil, false, domain.ErrNotFound, false},
86+ {"missing, reader may not create", tokenPrincipal(domain.ScopeRepoRead), nil, true, domain.ErrNotFound, false},
87+ {"missing, writer limited elsewhere", tokenPrincipal(domain.ScopeRepoWrite, "other"), nil, true, domain.ErrNotFound, false},
88+ }
89+ for _, tt := range tests {
90+ t.Run(tt.name, func(t *testing.T) {
91+ f := newPushFixture(t)
92+ if !tt.p.IsAnonymous() {
93+ if tt.repo != nil {
94+ f.meta.EXPECT().Get(ctx, n).Return(*tt.repo, nil)
95+ } else {
96+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
97+ }
98+ }
99+ repo, created, err := f.pushes.Open(ctx, tt.p, n, tt.create)
100+ if tt.want != nil {
101+ assert.ErrorIs(t, err, tt.want)
102+ assert.False(t, created)
103+ return
104+ }
105+ require.NoError(t, err)
106+ assert.Equal(t, tt.created, created)
107+ assert.Equal(t, n, repo.Name)
108+ })
109+ }
110+
111+ t.Run("creates a private repository and publishes RepoCreated", func(t *testing.T) {
112+ f := newPushFixture(t)
113+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
114+ f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
115+ want := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now}
116+ f.meta.EXPECT().Create(ctx, want).Return(nil)
117+ f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
118+ f.bus.EXPECT().Publish(ctx, domain.RepoCreated{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return()
119+
120+ repo, created, err := f.pushes.Open(ctx, tokenPrincipal(domain.ScopeRepoWrite, "tools"), n, true)
121+ require.NoError(t, err)
122+ assert.True(t, created)
123+ assert.Equal(t, want, repo)
124+ })
125+
126+ t.Run("a concurrent first push wins: this one pushes to its repository", func(t *testing.T) {
127+ f := newPushFixture(t)
128+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound).Once()
129+ f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
130+ f.meta.EXPECT().Create(ctx, mock.Anything).Return(domain.ErrAlreadyExists)
131+ f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil)
132+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
133+
134+ repo, created, err := f.pushes.Open(ctx, domain.Root(), n, true)
135+ require.NoError(t, err)
136+ assert.False(t, created)
137+ assert.Equal(t, priv, repo)
138+ })
139+
140+ t.Run("store failure", func(t *testing.T) {
141+ f := newPushFixture(t)
142+ boom := errors.New("boom")
143+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom)
144+ _, _, err := f.pushes.Open(ctx, domain.Root(), n, true)
145+ assert.ErrorIs(t, err, boom)
146+ })
147+
148+ t.Run("create failure is returned and nothing is published", func(t *testing.T) {
149+ f := newPushFixture(t)
150+ boom := errors.New("boom")
151+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
152+ f.git.EXPECT().Stage(ctx).Return(ports.StagedID(""), boom)
153+ _, _, err := f.pushes.Open(ctx, domain.Root(), n, true)
154+ assert.ErrorIs(t, err, boom)
155+ })
156+}
157+
158+func TestPushesCheck(t *testing.T) {
159+ ctx := context.Background()
160+ n := name(t, "tools")
161+ f := newPushFixture(t)
162+ ups := []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}}
163+
164+ require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, ups))
165+ require.NoError(t, f.pushes.Check(ctx, tokenPrincipal(domain.ScopeRepoWrite, "tools"), n, ups))
166+ assert.ErrorIs(t, f.pushes.Check(ctx, tokenPrincipal(domain.ScopeRepoRead), n, ups), domain.ErrRejected)
167+ assert.ErrorIs(t, f.pushes.Check(ctx, domain.Anonymous(), n, ups), domain.ErrRejected)
168+}
169+
170+func TestPushesRecord(t *testing.T) {
171+ ctx := context.Background()
172+ n := name(t, "tools")
173+ p := tokenPrincipal(domain.ScopeRepoWrite)
174+ ups := []domain.RefUpdate{
175+ {Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/dev"},
176+ {Old: domain.ZeroOID, New: oidB, Ref: "refs/heads/main"},
177+ {Old: domain.ZeroOID, New: oidB, Ref: "refs/tags/v1"},
178+ }
179+
180+ t.Run("sets pushed_at, publishes the exact updates, schedules maintenance", func(t *testing.T) {
181+ f := newPushFixture(t)
182+ repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)}
183+ f.meta.EXPECT().Get(ctx, n).Return(repo, nil)
184+ f.meta.EXPECT().Update(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: repo.CreatedAt, PushedAt: now}).Return(nil)
185+ f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
186+ f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now}).Return()
187+
188+ require.NoError(t, f.pushes.Record(ctx, p, n, ups))
189+ assert.Equal(t, []domain.RepoName{n}, f.sched.names)
190+ })
191+
192+ t.Run("pushed_at retries after a conflict and never goes backwards", func(t *testing.T) {
193+ f := newPushFixture(t)
194+ repo := domain.Repo{Name: n}
195+ f.meta.EXPECT().Get(ctx, n).Return(repo, nil).Once()
196+ f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()
197+ later := domain.Repo{Name: n, PushedAt: now.Add(time.Second)}
198+ f.meta.EXPECT().Get(ctx, n).Return(later, nil).Once() // a later push recorded meanwhile
199+ f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
200+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
201+ require.NoError(t, f.pushes.Record(ctx, p, n, ups))
202+ })
203+
204+ t.Run("a deleted repository is not an error", func(t *testing.T) {
205+ f := newPushFixture(t)
206+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
207+ f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
208+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
209+ require.NoError(t, f.pushes.Record(ctx, p, n, ups))
210+ })
211+
212+ t.Run("every step runs when one fails", func(t *testing.T) {
213+ f := newPushFixture(t)
214+ boom := errors.New("boom")
215+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom)
216+ f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, boom)
217+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
218+ err := f.pushes.Record(ctx, p, n, ups)
219+ assert.ErrorIs(t, err, boom)
220+ assert.Equal(t, []domain.RepoName{n}, f.sched.names, "maintenance still scheduled")
221+ })
222+
223+ t.Run("nil scheduler is allowed", func(t *testing.T) {
224+ f := newPushFixture(t)
225+ repos := app.NewRepos(f.meta, f.git, f.clock)
226+ pushes := app.NewPushes(f.meta, repos, f.git, f.bus, f.clock, nil)
227+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil)
228+ f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)
229+ f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
230+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
231+ require.NoError(t, pushes.Record(ctx, p, n, ups))
232+ })
233+}
234+
235+func TestPushesDefaultBranchRule(t *testing.T) {
236+ ctx := context.Background()
237+ n := name(t, "tools")
238+ p := domain.Root()
239+ up := func(ref string) domain.RefUpdate { return domain.RefUpdate{Old: domain.ZeroOID, New: oidA, Ref: ref} }
240+ del := func(ref string) domain.RefUpdate { return domain.RefUpdate{Old: oidA, New: domain.ZeroOID, Ref: ref} }
241+
242+ tests := []struct {
243+ name string
244+ head string
245+ exists bool
246+ updates []domain.RefUpdate
247+ want string // "" = HEAD untouched
248+ }{
249+ {"HEAD already valid", "main", true, []domain.RefUpdate{up("refs/heads/dev")}, ""},
250+ {"main preferred", "trunk", false, []domain.RefUpdate{up("refs/heads/dev"), up("refs/heads/main")}, "main"},
251+ {"first pushed branch", "main", false, []domain.RefUpdate{up("refs/tags/v1"), up("refs/heads/dev"), up("refs/heads/feature")}, "dev"},
252+ {"HEAD names another missing branch", "trunk", false, []domain.RefUpdate{up("refs/heads/dev")}, "dev"},
253+ {"only tags pushed", "main", false, []domain.RefUpdate{up("refs/tags/v1")}, ""},
254+ {"deleted branches never become HEAD", "main", false, []domain.RefUpdate{del("refs/heads/old"), up("refs/heads/dev")}, "dev"},
255+ {"only deletions", "main", false, []domain.RefUpdate{del("refs/heads/old")}, ""},
256+ {"HEAD already points to the only candidate", "dev", false, []domain.RefUpdate{up("refs/heads/dev")}, ""},
257+ }
258+ for _, tt := range tests {
259+ t.Run(tt.name, func(t *testing.T) {
260+ f := newPushFixture(t)
261+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)
262+ f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil)
263+ if tt.want != "" {
264+ f.git.EXPECT().SetDefaultBranch(ctx, n, tt.want).Return(nil)
265+ }
266+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
267+ require.NoError(t, f.pushes.Record(ctx, p, n, tt.updates))
268+ })
269+ }
270+
271+ t.Run("set failure is reported", func(t *testing.T) {
272+ f := newPushFixture(t)
273+ boom := errors.New("boom")
274+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)
275+ f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil)
276+ f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
277+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
278+ assert.ErrorIs(t, f.pushes.Record(ctx, p, n, []domain.RefUpdate{up("refs/heads/dev")}), boom)
279+ })
280+}
281+
282+// mockAny matches any argument of a mocked call.
283+var mockAny = mock.Anything
@@ -0,0 +1,283 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app_test
4+
5+import (
6+ "context"
7+ "errors"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/app"
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
20+)
21+
22+type scheduler struct {
23+ mu sync.Mutex
24+ names []domain.RepoName
25+}
26+
27+func (s *scheduler) Schedule(n domain.RepoName) {
28+ s.mu.Lock()
29+ defer s.mu.Unlock()
30+ s.names = append(s.names, n)
31+}
32+
33+type pushFixture struct {
34+ meta *mocks.RepoMetaStore
35+ git *mocks.GitRepos
36+ bus *mocks.EventBus
37+ clock *mocks.Clock
38+ sched *scheduler
39+ pushes *app.Pushes
40+}
41+
42+func newPushFixture(t *testing.T) *pushFixture {
43+ t.Helper()
44+ f := &pushFixture{
45+ meta: mocks.NewRepoMetaStore(t),
46+ git: mocks.NewGitRepos(t),
47+ bus: mocks.NewEventBus(t),
48+ clock: mocks.NewClock(t),
49+ sched: &scheduler{},
50+ }
51+ f.clock.EXPECT().Now().Return(now).Maybe()
52+ repos := app.NewRepos(f.meta, f.git, f.clock)
53+ f.pushes = app.NewPushes(f.meta, repos, f.git, f.bus, f.clock, f.sched)
54+ return f
55+}
56+
57+const (
58+ oidA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
59+ oidB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
60+)
61+
62+func TestPushesOpen(t *testing.T) {
63+ ctx := context.Background()
64+ n := name(t, "tools")
65+ priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}
66+ pub := domain.Repo{Name: n, Visibility: domain.VisibilityPublic}
67+
68+ tests := []struct {
69+ name string
70+ p domain.Principal
71+ repo *domain.Repo // nil: missing
72+ create bool
73+ want error
74+ created bool
75+ }{
76+ {"anonymous", domain.Anonymous(), &pub, false, domain.ErrUnauthenticated, false},
77+ {"root, existing", domain.Root(), &priv, false, nil, false},
78+ {"writer, existing", tokenPrincipal(domain.ScopeRepoWrite), &priv, false, nil, false},
79+ {"writer limited to the repo", tokenPrincipal(domain.ScopeRepoWrite, "tools"), &priv, false, nil, false},
80+ {"writer limited to another repo, private", tokenPrincipal(domain.ScopeRepoWrite, "other"), &priv, false, domain.ErrNotFound, false},
81+ {"writer limited to another repo, public", tokenPrincipal(domain.ScopeRepoWrite, "other"), &pub, false, domain.ErrForbidden, false},
82+ {"reader of a private repo", tokenPrincipal(domain.ScopeRepoRead), &priv, false, domain.ErrForbidden, false},
83+ {"reader of a public repo", tokenPrincipal(domain.ScopeRepoRead), &pub, false, domain.ErrForbidden, false},
84+ {"token with no relevant scope, private", tokenPrincipal(domain.ScopeMRWrite), &priv, false, domain.ErrNotFound, false},
85+ {"missing, no create", domain.Root(), nil, false, domain.ErrNotFound, false},
86+ {"missing, reader may not create", tokenPrincipal(domain.ScopeRepoRead), nil, true, domain.ErrNotFound, false},
87+ {"missing, writer limited elsewhere", tokenPrincipal(domain.ScopeRepoWrite, "other"), nil, true, domain.ErrNotFound, false},
88+ }
89+ for _, tt := range tests {
90+ t.Run(tt.name, func(t *testing.T) {
91+ f := newPushFixture(t)
92+ if !tt.p.IsAnonymous() {
93+ if tt.repo != nil {
94+ f.meta.EXPECT().Get(ctx, n).Return(*tt.repo, nil)
95+ } else {
96+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
97+ }
98+ }
99+ repo, created, err := f.pushes.Open(ctx, tt.p, n, tt.create)
100+ if tt.want != nil {
101+ assert.ErrorIs(t, err, tt.want)
102+ assert.False(t, created)
103+ return
104+ }
105+ require.NoError(t, err)
106+ assert.Equal(t, tt.created, created)
107+ assert.Equal(t, n, repo.Name)
108+ })
109+ }
110+
111+ t.Run("creates a private repository and publishes RepoCreated", func(t *testing.T) {
112+ f := newPushFixture(t)
113+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
114+ f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
115+ want := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now}
116+ f.meta.EXPECT().Create(ctx, want).Return(nil)
117+ f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
118+ f.bus.EXPECT().Publish(ctx, domain.RepoCreated{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return()
119+
120+ repo, created, err := f.pushes.Open(ctx, tokenPrincipal(domain.ScopeRepoWrite, "tools"), n, true)
121+ require.NoError(t, err)
122+ assert.True(t, created)
123+ assert.Equal(t, want, repo)
124+ })
125+
126+ t.Run("a concurrent first push wins: this one pushes to its repository", func(t *testing.T) {
127+ f := newPushFixture(t)
128+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound).Once()
129+ f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
130+ f.meta.EXPECT().Create(ctx, mock.Anything).Return(domain.ErrAlreadyExists)
131+ f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil)
132+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
133+
134+ repo, created, err := f.pushes.Open(ctx, domain.Root(), n, true)
135+ require.NoError(t, err)
136+ assert.False(t, created)
137+ assert.Equal(t, priv, repo)
138+ })
139+
140+ t.Run("store failure", func(t *testing.T) {
141+ f := newPushFixture(t)
142+ boom := errors.New("boom")
143+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom)
144+ _, _, err := f.pushes.Open(ctx, domain.Root(), n, true)
145+ assert.ErrorIs(t, err, boom)
146+ })
147+
148+ t.Run("create failure is returned and nothing is published", func(t *testing.T) {
149+ f := newPushFixture(t)
150+ boom := errors.New("boom")
151+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
152+ f.git.EXPECT().Stage(ctx).Return(ports.StagedID(""), boom)
153+ _, _, err := f.pushes.Open(ctx, domain.Root(), n, true)
154+ assert.ErrorIs(t, err, boom)
155+ })
156+}
157+
158+func TestPushesCheck(t *testing.T) {
159+ ctx := context.Background()
160+ n := name(t, "tools")
161+ f := newPushFixture(t)
162+ ups := []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}}
163+
164+ require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, ups))
165+ require.NoError(t, f.pushes.Check(ctx, tokenPrincipal(domain.ScopeRepoWrite, "tools"), n, ups))
166+ assert.ErrorIs(t, f.pushes.Check(ctx, tokenPrincipal(domain.ScopeRepoRead), n, ups), domain.ErrRejected)
167+ assert.ErrorIs(t, f.pushes.Check(ctx, domain.Anonymous(), n, ups), domain.ErrRejected)
168+}
169+
170+func TestPushesRecord(t *testing.T) {
171+ ctx := context.Background()
172+ n := name(t, "tools")
173+ p := tokenPrincipal(domain.ScopeRepoWrite)
174+ ups := []domain.RefUpdate{
175+ {Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/dev"},
176+ {Old: domain.ZeroOID, New: oidB, Ref: "refs/heads/main"},
177+ {Old: domain.ZeroOID, New: oidB, Ref: "refs/tags/v1"},
178+ }
179+
180+ t.Run("sets pushed_at, publishes the exact updates, schedules maintenance", func(t *testing.T) {
181+ f := newPushFixture(t)
182+ repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)}
183+ f.meta.EXPECT().Get(ctx, n).Return(repo, nil)
184+ f.meta.EXPECT().Update(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: repo.CreatedAt, PushedAt: now}).Return(nil)
185+ f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
186+ f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now}).Return()
187+
188+ require.NoError(t, f.pushes.Record(ctx, p, n, ups))
189+ assert.Equal(t, []domain.RepoName{n}, f.sched.names)
190+ })
191+
192+ t.Run("pushed_at retries after a conflict and never goes backwards", func(t *testing.T) {
193+ f := newPushFixture(t)
194+ repo := domain.Repo{Name: n}
195+ f.meta.EXPECT().Get(ctx, n).Return(repo, nil).Once()
196+ f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()
197+ later := domain.Repo{Name: n, PushedAt: now.Add(time.Second)}
198+ f.meta.EXPECT().Get(ctx, n).Return(later, nil).Once() // a later push recorded meanwhile
199+ f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
200+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
201+ require.NoError(t, f.pushes.Record(ctx, p, n, ups))
202+ })
203+
204+ t.Run("a deleted repository is not an error", func(t *testing.T) {
205+ f := newPushFixture(t)
206+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
207+ f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
208+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
209+ require.NoError(t, f.pushes.Record(ctx, p, n, ups))
210+ })
211+
212+ t.Run("every step runs when one fails", func(t *testing.T) {
213+ f := newPushFixture(t)
214+ boom := errors.New("boom")
215+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom)
216+ f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, boom)
217+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
218+ err := f.pushes.Record(ctx, p, n, ups)
219+ assert.ErrorIs(t, err, boom)
220+ assert.Equal(t, []domain.RepoName{n}, f.sched.names, "maintenance still scheduled")
221+ })
222+
223+ t.Run("nil scheduler is allowed", func(t *testing.T) {
224+ f := newPushFixture(t)
225+ repos := app.NewRepos(f.meta, f.git, f.clock)
226+ pushes := app.NewPushes(f.meta, repos, f.git, f.bus, f.clock, nil)
227+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil)
228+ f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)
229+ f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
230+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
231+ require.NoError(t, pushes.Record(ctx, p, n, ups))
232+ })
233+}
234+
235+func TestPushesDefaultBranchRule(t *testing.T) {
236+ ctx := context.Background()
237+ n := name(t, "tools")
238+ p := domain.Root()
239+ up := func(ref string) domain.RefUpdate { return domain.RefUpdate{Old: domain.ZeroOID, New: oidA, Ref: ref} }
240+ del := func(ref string) domain.RefUpdate { return domain.RefUpdate{Old: oidA, New: domain.ZeroOID, Ref: ref} }
241+
242+ tests := []struct {
243+ name string
244+ head string
245+ exists bool
246+ updates []domain.RefUpdate
247+ want string // "" = HEAD untouched
248+ }{
249+ {"HEAD already valid", "main", true, []domain.RefUpdate{up("refs/heads/dev")}, ""},
250+ {"main preferred", "trunk", false, []domain.RefUpdate{up("refs/heads/dev"), up("refs/heads/main")}, "main"},
251+ {"first pushed branch", "main", false, []domain.RefUpdate{up("refs/tags/v1"), up("refs/heads/dev"), up("refs/heads/feature")}, "dev"},
252+ {"HEAD names another missing branch", "trunk", false, []domain.RefUpdate{up("refs/heads/dev")}, "dev"},
253+ {"only tags pushed", "main", false, []domain.RefUpdate{up("refs/tags/v1")}, ""},
254+ {"deleted branches never become HEAD", "main", false, []domain.RefUpdate{del("refs/heads/old"), up("refs/heads/dev")}, "dev"},
255+ {"only deletions", "main", false, []domain.RefUpdate{del("refs/heads/old")}, ""},
256+ {"HEAD already points to the only candidate", "dev", false, []domain.RefUpdate{up("refs/heads/dev")}, ""},
257+ }
258+ for _, tt := range tests {
259+ t.Run(tt.name, func(t *testing.T) {
260+ f := newPushFixture(t)
261+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)
262+ f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil)
263+ if tt.want != "" {
264+ f.git.EXPECT().SetDefaultBranch(ctx, n, tt.want).Return(nil)
265+ }
266+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
267+ require.NoError(t, f.pushes.Record(ctx, p, n, tt.updates))
268+ })
269+ }
270+
271+ t.Run("set failure is reported", func(t *testing.T) {
272+ f := newPushFixture(t)
273+ boom := errors.New("boom")
274+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)
275+ f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil)
276+ f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
277+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
278+ assert.ErrorIs(t, f.pushes.Record(ctx, p, n, []domain.RefUpdate{up("refs/heads/dev")}), boom)
279+ })
280+}
281+
282+// mockAny matches any argument of a mocked call.
283+var mockAny = mock.Anything
A internal/ports/mocks/EventBus.go +147 -0
@@ -0,0 +1,147 @@
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+// NewEventBus creates a new instance of EventBus. 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 NewEventBus(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *EventBus {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &EventBus{}
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+// EventBus is an autogenerated mock type for the EventBus type
38+type EventBus struct {
39+ mock.Mock
40+}
41+
42+type EventBus_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *EventBus) EXPECT() *EventBus_Expecter {
47+ return &EventBus_Expecter{mock: &_m.Mock}
48+}
49+
50+// Publish provides a mock function for the type EventBus
51+func (_mock *EventBus) Publish(ctx context.Context, ev domain.Event) {
52+ _mock.Called(ctx, ev)
53+ return
54+}
55+
56+// EventBus_Publish_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Publish'
57+type EventBus_Publish_Call struct {
58+ *mock.Call
59+}
60+
61+// Publish is a helper method to define mock.On call
62+// - ctx context.Context
63+// - ev domain.Event
64+func (_e *EventBus_Expecter) Publish(ctx any, ev any) *EventBus_Publish_Call {
65+ return &EventBus_Publish_Call{Call: _e.mock.On("Publish", ctx, ev)}
66+}
67+
68+func (_c *EventBus_Publish_Call) Run(run func(ctx context.Context, ev domain.Event)) *EventBus_Publish_Call {
69+ _c.Call.Run(func(args mock.Arguments) {
70+ var arg0 context.Context
71+ if args[0] != nil {
72+ arg0 = args[0].(context.Context)
73+ }
74+ var arg1 domain.Event
75+ if args[1] != nil {
76+ arg1 = args[1].(domain.Event)
77+ }
78+ run(
79+ arg0,
80+ arg1,
81+ )
82+ })
83+ return _c
84+}
85+
86+func (_c *EventBus_Publish_Call) Return() *EventBus_Publish_Call {
87+ _c.Call.Return()
88+ return _c
89+}
90+
91+func (_c *EventBus_Publish_Call) RunAndReturn(run func(ctx context.Context, ev domain.Event)) *EventBus_Publish_Call {
92+ _c.Run(run)
93+ return _c
94+}
95+
96+// Subscribe provides a mock function for the type EventBus
97+func (_mock *EventBus) Subscribe(ctx context.Context) <-chan domain.Event {
98+ ret := _mock.Called(ctx)
99+
100+ if len(ret) == 0 {
101+ panic("no return value specified for Subscribe")
102+ }
103+
104+ var r0 <-chan domain.Event
105+ if returnFunc, ok := ret.Get(0).(func(context.Context) <-chan domain.Event); ok {
106+ r0 = returnFunc(ctx)
107+ } else {
108+ if ret.Get(0) != nil {
109+ r0 = ret.Get(0).(<-chan domain.Event)
110+ }
111+ }
112+ return r0
113+}
114+
115+// EventBus_Subscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Subscribe'
116+type EventBus_Subscribe_Call struct {
117+ *mock.Call
118+}
119+
120+// Subscribe is a helper method to define mock.On call
121+// - ctx context.Context
122+func (_e *EventBus_Expecter) Subscribe(ctx any) *EventBus_Subscribe_Call {
123+ return &EventBus_Subscribe_Call{Call: _e.mock.On("Subscribe", ctx)}
124+}
125+
126+func (_c *EventBus_Subscribe_Call) Run(run func(ctx context.Context)) *EventBus_Subscribe_Call {
127+ _c.Call.Run(func(args mock.Arguments) {
128+ var arg0 context.Context
129+ if args[0] != nil {
130+ arg0 = args[0].(context.Context)
131+ }
132+ run(
133+ arg0,
134+ )
135+ })
136+ return _c
137+}
138+
139+func (_c *EventBus_Subscribe_Call) Return(eventCh <-chan domain.Event) *EventBus_Subscribe_Call {
140+ _c.Call.Return(eventCh)
141+ return _c
142+}
143+
144+func (_c *EventBus_Subscribe_Call) RunAndReturn(run func(ctx context.Context) <-chan domain.Event) *EventBus_Subscribe_Call {
145+ _c.Call.Return(run)
146+ return _c
147+}
@@ -0,0 +1,147 @@
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+// NewEventBus creates a new instance of EventBus. 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 NewEventBus(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *EventBus {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &EventBus{}
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+// EventBus is an autogenerated mock type for the EventBus type
38+type EventBus struct {
39+ mock.Mock
40+}
41+
42+type EventBus_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *EventBus) EXPECT() *EventBus_Expecter {
47+ return &EventBus_Expecter{mock: &_m.Mock}
48+}
49+
50+// Publish provides a mock function for the type EventBus
51+func (_mock *EventBus) Publish(ctx context.Context, ev domain.Event) {
52+ _mock.Called(ctx, ev)
53+ return
54+}
55+
56+// EventBus_Publish_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Publish'
57+type EventBus_Publish_Call struct {
58+ *mock.Call
59+}
60+
61+// Publish is a helper method to define mock.On call
62+// - ctx context.Context
63+// - ev domain.Event
64+func (_e *EventBus_Expecter) Publish(ctx any, ev any) *EventBus_Publish_Call {
65+ return &EventBus_Publish_Call{Call: _e.mock.On("Publish", ctx, ev)}
66+}
67+
68+func (_c *EventBus_Publish_Call) Run(run func(ctx context.Context, ev domain.Event)) *EventBus_Publish_Call {
69+ _c.Call.Run(func(args mock.Arguments) {
70+ var arg0 context.Context
71+ if args[0] != nil {
72+ arg0 = args[0].(context.Context)
73+ }
74+ var arg1 domain.Event
75+ if args[1] != nil {
76+ arg1 = args[1].(domain.Event)
77+ }
78+ run(
79+ arg0,
80+ arg1,
81+ )
82+ })
83+ return _c
84+}
85+
86+func (_c *EventBus_Publish_Call) Return() *EventBus_Publish_Call {
87+ _c.Call.Return()
88+ return _c
89+}
90+
91+func (_c *EventBus_Publish_Call) RunAndReturn(run func(ctx context.Context, ev domain.Event)) *EventBus_Publish_Call {
92+ _c.Run(run)
93+ return _c
94+}
95+
96+// Subscribe provides a mock function for the type EventBus
97+func (_mock *EventBus) Subscribe(ctx context.Context) <-chan domain.Event {
98+ ret := _mock.Called(ctx)
99+
100+ if len(ret) == 0 {
101+ panic("no return value specified for Subscribe")
102+ }
103+
104+ var r0 <-chan domain.Event
105+ if returnFunc, ok := ret.Get(0).(func(context.Context) <-chan domain.Event); ok {
106+ r0 = returnFunc(ctx)
107+ } else {
108+ if ret.Get(0) != nil {
109+ r0 = ret.Get(0).(<-chan domain.Event)
110+ }
111+ }
112+ return r0
113+}
114+
115+// EventBus_Subscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Subscribe'
116+type EventBus_Subscribe_Call struct {
117+ *mock.Call
118+}
119+
120+// Subscribe is a helper method to define mock.On call
121+// - ctx context.Context
122+func (_e *EventBus_Expecter) Subscribe(ctx any) *EventBus_Subscribe_Call {
123+ return &EventBus_Subscribe_Call{Call: _e.mock.On("Subscribe", ctx)}
124+}
125+
126+func (_c *EventBus_Subscribe_Call) Run(run func(ctx context.Context)) *EventBus_Subscribe_Call {
127+ _c.Call.Run(func(args mock.Arguments) {
128+ var arg0 context.Context
129+ if args[0] != nil {
130+ arg0 = args[0].(context.Context)
131+ }
132+ run(
133+ arg0,
134+ )
135+ })
136+ return _c
137+}
138+
139+func (_c *EventBus_Subscribe_Call) Return(eventCh <-chan domain.Event) *EventBus_Subscribe_Call {
140+ _c.Call.Return(eventCh)
141+ return _c
142+}
143+
144+func (_c *EventBus_Subscribe_Call) RunAndReturn(run func(ctx context.Context) <-chan domain.Event) *EventBus_Subscribe_Call {
145+ _c.Call.Return(run)
146+ return _c
147+}

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 3

Tags 44

↑↓ moveenter openesc close