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}