internal/store/repos.go
215 lines · 6651 bytes
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 Settings RepoSettings
20}
21
22type RepoSettings struct {
23 ProtectedBranches []string `json:"protected_branches,omitempty"`
24 RequireSignedCommits bool `json:"require_signed_commits,omitempty"`
25 GitDaemon bool `json:"git_daemon,omitempty"`
26}
27
28// Path returns the canonical owner/name form.
29func (r Repo) Path() string { return r.OwnerName + "/" + r.Name }
30
31func (s *Store) CreateRepo(ownerKind string, ownerID int64, name, visibility string) (int64, error) {
32 res, err := s.DB.Exec(
33 "INSERT INTO repos (owner_kind, owner_id, name, visibility) VALUES (?, ?, ?, ?)",
34 ownerKind, ownerID, name, visibility)
35 if err != nil {
36 if isUniqueErr(err) {
37 return 0, fmt.Errorf("repository %q already exists", name)
38 }
39 return 0, err
40 }
41 return res.LastInsertId()
42}
43
44// RepoByPath resolves "owner/name". Only user owners exist until orgs land.
45func (s *Store) RepoByPath(path string) (Repo, error) {
46 owner, name, ok := strings.Cut(strings.TrimSuffix(strings.TrimPrefix(path, "/"), ".git"), "/")
47 if !ok || owner == "" || name == "" || strings.Contains(name, "/") {
48 return Repo{}, fmt.Errorf("%w: repository path must be owner/name", ErrNotFound)
49 }
50 var r Repo
51 var settingsJSON string
52 err := s.DB.QueryRow(`
53 SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json
54 FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
55 WHERE u.username = ? AND r.name = ?`, owner, name).
56 Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON)
57 if errors.Is(err, sql.ErrNoRows) {
58 return Repo{}, ErrNotFound
59 }
60 if err != nil {
61 return Repo{}, err
62 }
63 if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil {
64 return Repo{}, fmt.Errorf("repo %d settings: %w", r.ID, err)
65 }
66 return r, nil
67}
68
69func (s *Store) SetRepoSettings(repoID int64, settings RepoSettings) error {
70 raw, err := json.Marshal(settings)
71 if err != nil {
72 return err
73 }
74 _, err = s.DB.Exec("UPDATE repos SET settings_json = ? WHERE id = ?", string(raw), repoID)
75 return err
76}
77
78func (s *Store) DeleteRepo(repoID int64) error {
79 res, err := s.DB.Exec("DELETE FROM repos WHERE id = ?", repoID)
80 if err != nil {
81 return err
82 }
83 if n, _ := res.RowsAffected(); n == 0 {
84 return ErrNotFound
85 }
86 return nil
87}
88
89// ListReposForUser returns repos the user owns or has an explicit grant on.
90func (s *Store) ListReposForUser(userID int64) ([]Repo, error) {
91 rows, err := s.DB.Query(`
92 SELECT DISTINCT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json
93 FROM repos r
94 JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
95 LEFT JOIN repo_access a ON a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ?
96 WHERE r.owner_id = ? OR a.subject_id IS NOT NULL
97 ORDER BY u.username, r.name`, userID, userID)
98 if err != nil {
99 return nil, err
100 }
101 defer rows.Close()
102 var out []Repo
103 for rows.Next() {
104 var r Repo
105 var settingsJSON string
106 if err := rows.Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON); err != nil {
107 return nil, err
108 }
109 if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil {
110 return nil, err
111 }
112 out = append(out, r)
113 }
114 return out, rows.Err()
115}
116
117// AccessRole returns the explicit grant for userID on repoID ("" if none).
118func (s *Store) AccessRole(repoID, userID int64) (string, error) {
119 var role string
120 err := s.DB.QueryRow(
121 "SELECT role FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?",
122 repoID, userID).Scan(&role)
123 if errors.Is(err, sql.ErrNoRows) {
124 return "", nil
125 }
126 return role, err
127}
128
129func (s *Store) GrantAccess(repoID, userID int64, role string) error {
130 _, err := s.DB.Exec(`
131 INSERT INTO repo_access (repo_id, subject_kind, subject_id, role) VALUES (?, 'user', ?, ?)
132 ON CONFLICT (repo_id, subject_kind, subject_id) DO UPDATE SET role = excluded.role`,
133 repoID, userID, role)
134 return err
135}
136
137func (s *Store) RevokeAccess(repoID, userID int64) error {
138 res, err := s.DB.Exec(
139 "DELETE FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?",
140 repoID, userID)
141 if err != nil {
142 return err
143 }
144 if n, _ := res.RowsAffected(); n == 0 {
145 return ErrNotFound
146 }
147 return nil
148}
149
150type AccessEntry struct {
151 Username string
152 Role string
153}
154
155func (s *Store) ListAccess(repoID int64) ([]AccessEntry, error) {
156 rows, err := s.DB.Query(`
157 SELECT u.username, a.role FROM repo_access a
158 JOIN users u ON a.subject_kind = 'user' AND u.id = a.subject_id
159 WHERE a.repo_id = ? ORDER BY u.username`, repoID)
160 if err != nil {
161 return nil, err
162 }
163 defer rows.Close()
164 var out []AccessEntry
165 for rows.Next() {
166 var e AccessEntry
167 if err := rows.Scan(&e.Username, &e.Role); err != nil {
168 return nil, err
169 }
170 out = append(out, e)
171 }
172 return out, rows.Err()
173}
174
175func (s *Store) RepoByID(id int64) (Repo, error) {
176 var r Repo
177 var settingsJSON string
178 err := s.DB.QueryRow(`
179 SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json
180 FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
181 WHERE r.id = ?`, id).
182 Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON)
183 if errors.Is(err, sql.ErrNoRows) {
184 return Repo{}, ErrNotFound
185 }
186 if err != nil {
187 return Repo{}, err
188 }
189 if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil {
190 return Repo{}, err
191 }
192 return r, nil
193}
194
195// ListPublicRepos returns all public repositories, for the anonymous index.
196func (s *Store) ListPublicRepos() ([]Repo, error) {
197 rows, err := s.DB.Query(`
198 SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json
199 FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
200 WHERE r.visibility = 'public' ORDER BY u.username, r.name`)
201 if err != nil {
202 return nil, err
203 }
204 defer rows.Close()
205 var out []Repo
206 for rows.Next() {
207 var r Repo
208 var settingsJSON string
209 if err := rows.Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON); err != nil {
210 return nil, err
211 }
212 out = append(out, r)
213 }
214 return out, rows.Err()
215}