1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package usecase holds the use cases of ermite. It depends on the domain and on
4// the ports only.
5package usecase
6
7import (
8 "context"
9 "errors"
10 "fmt"
11
12 "github.com/bnema/zerowrap"
13
14 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/ports"
16)
17
18// Repos are the repository use cases.
19type Repos struct {
20 meta ports.RepoMetaStore
21 git ports.GitRepos
22 bus ports.EventBus
23 clock ports.Clock
24 ci ports.CIRunCleaner // nil: no CI logs to remove
25 autoMerge ports.AutoMergeTrigger
26 mergeGate ports.MergeGate
27 mirror ports.MirrorTrigger // nil: no outbound mirror
28 releases ports.ReleaseRepoCleaner // nil: no release blobs to remove
29}
30
31var _ ports.RepoService = (*Repos)(nil)
32
33// NewRepos builds the repository use cases over their ports. Changes are
34// published on bus.
35func NewRepos(meta ports.RepoMetaStore, git ports.GitRepos, bus ports.EventBus, clock ports.Clock) *Repos {
36 return &Repos{meta: meta, git: git, bus: bus, clock: clock}
37}
38
39// WithMirror makes a default-branch change notify outbound mirrors. Call it
40// before the first request; it returns r.
41func (r *Repos) WithMirror(t ports.MirrorTrigger) *Repos {
42 r.mirror = t
43 return r
44}
45
46// WithReleases makes Delete remove release blobs after metadata deletion.
47// Call it before the first request; it returns r.
48func (r *Repos) WithReleases(c ports.ReleaseRepoCleaner) *Repos {
49 r.releases = c
50 return r
51}
52
53// WithCIRunCleaner makes Delete remove the CI logs of the repository. Call it
54// before the first request; it returns r.
55func (r *Repos) WithCIRunCleaner(c ports.CIRunCleaner) *Repos {
56 r.ci = c
57 return r
58}
59
60// Create makes a repository and publishes RepoCreated: it stages a bare repository, records the
61// metadata (which fails with domain.ErrAlreadyExists if the name is taken,
62// also when a concurrent Create wins) and publishes the directory. A failure
63// after the metadata is written removes it again and discards the staged
64// repository.
65//
66// The principal needs repo:write on the name and no branch prefixes: anonymous
67// callers get domain.ErrUnauthenticated, other callers without it (a token
68// limited to some branches included) domain.ErrForbidden.
69func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.CreateRepoParams) (domain.Repo, error) {
70 if params.Name.IsZero() {
71 return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
72 }
73 if p.IsAnonymous() {
74 return domain.Repo{}, domain.ErrUnauthenticated
75 }
76 if !canCreateRepo(p, params.Name) {
77 return domain.Repo{}, domain.ErrForbidden
78 }
79 if err := validateDescription(params.Description); err != nil {
80 return domain.Repo{}, err
81 }
82 topics, err := domain.NormalizeTopics(params.Topics)
83 if err != nil {
84 return domain.Repo{}, err
85 }
86 vis := params.Visibility
87 if vis == "" {
88 vis = domain.VisibilityPrivate
89 }
90 if _, err := domain.ParseVisibility(vis.String()); err != nil {
91 return domain.Repo{}, err
92 }
93 repo := domain.Repo{
94 Name: params.Name,
95 Description: params.Description,
96 Topics: topics,
97 Visibility: vis,
98 CreatedAt: r.clock.Now().UTC(),
99 Settings: domain.DefaultRepoSettings(),
100 }
101
102 log := zerowrap.FromCtx(ctx)
103 // Cleanup must finish even when the caller gives up.
104 cleanup := context.WithoutCancel(ctx)
105
106 id, err := r.git.Stage(ctx)
107 if err != nil {
108 return domain.Repo{}, fmt.Errorf("stage repository %q: %w", repo.Name, err)
109 }
110 if err := r.meta.Create(ctx, repo); err != nil {
111 r.discard(cleanup, id)
112 if errors.Is(err, domain.ErrAlreadyExists) {
113 return domain.Repo{}, fmt.Errorf("repository %q: %w", repo.Name, domain.ErrAlreadyExists)
114 }
115 return domain.Repo{}, fmt.Errorf("store repository %q: %w", repo.Name, err)
116 }
117 if err := r.git.Publish(ctx, id, repo.Name); err != nil {
118 if derr := r.meta.Delete(cleanup, repo.Name); derr != nil {
119 log.Error().Err(derr).Str("repo", repo.Name.String()).Msg("remove metadata after failed publish")
120 }
121 r.discard(cleanup, id)
122 if errors.Is(err, domain.ErrAlreadyExists) {
123 return domain.Repo{}, fmt.Errorf("repository %q: directory %w", repo.Name, domain.ErrAlreadyExists)
124 }
125 return domain.Repo{}, fmt.Errorf("publish repository %q: %w", repo.Name, err)
126 }
127 log.Info().Str("repo", repo.Name.String()).Str("visibility", vis.String()).Msg("repository created")
128 r.bus.Publish(ctx, domain.RepoCreated{Repo: repo.Name, Visibility: repo.Visibility, At: repo.CreatedAt})
129 return repo, nil
130}
131
132func (r *Repos) discard(ctx context.Context, id ports.StagedID) {
133 if err := r.git.Discard(ctx, id); err != nil {
134 log := zerowrap.FromCtx(ctx)
135 log.Error().Err(err).Str("staged", string(id)).Msg("discard staged repository")
136 }
137}
138
139// lookupRepo reads the metadata of a repository: domain.ErrInvalidName for the
140// zero name, domain.ErrNotFound for a missing repository. Access rules are the
141// caller's.
142func lookupRepo(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName) (domain.Repo, error) {
143 if name.IsZero() {
144 return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
145 }
146 return meta.Get(ctx, name)
147}
148
149// Get returns a repository. A repository the principal may not read answers
150// domain.ErrNotFound, like a missing one.
151func (r *Repos) Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
152 repo, err := lookupRepo(ctx, r.meta, name)
153 if err != nil {
154 return domain.Repo{}, err
155 }
156 if !canRead(p, repo) {
157 return domain.Repo{}, domain.ErrNotFound
158 }
159 return repo, nil
160}
161
162// List returns the repositories the principal may read, ordered by name.
163func (r *Repos) List(ctx context.Context, p domain.Principal) ([]domain.Repo, error) {
164 all, err := r.meta.List(ctx)
165 if err != nil {
166 return nil, err
167 }
168 visible := make([]domain.Repo, 0, len(all))
169 for _, repo := range all {
170 if canRead(p, repo) {
171 visible = append(visible, repo)
172 }
173 }
174 return visible, nil
175}
176
177// ReconcileReport tells what Reconcile found.
178type ReconcileReport struct {
179 // Registered are directories without metadata, now registered private.
180 Registered []domain.RepoName
181 // MissingDirs are repositories with metadata and no directory. They are
182 // left untouched.
183 MissingDirs []domain.RepoName
184 // InvalidDirs are directory entries that cannot be repositories.
185 InvalidDirs []string
186}
187
188// Reconcile aligns the repository directories with the metadata at startup:
189// it removes staging leftovers, registers a directory without metadata as
190// private, skips invalid directory names, and reports metadata without a
191// directory at error level without touching it.
192func (r *Repos) Reconcile(ctx context.Context) (ReconcileReport, error) {
193 log := zerowrap.FromCtx(ctx)
194 var rep ReconcileReport
195
196 if err := r.git.PurgeStaging(ctx); err != nil {
197 return rep, fmt.Errorf("purge staging: %w", err)
198 }
199 dirs, err := r.git.List(ctx)
200 if err != nil {
201 return rep, fmt.Errorf("list repository directories: %w", err)
202 }
203 metas, err := r.meta.List(ctx)
204 if err != nil {
205 return rep, fmt.Errorf("list repository metadata: %w", err)
206 }
207
208 for _, entry := range dirs.Invalid {
209 log.Warn().Str("entry", entry).Msg("skip directory that is not a valid repository name")
210 }
211 rep.InvalidDirs = dirs.Invalid
212
213 hasMeta := make(map[domain.RepoName]bool, len(metas))
214 for _, m := range metas {
215 hasMeta[m.Name] = true
216 }
217 hasDir := make(map[domain.RepoName]bool, len(dirs.Names))
218 for _, name := range dirs.Names {
219 hasDir[name] = true
220 }
221
222 for _, name := range dirs.Names {
223 if hasMeta[name] {
224 continue
225 }
226 repo := domain.Repo{
227 Name: name,
228 Visibility: domain.VisibilityPrivate,
229 CreatedAt: r.clock.Now().UTC(),
230 Settings: domain.DefaultRepoSettings(),
231 }
232 if err := r.meta.Create(ctx, repo); err != nil && !errors.Is(err, domain.ErrAlreadyExists) {
233 return rep, fmt.Errorf("register repository %q: %w", name, err)
234 }
235 log.Warn().Str("repo", name.String()).Msg("registered repository directory without metadata as private")
236 rep.Registered = append(rep.Registered, name)
237 }
238 for _, m := range metas {
239 if !hasDir[m.Name] {
240 log.Error().Str("repo", m.Name.String()).Msg("repository metadata without a directory, left untouched")
241 rep.MissingDirs = append(rep.MissingDirs, m.Name)
242 }
243 }
244 return rep, nil
245}
246
247// WithAutoMerge installs the settings-change trigger before serving requests.
248func (r *Repos) WithAutoMerge(t ports.AutoMergeTrigger) *Repos { r.autoMerge = t; return r }
249
250// WithMergeGate installs the settings-write gate before serving requests.
251func (r *Repos) WithMergeGate(g ports.MergeGate) *Repos { r.mergeGate = g; return r }