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