repos.go RawHistoryFolder

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 }

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 4

Tags 45

↑↓ moveenter openesc close