krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

main: internal/store/repos.go · raw

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