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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 98 | 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 | 107 | // ReleaseTagTrigger handles tag deletion; *usecase.Releases implements it. | |
| 102 | 108 | type ReleaseTagTrigger interface { | |
| 103 | 109 | 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 ( | |||
| 17 | 17 | ||
| 18 | 18 | // Pushes are the use cases around a Git push. | |
| 19 | 19 | 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 | |
| 34 | 35 | } | |
| 35 | 36 | ||
| 36 | 37 | var ( | |
| @@ -45,6 +46,9 @@ func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus p | |||
| 45 | 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 | 52 | // WithIssueLinks enables synchronous best-effort issue indexing before the first push. | |
| 49 | 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 | 276 | if u.links != nil { | |
| 273 | 277 | u.links.OnBranchesMoved(ctx, name, updates) | |
| 274 | 278 | } | |
| 279 | + | if u.releases != nil { | |
| 280 | + | u.releases.OnRefsMoved(ctx, name, updates) | |
| 281 | + | } | |
| 275 | 282 | u.bus.Publish(ctx, domain.PushEvent{ | |
| 276 | 283 | Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now, | |
| 277 | 284 | 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.IssueLinkTrigger | 20 | + | releases ports.ReleaseTagTrigger |
| 21 | - | mirror ports.MirrorTrigger // nil: no outbound mirror | 21 | + | links ports.IssueLinkTrigger |
| 22 | - | meta ports.RepoMetaStore | 22 | + | mirror ports.MirrorTrigger // nil: no outbound mirror |
| 23 | - | repos *Repos | 23 | + | meta ports.RepoMetaStore |
| 24 | - | git ports.GitRepos | 24 | + | repos *Repos |
| 25 | - | bus ports.EventBus | 25 | + | git ports.GitRepos |
| 26 | - | clock ports.Clock | 26 | + | bus ports.EventBus |
| 27 | - | maint ports.MaintenanceScheduler | 27 | + | clock ports.Clock |
| 28 | - | auth ports.Revalidator | 28 | + | maint ports.MaintenanceScheduler |
| 29 | - | fresh ports.RepoFreshener // nil: the reader needs no telling | 29 | + | auth ports.Revalidator |
| 30 | - | ci ports.WorkflowTrigger // nil: no CI | 30 | + | fresh ports.RepoFreshener // nil: the reader needs no telling |
| 31 | - | trees ports.PushTreeReader // nil: pushes by job tokens are refused | 31 | + | ci ports.WorkflowTrigger // nil: no CI |
| 32 | - | tips ports.CIRefTipStore // nil: who pushes a ref is not recorded | 32 | + | trees ports.PushTreeReader // nil: pushes by job tokens are refused |
| 33 | - | mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked | 33 | + | 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 { | |||
| 24 | 24 | ci ports.CIRunCleaner // nil: no CI logs to remove | |
| 25 | 25 | autoMerge ports.AutoMergeTrigger | |
| 26 | 26 | 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 | |
| 28 | 29 | } | |
| 29 | 30 | ||
| 30 | 31 | var _ ports.RepoService = (*Repos)(nil) | |
| @@ -42,6 +43,13 @@ func (r *Repos) WithMirror(t ports.MirrorTrigger) *Repos { | |||
| 42 | 43 | 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 | 53 | // WithCIRunCleaner makes Delete remove the CI logs of the repository. Call it | |
| 46 | 54 | // before the first request; it returns r. | |
| 47 | 55 | func (r *Repos) WithCIRunCleaner(c ports.CIRunCleaner) *Repos { | |
| @@ -24,7 +24,8 @@ type Repos struct { | |||||
| 24 | ci ports.CIRunCleaner // nil: no CI logs to remove | 24 | ci ports.CIRunCleaner // nil: no CI logs to remove | ||
| 25 | autoMerge ports.AutoMergeTrigger | 25 | autoMerge ports.AutoMergeTrigger | ||
| 26 | mergeGate ports.MergeGate | 26 | 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 | |||
| 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 r | 43 | 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 it | 53 | // 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 | |||
| 226 | 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 | 237 | if err := r.meta.Delete(ctx, name); err != nil { | |
| 230 | 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 | 243 | if r.ci != nil { | |
| 233 | 244 | r.ci.ForgetRepo(name) | |
| 234 | 245 | } | |
| @@ -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 | } | ||