internal/store/repos.go
420 lines · 13457 bytes
1package store
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "strings"
10)
11
12type Repo struct {
13 ID int64
14 OwnerKind string // user | org
15 OwnerID int64
16 OwnerName string // resolved for display and disk paths
17 Name string
18 Visibility string // public | private
19 DefaultBranch string
20 ForkOf int64 // 0 when not a fork
21 Settings RepoSettings
22}
23
24type RepoSettings struct {
25 ProtectedBranches []string `json:"protected_branches,omitempty"`
26 RequireSignedCommits bool `json:"require_signed_commits,omitempty"`
27 RequireChecks bool `json:"require_checks,omitempty"`
28 RequireApprovals int `json:"require_approvals,omitempty"`
29 RequireResolved bool `json:"require_resolved,omitempty"`
30 RequireCodeowners bool `json:"require_codeowners,omitempty"`
31 GitDaemon bool `json:"git_daemon,omitempty"`
32 Archived bool `json:"archived,omitempty"`
33 Website string `json:"website,omitempty"`
34}
35
36// Path returns the canonical owner/name form.
37func (r Repo) Path() string { return r.OwnerName + "/" + r.Name }
38
39func (s *Store) CreateRepo(ownerKind string, ownerID int64, name, visibility string) (int64, error) {
40 res, err := s.DB.Exec(
41 "INSERT INTO repos (owner_kind, owner_id, name, visibility) VALUES (?, ?, ?, ?)",
42 ownerKind, ownerID, name, visibility)
43 if err != nil {
44 if isUniqueErr(err) {
45 return 0, fmt.Errorf("repository %q already exists", name)
46 }
47 return 0, err
48 }
49 return res.LastInsertId()
50}
51
52// repoSelect resolves the owner name from whichever table owns the repo.
53const repoSelect = `
54 SELECT r.id, r.owner_kind, r.owner_id, COALESCE(u.username, o.name),
55 r.name, r.visibility, r.default_branch, COALESCE(r.fork_of, 0), r.settings_json
56 FROM repos r
57 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
58 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id`
59
60func scanRepo(row interface{ Scan(...any) error }) (Repo, error) {
61 var r Repo
62 var settingsJSON string
63 err := row.Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &r.ForkOf, &settingsJSON)
64 if err != nil {
65 return r, err
66 }
67 if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil {
68 return r, fmt.Errorf("repo %d settings: %w", r.ID, err)
69 }
70 return r, nil
71}
72
73// RepoByPath resolves "owner/name"; the owner may be a user or an org.
74func (s *Store) RepoByPath(path string) (Repo, error) {
75 owner, name, ok := strings.Cut(strings.TrimSuffix(strings.TrimPrefix(path, "/"), ".git"), "/")
76 if !ok || owner == "" || name == "" || strings.Contains(name, "/") {
77 return Repo{}, fmt.Errorf("%w: repository path must be owner/name", ErrNotFound)
78 }
79 r, err := scanRepo(s.DB.QueryRow(
80 repoSelect+" WHERE COALESCE(u.username, o.name) = ? AND r.name = ?", owner, name))
81 if errors.Is(err, sql.ErrNoRows) {
82 return Repo{}, ErrNotFound
83 }
84 return r, err
85}
86
87// SetRepoVisibility switches a repository between public and private.
88func (s *Store) SetRepoVisibility(repoID int64, visibility string) error {
89 if visibility != "public" && visibility != "private" {
90 return fmt.Errorf("visibility must be public or private")
91 }
92 _, err := s.DB.Exec("UPDATE repos SET visibility = ? WHERE id = ?", visibility, repoID)
93 return err
94}
95
96// UpdateRepoSettings applies mutate to the repository's settings and
97// stores the result, returning what was stored.
98//
99// settings_json is one blob, so changing one field means writing all of
100// them. Callers used to read the struct off a Repo they had loaded
101// earlier, change a field and write the whole blob back, which loses the
102// other admin's change whenever two ran at once — last write wins over a
103// value it never read. The read and the write happen here instead, inside
104// one transaction, and BEGIN IMMEDIATE takes the write lock up front: a
105// second updater waits at the start rather than discovering the conflict
106// after it has already read a stale blob.
107func (s *Store) UpdateRepoSettings(repoID int64, mutate func(*RepoSettings)) (RepoSettings, error) {
108 ctx := context.Background()
109 var out RepoSettings
110 // The whole exchange must run on one connection for BEGIN to bracket
111 // it; the pool would otherwise be free to hand the statements out
112 // separately.
113 conn, err := s.DB.Conn(ctx)
114 if err != nil {
115 return out, err
116 }
117 defer conn.Close()
118 if _, err := conn.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil {
119 return out, err
120 }
121 committed := false
122 defer func() {
123 if !committed {
124 conn.ExecContext(ctx, "ROLLBACK")
125 }
126 }()
127 var raw string
128 if err := conn.QueryRowContext(ctx,
129 "SELECT settings_json FROM repos WHERE id = ?", repoID).Scan(&raw); err != nil {
130 if errors.Is(err, sql.ErrNoRows) {
131 return out, ErrNotFound
132 }
133 return out, err
134 }
135 if raw != "" {
136 if err := json.Unmarshal([]byte(raw), &out); err != nil {
137 return out, err
138 }
139 }
140 mutate(&out)
141 next, err := json.Marshal(out)
142 if err != nil {
143 return out, err
144 }
145 if _, err := conn.ExecContext(ctx,
146 "UPDATE repos SET settings_json = ? WHERE id = ?", string(next), repoID); err != nil {
147 return out, err
148 }
149 if _, err := conn.ExecContext(ctx, "COMMIT"); err != nil {
150 return out, err
151 }
152 committed = true
153 return out, nil
154}
155
156// CreateFork is CreateRepo with fork_of set in the same insert, so a fork
157// never exists for a moment as a plain repository (#108).
158func (s *Store) CreateFork(ownerKind string, ownerID int64, name, visibility string, forkOf int64) (int64, error) {
159 res, err := s.DB.Exec(
160 "INSERT INTO repos (owner_kind, owner_id, name, visibility, fork_of) VALUES (?, ?, ?, ?, ?)",
161 ownerKind, ownerID, name, visibility, forkOf)
162 if err != nil {
163 if isUniqueErr(err) {
164 return 0, fmt.Errorf("repository %q already exists", name)
165 }
166 return 0, err
167 }
168 return res.LastInsertId()
169}
170
171func (s *Store) SetForkOf(repoID, parentID int64) error {
172 _, err := s.DB.Exec("UPDATE repos SET fork_of = ? WHERE id = ?", parentID, repoID)
173 return err
174}
175
176func (s *Store) DeleteRepo(repoID int64) error {
177 res, err := s.DB.Exec("DELETE FROM repos WHERE id = ?", repoID)
178 if err != nil {
179 return err
180 }
181 if n, _ := res.RowsAffected(); n == 0 {
182 return ErrNotFound
183 }
184 return nil
185}
186
187// ListReposForUser returns repos the user owns, reaches through an org
188// (unless the org scopes members to 'none'), has an explicit grant on, or
189// reaches through a team. limit 0 means everything; after (an owner/name
190// path) starts the page strictly beyond it, matching the path-ascending
191// order.
192func (s *Store) ListReposForUser(userID int64, limit int, after string) ([]Repo, error) {
193 q := repoSelect + `
194 LEFT JOIN repo_access a ON a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ?
195 LEFT JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ?
196 LEFT JOIN orgs og ON r.owner_kind = 'org' AND og.id = r.owner_id
197 WHERE ((r.owner_kind = 'user' AND r.owner_id = ?)
198 OR a.subject_id IS NOT NULL
199 OR (m.user_id IS NOT NULL AND (m.role = 'admin' OR og.members_role <> 'none'))
200 OR EXISTS (SELECT 1 FROM team_repos tr
201 JOIN team_members tm ON tm.team_id = tr.team_id AND tm.user_id = ?
202 WHERE tr.repo_id = r.id))`
203 args := []any{userID, userID, userID, userID}
204 if after != "" {
205 owner, name, _ := strings.Cut(after, "/")
206 q += ` AND (COALESCE(u.username, o.name) > ?
207 OR (COALESCE(u.username, o.name) = ? AND r.name > ?))`
208 args = append(args, owner, owner, name)
209 }
210 q += `
211 GROUP BY r.id
212 ORDER BY 4, r.name`
213 if limit > 0 {
214 q += " LIMIT ?"
215 args = append(args, limit)
216 }
217 rows, err := s.DB.Query(q, args...)
218 if err != nil {
219 return nil, err
220 }
221 defer rows.Close()
222 var out []Repo
223 for rows.Next() {
224 r, err := scanRepo(rows)
225 if err != nil {
226 return nil, err
227 }
228 out = append(out, r)
229 }
230 return out, rows.Err()
231}
232
233// AccessRole returns the user's effective role on the repo ("" if none):
234// the strongest of any explicit grant, the role derived from org
235// membership (org admin -> admin; plain member -> the org's members_role,
236// 'write' by default so the pre-teams model is the degenerate case), and
237// any team grants on the repo.
238func (s *Store) AccessRole(repoID, userID int64) (string, error) {
239 rank := map[string]int{"": 0, "none": 0, "read": 1, "write": 2, "admin": 3}
240 best := ""
241 better := func(role string) {
242 if rank[role] > rank[best] {
243 best = role
244 }
245 }
246
247 var explicit string
248 err := s.DB.QueryRow(
249 "SELECT role FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?",
250 repoID, userID).Scan(&explicit)
251 if err != nil && !errors.Is(err, sql.ErrNoRows) {
252 return "", err
253 }
254 better(explicit)
255
256 var orgRole, membersRole string
257 err = s.DB.QueryRow(`
258 SELECT m.role, o.members_role FROM repos r
259 JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ?
260 JOIN orgs o ON o.id = r.owner_id
261 WHERE r.id = ?`, userID, repoID).Scan(&orgRole, &membersRole)
262 if err != nil && !errors.Is(err, sql.ErrNoRows) {
263 return "", err
264 }
265 if orgRole == "admin" {
266 better("admin")
267 } else if orgRole == "member" {
268 better(membersRole) // write | read | none
269 }
270
271 var teamRole string
272 err = s.DB.QueryRow(`
273 SELECT tr.role FROM team_repos tr
274 JOIN team_members tm ON tm.team_id = tr.team_id AND tm.user_id = ?
275 WHERE tr.repo_id = ?
276 ORDER BY CASE tr.role WHEN 'admin' THEN 3 WHEN 'write' THEN 2 ELSE 1 END DESC
277 LIMIT 1`, userID, repoID).Scan(&teamRole)
278 if err != nil && !errors.Is(err, sql.ErrNoRows) {
279 return "", err
280 }
281 better(teamRole)
282 return best, nil
283}
284
285func (s *Store) GrantAccess(repoID, userID int64, role string) error {
286 _, err := s.DB.Exec(`
287 INSERT INTO repo_access (repo_id, subject_kind, subject_id, role) VALUES (?, 'user', ?, ?)
288 ON CONFLICT (repo_id, subject_kind, subject_id) DO UPDATE SET role = excluded.role`,
289 repoID, userID, role)
290 return err
291}
292
293func (s *Store) RevokeAccess(repoID, userID int64) error {
294 res, err := s.DB.Exec(
295 "DELETE FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?",
296 repoID, userID)
297 if err != nil {
298 return err
299 }
300 if n, _ := res.RowsAffected(); n == 0 {
301 return ErrNotFound
302 }
303 return nil
304}
305
306type AccessEntry struct {
307 Username string
308 Role string
309}
310
311func (s *Store) ListAccess(repoID int64) ([]AccessEntry, error) {
312 rows, err := s.DB.Query(`
313 SELECT u.username, a.role FROM repo_access a
314 JOIN users u ON a.subject_kind = 'user' AND u.id = a.subject_id
315 WHERE a.repo_id = ? ORDER BY u.username`, repoID)
316 if err != nil {
317 return nil, err
318 }
319 defer rows.Close()
320 var out []AccessEntry
321 for rows.Next() {
322 var e AccessEntry
323 if err := rows.Scan(&e.Username, &e.Role); err != nil {
324 return nil, err
325 }
326 out = append(out, e)
327 }
328 return out, rows.Err()
329}
330
331func (s *Store) RepoByID(id int64) (Repo, error) {
332 r, err := scanRepo(s.DB.QueryRow(repoSelect+" WHERE r.id = ?", id))
333 if errors.Is(err, sql.ErrNoRows) {
334 return Repo{}, ErrNotFound
335 }
336 return r, err
337}
338
339// ListPublicRepos returns all public repositories, for the anonymous index.
340func (s *Store) ListPublicRepos() ([]Repo, error) {
341 rows, err := s.DB.Query(repoSelect + " WHERE r.visibility = 'public' ORDER BY 4, r.name")
342 if err != nil {
343 return nil, err
344 }
345 defer rows.Close()
346 var out []Repo
347 for rows.Next() {
348 r, err := scanRepo(rows)
349 if err != nil {
350 return nil, err
351 }
352 out = append(out, r)
353 }
354 return out, rows.Err()
355}
356
357// ListForks returns the repositories forked from one repo. The caller
358// filters by what the viewer may see.
359func (s *Store) ListForks(repoID int64) ([]Repo, error) {
360 rows, err := s.DB.Query(repoSelect+" WHERE r.fork_of = ? ORDER BY 4, r.name", repoID)
361 if err != nil {
362 return nil, err
363 }
364 defer rows.Close()
365 var out []Repo
366 for rows.Next() {
367 r, err := scanRepo(rows)
368 if err != nil {
369 return nil, err
370 }
371 out = append(out, r)
372 }
373 return out, rows.Err()
374}
375
376func (s *Store) UpdateDefaultBranch(repoID int64, branch string) error {
377 _, err := s.DB.Exec("UPDATE repos SET default_branch = ? WHERE id = ?", branch, repoID)
378 return err
379}
380
381// ListReposForOwner returns every repo owned by one user or org; the caller
382// filters by viewer visibility.
383func (s *Store) ListReposForOwner(ownerKind string, ownerID int64) ([]Repo, error) {
384 rows, err := s.DB.Query(repoSelect+" WHERE r.owner_kind = ? AND r.owner_id = ? ORDER BY r.name",
385 ownerKind, ownerID)
386 if err != nil {
387 return nil, err
388 }
389 defer rows.Close()
390 var out []Repo
391 for rows.Next() {
392 r, err := scanRepo(rows)
393 if err != nil {
394 return nil, err
395 }
396 out = append(out, r)
397 }
398 return out, rows.Err()
399}
400
401// RenameRepo changes a repository's name under the same owner. The unique
402// index on (owner_kind, owner_id, name) refuses collisions.
403func (s *Store) RenameRepo(repoID int64, newName string) error {
404 _, err := s.DB.Exec("UPDATE repos SET name = ? WHERE id = ?", newName, repoID)
405 if isUniqueErr(err) {
406 return fmt.Errorf("the owner already has a repository by that name")
407 }
408 return err
409}
410
411// TransferRepo moves a repository to a new owner. The unique index on
412// (owner_kind, owner_id, name) refuses collisions in the target namespace.
413func (s *Store) TransferRepo(repoID int64, newKind string, newOwnerID int64) error {
414 _, err := s.DB.Exec("UPDATE repos SET owner_kind = ?, owner_id = ? WHERE id = ?",
415 newKind, newOwnerID, repoID)
416 if isUniqueErr(err) {
417 return fmt.Errorf("the target owner already has a repository by that name")
418 }
419 return err
420}