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