internal/store/repos.go

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

410 lines · 13064 bytes

  1package store
  2
  3import (
  4	"context"
  5	"database/sql"
  6	"encoding/json"
  7	"errors"
  8	"fmt"
  9	"strings"
 10)
 11
 12type Repo struct {
 13	ID            int64
 14	OwnerKind     string // user | org
 15	OwnerID       int64
 16	OwnerName     string // resolved for display and disk paths
 17	Name          string
 18	Visibility    string // public | private
 19	DefaultBranch string
 20	ForkOf        int64 // 0 when not a fork
 21	Settings      RepoSettings
 22}
 23
 24type RepoSettings struct {
 25	ProtectedBranches    []string `json:"protected_branches,omitempty"`
 26	RequireSignedCommits bool     `json:"require_signed_commits,omitempty"`
 27	RequireChecks        bool     `json:"require_checks,omitempty"`
 28	RequireApprovals     int      `json:"require_approvals,omitempty"`
 29	RequireResolved      bool     `json:"require_resolved,omitempty"`
 30	RequireCodeowners    bool     `json:"require_codeowners,omitempty"`
 31	GitDaemon            bool     `json:"git_daemon,omitempty"`
 32	Archived             bool     `json:"archived,omitempty"`
 33	Website              string   `json:"website,omitempty"`
 34}
 35
 36// Path returns the canonical owner/name form.
 37func (r Repo) Path() string { return r.OwnerName + "/" + r.Name }
 38
 39func (s *Store) CreateRepo(ownerKind string, ownerID int64, name, visibility string) (int64, error) {
 40	res, err := s.DB.Exec(
 41		"INSERT INTO repos (owner_kind, owner_id, name, visibility) VALUES (?, ?, ?, ?)",
 42		ownerKind, ownerID, name, visibility)
 43	if err != nil {
 44		if isUniqueErr(err) {
 45			return 0, fmt.Errorf("repository %q already exists", name)
 46		}
 47		return 0, err
 48	}
 49	return res.LastInsertId()
 50}
 51
 52// repoSelect resolves the owner name from whichever table owns the repo.
 53const repoSelect = `
 54	SELECT r.id, r.owner_kind, r.owner_id, COALESCE(u.username, o.name),
 55	       r.name, r.visibility, r.default_branch, COALESCE(r.fork_of, 0), r.settings_json
 56	FROM repos r
 57	LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
 58	LEFT JOIN orgs o  ON r.owner_kind = 'org'  AND o.id = r.owner_id`
 59
 60func scanRepo(row interface{ Scan(...any) error }) (Repo, error) {
 61	var r Repo
 62	var settingsJSON string
 63	err := row.Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &r.ForkOf, &settingsJSON)
 64	if err != nil {
 65		return r, err
 66	}
 67	if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil {
 68		return r, fmt.Errorf("repo %d settings: %w", r.ID, err)
 69	}
 70	return r, nil
 71}
 72
 73// RepoByPath resolves "owner/name"; the owner may be a user or an org.
 74func (s *Store) RepoByPath(path string) (Repo, error) {
 75	owner, name, ok := strings.Cut(strings.TrimSuffix(strings.TrimPrefix(path, "/"), ".git"), "/")
 76	if !ok || owner == "" || name == "" || strings.Contains(name, "/") {
 77		return Repo{}, fmt.Errorf("%w: repository path must be owner/name", ErrNotFound)
 78	}
 79	r, err := scanRepo(s.DB.QueryRow(
 80		repoSelect+" WHERE COALESCE(u.username, o.name) = ? AND r.name = ?", owner, name))
 81	if errors.Is(err, sql.ErrNoRows) {
 82		return Repo{}, ErrNotFound
 83	}
 84	return r, err
 85}
 86
 87// SetRepoVisibility switches a repository between public and private.
 88func (s *Store) SetRepoVisibility(repoID int64, visibility string) error {
 89	if visibility != "public" && visibility != "private" {
 90		return fmt.Errorf("visibility must be public or private")
 91	}
 92	_, err := s.DB.Exec("UPDATE repos SET visibility = ? WHERE id = ?", visibility, repoID)
 93	return err
 94}
 95
 96// UpdateRepoSettings applies mutate to the repository's settings and
 97// stores the result, returning what was stored.
 98//
 99// settings_json is one blob, so changing one field means writing all of
