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