detached 0c4e1e3

feat(releases): git tag writer and release hooks on pushes and repository deletion

commit
0c4e1e32749a557722a0b25c591f4711f40b2dd8
author
brice · · 2026-10-21 14:29 UTC
committer
brice · · 2026-10-21 17:53 UTC
parent
8fd2b19
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +294 -15
A internal/adapters/gitcli/tags.go +42 -0
@@ -0,0 +1,42 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ "github.com/bnema/zerowrap"
13+)
14+
15+var _ ports.ReleaseTagWriter = (*Git)(nil)
16+
17+// CreateTag creates a lightweight tag without ever moving an existing ref.
18+func (g *Git) CreateTag(ctx context.Context, repo domain.RepoName, tag string, target domain.ObjectID) error {
19+ if _, err := domain.ParseReleaseTag(tag); err != nil {
20+ return err
21+ }
22+ dir, err := g.repoDir(repo)
23+ if err != nil {
24+ return err
25+ }
26+ ref := "refs/tags/" + tag
27+ _, err = g.run(ctx, "--git-dir="+dir, "update-ref", ref, target.String(), domain.ZeroOID)
28+ if err != nil {
29+ // Distinguish a concurrent creator from an unrelated Git failure. ResolveRevision
30+ // peels tags, so use show-ref to also recognize tags pointing at non-commits.
31+ if _, existsErr := g.run(ctx, "--git-dir="+dir, "show-ref", "--verify", "--", ref); existsErr == nil {
32+ return fmt.Errorf("tag %q in %q: %w", tag, repo, domain.ErrAlreadyExists)
33+ }
34+ if errors.Is(err, context.Canceled) {
35+ return err
36+ }
37+ return fmt.Errorf("create tag %q in %q: %w", tag, repo, err)
38+ }
39+ log := zerowrap.FromCtx(ctx)
40+ log.Info().Str("repo", repo.String()).Str("tag", tag).Msg("release tag created")
41+ return nil
42+}
@@ -0,0 +1,42 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ "github.com/bnema/zerowrap"
13+)
14+
15+var _ ports.ReleaseTagWriter = (*Git)(nil)
16+
17+// CreateTag creates a lightweight tag without ever moving an existing ref.
18+func (g *Git) CreateTag(ctx context.Context, repo domain.RepoName, tag string, target domain.ObjectID) error {
19+ if _, err := domain.ParseReleaseTag(tag); err != nil {
20+ return err
21+ }
22+ dir, err := g.repoDir(repo)
23+ if err != nil {
24+ return err
25+ }
26+ ref := "refs/tags/" + tag
27+ _, err = g.run(ctx, "--git-dir="+dir, "update-ref", ref, target.String(), domain.ZeroOID)
28+ if err != nil {
29+ // Distinguish a concurrent creator from an unrelated Git failure. ResolveRevision
30+ // peels tags, so use show-ref to also recognize tags pointing at non-commits.
31+ if _, existsErr := g.run(ctx, "--git-dir="+dir, "show-ref", "--verify", "--", ref); existsErr == nil {
32+ return fmt.Errorf("tag %q in %q: %w", tag, repo, domain.ErrAlreadyExists)
33+ }
34+ if errors.Is(err, context.Canceled) {
35+ return err
36+ }
37+ return fmt.Errorf("create tag %q in %q: %w", tag, repo, err)
38+ }
39+ log := zerowrap.FromCtx(ctx)
40+ log.Info().Str("repo", repo.String()).Str("tag", tag).Msg("release tag created")
41+ return nil
42+}
A internal/adapters/gitcli/tags_test.go +31 -0
@@ -0,0 +1,31 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "bnema.dev/ermite/internal/domain"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestCreateReleaseTag(t *testing.T) {
14+ g, data := newGit(t)
15+ ctx := context.Background()
16+ m := newMergeFixture(t, data)
17+ t.Cleanup(func() { _ = g.Close(ctx) })
18+ require.NoError(t, g.CreateTag(ctx, m.f.Name, "v1.0", m.a))
19+ rev, err := domain.ParseRevision("refs/tags/v1.0")
20+ require.NoError(t, err)
21+ got, err := g.ResolveRevision(ctx, m.f.Name, rev)
22+ require.NoError(t, err)
23+ require.Equal(t, m.a, got.CommitID)
24+ require.ErrorIs(t, g.CreateTag(ctx, m.f.Name, "v1.0", m.d), domain.ErrAlreadyExists)
25+ got, err = g.ResolveRevision(ctx, m.f.Name, rev)
26+ require.NoError(t, err)
27+ require.Equal(t, m.a, got.CommitID)
28+ for _, tag := range []string{"", "../bad", "bad^{commit}", "bad\\name"} {
29+ require.ErrorIs(t, g.CreateTag(ctx, m.f.Name, tag, m.d), domain.ErrInvalid, tag)
30+ }
31+}
@@ -0,0 +1,31 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "bnema.dev/ermite/internal/domain"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestCreateReleaseTag(t *testing.T) {
14+ g, data := newGit(t)
15+ ctx := context.Background()
16+ m := newMergeFixture(t, data)
17+ t.Cleanup(func() { _ = g.Close(ctx) })
18+ require.NoError(t, g.CreateTag(ctx, m.f.Name, "v1.0", m.a))
19+ rev, err := domain.ParseRevision("refs/tags/v1.0")
20+ require.NoError(t, err)
21+ got, err := g.ResolveRevision(ctx, m.f.Name, rev)
22+ require.NoError(t, err)
23+ require.Equal(t, m.a, got.CommitID)
24+ require.ErrorIs(t, g.CreateTag(ctx, m.f.Name, "v1.0", m.d), domain.ErrAlreadyExists)
25+ got, err = g.ResolveRevision(ctx, m.f.Name, rev)
26+ require.NoError(t, err)
27+ require.Equal(t, m.a, got.CommitID)
28+ for _, tag := range []string{"", "../bad", "bad^{commit}", "bad\\name"} {
29+ require.ErrorIs(t, g.CreateTag(ctx, m.f.Name, tag, m.d), domain.ErrInvalid, tag)
30+ }
31+}
A internal/ports/mocks/ReleaseTagWriter.go +117 -0
@@ -0,0 +1,117 @@
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+// NewReleaseTagWriter creates a new instance of ReleaseTagWriter. 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 NewReleaseTagWriter(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *ReleaseTagWriter {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &ReleaseTagWriter{}
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+// ReleaseTagWriter is an autogenerated mock type for the ReleaseTagWriter type
38+type ReleaseTagWriter struct {
39+ mock.Mock
40+}
41+
42+type ReleaseTagWriter_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *ReleaseTagWriter) EXPECT() *ReleaseTagWriter_Expecter {
47+ return &ReleaseTagWriter_Expecter{mock: &_m.Mock}
48+}
49+
50+// CreateTag provides a mock function for the type ReleaseTagWriter
51+func (_mock *ReleaseTagWriter) CreateTag(ctx context.Context, repo domain.RepoName, tag string, target domain.ObjectID) error {
52+ ret := _mock.Called(ctx, repo, tag, target)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for CreateTag")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, domain.ObjectID) error); ok {
60+ r0 = returnFunc(ctx, repo, tag, target)
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// ReleaseTagWriter_CreateTag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateTag'
68+type ReleaseTagWriter_CreateTag_Call struct {
69+ *mock.Call
70+}
71+
72+// CreateTag is a helper method to define mock.On call
73+// - ctx context.Context
74+// - repo domain.RepoName
75+// - tag string
76+// - target domain.ObjectID
77+func (_e *ReleaseTagWriter_Expecter) CreateTag(ctx any, repo any, tag any, target any) *ReleaseTagWriter_CreateTag_Call {
78+ return &ReleaseTagWriter_CreateTag_Call{Call: _e.mock.On("CreateTag", ctx, repo, tag, target)}
79+}
80+
81+func (_c *ReleaseTagWriter_CreateTag_Call) Run(run func(ctx context.Context, repo domain.RepoName, tag string, target domain.ObjectID)) *ReleaseTagWriter_CreateTag_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 context.Context
84+ if args[0] != nil {
85+ arg0 = args[0].(context.Context)
86+ }
87+ var arg1 domain.RepoName
88+ if args[1] != nil {
89+ arg1 = args[1].(domain.RepoName)
90+ }
91+ var arg2 string
92+ if args[2] != nil {
93+ arg2 = args[2].(string)
94+ }
95+ var arg3 domain.ObjectID
96+ if args[3] != nil {
97+ arg3 = args[3].(domain.ObjectID)
98+ }
99+ run(
100+ arg0,
101+ arg1,
102+ arg2,
103+ arg3,
104+ )
105+ })
106+ return _c
107+}
108+
109+func (_c *ReleaseTagWriter_CreateTag_Call) Return(err error) *ReleaseTagWriter_CreateTag_Call {
110+ _c.Call.Return(err)
111+ return _c
112+}
113+
114+func (_c *ReleaseTagWriter_CreateTag_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, tag string, target domain.ObjectID) error) *ReleaseTagWriter_CreateTag_Call {
115+ _c.Call.Return(run)
116+ return _c
117+}
@@ -0,0 +1,117 @@
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+// NewReleaseTagWriter creates a new instance of ReleaseTagWriter. 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 NewReleaseTagWriter(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *ReleaseTagWriter {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &ReleaseTagWriter{}
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+// ReleaseTagWriter is an autogenerated mock type for the ReleaseTagWriter type
38+type ReleaseTagWriter struct {
39+ mock.Mock
40+}
41+
42+type ReleaseTagWriter_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *ReleaseTagWriter) EXPECT() *ReleaseTagWriter_Expecter {
47+ return &ReleaseTagWriter_Expecter{mock: &_m.Mock}
48+}
49+
50+// CreateTag provides a mock function for the type ReleaseTagWriter
51+func (_mock *ReleaseTagWriter) CreateTag(ctx context.Context, repo domain.RepoName, tag string, target domain.ObjectID) error {
52+ ret := _mock.Called(ctx, repo, tag, target)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for CreateTag")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, domain.ObjectID) error); ok {
60+ r0 = returnFunc(ctx, repo, tag, target)
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// ReleaseTagWriter_CreateTag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateTag'
68+type ReleaseTagWriter_CreateTag_Call struct {
69+ *mock.Call
70+}
71+
72+// CreateTag is a helper method to define mock.On call
73+// - ctx context.Context
74+// - repo domain.RepoName
75+// - tag string
76+// - target domain.ObjectID
77+func (_e *ReleaseTagWriter_Expecter) CreateTag(ctx any, repo any, tag any, target any) *ReleaseTagWriter_CreateTag_Call {
78+ return &ReleaseTagWriter_CreateTag_Call{Call: _e.mock.On("CreateTag", ctx, repo, tag, target)}
79+}
80+
81+func (_c *ReleaseTagWriter_CreateTag_Call) Run(run func(ctx context.Context, repo domain.RepoName, tag string, target domain.ObjectID)) *ReleaseTagWriter_CreateTag_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 context.Context
84+ if args[0] != nil {
85+ arg0 = args[0].(context.Context)
86+ }
87+ var arg1 domain.RepoName
88+ if args[1] != nil {
89+ arg1 = args[1].(domain.RepoName)
90+ }
91+ var arg2 string
92+ if args[2] != nil {
93+ arg2 = args[2].(string)
94+ }
95+ var arg3 domain.ObjectID
96+ if args[3] != nil {
97+ arg3 = args[3].(domain.ObjectID)
98+ }
99+ run(
100+ arg0,
101+ arg1,
102+ arg2,
103+ arg3,
104+ )
105+ })
106+ return _c
107+}
108+
109+func (_c *ReleaseTagWriter_CreateTag_Call) Return(err error) *ReleaseTagWriter_CreateTag_Call {
110+ _c.Call.Return(err)
111+ return _c
112+}
113+
114+func (_c *ReleaseTagWriter_CreateTag_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, tag string, target domain.ObjectID) error) *ReleaseTagWriter_CreateTag_Call {
115+ _c.Call.Return(run)
116+ return _c
117+}
M internal/ports/release.go +6 -0
@@ -98,6 +98,12 @@ type ReleaseService interface {
9898 Usage(ctx context.Context, p domain.Principal, repo domain.RepoName) (ReleaseUsage, error)
9999 }
100100
101+// ReleaseTagWriter creates lightweight tags without moving existing refs.
102+// *gitcli.Git implements it. An existing ref returns domain.ErrAlreadyExists.
103+type ReleaseTagWriter interface {
104+ CreateTag(ctx context.Context, repo domain.RepoName, tag string, target domain.ObjectID) error
105+}
106+
101107 // ReleaseTagTrigger handles tag deletion; *usecase.Releases implements it.
102108 type ReleaseTagTrigger interface {
103109 OnRefsMoved(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)
@@ -98,6 +98,12 @@ type ReleaseService interface {
98 Usage(ctx context.Context, p domain.Principal, repo domain.RepoName) (ReleaseUsage, error)98 Usage(ctx context.Context, p domain.Principal, repo domain.RepoName) (ReleaseUsage, error)
99 }99 }
100 100
101+// ReleaseTagWriter creates lightweight tags without moving existing refs.
102+// *gitcli.Git implements it. An existing ref returns domain.ErrAlreadyExists.
103+type ReleaseTagWriter interface {
104+ CreateTag(ctx context.Context, repo domain.RepoName, tag string, target domain.ObjectID) error
105+}
106+
101 // ReleaseTagTrigger handles tag deletion; *usecase.Releases implements it.107 // ReleaseTagTrigger handles tag deletion; *usecase.Releases implements it.
102 type ReleaseTagTrigger interface {108 type ReleaseTagTrigger interface {
103 OnRefsMoved(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)109 OnRefsMoved(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)
M internal/usecase/pushes.go +21 -14
@@ -17,20 +17,21 @@ import (
1717
1818 // Pushes are the use cases around a Git push.
1919 type Pushes struct {
20- links ports.IssueLinkTrigger
21- mirror ports.MirrorTrigger // nil: no outbound mirror
22- meta ports.RepoMetaStore
23- repos *Repos
24- git ports.GitRepos
25- bus ports.EventBus
26- clock ports.Clock
27- maint ports.MaintenanceScheduler
28- auth ports.Revalidator
29- fresh ports.RepoFreshener // nil: the reader needs no telling
30- ci ports.WorkflowTrigger // nil: no CI
31- trees ports.PushTreeReader // nil: pushes by job tokens are refused
32- tips ports.CIRefTipStore // nil: who pushes a ref is not recorded
33- mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked
20+ releases ports.ReleaseTagTrigger
21+ links ports.IssueLinkTrigger
22+ mirror ports.MirrorTrigger // nil: no outbound mirror
23+ meta ports.RepoMetaStore
24+ repos *Repos
25+ git ports.GitRepos
26+ bus ports.EventBus
27+ clock ports.Clock
28+ maint ports.MaintenanceScheduler
29+ auth ports.Revalidator
30+ fresh ports.RepoFreshener // nil: the reader needs no telling
31+ ci ports.WorkflowTrigger // nil: no CI
32+ trees ports.PushTreeReader // nil: pushes by job tokens are refused
33+ tips ports.CIRefTipStore // nil: who pushes a ref is not recorded
34+ mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked
3435 }
3536
3637 var (
@@ -45,6 +46,9 @@ func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus p
4546 return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth}
4647 }
4748
49+// WithReleases enables synchronous release tag-deletion handling before the first push.
50+func (u *Pushes) WithReleases(t ports.ReleaseTagTrigger) *Pushes { u.releases = t; return u }
51+
4852 // WithIssueLinks enables synchronous best-effort issue indexing before the first push.
4953 func (u *Pushes) WithIssueLinks(t ports.IssueLinkTrigger) *Pushes { u.links = t; return u }
5054
@@ -272,6 +276,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
272276 if u.links != nil {
273277 u.links.OnBranchesMoved(ctx, name, updates)
274278 }
279+ if u.releases != nil {
280+ u.releases.OnRefsMoved(ctx, name, updates)
281+ }
275282 u.bus.Publish(ctx, domain.PushEvent{
276283 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
277284 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
@@ -17,20 +17,21 @@ import (
17 17
18 // Pushes are the use cases around a Git push.18 // Pushes are the use cases around a Git push.
19 type Pushes struct {19 type Pushes struct {
20- links ports.IssueLinkTrigger20+ releases ports.ReleaseTagTrigger
21- mirror ports.MirrorTrigger // nil: no outbound mirror21+ links ports.IssueLinkTrigger
22- meta ports.RepoMetaStore22+ mirror ports.MirrorTrigger // nil: no outbound mirror
23- repos *Repos23+ meta ports.RepoMetaStore
24- git ports.GitRepos24+ repos *Repos
25- bus ports.EventBus25+ git ports.GitRepos
26- clock ports.Clock26+ bus ports.EventBus
27- maint ports.MaintenanceScheduler27+ clock ports.Clock
28- auth ports.Revalidator28+ maint ports.MaintenanceScheduler
29- fresh ports.RepoFreshener // nil: the reader needs no telling29+ auth ports.Revalidator
30- ci ports.WorkflowTrigger // nil: no CI30+ fresh ports.RepoFreshener // nil: the reader needs no telling
31- trees ports.PushTreeReader // nil: pushes by job tokens are refused31+ ci ports.WorkflowTrigger // nil: no CI
32- tips ports.CIRefTipStore // nil: who pushes a ref is not recorded32+ trees ports.PushTreeReader // nil: pushes by job tokens are refused
33- mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked33+ tips ports.CIRefTipStore // nil: who pushes a ref is not recorded
34+ mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked
34 }35 }
35 36
36 var (37 var (
@@ -45,6 +46,9 @@ func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus p
45 return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth}46 return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth}
46 }47 }
47 48
49+// WithReleases enables synchronous release tag-deletion handling before the first push.
50+func (u *Pushes) WithReleases(t ports.ReleaseTagTrigger) *Pushes { u.releases = t; return u }
51+
48 // WithIssueLinks enables synchronous best-effort issue indexing before the first push.52 // WithIssueLinks enables synchronous best-effort issue indexing before the first push.
49 func (u *Pushes) WithIssueLinks(t ports.IssueLinkTrigger) *Pushes { u.links = t; return u }53 func (u *Pushes) WithIssueLinks(t ports.IssueLinkTrigger) *Pushes { u.links = t; return u }
50 54
@@ -272,6 +276,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
272 if u.links != nil {276 if u.links != nil {
273 u.links.OnBranchesMoved(ctx, name, updates)277 u.links.OnBranchesMoved(ctx, name, updates)
274 }278 }
279+ if u.releases != nil {
280+ u.releases.OnRefsMoved(ctx, name, updates)
281+ }
275 u.bus.Publish(ctx, domain.PushEvent{282 u.bus.Publish(ctx, domain.PushEvent{
276 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,283 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
277 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,284 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
A internal/usecase/releases_test.go +57 -0
@@ -0,0 +1,57 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "bnema.dev/ermite/internal/domain"
10+ "bnema.dev/ermite/internal/ports/mocks"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestReleaseTagDeletionHook(t *testing.T) {
16+ ctx := context.Background()
17+ n := name(t, "tools")
18+ f := newPushFixture(t)
19+ trigger := mocks.NewReleaseTagTrigger(t)
20+ f.pushes.WithReleases(trigger)
21+ updates := []domain.RefUpdate{{Old: oidA, New: domain.ZeroOID, Ref: "refs/tags/v1"}}
22+ expectPushMutation(t, f.meta, domain.Repo{Name: n}, nil)
23+ f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
24+ trigger.EXPECT().OnRefsMoved(ctx, n, updates).Return().Once()
25+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
26+ f.sched.EXPECT().Schedule(ctx, n).Return().Once()
27+ require.NoError(t, f.pushes.Record(ctx, domain.Root(), n, updates))
28+}
29+
30+func TestReleaseRepositoryDeletionHook(t *testing.T) {
31+ for _, failed := range []bool{false, true} {
32+ t.Run(map[bool]string{false: "deleted", true: "metadata failure"}[failed], func(t *testing.T) {
33+ ctx := context.Background()
34+ n := name(t, "tools")
35+ f := newFixture(t)
36+ cleaner := mocks.NewReleaseRepoCleaner(t)
37+ f.repos.WithReleases(cleaner)
38+ assets := []domain.ReleaseAsset{{ID: 42, Repo: n, ReleaseID: 1}}
39+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil)
40+ f.git.EXPECT().Trash(ctx, n).Return(nil)
41+ cleaner.EXPECT().AssetsOf(ctx, n).Return(assets, nil).Once()
42+ if failed {
43+ f.meta.EXPECT().Delete(ctx, n).Return(domain.ErrUnavailable).Once()
44+ require.ErrorIs(t, f.repos.Delete(ctx, domain.Root(), n), domain.ErrUnavailable)
45+ } else {
46+ deleted := false
47+ f.meta.EXPECT().Delete(ctx, n).Run(func(context.Context, domain.RepoName) { deleted = true }).Return(nil).Once()
48+ cleaner.EXPECT().OnRepoDeleted(mock.Anything, n, assets).Run(func(ctx context.Context, _ domain.RepoName, _ []domain.ReleaseAsset) {
49+ require.True(t, deleted)
50+ require.Nil(t, ctx.Done())
51+ }).Return().Once()
52+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
53+ require.NoError(t, f.repos.Delete(ctx, domain.Root(), n))
54+ }
55+ })
56+ }
57+}
@@ -0,0 +1,57 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "bnema.dev/ermite/internal/domain"
10+ "bnema.dev/ermite/internal/ports/mocks"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestReleaseTagDeletionHook(t *testing.T) {
16+ ctx := context.Background()
17+ n := name(t, "tools")
18+ f := newPushFixture(t)
19+ trigger := mocks.NewReleaseTagTrigger(t)
20+ f.pushes.WithReleases(trigger)
21+ updates := []domain.RefUpdate{{Old: oidA, New: domain.ZeroOID, Ref: "refs/tags/v1"}}
22+ expectPushMutation(t, f.meta, domain.Repo{Name: n}, nil)
23+ f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
24+ trigger.EXPECT().OnRefsMoved(ctx, n, updates).Return().Once()
25+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
26+ f.sched.EXPECT().Schedule(ctx, n).Return().Once()
27+ require.NoError(t, f.pushes.Record(ctx, domain.Root(), n, updates))
28+}
29+
30+func TestReleaseRepositoryDeletionHook(t *testing.T) {
31+ for _, failed := range []bool{false, true} {
32+ t.Run(map[bool]string{false: "deleted", true: "metadata failure"}[failed], func(t *testing.T) {
33+ ctx := context.Background()
34+ n := name(t, "tools")
35+ f := newFixture(t)
36+ cleaner := mocks.NewReleaseRepoCleaner(t)
37+ f.repos.WithReleases(cleaner)
38+ assets := []domain.ReleaseAsset{{ID: 42, Repo: n, ReleaseID: 1}}
39+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil)
40+ f.git.EXPECT().Trash(ctx, n).Return(nil)
41+ cleaner.EXPECT().AssetsOf(ctx, n).Return(assets, nil).Once()
42+ if failed {
43+ f.meta.EXPECT().Delete(ctx, n).Return(domain.ErrUnavailable).Once()
44+ require.ErrorIs(t, f.repos.Delete(ctx, domain.Root(), n), domain.ErrUnavailable)
45+ } else {
46+ deleted := false
47+ f.meta.EXPECT().Delete(ctx, n).Run(func(context.Context, domain.RepoName) { deleted = true }).Return(nil).Once()
48+ cleaner.EXPECT().OnRepoDeleted(mock.Anything, n, assets).Run(func(ctx context.Context, _ domain.RepoName, _ []domain.ReleaseAsset) {
49+ require.True(t, deleted)
50+ require.Nil(t, ctx.Done())
51+ }).Return().Once()
52+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
53+ require.NoError(t, f.repos.Delete(ctx, domain.Root(), n))
54+ }
55+ })
56+ }
57+}
M internal/usecase/repos.go +9 -1
@@ -24,7 +24,8 @@ type Repos struct {
2424 ci ports.CIRunCleaner // nil: no CI logs to remove
2525 autoMerge ports.AutoMergeTrigger
2626 mergeGate ports.MergeGate
27- mirror ports.MirrorTrigger // nil: no outbound mirror
27+ mirror ports.MirrorTrigger // nil: no outbound mirror
28+ releases ports.ReleaseRepoCleaner // nil: no release blobs to remove
2829 }
2930
3031 var _ ports.RepoService = (*Repos)(nil)
@@ -42,6 +43,13 @@ func (r *Repos) WithMirror(t ports.MirrorTrigger) *Repos {
4243 return r
4344 }
4445
46+// WithReleases makes Delete remove release blobs after metadata deletion.
47+// Call it before the first request; it returns r.
48+func (r *Repos) WithReleases(c ports.ReleaseRepoCleaner) *Repos {
49+ r.releases = c
50+ return r
51+}
52+
4553 // WithCIRunCleaner makes Delete remove the CI logs of the repository. Call it
4654 // before the first request; it returns r.
4755 func (r *Repos) WithCIRunCleaner(c ports.CIRunCleaner) *Repos {
@@ -24,7 +24,8 @@ type Repos struct {
24 ci ports.CIRunCleaner // nil: no CI logs to remove24 ci ports.CIRunCleaner // nil: no CI logs to remove
25 autoMerge ports.AutoMergeTrigger25 autoMerge ports.AutoMergeTrigger
26 mergeGate ports.MergeGate26 mergeGate ports.MergeGate
27- mirror ports.MirrorTrigger // nil: no outbound mirror27+ mirror ports.MirrorTrigger // nil: no outbound mirror
28+ releases ports.ReleaseRepoCleaner // nil: no release blobs to remove
28 }29 }
29 30
30 var _ ports.RepoService = (*Repos)(nil)31 var _ ports.RepoService = (*Repos)(nil)
@@ -42,6 +43,13 @@ func (r *Repos) WithMirror(t ports.MirrorTrigger) *Repos {
42 return r43 return r
43 }44 }
44 45
46+// WithReleases makes Delete remove release blobs after metadata deletion.
47+// Call it before the first request; it returns r.
48+func (r *Repos) WithReleases(c ports.ReleaseRepoCleaner) *Repos {
49+ r.releases = c
50+ return r
51+}
52+
45 // WithCIRunCleaner makes Delete remove the CI logs of the repository. Call it53 // WithCIRunCleaner makes Delete remove the CI logs of the repository. Call it
46 // before the first request; it returns r.54 // before the first request; it returns r.
47 func (r *Repos) WithCIRunCleaner(c ports.CIRunCleaner) *Repos {55 func (r *Repos) WithCIRunCleaner(c ports.CIRunCleaner) *Repos {
M internal/usecase/repos_write.go +11 -0
@@ -226,9 +226,20 @@ func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.Repo
226226 log.Warn().Err(err).Str("repo", name.String()).Msg("list the CI runs of the repository; their logs are left to the sweep")
227227 }
228228 }
229+ var assets []domain.ReleaseAsset
230+ if r.releases != nil {
231+ var err error
232+ assets, err = r.releases.AssetsOf(ctx, name)
233+ if err != nil {
234+ log.Warn().Err(err).Str("repo", name.String()).Msg("list release assets; orphan sweep will remove their blobs")
235+ }
236+ }
229237 if err := r.meta.Delete(ctx, name); err != nil {
230238 return fmt.Errorf("delete metadata of %q: %w", name, err)
231239 }
240+ if r.releases != nil {
241+ r.releases.OnRepoDeleted(context.WithoutCancel(ctx), name, assets)
242+ }
232243 if r.ci != nil {
233244 r.ci.ForgetRepo(name)
234245 }
@@ -226,9 +226,20 @@ func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.Repo
226 log.Warn().Err(err).Str("repo", name.String()).Msg("list the CI runs of the repository; their logs are left to the sweep")226 log.Warn().Err(err).Str("repo", name.String()).Msg("list the CI runs of the repository; their logs are left to the sweep")
227 }227 }
228 }228 }
229+ var assets []domain.ReleaseAsset
230+ if r.releases != nil {
231+ var err error
232+ assets, err = r.releases.AssetsOf(ctx, name)
233+ if err != nil {
234+ log.Warn().Err(err).Str("repo", name.String()).Msg("list release assets; orphan sweep will remove their blobs")
235+ }
236+ }
229 if err := r.meta.Delete(ctx, name); err != nil {237 if err := r.meta.Delete(ctx, name); err != nil {
230 return fmt.Errorf("delete metadata of %q: %w", name, err)238 return fmt.Errorf("delete metadata of %q: %w", name, err)
231 }239 }
240+ if r.releases != nil {
241+ r.releases.OnRepoDeleted(context.WithoutCancel(ctx), name, assets)
242+ }
232 if r.ci != nil {243 if r.ci != nil {
233 r.ci.ForgetRepo(name)244 r.ci.ForgetRepo(name)
234 }245 }

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 42

↑↓ moveenter openesc close