internal/store/repos.go

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