internal/store/repos.go

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

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