100// them. Callers used to read the struct off a Repo they had loaded
101// earlier, change a field and write the whole blob back, which loses the
102// other admin's change whenever two ran at once — last write wins over a
103// value it never read. The read and the write happen here instead, inside
104// one transaction, and BEGIN IMMEDIATE takes the write lock up front: a
105// second updater waits at the start rather than discovering the conflict
106// after it has already read a stale blob.
107func (s *Store) UpdateRepoSettings(repoID int64, mutate func(*RepoSettings)) (RepoSettings, error) {
108	ctx := context.Background()
109	var out RepoSettings
110	// The whole exchange must run on one connection for BEGIN to bracket
111	// it; the pool would otherwise be free to hand the statements out
112	// separately.
113	conn, err := s.DB.Conn(ctx)
114	if err != nil {
115		return out, err
116	}
117	defer conn.Close()
118	if _, err := conn.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil {
119		return out, err
120	}
121	committed := false
122	defer func() {
123		if !committed {
124			conn.ExecContext(ctx, "ROLLBACK")
125		}
126	}()
127	var raw string
128	if err := conn.QueryRowContext(ctx,
129		"SELECT settings_json FROM repos WHERE id = ?", repoID).Scan(&raw); err != nil {
130		if errors.Is(err, sql.ErrNoRows) {
131			return out, ErrNotFound
132		}
133		return out, err
134	}
135	if raw != "" {
136		if err := json.Unmarshal([]byte(raw), &out); err != nil {
137			return out, err
138		}
139	}
140	mutate(&out)
141	next, err := json.Marshal(out)
142	if err != nil {
143		return out, err
144	}
145	if _, err := conn.ExecContext(ctx,
146		"UPDATE repos SET settings_json = ? WHERE id = ?", string(next), repoID); err != nil {
147		return out, err
148	}
149	if _, err := conn.ExecContext(ctx, "COMMIT"); err != nil {
150		return out, err
151	}
152	committed = true
153	return out, nil
154}
155
156// CreateFork is CreateRepo with fork_of set in the same insert, so a fork
157// never exists for a moment as a plain repository (#108).
158func (s *Store) CreateFork(ownerKind string, ownerID int64, name, visibility string, forkOf int64) (int64, error) {
159	res, err := s.DB.Exec(
160		"INSERT INTO repos (owner_kind, owner_id, name, visibility, fork_of) VALUES (?, ?, ?, ?, ?)",
161		ownerKind, ownerID, name, visibility, forkOf)
162	if err != nil {
163		if isUniqueErr(err) {
164			return 0, fmt.Errorf("repository %q already exists", name)
165		}
166		return 0, err
167	}
168	return res.LastInsertId()
169}
170
171func (s *Store) SetForkOf(repoID, parentID int64) error {
172	_, err := s.DB.Exec("UPDATE repos SET fork_of = ? WHERE id = ?", parentID, repoID)
173	return err
174}
175
176func (s *Store) DeleteRepo(repoID int64) error {
177	res, err := s.DB.Exec("DELETE FROM repos WHERE id = ?", repoID)
178	if err != nil {
179		return err
180	}
181	if n, _ := res.RowsAffected(); n == 0 {
182		return ErrNotFound
183	}
184	return nil
185}
186
187// ListReposForUser returns repos the user owns, reaches through an org
188// (unless the org scopes members to 'none'), has an explicit grant on, or
189// reaches through a team. limit 0 means everything; after (an owner/name
190// path) starts the page strictly beyond it, matching the path-ascending
191// order.
192func (s *Store) ListReposForUser(userID int64, limit int, after string) ([]Repo, error) {
193	q := repoSelect + `
194		LEFT JOIN repo_access a ON a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ?
195		LEFT JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ?
196		LEFT JOIN orgs og ON r.owner_kind = 'org' AND og.id = r.owner_id
197		WHERE ((r.owner_kind = 'user' AND r.owner_id = ?)
198		   OR a.subject_id IS NOT NULL
199		   OR (m.user_id IS NOT NULL AND (m.role = 'admin' OR og.members_role <> 'none'))
200		   OR EXISTS (SELECT 1 FROM team_repos tr
201		              JOIN team_members tm ON tm.team_id = tr.team_id AND tm.user_id = ?
202		              WHERE tr.repo_id = r.id))`
203	args := []any{userID, userID, userID, userID}
204	if after != "" {
205		owner, name, _ := strings.Cut(after, "/")
206		q += ` AND (COALESCE(u.username, o.name) > ?
207		         OR (COALESCE(u.username, o.name) = ? AND r.name > ?))`
208		args = append(args, owner, owner, name)
209	}
210	q += `
211		GROUP BY r.id
212		ORDER BY 4, r.name`
213	if limit > 0 {
214		q += " LIMIT ?"
215		args = append(args, limit)
216	}
217	rows, err := s.DB.Query(q, args...)
218	if err != nil {
219		return nil, err
220	}
221	defer rows.Close()
222	var out []Repo
223	for rows.Next() {
224		r, err := scanRepo(rows)
225		if err != nil {
226			return nil, err
227		}
228		out = append(out, r)
229	}
230	return out, rows.Err()
231}
232
233// AccessRole returns the user's effective role on the repo ("" if none):
234// the strongest of any explicit grant, the role derived from org
235// membership (org admin -> admin; plain member -> the org's members_role,
236// 'write' by default so the pre-teams model is the degenerate case), and
237// any team grants on the repo.
238func (s *Store) AccessRole(repoID, userID int64) (string, error) {
239	rank := map[string]int{"": 0, "none": 0, "read": 1, "write": 2, "admin": 3}
240	best := ""
241	better := func(role string) {
242		if rank[role] > rank[best] {
243			best = role
244		}
245	}
246
247	var explicit string
248	err := s.DB.QueryRow(
249		"SELECT role FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?",
250		repoID, userID).Scan(&explicit)
251	if err != nil && !errors.Is(err, sql.ErrNoRows) {
252		return "", err
253	}
254	better(explicit)
255
256	var orgRole, membersRole string
257	err = s.DB.QueryRow(`
258		SELECT m.role, o.members_role FROM repos r
259		JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ?
260		JOIN orgs o ON o.id = r.owner_id
261		WHERE r.id = ?`, userID, repoID).Scan(&orgRole, &membersRole)
262	if err != nil && !errors.Is(err, sql.ErrNoRows) {
263		return "", err
264	}
265	if orgRole == "admin" {
266		better("admin")
267	} else if orgRole == "member" {
268		better(membersRole) // write | read | none
269	}
270
271	var teamRole string
272	err = s.DB.QueryRow(`
273		SELECT tr.role FROM team_repos tr
274		JOIN team_members tm ON tm.team_id = tr.team_id AND tm.user_id = ?
275		WHERE tr.repo_id = ?
276		ORDER BY CASE tr.role WHEN 'admin' THEN 3 WHEN 'write' THEN 2 ELSE 1 END DESC
277		LIMIT 1`, userID, repoID).Scan(&teamRole)
278	if err != nil && !errors.Is(err, sql.ErrNoRows) {
279		return "", err
280	}
281	better(teamRole)
282	return best, nil
283}
284
285func (s *Store) GrantAccess(repoID, userID int64, role string) error {
286	_, err := s.DB.Exec(`
287		INSERT INTO repo_access (repo_id, subject_kind, subject_id, role) VALUES (?, 'user', ?, ?)
288		ON CONFLICT (repo_id, subject_kind, subject_id) DO UPDATE SET role = excluded.role`,
289		repoID, userID, role)
290	return err
291}
292
293func (s *Store) RevokeAccess(repoID, userID int64) error {
294	res, err := s.DB.Exec(
295		"DELETE FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?",
296		repoID, userID)
297	if err != nil {
298		return err
299	}
300	if n, _ := res.RowsAffected(); n == 0 {
301		return ErrNotFound
302	}
303	return nil
304}
305
306type AccessEntry struct {
307	Username string
308	Role     string
309}
310
311func (s *Store) ListAccess(repoID int64) ([]AccessEntry, error) {
312	rows, err := s.DB.Query(`
313		SELECT u.username, a.role FROM repo_access a
314		JOIN users u ON a.subject_kind = 'user' AND u.id = a.subject_id
315		WHERE a.repo_id = ? ORDER BY u.username`, repoID)
316	if err != nil {
317		return nil, err
318	}
319	defer rows.Close()
320	var out []AccessEntry
321	for rows.Next() {
322		var e AccessEntry
323		if err := rows.Scan(&e.Username, &e.Role); err != nil {
324			return nil, err
325		}
326		out = append(out, e)
327	}
328	return out, rows.Err()
329}
330
331func (s *Store) RepoByID(id int64) (Repo, error) {
332	r, err := scanRepo(s.DB.QueryRow(repoSelect+" WHERE r.id = ?", id))
333	if errors.Is(err, sql.ErrNoRows) {
334		return Repo{}, ErrNotFound
335	}
336	return r, err
337}
338
339// ListPublicRepos returns all public repositories, for the anonymous index.
340func (s *Store) ListPublicRepos() ([]Repo, error) {
341	rows, err := s.DB.Query(repoSelect + " WHERE r.visibility = 'public' ORDER BY 4, r.name")
342	if err != nil {
343		return nil, err
344	}
345	defer rows.Close()
346	var out []Repo
347	for rows.Next() {
348		r, err := scanRepo(rows)
349		if err != nil {
350			return nil, err
351		}
352		out = append(out, r)
353	}
354	return out, rows.Err()
355}
356
357// ListForks returns the repositories forked from one repo. The caller
358// filters by what the viewer may see.
359func (s *Store) ListForks(repoID int64) ([]Repo, error) {
360	rows, err := s.DB.Query(repoSelect+" WHERE r.fork_of = ? ORDER BY 4, r.name", repoID)
361	if err != nil {
362		return nil, err
363	}
364	defer rows.Close()
365	var out []Repo
366	for rows.Next() {
367		r, err := scanRepo(rows)
368		if err != nil {
369			return nil, err
370		}
371		out = append(out, r)
372	}
373	return out, rows.Err()
374}
375
376func (s *Store) UpdateDefaultBranch(repoID int64, branch string) error {
377	_, err := s.DB.Exec("UPDATE repos SET default_branch = ? WHERE id = ?", branch, repoID)
378	return err
379}
380
381// ListReposForOwner returns every repo owned by one user or org; the caller
382// filters by viewer visibility.
383func (s *Store) ListReposForOwner(ownerKind string, ownerID int64) ([]Repo, error) {
384	rows, err := s.DB.Query(repoSelect+" WHERE r.owner_kind = ? AND r.owner_id = ? ORDER BY r.name",
385		ownerKind, ownerID)
386	if err != nil {
387		return nil, err
388	}
389	defer rows.Close()
390	var out []Repo
391	for rows.Next() {
392		r, err := scanRepo(rows)
393		if err != nil {
394			return nil, err
395		}
396		out = append(out, r)
397	}
398	return out, rows.Err()
399}
400
401// TransferRepo moves a repository to a new owner. The unique index on
402// (owner_kind, owner_id, name) refuses collisions in the target namespace.
403func (s *Store) TransferRepo(repoID int64, newKind string, newOwnerID int64) error {
404	_, err := s.DB.Exec("UPDATE repos SET owner_kind = ?, owner_id = ? WHERE id = ?",
405		newKind, newOwnerID, repoID)
406	if isUniqueErr(err) {
407		return fmt.Errorf("the target owner already has a repository by that name")
408	}
409	return err
410}