1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
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)
16
17// MaxDescriptionLen is the longest repository description, in bytes.
18const MaxDescriptionLen = 500
19
20// validateDescription accepts a one-line description of valid UTF-8.
21func validateDescription(s string) error {
22 if len(s) > MaxDescriptionLen {
23 return fmt.Errorf("%w: description is longer than %d bytes", domain.ErrInvalid, MaxDescriptionLen)
24 }
25 if !utf8.ValidString(s) {
26 return fmt.Errorf("%w: description is not valid UTF-8", domain.ErrInvalid)
27 }
28 for _, c := range s {
29 if unicode.IsControl(c) {
30 return fmt.Errorf("%w: description must be one line without control characters", domain.ErrInvalid)
31 }
32 }
33 return nil
34}
35
36// RepoView is a repository with the Git state that belongs in its summary.
37type RepoView struct {
38 domain.Repo
39 // DefaultBranch is the branch HEAD points to, even when it does not exist
40 // yet; empty when HEAD is detached or the directory is missing.
41 DefaultBranch string
42}
43
44// View adds the default branch to a repository the caller has already been
45// allowed to see. A repository without a directory has no default branch.
46func (r *Repos) View(ctx context.Context, repo domain.Repo) (RepoView, error) {
47 branch, err := r.git.HeadBranch(ctx, repo.Name)
48 if err != nil && !errors.Is(err, domain.ErrNotFound) {
49 return RepoView{}, fmt.Errorf("read default branch of %q: %w", repo.Name, err)
50 }
51 return RepoView{Repo: repo, DefaultBranch: branch}, nil
52}
53
54// UpdateParams are the changes of Update; a nil field is left alone.
55type UpdateParams struct {
56 Description *string
57 Visibility *domain.Visibility
58 DefaultBranch *string
59 // AllowedMergeModes replaces the allowed merge modes; it must hold at
60 // least one known mode.
61 AllowedMergeModes *[]domain.MergeMode
62 // FailingChecksBlock replaces the setting of that name.
63 FailingChecksBlock *bool
64}
65
66// Update changes the description, the visibility, the default branch and the
67// merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it
68// (see adminAccess for what the others learn). At least one field must be set,
69// else domain.ErrInvalid. A default branch that does not exist gives
70// domain.ErrPreconditionFailed; nothing is written before every check passed.
71// The metadata is written first, then HEAD, and RepoUpdated is published after
72// both. When only the HEAD move fails, RepoUpdated is still published for the
73// stored metadata and the HEAD error is returned.
74func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.RepoName, params UpdateParams) (domain.Repo, error) {
75 repo, err := lookupRepo(ctx, r.meta, name)
76 if err != nil {
77 return domain.Repo{}, err
78 }
79 if err := adminAccess(p, repo); err != nil {
80 return domain.Repo{}, err
81 }
82 before := repo
83 if params.Description == nil && params.Visibility == nil && params.DefaultBranch == nil &&
84 params.AllowedMergeModes == nil && params.FailingChecksBlock == nil {
85 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
86 }
87 if params.AllowedMergeModes != nil {
88 modes, err := domain.NormalizeMergeModes(*params.AllowedMergeModes)
89 if err != nil {
90 return domain.Repo{}, err
91 }
92 params.AllowedMergeModes = &modes
93 }
94 if params.Description != nil {
95 if err := validateDescription(*params.Description); err != nil {
96 return domain.Repo{}, err
97 }
98 }
99 if params.Visibility != nil {
100 if _, err := domain.ParseVisibility(params.Visibility.String()); err != nil {
101 return domain.Repo{}, err
102 }
103 }
104 if params.DefaultBranch != nil {
105 if *params.DefaultBranch == "" {
106 return domain.Repo{}, fmt.Errorf("%w: default branch is empty", domain.ErrInvalid)
107 }
108 ok, err := r.git.HasBranch(ctx, name, *params.DefaultBranch)
109 if err != nil {
110 return domain.Repo{}, fmt.Errorf("check branch %q of %q: %w", *params.DefaultBranch, name, err)
111 }
112 if !ok {
113 return domain.Repo{}, fmt.Errorf("%w: branch %q does not exist", domain.ErrPreconditionFailed, *params.DefaultBranch)
114 }
115 }
116
117 // Metadata first, then HEAD. The two writes are not atomic: when moving
118 // HEAD fails after the metadata was stored, the stored change is still
119 // published as RepoUpdated (a repository made private must be withdrawn
120 // from the watchers) and the call then fails with the HEAD error. The
121 // branch was checked above, so the remaining causes are a vanished
122 // repository or an I/O failure.
123 metaDone := false
124 if params.Description != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil {
125 if repo, err = r.updateMeta(ctx, name, params); err != nil {
126 return domain.Repo{}, err
127 }
128 metaDone = true
129 }
130 var headErr error
131 if params.DefaultBranch != nil {
132 if err := r.git.SetDefaultBranch(ctx, name, *params.DefaultBranch); err != nil {
133 headErr = fmt.Errorf("set default branch of %q: %w", name, err)
134 if !metaDone {
135 return domain.Repo{}, headErr
136 }
137 }
138 }
139
140 log := zerowrap.FromCtx(ctx)
141 log.Info().
142 Str("repo", name.String()).
143 Bool("description", params.Description != nil).
144 Bool("visibility", params.Visibility != nil).
145 Bool("default_branch", params.DefaultBranch != nil).
146 Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil).
147 Str("principal", p.Label()).Str("token_id", p.TokenID()).
148 Msg("repository updated")
149 r.bus.Publish(ctx, domain.RepoUpdated{
150 Repo: name, At: r.clock.Now().UTC(),
151 Visibility: repo.Visibility, Previous: before.Visibility, CreatedAt: repo.CreatedAt,
152 })
153 if headErr != nil {
154 return domain.Repo{}, headErr
155 }
156 return repo, nil
157}
158
159// updateMeta applies the metadata fields, retrying when a write (a push
160// recording pushed_at) gets in between.
161func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params UpdateParams) (domain.Repo, error) {
162 for range pushRetries {
163 repo, err := r.meta.Get(ctx, name)
164 if err != nil {
165 return domain.Repo{}, err
166 }
167 if params.Description != nil {
168 repo.Description = *params.Description
169 }
170 if params.Visibility != nil {
171 repo.Visibility = *params.Visibility
172 }
173 if repo.Settings.IsZero() {
174 repo.Settings = domain.DefaultRepoSettings()
175 }
176 if params.AllowedMergeModes != nil {
177 repo.Settings.AllowedMergeModes = *params.AllowedMergeModes
178 }
179 if params.FailingChecksBlock != nil {
180 repo.Settings.FailingChecksBlock = *params.FailingChecksBlock
181 }
182 err = r.meta.Update(ctx, repo)
183 if errors.Is(err, domain.ErrConflict) {
184 continue
185 }
186 if err != nil {
187 return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, err)
188 }
189 return repo, nil
190 }
191 return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, domain.ErrConflict)
192}
193
194// Delete moves the repository directory to the trash, removes its metadata
195// and publishes RepoDeleted. The principal needs repo:admin on it (see
196// adminAccess). A repository whose directory is already gone, left by a
197// delete that failed half way, loses its metadata.
198func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.RepoName) error {
199 repo, err := lookupRepo(ctx, r.meta, name)
200 if err != nil {
201 return err
202 }
203 if err := adminAccess(p, repo); err != nil {
204 return err
205 }
206 log := zerowrap.FromCtx(ctx)
207 switch err := r.git.Trash(ctx, name); {
208 case errors.Is(err, domain.ErrNotFound):
209 log.Warn().Str("repo", name.String()).Msg("repository directory already gone, removing its metadata")
210 case err != nil:
211 return fmt.Errorf("trash repository %q: %w", name, err)
212 }
213 if err := r.meta.Delete(ctx, name); err != nil {
214 return fmt.Errorf("delete metadata of %q: %w", name, err)
215 }
216 log.Info().Str("repo", name.String()).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("repository deleted")
217 r.bus.Publish(ctx, domain.RepoDeleted{Repo: name, Visibility: repo.Visibility, At: r.clock.Now().UTC(), CreatedAt: repo.CreatedAt})
218 return nil
219}