internal/store/repos.go

160fc0ec2bbbb4367ce5db1814c1a7404b656048
gitbay/internal/store/repos.go history · blame · raw

323 lines · 10256 bytes

  1package store
  2
  3import (
  4	"database/sql"
  5	"encoding/json"
  6	"errors"
  7	"fmt"
  8	"strings"
  9)
 10
 11type Repo struct {
 12	ID            int64
 13	OwnerKind     string // user | org
 14	OwnerID       int64
 15	OwnerName     string // resolved for display and disk paths
 16	Name          string
 17	Visibility    string // public | private
 18	DefaultBranch string
 19	ForkOf        int64 // 0 when not a fork
 20	Settings      RepoSettings
 21}
 22
 23type RepoSettings struct {
 24	ProtectedBranches    []string `json:"protected_branches,omitempty"`
 25	RequireSignedCommits bool     `json:"require_signed_commits,omitempty"`
 26	RequireChecks        bool     `json:"require_checks,omitempty"`
 27	RequireApprovals     int      `json:"require_approvals,omitempty"`
 28	RequireResolved      bool     `json:"require_resolved,omitempty"`
 29	GitDaemon            bool     `json:"git_daemon,omitempty"`
 30	Archived             bool     `json:"archived,omitempty"`
 31	Website              string   `json:"website,omitempty"`
 32}
 33
 34// Path returns the canonical owner/name form.
 35func (r Repo) Path() string { return r.OwnerName + "/" + r.Name }
 36
 37func (s *Store) CreateRepo(ownerKind string, ownerID int64, name, visibility string) (int64, error) {
 38	res, err := s.DB.Exec(
 39		"INSERT INTO repos (owner_kind, owner_id, name, visibility) VALUES (?, ?, ?, ?)",
 40		ownerKind, ownerID, name, visibility)
 41	if err != nil {
 42		if isUniqueErr(err) {
 43			return 0, fmt.Errorf("repository %q already exists", name)
 44		}
 45		return 0, err
 46	}
 47	return res.LastInsertId()
 48}
 49
 50// repoSelect resolves the owner name from whichever table owns the repo.
 51const repoSelect = `
 52	SELECT r.id, r.owner_kind, r.owner_id, COALESCE(u.username, o.name),
 53	       r.name, r.visibility, r.default_branch, COALESCE(r.fork_of, 0), r.settings_json
 54	FROM repos r
 55	LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
 56	LEFT JOIN orgs o  ON r.owner_kind = 'org'  AND o.id = r.owner_id`
 57
 58func scanRepo(row interface{ Scan(...any) error }) (Repo, error) {
 59	var r Repo
 60	var settingsJSON string
 61	err := row.Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &r.ForkOf, &settingsJSON)
 62	if err != nil {
 63		return r, err
 64	}
 65	if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil {
 66		return r, fmt.Errorf("repo %d settings: %w", r.ID, err)
 67	}
 68	return r, nil
 69}
 70
 71// RepoByPath resolves "owner/name"; the owner may be a user or an org.
 72func (s *Store) RepoByPath(path string) (Repo, error) {
 73	owner, name, ok := strings.Cut(strings.TrimSuffix(strings.TrimPrefix(path, "/"), ".git"), "/")
 74	if !ok || owner == "" || name == "" || strings.Contains(name, "/") {
 75		return Repo{}, fmt.Errorf("%w: repository path must be owner/name", ErrNotFound)
 76	}
 77	r, err := scanRepo(s.DB.QueryRow(
 78		repoSelect+" WHERE COALESCE(u.username, o.name) = ? AND r.name = ?", owner, name))
 79	if errors.Is(err, sql.ErrNoRows) {
 80		return Repo{}, ErrNotFound
 81	}
 82	return r, err
 83}
 84
 85// SetRepoVisibility switches a repository between public and private.
 86func (s *Store) SetRepoVisibility(repoID int64, visibility string) error {
 87	if visibility != "public" && visibility != "private" {
 88		return fmt.Errorf("visibility must be public or private")
 89	}
 90	_, err := s.DB.Exec("UPDATE repos SET visibility = ? WHERE id = ?", visibility, repoID)
 91	return err
 92}
 93
 94func (s *Store) SetRepoSettings(repoID int64, settings RepoSettings) error {
 95	raw, err := json.Marshal(settings)
 96	if err != nil {
 97		return err
 98	}
 99	_, err = s.DB.Exec("UPDATE repos SET settings_json = ? WHERE id = ?", string(raw), repoID)
100	return err
101}
102
103func (s *Store) SetForkOf(repoID, parentID int64) error {
104	_, err := s.DB.Exec("UPDATE repos SET fork_of = ? WHERE id = ?", parentID, repoID)
105	return err
106}
107
108func (s *Store) DeleteRepo(repoID int64) error {
109	res, err := s.DB.Exec("DELETE FROM repos WHERE id = ?", repoID)
110	if err != nil {
111		return err
112	}
113	if n, _ := res.RowsAffected(); n == 0 {
114		return ErrNotFound
115	}
116	return nil
117}
118
119// ListReposForUser returns repos the user owns, reaches through an org
120// (unless the org scopes members to 'none'), has an explicit grant on, or
121// reaches through a team. limit 0 means everything; after (an owner/name
122// path) starts the page strictly beyond it, matching the path-ascending
123// order.
124func (s *Store) ListReposForUser(userID int64, limit int, after string) ([]Repo, error) {
125	q := repoSelect + `
126		LEFT JOIN repo_access a ON a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ?
127		LEFT JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ?
128		LEFT JOIN orgs og ON r.owner_kind = 'org' AND og.id = r.owner_id
129		WHERE ((r.owner_kind = 'user' AND r.owner_id = ?)
130		   OR a.subject_id IS NOT NULL
131		   OR (m.user_id IS NOT NULL AND (m.role = 'admin' OR og.members_role <> 'none'))
132		   OR EXISTS (SELECT 1 FROM team_repos tr
133		              JOIN team_members tm ON tm.team_id = tr.team_id AND tm.user_id = ?
134		              WHERE tr.repo_id = r.id))`
135	args := []any{userID, userID, userID, userID}
136	if after != "" {
137		owner, name, _ := strings.Cut(after, "/")
138		q += ` AND (COALESCE(u.username, o.name) > ?
139		         OR (COALESCE(u.username, o.name) = ? AND r.name > ?))`
140		args = append(args, owner, owner, name)
141	}
142	q += `
143		GROUP BY r.id
144		ORDER BY 4, r.name`
145	if limit > 0 {
146		q += " LIMIT ?"
147		args = append(args, limit)
148	}
149	rows, err := s.DB.Query(q, args...)
150	if err != nil {
151		return nil, err
152	}
153	defer rows.Close()
154	var out []Repo
155	for rows.Next() {
156		r, err := scanRepo(rows)
157		if err != nil {
158			return nil, err
159		}
160		out = append(out, r)
161	}
162	return out, rows.Err()
163}
164
165// AccessRole returns the user's effective role on the repo ("" if none):
166// the strongest of any explicit grant, the role derived from org
167// membership (org admin -> admin; plain member -> the org's members_role,
168// 'write' by default so the pre-teams model is the degenerate case), and
169// any team grants on the repo.
170func (s *Store) AccessRole(repoID, userID int64) (string, error) {
171	rank := map[string]int{"": 0, "none": 0, "read": 1, "write": 2, "admin": 3}
172	best := ""
173	better := func(role string) {
174		if rank[role] > rank[best] {
175			best = role
176		}
177	}
178
179	var explicit string
180	err := s.DB.QueryRow(
181		"SELECT role FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?",
182		repoID, userID).Scan(&explicit)
183	if err != nil && !errors.Is(err, sql.ErrNoRows) {
184		return "", err
185	}
186	better(explicit)
187
188	var orgRole, membersRole string
189	err = s.DB.QueryRow(`
190		SELECT m.role, o.members_role FROM repos r
191		JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ?
192		JOIN orgs o ON o.id = r.owner_id
193		WHERE r.id = ?`, userID, repoID).Scan(&orgRole, &membersRole)
194	if err != nil && !errors.Is(err, sql.ErrNoRows) {
195		return "", err
196	}
197	if orgRole == "admin" {
198		better("admin")
199	} else if orgRole == "member" {
200		better(membersRole) // write | read | none
201	}
202
203	var teamRole string
204	err = s.DB.QueryRow(`
205		SELECT tr.role FROM team_repos tr
206		JOIN team_members tm ON tm.team_id = tr.team_id AND tm.user_id = ?
207		WHERE tr.repo_id = ?
208		ORDER BY CASE tr.role WHEN 'admin' THEN 3 WHEN 'write' THEN 2 ELSE 1 END DESC
209		LIMIT 1`, userID, repoID).Scan(&teamRole)
210	if err != nil && !errors.Is(err, sql.ErrNoRows) {
211		return "", err
212	}
213	better(teamRole)
214	return best, nil
215}
216
217func (s *Store) GrantAccess(repoID, userID int64, role string) error {
218	_, err := s.DB.Exec(`
219		INSERT INTO repo_access (repo_id, subject_kind, subject_id, role) VALUES (?, 'user', ?, ?)
220		ON CONFLICT (repo_id, subject_kind, subject_id) DO UPDATE SET role = excluded.role`,
221		repoID, userID, role)
222	return err
223}
224
225func (s *Store) RevokeAccess(repoID, userID int64) error {
226	res, err := s.DB.Exec(
227		"DELETE FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?",
228		repoID, userID)
229	if err != nil {
230		return err
231	}
232	if n, _ := res.RowsAffected(); n == 0 {
233		return ErrNotFound
234	}
235	return nil
236}
237
238type AccessEntry struct {
239	Username string
240	Role     string
241}
242
243func (s *Store) ListAccess(repoID int64) ([]AccessEntry, error) {
244	rows, err := s.DB.Query(`
245		SELECT u.username, a.role FROM repo_access a
246		JOIN users u ON a.subject_kind = 'user' AND u.id = a.subject_id
247		WHERE a.repo_id = ? ORDER BY u.username`, repoID)
248	if err != nil {
249		return nil, err
250	}
251	defer rows.Close()
252	var out []AccessEntry
253	for rows.Next() {
254		var e AccessEntry
255		if err := rows.Scan(&e.Username, &e.Role); err != nil {
256			return nil, err
257		}
258		out = append(out, e)
259	}
260	return out, rows.Err()
261}
262
263func (s *Store) RepoByID(id int64) (Repo, error) {
264	r, err := scanRepo(s.DB.QueryRow(repoSelect+" WHERE r.id = ?", id))
265	if errors.Is(err, sql.ErrNoRows) {
266		return Repo{}, ErrNotFound
267	}
268	return r, err
269}
270
271// ListPublicRepos returns all public repositories, for the anonymous index.
272func (s *Store) ListPublicRepos() ([]Repo, error) {
273	rows, err := s.DB.Query(repoSelect + " WHERE r.visibility = 'public' ORDER BY 4, r.name")
274	if err != nil {
275		return nil, err
276	}
277	defer rows.Close()
278	var out []Repo
279	for rows.Next() {
280		r, err := scanRepo(rows)
281		if err != nil {
282			return nil, err
283		}
284		out = append(out, r)
285	}
286	return out, rows.Err()
287}
288
289func (s *Store) UpdateDefaultBranch(repoID int64, branch string) error {
290	_, err := s.DB.Exec("UPDATE repos SET default_branch = ? WHERE id = ?", branch, repoID)
291	return err
292}
293
294// ListReposForOwner returns every repo owned by one user or org; the caller
295// filters by viewer visibility.
296func (s *Store) ListReposForOwner(ownerKind string, ownerID int64) ([]Repo, error) {
297	rows, err := s.DB.Query(repoSelect+" WHERE r.owner_kind = ? AND r.owner_id = ? ORDER BY r.name",
298		ownerKind, ownerID)
299	if err != nil {
300		return nil, err
301	}
302	defer rows.Close()
303	var out []Repo
304	for rows.Next() {
305		r, err := scanRepo(rows)
306		if err != nil {
307			return nil, err
308		}
309		out = append(out, r)
310	}
311	return out, rows.Err()
312}
313
314// TransferRepo moves a repository to a new owner. The unique index on
315// (owner_kind, owner_id, name) refuses collisions in the target namespace.
316func (s *Store) TransferRepo(repoID int64, newKind string, newOwnerID int64) error {
317	_, err := s.DB.Exec("UPDATE repos SET owner_kind = ?, owner_id = ? WHERE id = ?",
318		newKind, newOwnerID, repoID)
319	if isUniqueErr(err) {
320		return fmt.Errorf("the target owner already has a repository by that name")
321	}
322	return err
323}