v0.3.0 9fdf386

pushes.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"strings"
10	"time"
11
12	"github.com/bnema/zerowrap"
13
14	"bnema.dev/ermite/internal/domain"
15	"bnema.dev/ermite/internal/ports"
16)
17
18// pushRetries bounds the retries of a metadata update that loses a race.
19const pushRetries = 5
20
21// MaintenanceScheduler queues repository maintenance. *Maintainer fits.
22type MaintenanceScheduler interface {
23	Schedule(ctx context.Context, name domain.RepoName)
24}
25
26// Revalidator confirms that a principal obtained earlier is still
27// authorized. *Auth fits.
28type Revalidator interface {
29	Revalidate(ctx context.Context, p domain.Principal) error
30}
31
32// Pushes are the use cases around a Git push.
33type Pushes struct {
34	meta  ports.RepoMetaStore
35	repos *Repos
36	git   ports.GitRepos
37	bus   ports.EventBus
38	clock ports.Clock
39	maint MaintenanceScheduler
40	auth  Revalidator
41	fresh ports.RepoFreshener // nil: the reader needs no telling
42}
43
44// NewPushes builds the push use cases. Every argument is required: auth
45// re-validates the token of a push when its pre-receive hook runs, maint
46// queues the maintenance that follows a push.
47func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus ports.EventBus, clock ports.Clock, maint MaintenanceScheduler, auth Revalidator) *Pushes {
48	return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth}
49}
50
51// WithFreshener makes Record tell the reader that a repository changed, so that
52// it serves the push at once. Call it before the first push; it returns u.
53func (u *Pushes) WithFreshener(f ports.RepoFreshener) *Pushes {
54	u.fresh = f
55	return u
56}
57
58// Open decides whether the principal may push to the repository and, with
59// create, creates it when it does not exist yet (private, published as
60// RepoCreated by Repos.Create). create is for the first request of a push only.
61//
62// Anonymous callers get domain.ErrUnauthenticated. A repository the caller
63// cannot read and cannot write, or a missing one it may not create, answers
64// domain.ErrNotFound, so that a private repository never shows itself. One it
65// can read but not write answers domain.ErrForbidden.
66func (u *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error {
67	if p.IsAnonymous() {
68		return domain.ErrUnauthenticated
69	}
70	repo, err := u.meta.Get(ctx, name)
71	switch {
72	case err == nil:
73		return openExisting(p, repo)
74	case !errors.Is(err, domain.ErrNotFound):
75		return fmt.Errorf("get repository %q: %w", name, err)
76	}
77
78	if !create || !canWrite(p, name) {
79		return domain.ErrNotFound
80	}
81	_, err = u.repos.Create(ctx, p, CreateParams{Name: name, Visibility: domain.VisibilityPrivate})
82	switch {
83	case err == nil:
84		return nil // Create published RepoCreated
85	case errors.Is(err, domain.ErrAlreadyExists):
86		// A concurrent first push won the race: this one pushes to its repository.
87		repo, gerr := u.meta.Get(ctx, name)
88		if gerr != nil {
89			return fmt.Errorf("get repository %q: %w", name, gerr)
90		}
91		return openExisting(p, repo)
92	}
93	return err
94}
95
96func openExisting(p domain.Principal, repo domain.Repo) error {
97	switch {
98	case canWrite(p, repo.Name):
99		return nil
100	case canRead(p, repo):
101		return domain.ErrForbidden
102	}
103	return domain.ErrNotFound
104}
105
106// Check is the pre-receive decision. v1 accepts every push of a principal that
107// may write the repository; protected branches and limits will refuse here by
108// returning an error that matches domain.ErrRejected.
109//
110// The token of the push was valid when the request began, but a push can run
111// for long: it is re-validated here, so a token revoked or expired meanwhile
112// stops the push before Git applies it. A failure to tell refuses it too.
113func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
114	if err := u.auth.Revalidate(ctx, p); err != nil {
115		if errors.Is(err, domain.ErrUnauthenticated) {
116			return fmt.Errorf("%w: the token is no longer valid", domain.ErrRejected)
117		}
118		return fmt.Errorf("revalidate token: %w", err)
119	}
120	if !canWrite(p, name) {
121		return fmt.Errorf("%w: no write access to %s", domain.ErrRejected, name)
122	}
123	log := zerowrap.FromCtx(ctx)
124	log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked")
125	return nil
126}
127
128// Record is the post-receive step of an accepted push: it sets pushed_at,
129// repairs HEAD when it points to a missing branch, tells the reader that the
130// repository changed, publishes a PushEvent and schedules maintenance. Every
131// step runs even when an earlier one fails; the failures are returned together
132// and the push itself is not affected.
133func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
134	log := zerowrap.FromCtx(ctx)
135	now := u.clock.Now().UTC()
136	var errs []error
137
138	repo, err := touchPushedAt(ctx, u.meta, name, now)
139	if err != nil {
140		errs = append(errs, err)
141	}
142	if err := u.fixDefaultBranch(ctx, name, updates); err != nil {
143		errs = append(errs, err)
144	}
145	if u.fresh != nil {
146		u.fresh.Refresh(ctx, name)
147	}
148	u.bus.Publish(ctx, domain.PushEvent{
149		Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
150		Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
151	})
152	u.maint.Schedule(ctx, name)
153	log.Info().
154		Str("repo", name.String()).Int("refs", len(updates)).
155		Str("principal", p.Label()).Str("token_id", p.TokenID()).
156		Msg("push received")
157	return errors.Join(errs...)
158}
159
160// touchPushedAt stores pushed_at, retrying when another write got in between.
161// A repository deleted meanwhile is not an error. It returns the repository
162// as read, or the zero value when it is unknown. A push (Pushes.Record) and a
163// merge, which moves a branch too, both record through it.
164func touchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
165	for range pushRetries {
166		repo, err := meta.Get(ctx, name)
167		if errors.Is(err, domain.ErrNotFound) {
168			return domain.Repo{}, nil
169		}
170		if err != nil {
171			return domain.Repo{}, fmt.Errorf("get repository %q: %w", name, err)
172		}
173		if !now.After(repo.PushedAt) {
174			return repo, nil // a later push already recorded
175		}
176		repo.PushedAt = now
177		err = meta.Update(ctx, repo)
178		if errors.Is(err, domain.ErrConflict) {
179			continue
180		}
181		if errors.Is(err, domain.ErrNotFound) {
182			return domain.Repo{}, nil
183		}
184		if err != nil {
185			return repo, fmt.Errorf("update repository %q: %w", name, err)
186		}
187		return repo, nil
188	}
189	return domain.Repo{}, fmt.Errorf("update pushed_at of %q: %w", name, domain.ErrConflict)
190}
191
192// fixDefaultBranch points HEAD to a branch that exists when it points to a
193// missing one: main when it was pushed, else the first pushed branch.
194func (u *Pushes) fixDefaultBranch(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate) error {
195	branch, exists, err := u.git.DefaultBranch(ctx, name)
196	if errors.Is(err, domain.ErrNotFound) {
197		return nil // deleted meanwhile
198	}
199	if err != nil {
200		return fmt.Errorf("read default branch of %q: %w", name, err)
201	}
202	if exists {
203		return nil
204	}
205	target := ""
206	for _, up := range updates {
207		b, ok := strings.CutPrefix(up.Ref, "refs/heads/")
208		if !ok || up.IsDelete() {
209			continue
210		}
211		if b == "main" {
212			target = b
213			break
214		}
215		if target == "" {
216			target = b
217		}
218	}
219	if target == "" || target == branch {
220		return nil
221	}
222	err = u.git.SetDefaultBranch(ctx, name, target)
223	if errors.Is(err, domain.ErrNotFound) {
224		return nil // deleted meanwhile
225	}
226	if err != nil {
227		return fmt.Errorf("set default branch of %q to %q: %w", name, target, err)
228	}
229	log := zerowrap.FromCtx(ctx)
230	log.Info().Str("repo", name.String()).Str("branch", target).Msg("default branch set")
231	return nil
232}

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 44

↑↓ moveenter openesc close