v0.31.0 d5e0505

repos_write.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"unicode"
10	"unicode/utf8"
11
12	"github.com/bnema/zerowrap"
13
14	"bnema.dev/ermite/internal/domain"
15	"bnema.dev/ermite/internal/ports"
16)
17
18// MaxDescriptionLen is the longest repository description, in bytes.
19const MaxDescriptionLen = 500
20
21// validateDescription accepts a one-line description of valid UTF-8.
22func validateDescription(s string) error {
23	if len(s) > MaxDescriptionLen {
24		return fmt.Errorf("%w: description is longer than %d bytes", domain.ErrInvalid, MaxDescriptionLen)
25	}
26	if !utf8.ValidString(s) {
27		return fmt.Errorf("%w: description is not valid UTF-8", domain.ErrInvalid)
28	}
29	for _, c := range s {
30		if unicode.IsControl(c) {
31			return fmt.Errorf("%w: description must be one line without control characters", domain.ErrInvalid)
32		}
33	}
34	return nil
35}
36
37// View adds the default branch to a repository the caller has already been
38// allowed to see. A repository without a directory has no default branch.
39func (r *Repos) View(ctx context.Context, repo domain.Repo) (ports.RepoView, error) {
40	branch, err := r.git.HeadBranch(ctx, repo.Name)
41	if err != nil && !errors.Is(err, domain.ErrNotFound) {
42		return ports.RepoView{}, fmt.Errorf("read default branch of %q: %w", repo.Name, err)
43	}
44	return ports.RepoView{Repo: repo, DefaultBranch: branch}, nil
45}
46
47// Update changes the description, the topics, the visibility, the default branch and the
48// merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it
49// (see adminAccess for what the others learn) and no branch prefixes. At least one field must be set,
50// else domain.ErrInvalid. A default branch that does not exist gives
51// domain.ErrPreconditionFailed; nothing is written before every check passed.
52// The metadata is written first, then HEAD, and RepoUpdated is published after
53// both. When only the HEAD move fails, RepoUpdated is still published for the
54// stored metadata and the HEAD error is returned.
55func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.UpdateRepoParams) (domain.Repo, error) {
56	repo, err := lookupRepo(ctx, r.meta, name)
57	if err != nil {
58		return domain.Repo{}, err
59	}
60	if err := adminAccess(p, repo); err != nil {
61		return domain.Repo{}, err
62	}
63	before := repo
64	if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&
65		params.AllowedMergeModes == nil && params.FailingChecksBlock == nil && params.AllowAutoMerge == nil && params.Issues == nil {
66		return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
67	}
68	if params.AllowedMergeModes != nil {
69		modes, err := domain.NormalizeMergeModes(*params.AllowedMergeModes)
70		if err != nil {
71			return domain.Repo{}, err
72		}
73		params.AllowedMergeModes = &modes
74	}
75	if params.Issues != nil {
76		mode, err := domain.ParseIssuesMode(params.Issues.String())
77		if err != nil {
78			return domain.Repo{}, err
79		}
80		params.Issues = &mode
81	}
82	if params.Description != nil {
83		if err := validateDescription(*params.Description); err != nil {
84			return domain.Repo{}, err
85		}
86	}
87	if params.Topics != nil {
88		topics, err := domain.NormalizeTopics(*params.Topics)
89		if err != nil {
90			return domain.Repo{}, err
91		}
92		params.Topics = &topics
93	}
94	if params.Visibility != nil {
95		if _, err := domain.ParseVisibility(params.Visibility.String()); err != nil {
96			return domain.Repo{}, err
97		}
98	}
99	if params.DefaultBranch != nil {
100		if *params.DefaultBranch == "" {
101			return domain.Repo{}, fmt.Errorf("%w: default branch is empty", domain.ErrInvalid)
102		}
103		ok, err := r.git.HasBranch(ctx, name, *params.DefaultBranch)
104		if err != nil {
105			return domain.Repo{}, fmt.Errorf("check branch %q of %q: %w", *params.DefaultBranch, name, err)
106		}
107		if !ok {
108			return domain.Repo{}, fmt.Errorf("%w: branch %q does not exist", domain.ErrPreconditionFailed, *params.DefaultBranch)
109		}
110	}
111
112	// Metadata first, then HEAD. The two writes are not atomic: when moving
113	// HEAD fails after the metadata was stored, the stored change is still
114	// published as RepoUpdated (a repository made private must be withdrawn
115	// from the watchers) and the call then fails with the HEAD error. The
116	// branch was checked above, so the remaining causes are a vanished
117	// repository or an I/O failure.
118	metaDone := false
119	if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil {
120		update := func() error {
121			repo, err = r.updateMeta(ctx, name, params)
122			return err
123		}
124		if r.mergeGate != nil && (params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil) {
125			err = r.mergeGate.WithMergeLock(ctx, name, update)
126		} else {
127			err = update()
128		}
129		if err != nil {
130			return domain.Repo{}, err
131		}
132		metaDone = true
133		if r.autoMerge != nil {
134			r.autoMerge.OnRepoChanged(ctx, name)
135		}
136	}
137	var headErr error
138	if params.DefaultBranch != nil {
139		if err := r.git.SetDefaultBranch(ctx, name, *params.DefaultBranch); err != nil {
140			headErr = fmt.Errorf("set default branch of %q: %w", name, err)
141			if !metaDone {
142				return domain.Repo{}, headErr
143			}
144		} else if r.mirror != nil {
145			// The default branch decides which refs are mirrored.
146			r.mirror.OnPush(ctx, name, []domain.RefUpdate{{Ref: "HEAD"}})
147		}
148	}
149
150	log := zerowrap.FromCtx(ctx)
151	log.Info().
152		Str("repo", name.String()).
153		Bool("description", params.Description != nil).
154		Bool("topics", params.Topics != nil).
155		Bool("visibility", params.Visibility != nil).
156		Bool("default_branch", params.DefaultBranch != nil).
157		Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil).
158		Str("principal", p.Label()).Str("token_id", p.TokenID()).
159		Msg("repository updated")
160	r.bus.Publish(ctx, domain.RepoUpdated{
161		Repo: name, At: r.clock.Now().UTC(),
162		Visibility: repo.Visibility, Previous: before.Visibility, CreatedAt: repo.CreatedAt,
163	})
164	if headErr != nil {
165		return domain.Repo{}, headErr
166	}
167	return repo, nil
168}
169
170// updateMeta applies the metadata fields, retrying when a write (a push
171// recording pushed_at) gets in between.
172func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params ports.UpdateRepoParams) (domain.Repo, error) {
173	repo, err := r.meta.Mutate(ctx, name, func(repo *domain.Repo) (bool, error) {
174		if params.Description != nil {
175			repo.Description = *params.Description
176		}
177		if params.Topics != nil {
178			repo.Topics = *params.Topics
179		}
180		if params.Visibility != nil {
181			repo.Visibility = *params.Visibility
182		}
183		repo.Settings = repo.EffectiveSettings()
184		if params.AllowedMergeModes != nil {
185			repo.Settings.AllowedMergeModes = *params.AllowedMergeModes
186		}
187		if params.FailingChecksBlock != nil {
188			repo.Settings.FailingChecksBlock = *params.FailingChecksBlock
189		}
190		if params.AllowAutoMerge != nil {
191			repo.Settings.AllowAutoMerge = *params.AllowAutoMerge
192		}
193		if params.Issues != nil {
194			repo.Settings.Issues = *params.Issues
195		}
196		return true, nil
197	})
198	if err != nil {
199		return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, err)
200	}
201	return repo, nil
202}
203
204// Delete moves the repository directory to the trash, removes its metadata
205// and publishes RepoDeleted. The principal needs repo:admin on it (see
206// adminAccess) and no branch prefixes. A repository whose directory is already gone, left by a
207// delete that failed half way, loses its metadata.
208func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.RepoName) error {
209	repo, err := lookupRepo(ctx, r.meta, name)
210	if err != nil {
211		return err
212	}
213	if err := adminAccess(p, repo); err != nil {
214		return err
215	}
216	log := zerowrap.FromCtx(ctx)
217	switch err := r.git.Trash(ctx, name); {
218	case errors.Is(err, domain.ErrNotFound):
219		log.Warn().Str("repo", name.String()).Msg("repository directory already gone, removing its metadata")
220	case err != nil:
221		return fmt.Errorf("trash repository %q: %w", name, err)
222	}
223	var runIDs []int64
224	if r.ci != nil {
225		// Listed before the records go: the logs are found by run id.
226		if ids, err := r.ci.RunIDs(ctx, name); err == nil {
227			runIDs = ids
228		} else {
229			log.Warn().Err(err).Str("repo", name.String()).Msg("list the CI runs of the repository; their logs are left to the sweep")
230		}
231	}
232	if err := r.meta.Delete(ctx, name); err != nil {
233		return fmt.Errorf("delete metadata of %q: %w", name, err)
234	}
235	if r.ci != nil {
236		r.ci.ForgetRepo(name)
237	}
238	if len(runIDs) > 0 {
239		if err := r.ci.RemoveLogs(context.WithoutCancel(ctx), runIDs); err != nil {
240			log.Warn().Err(err).Str("repo", name.String()).Msg("remove the CI logs of the repository; the sweep will")
241		}
242	}
243	log.Info().Str("repo", name.String()).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("repository deleted")
244	r.bus.Publish(ctx, domain.RepoDeleted{Repo: name, Visibility: repo.Visibility, At: r.clock.Now().UTC(), CreatedAt: repo.CreatedAt})
245	return nil
246}

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