internal/store/repos.go

26b59e4e1845ca161d394403e2ef262040feebab
gitbay/internal/store/repos.go history · blame · raw

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