internal/store/repos.go

e6cd75b5f28bacf51620bb531320c30fd4e66bfd
gitbay/internal/store/repos.go history · blame · raw

614 lines · 19797 bytes

29 symbols in this file
  1package store
  2
  3import (
  4	"context"
  5	"database/sql"
  6	"encoding/json"
  7	"errors"
  8	"fmt"
  9	"sort"
 10	"strings"
 11)
 12
 13type Repo struct {
 14	ID            int64
 15	OwnerKind     string // user | org
 16	OwnerID       int64
 17	OwnerName     string // resolved for display and disk paths
 18	Name          string
 19	Visibility    string // public | private
 20	DefaultBranch string
 21	ForkOf        int64 // 0 when not a fork
 22	Settings      RepoSettings
 23}
 24
 25type RepoSettings struct {
 26	ProtectedBranches    []string `json:"protected_branches,omitempty"`
 27	ProtectedTags        []string `json:"protected_tags,omitempty"` // path.Match globs
 28	RequireSignedCommits bool     `json:"require_signed_commits,omitempty"`
 29	RequireChecks        bool     `json:"require_checks,omitempty"`
 30	// RequiredContexts are statuses require_checks waits for whether or
 31	// not they have reported; one that has not is pending. Setting a
 32	// non-empty list turns RequireChecks on (#258).
 33	RequiredContexts  []string `json:"required_contexts,omitempty"`
 34	RequireApprovals  int      `json:"require_approvals,omitempty"`
 35	RequireResolved   bool     `json:"require_resolved,omitempty"`
 36	RequireCodeowners bool     `json:"require_codeowners,omitempty"`
 37	RequireMR         bool     `json:"require_mr,omitempty"`
 38	GitDaemon         bool     `json:"git_daemon,omitempty"`
 39	Archived          bool     `json:"archived,omitempty"`
 40	Website           string   `json:"website,omitempty"`
 41}
 42
 43// Path returns the canonical owner/name form.
 44func (r Repo) Path() string { return r.OwnerName + "/" + r.Name }
 45
 46func (s *Store) CreateRepo(ownerKind string, ownerID int64, name, visibility string) (int64, error) {
 47	res, err := s.DB.Exec(
 48		"INSERT INTO repos (owner_kind, owner_id, name, visibility) VALUES (?, ?, ?, ?)",
 49		ownerKind, ownerID, name, visibility)
 50	if err != nil {
 51		if isUniqueErr(err) {
 52			return 0, fmt.Errorf("repository %q already exists", name)
 53		}
 54		return 0, err
 55	}
 56	return res.LastInsertId()
 57}
 58
 59// repoSelect resolves the owner name from whichever table owns the repo.
 60const repoSelect = `
 61	SELECT r.id, r.owner_kind, r.owner_id, COALESCE(u.username, o.name),
 62	       r.name, r.visibility, r.default_branch, COALESCE(r.fork_of, 0), r.settings_json
 63	FROM repos r
 64	LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
 65	LEFT JOIN orgs o  ON r.owner_kind = 'org'  AND o.id = r.owner_id`
 66
 67func scanRepo(row interface{ Scan(...any) error }) (Repo, error) {
 68	var r Repo
 69	var settingsJSON string
 70	err := row.Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &r.ForkOf, &settingsJSON)
 71	if err != nil {
 72		return r, err
 73	}
 74	if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil {
 75		return r, fmt.Errorf("repo %d settings: %w", r.ID, err)
 76	}
 77	return r, nil
 78}
 79
 80// RepoByPath resolves "owner/name"; the owner may be a user or an org.
 81func (s *Store) RepoByPath(path string) (Repo, error) {
 82	owner, name, ok := strings.Cut(strings.TrimSuffix(strings.TrimPrefix(path, "/"), ".git"), "/")
 83	if !ok || owner == "" || name == "" || strings.Contains(name, "/") {
 84		return Repo{}, fmt.Errorf("%w: repository path must be owner/name", ErrNotFound)
 85	}
 86	r, err := scanRepo(s.DB.QueryRow(
 87		repoSelect+" WHERE COALESCE(u.username, o.name) = ? AND r.name = ?", owner, name))
 88	if errors.Is(err, sql.ErrNoRows) {
 89		return Repo{}, ErrNotFound
 90	}
 91	return r, err
 92}
 93
 94// SetRepoVisibility switches a repository between public and private.
 95func (s *Store) SetRepoVisibility(repoID int64, visibility string) error {
 96	if visibility != "public" && visibility != "private" {
 97		return fmt.Errorf("visibility must be public or private")
 98	}
 99	_, err := s.DB.Exec("UPDATE repos SET visibility = ? WHERE id = ?", visibility, repoID)
100	return err
101}
102
103// UpdateRepoSettings applies mutate to the repository's settings and
104// stores the result, returning what was stored.
105//
106// settings_json is one blob, so changing one field means writing all of
107// them. Callers used to read the struct off a Repo they had loaded
108// earlier, change a field and write the whole blob back, which loses the
109// other admin's change whenever two ran at once — last write wins over a
110// value it never read. The read and the write happen here instead, inside
111// one transaction, and BEGIN IMMEDIATE takes the write lock up front: a
112// second updater waits at the start rather than discovering the conflict
113// after it has already read a stale blob.
114func (s *Store) UpdateRepoSettings(repoID int64, mutate func(*RepoSettings)) (RepoSettings, error) {
115	ctx := context.Background()
116	var out RepoSettings
117	// The whole exchange must run on one connection for BEGIN to bracket
118	// it; the pool would otherwise be free to hand the statements out
119	// separately.
120	conn, err := s.DB.Conn(ctx)
121	if err != nil {
122		return out, err
123	}
124	defer conn.Close()
125	if _, err := conn.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil {
126		return out, err
127	}
128	committed := false
129	defer func() {
130		if !committed {
131			conn.ExecContext(ctx, "ROLLBACK")
132		}
133	}()
134	var raw string
135	if err := conn.QueryRowContext(ctx,
136		"SELECT settings_json FROM repos WHERE id = ?", repoID).Scan(&raw); err != nil {
137		if errors.Is(err, sql.ErrNoRows) {
138			return out, ErrNotFound
139		}
140		return out, err
141	}
142	if raw != "" {
143		if err := json.Unmarshal([]byte(raw), &out); err != nil {
144			return out, err
145		}
146	}
147	mutate(&out)
148	next, err := json.Marshal(out)
149	if err != nil {
150		return out, err
151	}
152	if _, err := conn.ExecContext(ctx,
153		"UPDATE repos SET settings_json = ? WHERE id = ?", string(next), repoID); err != nil {
154		return out, err
155	}
156	if _, err := conn.ExecContext(ctx, "COMMIT"); err != nil {
157		return out, err
158	}
159	committed = true
160	return out, nil
161}
162
163// CreateFork is CreateRepo with fork_of set in the same insert, so a fork
164// never exists for a moment as a plain repository (#108).
165func (s *Store) CreateFork(ownerKind string, ownerID int64, name, visibility string, forkOf int64) (int64, error) {
166	res, err := s.DB.Exec(
167		"INSERT INTO repos (owner_kind, owner_id, name, visibility, fork_of) VALUES (?, ?, ?, ?, ?)",
168		ownerKind, ownerID, name, visibility, forkOf)
169	if err != nil {
170		if isUniqueErr(err) {
171			return 0, fmt.Errorf("repository %q already exists", name)
172		}
173		return 0, err
174	}
175	return res.LastInsertId()
176}
177
178func (s *Store) SetForkOf(repoID, parentID int64) error {
179	_, err := s.DB.Exec("UPDATE repos SET fork_of = ? WHERE id = ?", parentID, repoID)
180	return err
181}
182
183// DeleteRepo removes the repository row, what cascades from it, and
184// the deploy keys scoped to it, which name it by id in their scope
185// rather than by a foreign key (#306).
186func (s *Store) DeleteRepo(repoID int64) error {
187	tx, err := s.DB.Begin()
188	if err != nil {
189		return err
190	}
191	defer tx.Rollback()
192	rows, err := tx.Query("DELETE FROM ssh_keys WHERE scope LIKE 'deploy:' || ? || ':%' RETURNING id", repoID)
193	if err != nil {
194		return err
195	}
196	var keyIDs []int64
197	for rows.Next() {
198		var id int64
199		if err := rows.Scan(&id); err != nil {
200			rows.Close()
201			return err
202		}
203		keyIDs = append(keyIDs, id)
204	}
205	rows.Close()
206	if err := rows.Err(); err != nil {
207		return err
208	}
209	res, err := tx.Exec("DELETE FROM repos WHERE id = ?", repoID)
210	if err != nil {
211		return err
212	}
213	if n, _ := res.RowsAffected(); n == 0 {
214		return ErrNotFound
215	}
216	if len(keyIDs) > 0 {
217		if err := bumpKeyEpoch(tx); err != nil {
218			return err
219		}
220	}
221	if err := tx.Commit(); err != nil {
222		return err
223	}
224	if len(keyIDs) > 0 {
225		s.announce(Revoked{KeyIDs: keyIDs})
226	}
227	return nil
228}
229
230// ListReposForUser returns repos the user owns, reaches through an org
231// (unless the org scopes members to 'none'), has an explicit grant on, or
232// reaches through a team. limit 0 means everything; after (an owner/name
233// path) starts the page strictly beyond it, matching the path-ascending
234// order.
235func (s *Store) ListReposForUser(userID int64, limit int, after string) ([]Repo, error) {
236	q := repoSelect + `
237		LEFT JOIN repo_access a ON a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ?
238		LEFT JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ?
239		LEFT JOIN orgs og ON r.owner_kind = 'org' AND og.id = r.owner_id
240		WHERE ((r.owner_kind = 'user' AND r.owner_id = ?)
241		   OR a.subject_id IS NOT NULL
242		   OR (m.user_id IS NOT NULL AND (m.role = 'admin' OR og.members_role <> 'none'))
243		   OR EXISTS (SELECT 1 FROM team_repos tr
244		              JOIN team_members tm ON tm.team_id = tr.team_id AND tm.user_id = ?
245		              WHERE tr.repo_id = r.id))`
246	args := []any{userID, userID, userID, userID}
247	if after != "" {
248		owner, name, _ := strings.Cut(after, "/")
249		q += ` AND (COALESCE(u.username, o.name) > ?
250		         OR (COALESCE(u.username, o.name) = ? AND r.name > ?))`
251		args = append(args, owner, owner, name)
252	}
253	q += `
254		GROUP BY r.id
255		ORDER BY 4, r.name`
256	if limit > 0 {
257		q += " LIMIT ?"
258		args = append(args, limit)
259	}
260	rows, err := s.DB.Query(q, args...)
261	if err != nil {
262		return nil, err
263	}
264	defer rows.Close()
265	var out []Repo
266	for rows.Next() {
267		r, err := scanRepo(rows)
268		if err != nil {
269			return nil, err
270		}
271		out = append(out, r)
272	}
273	return out, rows.Err()
274}
275
276// AccessRole returns the user's effective role on the repo ("" if none):
277// the strongest of any explicit grant, the role derived from org
278// membership (org admin -> admin; plain member -> the org's members_role,
279// 'write' by default so the pre-teams model is the degenerate case), and
280// any team grants on the repo.
281func (s *Store) AccessRole(repoID, userID int64) (string, error) {
282	rank := map[string]int{"": 0, "none": 0, "read": 1, "write": 2, "admin": 3}
283	best := ""
284	better := func(role string) {
285		if rank[role] > rank[best] {
286			best = role
287		}
288	}
289
290	var explicit string
291	err := s.DB.QueryRow(
292		"SELECT role FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?",
293		repoID, userID).Scan(&explicit)
294	if err != nil && !errors.Is(err, sql.ErrNoRows) {
295		return "", err
296	}
297	better(explicit)
298
299	var orgRole, membersRole string
300	err = s.DB.QueryRow(`
301		SELECT m.role, o.members_role FROM repos r
302		JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ?
303		JOIN orgs o ON o.id = r.owner_id
304		WHERE r.id = ?`, userID, repoID).Scan(&orgRole, &membersRole)
305	if err != nil && !errors.Is(err, sql.ErrNoRows) {
306		return "", err
307	}
308	if orgRole == "admin" {
309		better("admin")
310	} else if orgRole == "member" {
311		better(membersRole) // write | read | none
312	}
313
314	var teamRole string
315	err = s.DB.QueryRow(`
316		SELECT tr.role FROM team_repos tr
317		JOIN team_members tm ON tm.team_id = tr.team_id AND tm.user_id = ?
318		WHERE tr.repo_id = ?
319		ORDER BY CASE tr.role WHEN 'admin' THEN 3 WHEN 'write' THEN 2 ELSE 1 END DESC
320		LIMIT 1`, userID, repoID).Scan(&teamRole)
321	if err != nil && !errors.Is(err, sql.ErrNoRows) {
322		return "", err
323	}
324	better(teamRole)
325	return best, nil
326}
327
328func (s *Store) GrantAccess(repoID, userID int64, role string) error {
329	_, err := s.DB.Exec(`
330		INSERT INTO repo_access (repo_id, subject_kind, subject_id, role) VALUES (?, 'user', ?, ?)
331		ON CONFLICT (repo_id, subject_kind, subject_id) DO UPDATE SET role = excluded.role`,
332		repoID, userID, role)
333	return err
334}
335
336func (s *Store) RevokeAccess(repoID, userID int64) error {
337	res, err := s.DB.Exec(
338		"DELETE FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?",
339		repoID, userID)
340	if err != nil {
341		return err
342	}
343	if n, _ := res.RowsAffected(); n == 0 {
344		return ErrNotFound
345	}
346	return nil
347}
348
349// EffectiveEntry is one account's effective role on a repository and the
350// grant it comes from: owner, direct, org admin, org member, or team
351// <name>.
352type EffectiveEntry struct {
353	Username string
354	Role     string
355	Source   string
356}
357
358// EffectiveAccess lists every account that can reach a repository with
359// the highest role it holds and where that role comes from. Direct
360// grants, org roles and team grants are folded together the way
361// AccessRole folds them for one account.
362func (s *Store) EffectiveAccess(repoID int64) ([]EffectiveEntry, error) {
363	rank := map[string]int{"read": 1, "write": 2, "admin": 3}
364	best := map[string]EffectiveEntry{}
365	var order []string
366	add := func(user, role, source string) {
367		if rank[role] == 0 {
368			return
369		}
370		cur, ok := best[user]
371		if !ok {
372			order = append(order, user)
373		}
374		if !ok || rank[role] > rank[cur.Role] {
375			best[user] = EffectiveEntry{user, role, source}
376		}
377	}
378	collect := func(query string, source func(extra string) string, args ...any) error {
379		rows, err := s.DB.Query(query, args...)
380		if err != nil {
381			return err
382		}
383		defer rows.Close()
384		for rows.Next() {
385			var user, role, extra string
386			if err := rows.Scan(&user, &role, &extra); err != nil {
387				return err
388			}
389			add(user, role, source(extra))
390		}
391		return rows.Err()
392	}
393	fixed := func(name string) func(string) string { return func(string) string { return name } }
394
395	// The owner: a user outright, or the org's admins and members.
396	if err := collect(`
397		SELECT u.username, 'admin', '' FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
398		WHERE r.id = ?`, fixed("owner"), repoID); err != nil {
399		return nil, err
400	}
401	if err := collect(`
402		SELECT u.username, 'admin', '' FROM repos r
403		JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.role = 'admin'
404		JOIN users u ON u.id = m.user_id WHERE r.id = ?`, fixed("org admin"), repoID); err != nil {
405		return nil, err
406	}
407	if err := collect(`
408		SELECT u.username, a.role, '' FROM repo_access a
409		JOIN users u ON a.subject_kind = 'user' AND u.id = a.subject_id WHERE a.repo_id = ?`,
410		fixed("direct"), repoID); err != nil {
411		return nil, err
412	}
413	if err := collect(`
414		SELECT u.username, tr.role, t.name FROM team_repos tr
415		JOIN teams t ON t.id = tr.team_id
416		JOIN team_members tm ON tm.team_id = tr.team_id
417		JOIN users u ON u.id = tm.user_id WHERE tr.repo_id = ?
418		ORDER BY CASE tr.role WHEN 'admin' THEN 3 WHEN 'write' THEN 2 ELSE 1 END DESC, t.name`,
419		func(team string) string { return "team " + team }, repoID); err != nil {
420		return nil, err
421	}
422	if err := collect(`
423		SELECT u.username, o.members_role, '' FROM repos r
424		JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
425		JOIN org_members m ON m.org_id = o.id AND m.role = 'member'
426		JOIN users u ON u.id = m.user_id WHERE r.id = ?`, fixed("org member"), repoID); err != nil {
427		return nil, err
428	}
429	sort.Strings(order)
430	out := make([]EffectiveEntry, 0, len(order))
431	for _, u := range order {
432		out = append(out, best[u])
433	}
434	return out, nil
435}
436
437func (s *Store) RepoByID(id int64) (Repo, error) {
438	r, err := scanRepo(s.DB.QueryRow(repoSelect+" WHERE r.id = ?", id))
439	if errors.Is(err, sql.ErrNoRows) {
440		return Repo{}, ErrNotFound
441	}
442	return r, err
443}
444
445// ListPublicRepos returns all public repositories, for the anonymous index.
446func (s *Store) ListPublicRepos() ([]Repo, error) {
447	rows, err := s.DB.Query(repoSelect + " WHERE r.visibility = 'public' ORDER BY 4, r.name")
448	if err != nil {
449		return nil, err
450	}
451	defer rows.Close()
452	var out []Repo
453	for rows.Next() {
454		r, err := scanRepo(rows)
455		if err != nil {
456			return nil, err
457		}
458		out = append(out, r)
459	}
460	return out, rows.Err()
461}
462
463// ListPublicReposByActivity returns all public repositories, the most
464// recent event first. Builds are not activity: a nightly job would keep
465// its repository on top. A repository with no events sorts last, newest
466// first.
467func (s *Store) ListPublicReposByActivity() ([]Repo, error) {
468	rows, err := s.DB.Query(repoSelect + ` WHERE r.visibility = 'public'
469		ORDER BY COALESCE((SELECT MAX(e.id) FROM events e
470			WHERE e.repo_id = r.id AND e.kind NOT LIKE 'build.%'), 0) DESC, r.id DESC`)
471	if err != nil {
472		return nil, err
473	}
474	defer rows.Close()
475	var out []Repo
476	for rows.Next() {
477		r, err := scanRepo(rows)
478		if err != nil {
479			return nil, err
480		}
481		out = append(out, r)
482	}
483	return out, rows.Err()
484}
485
486// ListForks returns the repositories forked from one repo. The caller
487// filters by what the viewer may see.
488func (s *Store) ListForks(repoID int64) ([]Repo, error) {
489	rows, err := s.DB.Query(repoSelect+" WHERE r.fork_of = ? ORDER BY 4, r.name", repoID)
490	if err != nil {
491		return nil, err
492	}
493	defer rows.Close()
494	var out []Repo
495	for rows.Next() {
496		r, err := scanRepo(rows)
497		if err != nil {
498			return nil, err
499		}
500		out = append(out, r)
501	}
502	return out, rows.Err()
503}
504
505func (s *Store) UpdateDefaultBranch(repoID int64, branch string) error {
506	_, err := s.DB.Exec("UPDATE repos SET default_branch = ? WHERE id = ?", branch, repoID)
507	return err
508}
509
510// ListReposForOwner returns every repo owned by one user or org; the caller
511// filters by viewer visibility.
512func (s *Store) ListReposForOwner(ownerKind string, ownerID int64) ([]Repo, error) {
513	rows, err := s.DB.Query(repoSelect+" WHERE r.owner_kind = ? AND r.owner_id = ? ORDER BY r.name",
514		ownerKind, ownerID)
515	if err != nil {
516		return nil, err
517	}
518	defer rows.Close()
519	var out []Repo
520	for rows.Next() {
521		r, err := scanRepo(rows)
522		if err != nil {
523			return nil, err
524		}
525		out = append(out, r)
526	}
527	return out, rows.Err()
528}
529
530// RenameRepo changes a repository's name under the same owner. The unique
531// index on (owner_kind, owner_id, name) refuses collisions.
532func (s *Store) RenameRepo(repoID int64, newName string) error {
533	_, err := s.DB.Exec("UPDATE repos SET name = ? WHERE id = ?", newName, repoID)
534	if isUniqueErr(err) {
535		return fmt.Errorf("the owner already has a repository by that name")
536	}
537	return err
538}
539
540// TransferRepo moves a repository to a new owner. The unique index on
541// (owner_kind, owner_id, name) refuses collisions in the target namespace.
542// Moving into an org folds the repository's labels and milestones whose
543// names the org already holds into the org's rows, in the same
544// transaction, so the repository does not come out seeing two of each.
545// Moving out of an org needs no counterpart: the repository keeps what it
546// owns and stops seeing the org's rows.
547func (s *Store) TransferRepo(repoID int64, newKind string, newOwnerID int64) error {
548	tx, err := s.DB.Begin()
549	if err != nil {
550		return err
551	}
552	defer tx.Rollback()
553	if _, err := tx.Exec("UPDATE repos SET owner_kind = ?, owner_id = ? WHERE id = ?",
554		newKind, newOwnerID, repoID); err != nil {
555		if isUniqueErr(err) {
556			return fmt.Errorf("the target owner already has a repository by that name")
557		}
558		return err
559	}
560	if newKind == "org" {
561		if err := foldIntoOrg(tx, repoID, newOwnerID); err != nil {
562			return err
563		}
564	}
565	return tx.Commit()
566}
567
568// foldIntoOrg folds a repository's labels and milestones into the org's
569// rows of the same name, the way org label set and org milestone create
570// fold the repositories already under the org.
571func foldIntoOrg(tx *sql.Tx, repoID, orgID int64) error {
572	labels, err := sharedNameRows(tx, "labels", "name", repoID, orgID)
573	if err != nil {
574		return err
575	}
576	for _, p := range labels {
577		if err := foldLabelRow(tx, p.org, p.repo); err != nil {
578			return err
579		}
580	}
581	milestones, err := sharedNameRows(tx, "milestones", "title", repoID, orgID)
582	if err != nil {
583		return err
584	}
585	for _, p := range milestones {
586		if err := foldMilestoneRow(tx, p.org, p.repo); err != nil {
587			return err
588		}
589	}
590	return nil
591}
592
593// rowPair is one repository row and the org row it folds into.
594type rowPair struct{ repo, org int64 }
595
596// sharedNameRows pairs a repository's label or milestone rows with the
597// org's rows carrying the same name.
598func sharedNameRows(tx *sql.Tx, table, nameCol string, repoID, orgID int64) ([]rowPair, error) {
599	rows, err := tx.Query("SELECT t.id, o.id FROM "+table+" t JOIN "+table+" o"+
600		" ON o.org_id = ? AND o."+nameCol+" = t."+nameCol+" WHERE t.repo_id = ?", orgID, repoID)
601	if err != nil {
602		return nil, err
603	}
604	defer rows.Close()
605	var out []rowPair
606	for rows.Next() {
607		var p rowPair
608		if err := rows.Scan(&p.repo, &p.org); err != nil {
609			return nil, err
610		}
611		out = append(out, p)
612	}
613	return out, rows.Err()
614}