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