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