v0.12.0 dccd128

pushes.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
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// Pushes are the use cases around a Git push.
22type Pushes struct {
23	meta  ports.RepoMetaStore
24	repos *Repos
25	git   ports.GitRepos
26	bus   ports.EventBus
27	clock ports.Clock
28	maint ports.MaintenanceScheduler
29	auth  ports.Revalidator
30	fresh ports.RepoFreshener   // nil: the reader needs no telling
31	ci    ports.WorkflowTrigger // nil: no CI
32	wf    ports.WorkflowGuard   // nil: pushes by job tokens are refused
33}
34
35var (
36	_ ports.PushService     = (*Pushes)(nil)
37	_ ports.PushHookService = (*Pushes)(nil)
38)
39
40// NewPushes builds the push use cases. Every argument is required: auth
41// re-validates the token of a push when its pre-receive hook runs, maint
42// queues the maintenance that follows a push.
43func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus ports.EventBus, clock ports.Clock, maint ports.MaintenanceScheduler, auth ports.Revalidator) *Pushes {
44	return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth}
45}
46
47// WithFreshener makes Record tell the reader that a repository changed, so that
48// it serves the push at once. Call it before the first push; it returns u.
49func (u *Pushes) WithFreshener(f ports.RepoFreshener) *Pushes {
50	u.fresh = f
51	return u
52}
53
54// WithWorkflowTrigger makes Record start the workflows that match the push.
55// Call it before the first push; it returns u.
56func (u *Pushes) WithWorkflowTrigger(t ports.WorkflowTrigger) *Pushes {
57	u.ci = t
58	return u
59}
60
61// WithWorkflowGuard lets Check tell whether a push changes workflow files, which
62// a push by a job token may not do. Without it a push by a job token is
63// refused, as it cannot be judged. Call it before the first push; it returns u.
64func (u *Pushes) WithWorkflowGuard(g ports.WorkflowGuard) *Pushes {
65	u.wf = g
66	return u
67}
68
69// Open decides whether the principal may push to the repository and, with
70// create, creates it when it does not exist yet (private, published as
71// RepoCreated by Repos.Create). create is for the first request of a push only.
72//
73// Anonymous callers get domain.ErrUnauthenticated. A repository the caller
74// cannot read and cannot write, or a missing one it may not create, answers
75// domain.ErrNotFound, so that a private repository never shows itself. One it
76// can read but not write answers domain.ErrForbidden.
77func (u *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error {
78	if p.IsAnonymous() {
79		return domain.ErrUnauthenticated
80	}
81	repo, err := u.meta.Get(ctx, name)
82	switch {
83	case err == nil:
84		return openExisting(p, repo)
85	case !errors.Is(err, domain.ErrNotFound):
86		return fmt.Errorf("get repository %q: %w", name, err)
87	}
88
89	if !create || !canCreateRepo(p, name) {
90		return domain.ErrNotFound
91	}
92	_, err = u.repos.Create(ctx, p, ports.CreateRepoParams{Name: name, Visibility: domain.VisibilityPrivate})
93	switch {
94	case err == nil:
95		return nil // Create published RepoCreated
96	case errors.Is(err, domain.ErrAlreadyExists):
97		// A concurrent first push won the race: this one pushes to its repository.
98		repo, gerr := u.meta.Get(ctx, name)
99		if gerr != nil {
100			return fmt.Errorf("get repository %q: %w", name, gerr)
101		}
102		return openExisting(p, repo)
103	}
104	return err
105}
106
107func openExisting(p domain.Principal, repo domain.Repo) error {
108	switch {
109	case canWrite(p, repo.Name):
110		return nil
111	case canRead(p, repo):
112		return domain.ErrForbidden
113	}
114	return domain.ErrNotFound
115}
116
117// Check is the pre-receive decision. It accepts a push of a principal that
118// may write the repository. A token limited to branch prefixes may only update
119// (create, move or delete) branches under them: the first ref outside them
120// refuses the whole push, tags and every other ref included. Further rules
121// (protected branches, limits) will refuse here too, by returning an error
122// that matches domain.ErrRejected.
123//
124// The prefix rule is domain.Principal.AllowsRef and shares its assumptions: no
125// symbolic ref under refs/heads besides HEAD (ermite never creates one and
126// receive-pack cannot), and case-sensitive ref storage (Linux). The hook sees
127// every ref of the push, so a tag or any other ref is judged like a branch.
128//
129// The token of the push was valid when the request began, but a push can run
130// for long: it is re-validated here, so a token revoked or expired meanwhile
131// stops the push before Git applies it. A failure to tell refuses it too.
132//
133// The token of a CI job (GITHUB_TOKEN with contents: write) may not change
134// workflows: a push that creates or changes a file under .github/workflows/ on
135// any ref, branch or tag, is refused as a whole (CI-3, D3). A new ref counts
136// every workflow its commit holds, as on GitHub; deleting a ref is not a
137// change. objects locate the objects of the push, which are not in the
138// repository yet.
139func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) error {
140	if err := u.auth.Revalidate(ctx, p); err != nil {
141		if errors.Is(err, domain.ErrUnauthenticated) {
142			return fmt.Errorf("%w: the token is no longer valid", domain.ErrRejected)
143		}
144		return fmt.Errorf("revalidate token: %w", err)
145	}
146	if !canWrite(p, name) {
147		return fmt.Errorf("%w: no write access to %s", domain.ErrRejected, name)
148	}
149	for _, up := range updates {
150		if !p.AllowsRef(up.Ref) {
151			return fmt.Errorf("%w: %s is outside the branches this token may push (%s)",
152				domain.ErrRejected, up.Ref, strings.Join(p.BranchPrefixes(), ", "))
153		}
154	}
155	if p.ViaJobToken() {
156		if err := u.checkWorkflows(ctx, name, updates, objects); err != nil {
157			return err
158		}
159	}
160	log := zerowrap.FromCtx(ctx)
161	log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked")
162	return nil
163}
164
165// workflowRefusal is GitHub's refusal of a push by the token of a job that
166// changes a workflow (a push to a branch or a tag; the token lacks the
167// "workflows" permission, which no job token can have).
168const workflowRefusal = "refusing to allow a GitHub App to create or update workflow `%s` without `workflows` permission"
169
170func (u *Pushes) checkWorkflows(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) error {
171	if u.wf == nil {
172		return fmt.Errorf("%w: the token of a job may not push here", domain.ErrRejected)
173	}
174	path, err := u.wf.ChangedWorkflow(ctx, name, updates, objects)
175	switch {
176	case err != nil:
177		return fmt.Errorf("find the workflows changed by a push to %s: %w", name, err)
178	case path != "":
179		return fmt.Errorf("%w: "+workflowRefusal, domain.ErrRejected, path)
180	}
181	return nil
182}
183
184// Record is the post-receive step of an accepted push: it sets pushed_at,
185// repairs HEAD when it points to a missing branch, tells the reader that the
186// repository changed, queues the workflows the push triggers, publishes a
187// PushEvent and schedules maintenance. Every
188// step runs even when an earlier one fails; the failures are returned together
189// and the push itself is not affected.
190func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
191	log := zerowrap.FromCtx(ctx)
192	now := u.clock.Now().UTC()
193	var errs []error
194
195	repo, err := touchPushedAt(ctx, u.meta, name, now)
196	if err != nil {
197		errs = append(errs, err)
198	}
199	if err := u.fixDefaultBranch(ctx, name, updates); err != nil {
200		errs = append(errs, err)
201	}
202	if u.fresh != nil {
203		u.fresh.Refresh(ctx, name)
204	}
205	if u.ci != nil {
206		u.ci.OnPush(ctx, name, updates, p) // queues only: never slows or fails the push
207	}
208	u.bus.Publish(ctx, domain.PushEvent{
209		Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
210		Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
211	})
212	u.maint.Schedule(ctx, name)
213	log.Info().
214		Str("repo", name.String()).Int("refs", len(updates)).
215		Str("principal", p.Label()).Str("token_id", p.TokenID()).
216		Msg("push received")
217	return errors.Join(errs...)
218}
219
220// touchPushedAt stores pushed_at, retrying when another write got in between.
221// A repository deleted meanwhile is not an error. It returns the repository
222// as read, or the zero value when it is unknown. A push (Pushes.Record) and a
223// merge, which moves a branch too, both record through it.
224func touchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
225	for range pushRetries {
226		repo, err := meta.Get(ctx, name)
227		if errors.Is(err, domain.ErrNotFound) {
228			return domain.Repo{}, nil
229		}
230		if err != nil {
231			return domain.Repo{}, fmt.Errorf("get repository %q: %w", name, err)
232		}
233		if !now.After(repo.PushedAt) {
234			return repo, nil // a later push already recorded
235		}
236		repo.PushedAt = now
237		err = meta.Update(ctx, repo)
238		if errors.Is(err, domain.ErrConflict) {
239			continue
240		}
241		if errors.Is(err, domain.ErrNotFound) {
242			return domain.Repo{}, nil
243		}
244		if err != nil {
245			return repo, fmt.Errorf("update repository %q: %w", name, err)
246		}
247		return repo, nil
248	}
249	return domain.Repo{}, fmt.Errorf("update pushed_at of %q: %w", name, domain.ErrConflict)
250}
251
252// fixDefaultBranch points HEAD to a branch that exists when it points to a
253// missing one: main when it was pushed, else the first pushed branch.
254func (u *Pushes) fixDefaultBranch(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate) error {
255	branch, exists, err := u.git.DefaultBranch(ctx, name)
256	if errors.Is(err, domain.ErrNotFound) {
257		return nil // deleted meanwhile
258	}
259	if err != nil {
260		return fmt.Errorf("read default branch of %q: %w", name, err)
261	}
262	if exists {
263		return nil
264	}
265	target := ""
266	for _, up := range updates {
267		b, ok := strings.CutPrefix(up.Ref, "refs/heads/")
268		if !ok || up.IsDelete() {
269			continue
270		}
271		if b == "main" {
272			target = b
273			break
274		}
275		if target == "" {
276			target = b
277		}
278	}
279	if target == "" || target == branch {
280		return nil
281	}
282	err = u.git.SetDefaultBranch(ctx, name, target)
283	if errors.Is(err, domain.ErrNotFound) {
284		return nil // deleted meanwhile
285	}
286	if err != nil {
287		return fmt.Errorf("set default branch of %q to %q: %w", name, target, err)
288	}
289	log := zerowrap.FromCtx(ctx)
290	log.Info().Str("repo", name.String()).Str("branch", target).Msg("default branch set")
291	return nil
292}

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