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.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.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.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.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.AllowAutoMerge != nil {
188 repo.Settings.AllowAutoMerge = *params.AllowAutoMerge
189 }
190 if params.Issues != nil {
191 repo.Settings.Issues = *params.Issues
192 }
193 return true, nil
194 })
195 if err != nil {
196 return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, err)
197 }
198 return repo, nil
199}
200
201// Delete moves the repository directory to the trash, removes its metadata
202// and publishes RepoDeleted. The principal needs repo:admin on it (see
203// adminAccess) and no branch prefixes. A repository whose directory is already gone, left by a
204// delete that failed half way, loses its metadata.
205func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.RepoName) error {
206 repo, err := lookupRepo(ctx, r.meta, name)
207 if err != nil {
208 return err
209 }
210 if err := adminAccess(p, repo); err != nil {
211 return err
212 }
213 log := zerowrap.FromCtx(ctx)
214 switch err := r.git.Trash(ctx, name); {
215 case errors.Is(err, domain.ErrNotFound):
216 log.Warn().Str("repo", name.String()).Msg("repository directory already gone, removing its metadata")
217 case err != nil:
218 return fmt.Errorf("trash repository %q: %w", name, err)
219 }
220 var runIDs []int64
221 if r.ci != nil {
222 // Listed before the records go: the logs are found by run id.
223 if ids, err := r.ci.RunIDs(ctx, name); err == nil {
224 runIDs = ids
225 } else {
226 log.Warn().Err(err).Str("repo", name.String()).Msg("list the CI runs of the repository; their logs are left to the sweep")
227 }
228 }
229 if err := r.meta.Delete(ctx, name); err != nil {
230 return fmt.Errorf("delete metadata of %q: %w", name, err)
231 }
232 if r.ci != nil {
233 r.ci.ForgetRepo(name)
234 }
235 if len(runIDs) > 0 {
236 if err := r.ci.RemoveLogs(context.WithoutCancel(ctx), runIDs); err != nil {
237 log.Warn().Err(err).Str("repo", name.String()).Msg("remove the CI logs of the repository; the sweep will")
238 }
239 }
240 log.Info().Str("repo", name.String()).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("repository deleted")
241 r.bus.Publish(ctx, domain.RepoDeleted{Repo: name, Visibility: repo.Visibility, At: r.clock.Now().UTC(), CreatedAt: repo.CreatedAt})
242 return nil
243}