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