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