v0.32.0 53e25a3

repos.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package kvstore
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"slices"
10	"time"
11	"unicode/utf8"
12
13	kv "bnema.dev/ermite-kv"
14	"google.golang.org/protobuf/proto"
15	"google.golang.org/protobuf/types/known/timestamppb"
16
17	storagepb "bnema.dev/ermite/gen/ermite/storage"
18	"bnema.dev/ermite/internal/domain"
19)
20
21const repoPrefix = "repo/"
22
23func repoKey(name domain.RepoName) []byte { return []byte(repoPrefix + name.String()) }
24
25// Create stores repo with an "absent" precondition: a repository with that
26// name, or a concurrent Create that commits first, yields domain.ErrAlreadyExists.
27func (s *Store) Create(ctx context.Context, repo domain.Repo) error {
28	val, err := encodeRepo(repo)
29	if err != nil {
30		return err
31	}
32	key := repoKey(repo.Name)
33	var b kv.Batch
34	b.RequireAbsent(key)
35	b.Put(key, val)
36	if _, err := s.db.Apply(ctx, &b); err != nil {
37		if errors.Is(err, kv.ErrConflict) {
38			return domain.ErrAlreadyExists
39		}
40		return fmt.Errorf("create repository %q: %w", repo.Name, err)
41	}
42	return nil
43}
44
45// Get returns the repository or domain.ErrNotFound.
46func (s *Store) Get(ctx context.Context, name domain.RepoName) (domain.Repo, error) {
47	item, ok := s.db.Get(ctx, repoKey(name))
48	if !ok {
49		return domain.Repo{}, domain.ErrNotFound
50	}
51	return decodeRepo(item.Value)
52}
53
54// List returns every repository ordered by name.
55func (s *Store) List(ctx context.Context) ([]domain.Repo, error) {
56	var repos []domain.Repo
57	for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(repoPrefix)) {
58		repo, err := decodeRepo(item.Value)
59		if err != nil {
60			return nil, fmt.Errorf("decode %s: %w", key, err)
61		}
62		repos = append(repos, repo)
63	}
64	return repos, nil
65}
66
67const repoMutationTries = 8
68
69// Mutate applies a retry-safe mutation under the version it read.
70func (s *Store) Mutate(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) {
71	key := repoKey(name)
72	for range repoMutationTries {
73		if err := ctx.Err(); err != nil {
74			return domain.Repo{}, err
75		}
76		item, ok := s.db.Get(ctx, key)
77		if !ok {
78			return domain.Repo{}, domain.ErrNotFound
79		}
80		repo, err := decodeRepo(item.Value)
81		if err != nil {
82			return domain.Repo{}, err
83		}
84		changed, err := fn(&repo)
85		if err != nil {
86			return domain.Repo{}, err
87		}
88		if repo.Name != name {
89			return domain.Repo{}, fmt.Errorf("mutate repository name: %w", domain.ErrInvalidName)
90		}
91		if !changed {
92			return decodeRepo(item.Value)
93		}
94		val, err := encodeRepo(repo)
95		if err != nil {
96			return domain.Repo{}, err
97		}
98		var b kv.Batch
99		b.RequireVersion(key, item.Version)
100		b.Put(key, val)
101		if _, err := s.db.Apply(ctx, &b); err != nil {
102			if errors.Is(err, kv.ErrConflict) {
103				continue
104			}
105			return domain.Repo{}, fmt.Errorf("mutate repository %q: %w", name, err)
106		}
107		return decodeRepo(val)
108	}
109	return domain.Repo{}, domain.ErrConflict
110}
111
112// guardRepoKey makes the batch depend on the repository: it requires the
113// repository key to be at the version just read and rewrites it, which gives it
114// a new version. Every write that adds a key under the repository (a merge
115// request, a comment, a read position, a CI run, job or check) goes through it,
116// so that
117//
118//   - such a write fails with domain.ErrNotFound once the repository is deleted,
119//     and leaves no key that a repository created again under the name would
120//     inherit;
121//   - Store.Delete, which requires the version of the repository key it listed
122//     the keys at, notices a write that got in after the listing and lists again.
123//
124// It returns domain.ErrNotFound when the repository does not exist.
125func guardRepoKey(ctx context.Context, db *kv.DB, b *kv.Batch, repo domain.RepoName) error {
126	item, ok := db.Get(ctx, repoKey(repo))
127	if !ok {
128		return domain.ErrNotFound
129	}
130	b.RequireVersion(repoKey(repo), item.Version)
131	b.Put(repoKey(repo), item.Value)
132	return nil
133}
134
135// deleteChunk is how many merge request keys one batch deletes before the
136// last one, which also removes the repository.
137const deleteChunk = 1000
138
139// deleteTries bounds the listings of Delete: a listing is repeated when a merge
140// request write got in after it.
141const deleteTries = 32
142
143// Delete removes the repository and everything keyed to it: its merge
144// requests, their comments and read positions, and its number sequence, and
145// its CI runs, jobs, checks, run numbers and artifacts. The
146// Git data is moved to the trash by the caller; the merge requests are not
147// kept anywhere, like the repository metadata, so a repository created again
148// under the same name starts clean, with its numbers starting at 1. A missing
149// repository is not an error.
150//
151// The merge request keys go first, in chunks, and the repository key goes with
152// the last chunk: a delete that fails half way leaves the repository, which
153// the caller can delete again. The last batch requires the repository key to
154// be at the version the keys were listed at. Every write of a merge request key
155// rewrites the repository key (see guardRepoKey), so a write that
156// got in after the listing makes the batch fail and the keys are listed again,
157// and a write after the batch finds no repository: no key outlives the
158// repository to attach itself to a repository created again.
159func (s *Store) Delete(ctx context.Context, name domain.RepoName) error {
160	for range deleteTries {
161		snap := s.db.Snapshot(ctx)
162		repoItem, repoOK := snap.Get(ctx, repoKey(name))
163		keys := append(mergeRequestKeys(ctx, snap, name), issueKeys(ctx, snap, name)...)
164		keys = append(keys, pendingKeys(ctx, snap, name)...)
165		keys = append(keys, ciKeys(ctx, snap, name)...)
166		keys = append(keys, releaseKeys(ctx, snap, name)...)
167		keys = append(keys, settingsKeys(ctx, snap, name)...)
168		keys = append(keys, mirrorKeys(ctx, snap, name)...)
169		for len(keys) > deleteChunk {
170			var b kv.Batch
171			for _, k := range keys[:deleteChunk] {
172				b.Delete(k)
173			}
174			if _, err := s.db.Apply(ctx, &b); err != nil {
175				return fmt.Errorf("delete merge requests of %q: %w", name, err)
176			}
177			keys = keys[deleteChunk:]
178		}
179		var b kv.Batch
180		if repoOK {
181			b.RequireVersion(repoKey(name), repoItem.Version)
182		} else {
183			b.RequireAbsent(repoKey(name))
184		}
185		for _, k := range keys {
186			b.Delete(k)
187		}
188		b.Delete(repoKey(name))
189		_, err := s.db.Apply(ctx, &b)
190		if errors.Is(err, kv.ErrConflict) {
191			continue
192		}
193		if err != nil {
194			return fmt.Errorf("delete repository %q: %w", name, err)
195		}
196		return nil
197	}
198	return fmt.Errorf("delete repository %q: %w", name, domain.ErrConflict)
199}
200
201func encodeRepo(r domain.Repo) ([]byte, error) {
202	if r.Name.IsZero() {
203		return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
204	}
205	if !utf8.ValidString(r.Description) {
206		return nil, errors.New("repository description is not valid UTF-8")
207	}
208	vis, err := encodeVisibility(r.Visibility)
209	if err != nil {
210		return nil, err
211	}
212	// Store defaults explicitly so future default changes do not alter stored repositories.
213	settings := r.EffectiveSettings()
214	pbSettings, err := encodeSettings(settings)
215	if err != nil {
216		return nil, err
217	}
218	pb := &storagepb.Repo{
219		Name:        r.Name.String(),
220		Description: r.Description,
221		Topics:      r.Topics,
222		Visibility:  vis,
223		CreatedAt:   encodeTime(r.CreatedAt),
224		PushedAt:    encodeTime(r.PushedAt),
225		ActiveAt:    encodeTime(r.ActiveAt),
226		Settings:    pbSettings,
227	}
228	return mustMarshal(pb), nil
229}
230
231func decodeRepo(val []byte) (domain.Repo, error) {
232	var pb storagepb.Repo
233	if err := proto.Unmarshal(val, &pb); err != nil {
234		return domain.Repo{}, fmt.Errorf("decode repository: %w", err)
235	}
236	name, err := domain.ParseRepoName(pb.GetName())
237	if err != nil {
238		return domain.Repo{}, fmt.Errorf("decode repository: %w", err)
239	}
240	return domain.Repo{
241		Name:        name,
242		Description: pb.GetDescription(),
243		Topics:      slices.Clone(pb.GetTopics()),
244		Visibility:  decodeVisibility(pb.GetVisibility()),
245		CreatedAt:   decodeTime(pb.GetCreatedAt()),
246		PushedAt:    decodeTime(pb.GetPushedAt()),
247		ActiveAt:    decodeTime(pb.GetActiveAt()),
248		Settings:    decodeSettings(pb.GetSettings()),
249	}, nil
250}
251
252func encodeVisibility(v domain.Visibility) (storagepb.Visibility, error) {
253	switch v {
254	case domain.VisibilityPublic:
255		return storagepb.Visibility_VISIBILITY_PUBLIC, nil
256	case domain.VisibilityPrivate:
257		return storagepb.Visibility_VISIBILITY_PRIVATE, nil
258	}
259	return storagepb.Visibility_VISIBILITY_UNSPECIFIED, fmt.Errorf("%w: %q", domain.ErrInvalidVisibility, v)
260}
261
262// decodeVisibility maps anything but public to private, including the
263// unspecified value and values from a newer schema: the safe side.
264func decodeVisibility(v storagepb.Visibility) domain.Visibility {
265	if v == storagepb.Visibility_VISIBILITY_PUBLIC {
266		return domain.VisibilityPublic
267	}
268	return domain.VisibilityPrivate
269}
270
271func encodeTime(t time.Time) *timestamppb.Timestamp {
272	if t.IsZero() {
273		return nil
274	}
275	return timestamppb.New(t)
276}
277
278func decodeTime(ts *timestamppb.Timestamp) time.Time {
279	if ts == nil {
280		return time.Time{}
281	}
282	return ts.AsTime()
283}

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 2

Tags 45

↑↓ moveenter openesc close