internal/store/repos.go
323 lines · 10256 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 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 RequireChecks bool `json:"require_checks,omitempty"`
27 RequireApprovals int `json:"require_approvals,omitempty"`
28 RequireResolved bool `json:"require_resolved,omitempty"`
29 GitDaemon bool `json:"git_daemon,omitempty"`
30 Archived bool `json:"archived,omitempty"`
31 Website string `json:"website,omitempty"`
32}
33
34// Path returns the canonical owner/name form.
35func (r Repo) Path() string { return r.OwnerName + "/" + r.Name }
36
37func (s *Store) CreateRepo(ownerKind string, ownerID int64, name, visibility string) (int64, error) {
38 res, err := s.DB.Exec(
39 "INSERT INTO repos (owner_kind, owner_id, name, visibility) VALUES (?, ?, ?, ?)",
40 ownerKind, ownerID, name, visibility)
41 if err != nil {
42 if isUniqueErr(err) {
43 return 0, fmt.Errorf("repository %q already exists", name)
44 }
45 return 0, err
46 }
47 return res.LastInsertId()
48}
49
50// repoSelect resolves the owner name from whichever table owns the repo.
51const repoSelect = `
52 SELECT r.id, r.owner_kind, r.owner_id, COALESCE(u.username, o.name),
53 r.name, r.visibility, r.default_branch, COALESCE(r.fork_of, 0), r.settings_json
54 FROM repos r
55 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
56 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id`
57
58func scanRepo(row interface{ Scan(...any) error }) (Repo, error) {
59 var r Repo
60 var settingsJSON string
61 err := row.Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &r.ForkOf, &settingsJSON)
62 if err != nil {
63 return r, err
64 }
65 if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil {
66 return r, fmt.Errorf("repo %d settings: %w", r.ID, err)
67 }
68 return r, nil
69}
70
71// RepoByPath resolves "owner/name"; the owner may be a user or an org.
72func (s *Store) RepoByPath(path string) (Repo, error) {
73 owner, name, ok := strings.Cut(strings.TrimSuffix(strings.TrimPrefix(path, "/"), ".git"), "/")
74 if !ok || owner == "" || name == "" || strings.Contains(name, "/") {
75 return Repo{}, fmt.Errorf("%w: repository path must be owner/name", ErrNotFound)
76 }
77 r, err := scanRepo(s.DB.QueryRow(
78 repoSelect+" WHERE COALESCE(u.username, o.name) = ? AND r.name = ?", owner, name))
79 if errors.Is(err, sql.ErrNoRows) {
80 return Repo{}, ErrNotFound
81 }
82 return r, err
83}
84
85// SetRepoVisibility switches a repository between public and private.
86func (s *Store) SetRepoVisibility(repoID int64, visibility string) error {
87 if visibility != "public" && visibility != "private" {
88 return fmt.Errorf("visibility must be public or private")
89 }
90 _, err := s.DB.Exec("UPDATE repos SET visibility = ? WHERE id = ?", visibility, repoID)
91 return err
92}
93
94func (s *Store) SetRepoSettings(repoID int64, settings RepoSettings) error {
95 raw, err := json.Marshal(settings)
96 if err != nil {
97 return err
98 }
99 _, err = s.DB.Exec("UPDATE repos SET settings_json = ? WHERE id = ?", string(raw), repoID)
100 return err
101}
102
103func (s *Store) SetForkOf(repoID, parentID int64) error {
104 _, err := s.DB.Exec("UPDATE repos SET fork_of = ? WHERE id = ?", parentID, repoID)
105 return err
106}
107
108func (s *Store) DeleteRepo(repoID int64) error {
109 res, err := s.DB.Exec("DELETE FROM repos WHERE id = ?", repoID)
110 if err != nil {
111 return err
112 }
113 if n, _ := res.RowsAffected(); n == 0 {
114 return ErrNotFound
115 }
116 return nil
117}
118
119// ListReposForUser returns repos the user owns, reaches through an org
120// (unless the org scopes members to 'none'), has an explicit grant on, or
121// reaches through a team. limit 0 means everything; after (an owner/name
122// path) starts the page strictly beyond it, matching the path-ascending
123// order.
124func (s *Store) ListReposForUser(userID int64, limit int, after string) ([]Repo, error) {
125 q := repoSelect + `
126 LEFT JOIN repo_access a ON a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ?
127 LEFT JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ?
128 LEFT JOIN orgs og ON r.owner_kind = 'org' AND og.id = r.owner_id
129 WHERE ((r.owner_kind = 'user' AND r.owner_id = ?)
130 OR a.subject_id IS NOT NULL
131 OR (m.user_id IS NOT NULL AND (m.role = 'admin' OR og.members_role <> 'none'))
132 OR EXISTS (SELECT 1 FROM team_repos tr
133 JOIN team_members tm ON tm.team_id = tr.team_id AND tm.user_id = ?
134 WHERE tr.repo_id = r.id))`
135 args := []any{userID, userID, userID, userID}
136 if after != "" {
137 owner, name, _ := strings.Cut(after, "/")
138 q += ` AND (COALESCE(u.username, o.name) > ?
139 OR (COALESCE(u.username, o.name) = ? AND r.name > ?))`
140 args = append(args, owner, owner, name)
141 }
142 q += `
143 GROUP BY r.id
144 ORDER BY 4, r.name`
145 if limit > 0 {
146 q += " LIMIT ?"
147 args = append(args, limit)
148 }
149 rows, err := s.DB.Query(q, args...)
150 if err != nil {
151 return nil, err
152 }
153 defer rows.Close()
154 var out []Repo
155 for rows.Next() {
156 r, err := scanRepo(rows)
157 if err != nil {
158 return nil, err
159 }
160 out = append(out, r)
161 }
162 return out, rows.Err()
163}
164
165// AccessRole returns the user's effective role on the repo ("" if none):
166// the strongest of any explicit grant, the role derived from org
167// membership (org admin -> admin; plain member -> the org's members_role,
168// 'write' by default so the pre-teams model is the degenerate case), and
169// any team grants on the repo.
170func (s *Store) AccessRole(repoID, userID int64) (string, error) {
171 rank := map[string]int{"": 0, "none": 0, "read": 1, "write": 2, "admin": 3}
172 best := ""
173 better := func(role string) {
174 if rank[role] > rank[best] {
175 best = role
176 }
177 }
178
179 var explicit string
180 err := s.DB.QueryRow(
181 "SELECT role FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?",
182 repoID, userID).Scan(&explicit)
183 if err != nil && !errors.Is(err, sql.ErrNoRows) {
184 return "", err
185 }
186 better(explicit)
187
188 var orgRole, membersRole string
189 err = s.DB.QueryRow(`
190 SELECT m.role, o.members_role FROM repos r
191 JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ?
192 JOIN orgs o ON o.id = r.owner_id
193 WHERE r.id = ?`, userID, repoID).Scan(&orgRole, &membersRole)
194 if err != nil && !errors.Is(err, sql.ErrNoRows) {
195 return "", err
196 }
197 if orgRole == "admin" {
198 better("admin")
199 } else if orgRole == "member" {
200 better(membersRole) // write | read | none
201 }
202
203 var teamRole string
204 err = s.DB.QueryRow(`
205 SELECT tr.role FROM team_repos tr
206 JOIN team_members tm ON tm.team_id = tr.team_id AND tm.user_id = ?
207 WHERE tr.repo_id = ?
208 ORDER BY CASE tr.role WHEN 'admin' THEN 3 WHEN 'write' THEN 2 ELSE 1 END DESC
209 LIMIT 1`, userID, repoID).Scan(&teamRole)
210 if err != nil && !errors.Is(err, sql.ErrNoRows) {
211 return "", err
212 }
213 better(teamRole)
214 return best, nil
215}
216
217func (s *Store) GrantAccess(repoID, userID int64, role string) error {
218 _, err := s.DB.Exec(`
219 INSERT INTO repo_access (repo_id, subject_kind, subject_id, role) VALUES (?, 'user', ?, ?)
220 ON CONFLICT (repo_id, subject_kind, subject_id) DO UPDATE SET role = excluded.role`,
221 repoID, userID, role)
222 return err
223}
224
225func (s *Store) RevokeAccess(repoID, userID int64) error {
226 res, err := s.DB.Exec(
227 "DELETE FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?",
228 repoID, userID)
229 if err != nil {
230 return err
231 }
232 if n, _ := res.RowsAffected(); n == 0 {
233 return ErrNotFound
234 }
235 return nil
236}
237
238type AccessEntry struct {
239 Username string
240 Role string
241}
242
243func (s *Store) ListAccess(repoID int64) ([]AccessEntry, error) {
244 rows, err := s.DB.Query(`
245 SELECT u.username, a.role FROM repo_access a
246 JOIN users u ON a.subject_kind = 'user' AND u.id = a.subject_id
247 WHERE a.repo_id = ? ORDER BY u.username`, repoID)
248 if err != nil {
249 return nil, err
250 }
251 defer rows.Close()
252 var out []AccessEntry
253 for rows.Next() {
254 var e AccessEntry
255 if err := rows.Scan(&e.Username, &e.Role); err != nil {
256 return nil, err
257 }
258 out = append(out, e)
259 }
260 return out, rows.Err()
261}
262
263func (s *Store) RepoByID(id int64) (Repo, error) {
264 r, err := scanRepo(s.DB.QueryRow(repoSelect+" WHERE r.id = ?", id))
265 if errors.Is(err, sql.ErrNoRows) {
266 return Repo{}, ErrNotFound
267 }
268 return r, err
269}
270
271// ListPublicRepos returns all public repositories, for the anonymous index.
272func (s *Store) ListPublicRepos() ([]Repo, error) {
273 rows, err := s.DB.Query(repoSelect + " WHERE r.visibility = 'public' ORDER BY 4, r.name")
274 if err != nil {
275 return nil, err
276 }
277 defer rows.Close()
278 var out []Repo
279 for rows.Next() {
280 r, err := scanRepo(rows)
281 if err != nil {
282 return nil, err
283 }
284 out = append(out, r)
285 }
286 return out, rows.Err()
287}
288
289func (s *Store) UpdateDefaultBranch(repoID int64, branch string) error {
290 _, err := s.DB.Exec("UPDATE repos SET default_branch = ? WHERE id = ?", branch, repoID)
291 return err
292}
293
294// ListReposForOwner returns every repo owned by one user or org; the caller
295// filters by viewer visibility.
296func (s *Store) ListReposForOwner(ownerKind string, ownerID int64) ([]Repo, error) {
297 rows, err := s.DB.Query(repoSelect+" WHERE r.owner_kind = ? AND r.owner_id = ? ORDER BY r.name",
298 ownerKind, ownerID)
299 if err != nil {
300 return nil, err
301 }
302 defer rows.Close()
303 var out []Repo
304 for rows.Next() {
305 r, err := scanRepo(rows)
306 if err != nil {
307 return nil, err
308 }
309 out = append(out, r)
310 }
311 return out, rows.Err()
312}
313
314// TransferRepo moves a repository to a new owner. The unique index on
315// (owner_kind, owner_id, name) refuses collisions in the target namespace.
316func (s *Store) TransferRepo(repoID int64, newKind string, newOwnerID int64) error {
317 _, err := s.DB.Exec("UPDATE repos SET owner_kind = ?, owner_id = ? WHERE id = ?",
318 newKind, newOwnerID, repoID)
319 if isUniqueErr(err) {
320 return fmt.Errorf("the target owner already has a repository by that name")
321 }
322 return err
323}