internal/store/repos.go

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

551 lines · 18037 bytes

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