internal/store/repos.go

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

269 lines · 8160 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	GitDaemon            bool     `json:"git_daemon,omitempty"`
 28}
 29
 30// Path returns the canonical owner/name form.
 31func (r Repo) Path() string { return r.OwnerName + "/" + r.Name }
 32
 33func (s *Store) CreateRepo(ownerKind string, ownerID int64, name, visibility string) (int64, error) {
 34	res, err := s.DB.Exec(
 35		"INSERT INTO repos (owner_kind, owner_id, name, visibility) VALUES (?, ?, ?, ?)",
 36		ownerKind, ownerID, name, visibility)
 37	if err != nil {
 38		if isUniqueErr(err) {
 39			return 0, fmt.Errorf("repository %q already exists", name)
 40		}
 41		return 0, err
 42	}
 43	return res.LastInsertId()
 44}
 45
 46// repoSelect resolves the owner name from whichever table owns the repo.
 47const repoSelect = `
 48	SELECT r.id, r.owner_kind, r.owner_id, COALESCE(u.username, o.name),
 49	       r.name, r.visibility, r.default_branch, COALESCE(r.fork_of, 0), r.settings_json
 50	FROM repos r
 51	LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
 52	LEFT JOIN orgs o  ON r.owner_kind = 'org'  AND o.id = r.owner_id`
 53
 54func scanRepo(row interface{ Scan(...any) error }) (Repo, error) {
 55	var r Repo
 56	var settingsJSON string
 57	err := row.Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &r.ForkOf, &settingsJSON)
 58	if err != nil {
 59		return r, err
 60	}
 61	if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil {
 62		return r, fmt.Errorf("repo %d settings: %w", r.ID, err)
 63	}
 64	return r, nil
 65}
 66
 67// RepoByPath resolves "owner/name"; the owner may be a user or an org.
 68func (s *Store) RepoByPath(path string) (Repo, error) {
 69	owner, name, ok := strings.Cut(strings.TrimSuffix(strings.TrimPrefix(path, "/"), ".git"), "/")
 70	if !ok || owner == "" || name == "" || strings.Contains(name, "/") {
 71		return Repo{}, fmt.Errorf("%w: repository path must be owner/name", ErrNotFound)
 72	}
 73	r, err := scanRepo(s.DB.QueryRow(
 74		repoSelect+" WHERE COALESCE(u.username, o.name) = ? AND r.name = ?", owner, name))
 75	if errors.Is(err, sql.ErrNoRows) {
 76		return Repo{}, ErrNotFound
 77	}
 78	return r, err
 79}
 80
 81func (s *Store) SetRepoSettings(repoID int64, settings RepoSettings) error {
 82	raw, err := json.Marshal(settings)
 83	if err != nil {
 84		return err
 85	}
 86	_, err = s.DB.Exec("UPDATE repos SET settings_json = ? WHERE id = ?", string(raw), repoID)
 87	return err
 88}
 89
 90func (s *Store) SetForkOf(repoID, parentID int64) error {
 91	_, err := s.DB.Exec("UPDATE repos SET fork_of = ? WHERE id = ?", parentID, repoID)
 92	return err
 93}
 94
 95func (s *Store) DeleteRepo(repoID int64) error {
 96	res, err := s.DB.Exec("DELETE FROM repos WHERE id = ?", repoID)
 97	if err != nil {
 98		return err
 99	}
100	if n, _ := res.RowsAffected(); n == 0 {
101		return ErrNotFound
102	}
103	return nil
104}
105
106// ListReposForUser returns repos the user owns, belongs to through an org,
107// or has an explicit grant on.
108func (s *Store) ListReposForUser(userID int64) ([]Repo, error) {
109	rows, err := s.DB.Query(repoSelect+`
110		LEFT JOIN repo_access a ON a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ?
111		LEFT JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ?
112		WHERE (r.owner_kind = 'user' AND r.owner_id = ?) OR a.subject_id IS NOT NULL OR m.user_id IS NOT NULL
113		GROUP BY r.id
114		ORDER BY 4, r.name`, userID, userID, userID)
115	if err != nil {
116		return nil, err
117	}
118	defer rows.Close()
119	var out []Repo
120	for rows.Next() {
121		r, err := scanRepo(rows)
122		if err != nil {
123			return nil, err
124		}
125		out = append(out, r)
126	}
127	return out, rows.Err()
128}
129
130// AccessRole returns the user's effective role on the repo ("" if none):
131// the strongest of any explicit grant and, for org-owned repos, the role
132// derived from org membership (org admin -> admin, org member -> write).
133func (s *Store) AccessRole(repoID, userID int64) (string, error) {
134	rank := map[string]int{"": 0, "read": 1, "write": 2, "admin": 3}
135	best := ""
136
137	var explicit string
138	err := s.DB.QueryRow(
139		"SELECT role FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?",
140		repoID, userID).Scan(&explicit)
141	if err != nil && !errors.Is(err, sql.ErrNoRows) {
142		return "", err
143	}
144	if rank[explicit] > rank[best] {
145		best = explicit
146	}
147
148	var orgRole string
149	err = s.DB.QueryRow(`
150		SELECT m.role FROM repos r
151		JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ?
152		WHERE r.id = ?`, userID, repoID).Scan(&orgRole)
153	if err != nil && !errors.Is(err, sql.ErrNoRows) {
154		return "", err
155	}
156	derived := map[string]string{"admin": "admin", "member": "write"}[orgRole]
157	if rank[derived] > rank[best] {
158		best = derived
159	}
160	return best, nil
161}
162
163func (s *Store) GrantAccess(repoID, userID int64, role string) error {
164	_, err := s.DB.Exec(`
165		INSERT INTO repo_access (repo_id, subject_kind, subject_id, role) VALUES (?, 'user', ?, ?)
166		ON CONFLICT (repo_id, subject_kind, subject_id) DO UPDATE SET role = excluded.role`,
167		repoID, userID, role)
168	return err
169}
170
171func (s *Store) RevokeAccess(repoID, userID int64) error {
172	res, err := s.DB.Exec(
173		"DELETE FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?",
174		repoID, userID)
175	if err != nil {
176		return err
177	}
178	if n, _ := res.RowsAffected(); n == 0 {
179		return ErrNotFound
180	}
181	return nil
182}
183
184type AccessEntry struct {
185	Username string
186	Role     string
187}
188
189func (s *Store) ListAccess(repoID int64) ([]AccessEntry, error) {
190	rows, err := s.DB.Query(`
191		SELECT u.username, a.role FROM repo_access a
192		JOIN users u ON a.subject_kind = 'user' AND u.id = a.subject_id
193		WHERE a.repo_id = ? ORDER BY u.username`, repoID)
194	if err != nil {
195		return nil, err
196	}
197	defer rows.Close()
198	var out []AccessEntry
199	for rows.Next() {
200		var e AccessEntry
201		if err := rows.Scan(&e.Username, &e.Role); err != nil {
202			return nil, err
203		}
204		out = append(out, e)
205	}
206	return out, rows.Err()
207}
208
209func (s *Store) RepoByID(id int64) (Repo, error) {
210	r, err := scanRepo(s.DB.QueryRow(repoSelect+" WHERE r.id = ?", id))
211	if errors.Is(err, sql.ErrNoRows) {
212		return Repo{}, ErrNotFound
213	}
214	return r, err
215}
216
217// ListPublicRepos returns all public repositories, for the anonymous index.
218func (s *Store) ListPublicRepos() ([]Repo, error) {
219	rows, err := s.DB.Query(repoSelect + " WHERE r.visibility = 'public' ORDER BY 4, r.name")
220	if err != nil {
221		return nil, err
222	}
223	defer rows.Close()
224	var out []Repo
225	for rows.Next() {
226		r, err := scanRepo(rows)
227		if err != nil {
228			return nil, err
229		}
230		out = append(out, r)
231	}
232	return out, rows.Err()
233}
234
235func (s *Store) UpdateDefaultBranch(repoID int64, branch string) error {
236	_, err := s.DB.Exec("UPDATE repos SET default_branch = ? WHERE id = ?", branch, repoID)
237	return err
238}
239
240// ListReposForOwner returns every repo owned by one user or org; the caller
241// filters by viewer visibility.
242func (s *Store) ListReposForOwner(ownerKind string, ownerID int64) ([]Repo, error) {
243	rows, err := s.DB.Query(repoSelect+" WHERE r.owner_kind = ? AND r.owner_id = ? ORDER BY r.name",
244		ownerKind, ownerID)
245	if err != nil {
246		return nil, err
247	}
248	defer rows.Close()
249	var out []Repo
250	for rows.Next() {
251		r, err := scanRepo(rows)
252		if err != nil {
253			return nil, err
254		}
255		out = append(out, r)
256	}
257	return out, rows.Err()
258}
259
260// TransferRepo moves a repository to a new owner. The unique index on
261// (owner_kind, owner_id, name) refuses collisions in the target namespace.
262func (s *Store) TransferRepo(repoID int64, newKind string, newOwnerID int64) error {
263	_, err := s.DB.Exec("UPDATE repos SET owner_kind = ?, owner_id = ? WHERE id = ?",
264		newKind, newOwnerID, repoID)
265	if isUniqueErr(err) {
266		return fmt.Errorf("the target owner already has a repository by that name")
267	}
268	return err
269}