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