detached 59d5a0b

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// Pushes are the use cases around a Git push.
19type Pushes struct {
20	links   ports.IssueLinkTrigger
21	mirror  ports.MirrorTrigger // nil: no outbound mirror
22	meta    ports.RepoMetaStore
23	repos   *Repos
24	git     ports.GitRepos
25	bus     ports.EventBus
26	clock   ports.Clock
27	maint   ports.MaintenanceScheduler
28	auth    ports.Revalidator
29	fresh   ports.RepoFreshener       // nil: the reader needs no telling
30	ci      ports.WorkflowTrigger     // nil: no CI
31	trees   ports.PushTreeReader      // nil: pushes by job tokens are refused
32	tips    ports.CIRefTipStore       // nil: who pushes a ref is not recorded
33	mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked
34}
35
36var (
37	_ ports.PushService     = (*Pushes)(nil)
38	_ ports.PushHookService = (*Pushes)(nil)
39)
40
41// NewPushes builds the push use cases. Every argument is required: auth
42// re-validates the token of a push when its pre-receive hook runs, maint
43// queues the maintenance that follows a push.
44func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus ports.EventBus, clock ports.Clock, maint ports.MaintenanceScheduler, auth ports.Revalidator) *Pushes {
45	return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth}
46}
47
48// WithIssueLinks enables synchronous best-effort issue indexing before the first push.
49func (u *Pushes) WithIssueLinks(t ports.IssueLinkTrigger) *Pushes { u.links = t; return u }
50
51// WithMirror makes Record notify outbound mirrors. Call before the first push.
52func (u *Pushes) WithMirror(t ports.MirrorTrigger) *Pushes {
53	u.mirror = t
54	return u
55}
56
57// WithFreshener makes Record tell the reader that a repository changed, so that
58// it serves the push at once. Call it before the first push; it returns u.
59func (u *Pushes) WithFreshener(f ports.RepoFreshener) *Pushes {
60	u.fresh = f
61	return u
62}
63
64// WithWorkflowTrigger makes Record start the workflows that match the push.
65// Call it before the first push; it returns u.
66func (u *Pushes) WithWorkflowTrigger(t ports.WorkflowTrigger) *Pushes {
67	u.ci = t
68	return u
69}
70
71// WithMergeability makes Record queue the mergeability checks of the merge
72// requests whose branches the push moved. Call it before the first push; it
73// returns u.
74func (u *Pushes) WithMergeability(t ports.MergeabilityTrigger) *Pushes {
75	u.mergeab = t
76	return u
77}
78
79// WithRefTips makes Check record who is about to move each branch and tag
80// (domain.CIRefTip), before Git moves it. Call it before the first push; it
81// returns u.
82func (u *Pushes) WithRefTips(t ports.CIRefTipStore) *Pushes {
83	u.tips = t
84	return u
85}
86
87// WithPushTreeReader lets Check tell whether a push changes workflow files, which
88// a push by a job token may not do. Without it a push by a job token is
89// refused, as it cannot be judged. Call it before the first push; it returns u.
90func (u *Pushes) WithPushTreeReader(g ports.PushTreeReader) *Pushes {
91	u.trees = g
92	return u
93}
94
95// Open decides whether the principal may push to the repository and, with
96// create, creates it when it does not exist yet (private, published as
97// RepoCreated by Repos.Create). create is for the first request of a push only.
98//
99// Anonymous callers get domain.ErrUnauthenticated. A repository the caller
100// cannot read and cannot write, or a missing one it may not create, answers
101// domain.ErrNotFound, so that a private repository never shows itself. One it
102// can read but not write answers domain.ErrForbidden.
103func (u *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error {
104	if p.IsAnonymous() {
105		return domain.ErrUnauthenticated
106	}
107	repo, err := u.meta.Get(ctx, name)
108	switch {
109	case err == nil:
110		return openExisting(p, repo)
111	case !errors.Is(err, domain.ErrNotFound):
112		return fmt.Errorf("get repository %q: %w", name, err)
113	}
114
115	if !create || !canCreateRepo(p, name) {
116		return domain.ErrNotFound
117	}
118	_, err = u.repos.Create(ctx, p, ports.CreateRepoParams{Name: name, Visibility: domain.VisibilityPrivate})
119	switch {
120	case err == nil:
121		return nil // Create published RepoCreated
122	case errors.Is(err, domain.ErrAlreadyExists):
123		// A concurrent first push won the race: this one pushes to its repository.
124		repo, gerr := u.meta.Get(ctx, name)
125		if gerr != nil {
126			return fmt.Errorf("get repository %q: %w", name, gerr)
127		}
128		return openExisting(p, repo)
129	}
130	return err
131}
132
133func openExisting(p domain.Principal, repo domain.Repo) error {
134	switch {
135	case canWrite(p, repo.Name):
136		return nil
137	case canRead(p, repo):
138		return domain.ErrForbidden
139	}
140	return domain.ErrNotFound
141}
142
143// Check is the pre-receive decision. It accepts a push of a principal that
144// may write the repository. A token limited to branch prefixes may only update
145// (create, move or delete) branches under them: the first ref outside them
146// refuses the whole push, tags and every other ref included. Further rules
147// (protected branches, limits) will refuse here too, by returning an error
148// that matches domain.ErrRejected.
149//
150// The prefix rule is domain.Principal.AllowsRef and shares its assumptions: no
151// symbolic ref under refs/heads besides HEAD (ermite never creates one and
152// receive-pack cannot), and case-sensitive ref storage (Linux). The hook sees
153// every ref of the push, so a tag or any other ref is judged like a branch.
154//
155// The token of the push was valid when the request began, but a push can run
156// for long: it is re-validated here, so a token revoked or expired meanwhile
157// stops the push before Git applies it. A failure to tell refuses it too.
158//
159// The token of a CI job (GITHUB_TOKEN with contents: write) may not change
160// workflows (CI-3, D3). The push is refused as a whole, on any ref, branch or
161// tag, when a commit it brings changes .github/workflows (it has workflows that
162// differ from those of every parent; removing them is allowed), or when a ref
163// would end with workflows other than it had: an update with other than the old
164// ones, a new ref with other than those of the default branch head (or any, when
165// there is no head). A new branch cut from existing commits that change nothing
166// is therefore allowed; deleting a ref is not a change. objects locate the
167// objects of the push, which are not in the repository yet.
168func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) error {
169	if err := u.auth.Revalidate(ctx, p); err != nil {
170		if errors.Is(err, domain.ErrUnauthenticated) {
171			return fmt.Errorf("%w: the token is no longer valid", domain.ErrRejected)
172		}
173		return fmt.Errorf("revalidate token: %w", err)
174	}
175	if !canWrite(p, name) {
176		return fmt.Errorf("%w: no write access to %s", domain.ErrRejected, name)
177	}
178	for _, up := range updates {
179		// refs/pull/* belong to ermite (the head and the test merge of the merge
180		// requests): nobody pushes there, whatever the token, the workflow
181		// guard included.
182		if domain.IsPullRefName(up.Ref) {
183			return fmt.Errorf("%w: %s is maintained by ermite: refs/pull/* cannot be pushed", domain.ErrRejected, up.Ref)
184		}
185		if !p.AllowsRef(up.Ref) {
186			return fmt.Errorf("%w: %s is outside the branches this token may push (%s)",
187				domain.ErrRejected, up.Ref, strings.Join(p.BranchPrefixes(), ", "))
188		}
189	}
190	if p.ViaJobToken() {
191		if err := u.checkWorkflows(ctx, name, updates, objects); err != nil {
192			return err
193		}
194	}
195	// Last, once the push is accepted: who pushes the branches and tags is
196	// recorded before Git moves them, so that a tip never has the record of an
197	// earlier pusher at the same commit (domain.CIRefTip). A record that cannot
198	// be written refuses the push, as it cannot be run later with a known trust.
199	if err := recordRefTips(ctx, u.tips, name, updates, domain.AuthorName(p), domain.PusherOf(p), domain.TrustedActor(p)); err != nil {
200		return err
201	}
202	log := zerowrap.FromCtx(ctx)
203	log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked")
204	return nil
205}
206
207// workflowRefusal is GitHub's refusal of a push by the token of a job that
208// changes a workflow (a push to a branch or a tag; the token lacks the
209// "workflows" permission, which no job token can have).
210const workflowRefusal = "refusing to allow a GitHub App to create or update workflow `%s` without `workflows` permission"
211
212func (u *Pushes) checkWorkflows(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) error {
213	if u.trees == nil {
214		return fmt.Errorf("%w: the token of a job may not push here", domain.ErrRejected)
215	}
216	t, err := u.trees.PushSubtrees(ctx, name, updates, objects, domain.WorkflowDir, domain.DefaultPushWalkBounds())
217	if err != nil {
218		return workflowReadError(name, err)
219	}
220	c, found, err := domain.WorkflowChangeOf(updates, t)
221	if err != nil {
222		return fmt.Errorf("%w: %v", domain.ErrRejected, err)
223	}
224	if !found {
225		return nil
226	}
227	entry, err := u.trees.FirstDifferentEntry(ctx, name, objects, c.Old, c.New)
228	if err != nil {
229		return workflowReadError(name, err)
230	}
231	path := domain.WorkflowDir
232	if entry != "" {
233		path += "/" + entry
234	}
235	return fmt.Errorf("%w: "+workflowRefusal, domain.ErrRejected, path)
236}
237
238func workflowReadError(name domain.RepoName, err error) error {
239	if errors.Is(err, domain.ErrInvalid) {
240		return fmt.Errorf("%w: %v", domain.ErrRejected, err)
241	}
242	return fmt.Errorf("find the workflows changed by a push to %s: %w", name, err)
243}
244
245// Record is the post-receive step of an accepted push: it sets pushed_at and active_at,
246// repairs HEAD when it points to a missing branch, tells the reader that the
247// repository changed, queues the workflows the push triggers, publishes a
248// PushEvent and schedules maintenance. Every
249// step runs even when an earlier one fails; the failures are returned together
250// and the push itself is not affected.
251func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
252	log := zerowrap.FromCtx(ctx)
253	now := u.clock.Now().UTC()
254	var errs []error
255
256	repo, err := touchPushedAt(ctx, u.meta, name, now)
257	if err != nil {
258		errs = append(errs, err)
259	}
260	if err := u.fixDefaultBranch(ctx, name, updates); err != nil {
261		errs = append(errs, err)
262	}
263	if u.fresh != nil {
264		u.fresh.Refresh(ctx, name)
265	}
266	if u.ci != nil {
267		u.ci.OnPush(ctx, name, updates, p) // queues only: never slows or fails the push
268	}
269	if u.mergeab != nil {
270		u.mergeab.OnBranchesMoved(ctx, name, updates) // queues only
271	}
272	if u.links != nil {
273		u.links.OnBranchesMoved(ctx, name, updates)
274	}
275	u.bus.Publish(ctx, domain.PushEvent{
276		Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
277		Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
278	})
279	if u.mirror != nil {
280		u.mirror.OnPush(ctx, name, updates) // marks only: never blocks
281	}
282	u.maint.Schedule(ctx, name)
283	log.Info().
284		Str("repo", name.String()).Int("refs", len(updates)).
285		Str("principal", p.Label()).Str("token_id", p.TokenID()).
286		Msg("push received")
287	return errors.Join(errs...)
288}
289
290// touchPushedAt stores pushed_at and active_at, preserving later timestamps
291// and retrying when another write got in between.
292// A repository deleted meanwhile is not an error. It returns the repository
293// as stored after mutation, or the zero value when it is unknown. A push
294// (Pushes.Record) and a merge, which moves a branch too, both record through it.
295func touchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
296	repo, err := meta.Mutate(ctx, name, func(repo *domain.Repo) (bool, error) {
297		changed := false
298		if now.After(repo.PushedAt) {
299			repo.PushedAt = now
300			changed = true
301		}
302		if now.After(repo.ActiveAt) {
303			repo.ActiveAt = now
304			changed = true
305		}
306		return changed, nil
307	})
308	if errors.Is(err, domain.ErrNotFound) {
309		return domain.Repo{}, nil
310	}
311	if err != nil {
312		return repo, fmt.Errorf("update repository %q: %w", name, err)
313	}
314	return repo, nil
315}
316
317// fixDefaultBranch points HEAD to a branch that exists when it points to a
318// missing one: main when it was pushed, else the first pushed branch.
319func (u *Pushes) fixDefaultBranch(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate) error {
320	branch, exists, err := u.git.DefaultBranch(ctx, name)
321	if errors.Is(err, domain.ErrNotFound) {
322		return nil // deleted meanwhile
323	}
324	if err != nil {
325		return fmt.Errorf("read default branch of %q: %w", name, err)
326	}
327	if exists {
328		return nil
329	}
330	target := ""
331	for _, up := range updates {
332		b, ok := strings.CutPrefix(up.Ref, "refs/heads/")
333		if !ok || up.IsDelete() {
334			continue
335		}
336		if b == "main" {
337			target = b
338			break
339		}
340		if target == "" {
341			target = b
342		}
343	}
344	if target == "" || target == branch {
345		return nil
346	}
347	err = u.git.SetDefaultBranch(ctx, name, target)
348	if errors.Is(err, domain.ErrNotFound) {
349		return nil // deleted meanwhile
350	}
351	if err != nil {
352		return fmt.Errorf("set default branch of %q to %q: %w", name, target, err)
353	}
354	log := zerowrap.FromCtx(ctx)
355	log.Info().Str("repo", name.String()).Str("branch", target).Msg("default branch set")
356	return nil
357}

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