repos.go
⎇
Raw
1package db
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "strings"
8)
9
10type Repo struct {
11 ID int64
12 Name string
13 Description *string
14 IsPrivate bool
15 IsPinned bool
16 DefaultBranch string
17 CreatedAt string
18 IssueSeq int64
19 PatchSeq int64
20 IssueTemplate *string
21 PatchTemplate *string
22 AllowUserLabels bool
23}
24
25const repoColumns = `id, name, description, is_private, is_pinned, default_branch, created_at,
26 issue_seq, patch_seq, issue_template, patch_template, allow_user_labels`
27
28type rowScanner interface {
29 Scan(dest ...any) error
30}
31
32func scanRepo(s rowScanner) (*Repo, error) {
33 var r Repo
34 err := s.Scan(&r.ID, &r.Name, &r.Description, &r.IsPrivate, &r.IsPinned, &r.DefaultBranch,
35 &r.CreatedAt, &r.IssueSeq, &r.PatchSeq, &r.IssueTemplate, &r.PatchTemplate, &r.AllowUserLabels)
36 if errors.Is(err, sql.ErrNoRows) {
37 return nil, nil
38 }
39 if err != nil {
40 return nil, err
41 }
42 return &r, nil
43}
44
45func (d *DB) RepoByName(ctx context.Context, name string) (*Repo, error) {
46 return scanRepo(d.QueryRowContext(ctx, `SELECT `+repoColumns+` FROM repositories WHERE name = ?`, name))
47}
48
49// RepoByNameFold matches the name case-insensitively. Image names are
50// lowercase on the wire, repo names need not be. With two repos that only
51// differ in case the first by name wins.
52func (d *DB) RepoByNameFold(ctx context.Context, name string) (*Repo, error) {
53 return scanRepo(d.QueryRowContext(ctx,
54 `SELECT `+repoColumns+` FROM repositories WHERE name = ? OR lower(name) = lower(?) ORDER BY name = ? DESC, name LIMIT 1`,
55 name, name, name))
56}
57
58// GetRepo looks a repo up by name and hides private repos from non-admins.
59func (d *DB) GetRepo(ctx context.Context, name string, isAdmin bool) (*Repo, error) {
60 repo, err := d.RepoByName(ctx, name)
61 if err != nil || repo == nil {
62 return nil, err
63 }
64 if repo.IsPrivate && !isAdmin {
65 return nil, nil
66 }
67 return repo, nil
68}
69
70// searchClause builds the optional name/description LIKE filter. The pattern
71// is already escaped by the caller.
72func searchClause(pattern string, args []any) (string, []any) {
73 if pattern == "" {
74 return "", args
75 }
76 return ` AND (name LIKE ? ESCAPE '\' OR description LIKE ? ESCAPE '\')`, append(args, pattern, pattern)
77}
78
79// EscapeLike turns a raw search term into a LIKE pattern.
80func EscapeLike(term string) string {
81 r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
82 return "%" + r.Replace(term) + "%"
83}
84
85// visibilityClause hides private repos from non-admins. An admin sees
86// everything, so the clause becomes a no-op.
87func visibilityClause(isAdmin bool) string {
88 if isAdmin {
89 return `1 = 1`
90 }
91 return `is_private = 0`
92}
93
94func (d *DB) CountRepos(ctx context.Context, isAdmin bool, searchPattern string) (int, error) {
95 where, args := searchClause(searchPattern, nil)
96 var n int
97 err := d.QueryRowContext(ctx,
98 `SELECT COUNT(*) FROM repositories WHERE `+visibilityClause(isAdmin)+where, args...).Scan(&n)
99 return n, err
100}
101
102// ListRepos returns one page of repos. Pinned repos always come first; sort is
103// "name" or "created".
104func (d *DB) ListRepos(ctx context.Context, isAdmin bool, searchPattern, sort string, limit, offset int) ([]Repo, error) {
105 where, args := searchClause(searchPattern, nil)
106 order := `is_pinned DESC, created_at DESC`
107 if sort == "name" {
108 order = `is_pinned DESC, name ASC`
109 }
110 args = append(args, limit, offset)
111 rows, err := d.QueryContext(ctx,
112 `SELECT `+repoColumns+` FROM repositories WHERE `+visibilityClause(isAdmin)+where+
113 ` ORDER BY `+order+` LIMIT ? OFFSET ?`, args...)
114 if err != nil {
115 return nil, err
116 }
117 defer rows.Close()
118 var out []Repo
119 for rows.Next() {
120 r, err := scanRepo(rows)
121 if err != nil {
122 return nil, err
123 }
124 out = append(out, *r)
125 }
126 return out, rows.Err()
127}
128
129// CreateRepo inserts a repo and returns the stored row.
130func (d *DB) CreateRepo(ctx context.Context, name string, description *string, isPrivate bool,
131 defaultBranch, createdAt string,
132) (*Repo, error) {
133 res, err := d.ExecContext(ctx,
134 `INSERT INTO repositories (name, description, is_private, default_branch, created_at)
135 VALUES (?, ?, ?, ?, ?)`,
136 name, description, isPrivate, defaultBranch, createdAt)
137 if err != nil {
138 return nil, err
139 }
140 id, err := res.LastInsertId()
141 if err != nil {
142 return nil, err
143 }
144 return scanRepo(d.QueryRowContext(ctx, `SELECT `+repoColumns+` FROM repositories WHERE id = ?`, id))
145}
146
147func (d *DB) DeleteRepo(ctx context.Context, id int64) error {
148 _, err := d.ExecContext(ctx, `DELETE FROM repositories WHERE id = ?`, id)
149 return err
150}
151
152func (d *DB) DeleteRepoByName(ctx context.Context, name string) error {
153 _, err := d.ExecContext(ctx, `DELETE FROM repositories WHERE name = ?`, name)
154 return err
155}
156
157// UpdateRepoSettings writes the repo settings form in one statement.
158func (d *DB) UpdateRepoSettings(ctx context.Context, id int64, description *string,
159 isPrivate, isPinned, allowUserLabels bool, defaultBranch string,
160 issueTemplate, patchTemplate *string,
161) error {
162 _, err := d.ExecContext(ctx,
163 `UPDATE repositories SET description = ?, is_private = ?, is_pinned = ?,
164 allow_user_labels = ?, default_branch = ?, issue_template = ?, patch_template = ?
165 WHERE id = ?`,
166 description, isPrivate, isPinned, allowUserLabels, defaultBranch,
167 issueTemplate, patchTemplate, id)
168 return err
169}
170
171func (d *DB) RenameRepo(ctx context.Context, id int64, newName string) error {
172 _, err := d.ExecContext(ctx, `UPDATE repositories SET name = ? WHERE id = ?`, newName, id)
173 return err
174}
175
176func (d *DB) SetDefaultBranch(ctx context.Context, id int64, branch string) error {
177 _, err := d.ExecContext(ctx, `UPDATE repositories SET default_branch = ? WHERE id = ?`, branch, id)
178 return err
179}
180
181// RepoRef is the id/name pair used by the disk sync job.
182type RepoRef struct {
183 ID int64
184 Name string
185}
186
187func (d *DB) AllRepoRefs(ctx context.Context) ([]RepoRef, error) {
188 rows, err := d.QueryContext(ctx, `SELECT id, name FROM repositories`)
189 if err != nil {
190 return nil, err
191 }
192 defer rows.Close()
193 var out []RepoRef
194 for rows.Next() {
195 var r RepoRef
196 if err := rows.Scan(&r.ID, &r.Name); err != nil {
197 return nil, err
198 }
199 out = append(out, r)
200 }
201 return out, rows.Err()
202}
203
204// RepoCounts is a repo row with its issue and patch counts. A delete also
205// cascades to releases and CI runs, which are not counted here.
206type RepoCounts struct {
207 ID int64
208 Name string
209 Issues int
210 Patches int
211}
212
213// AllRepoCounts lists every repo with its issue and patch counts.
214func (d *DB) AllRepoCounts(ctx context.Context) ([]RepoCounts, error) {
215 rows, err := d.QueryContext(ctx, `
216 SELECT r.id, r.name,
217 (SELECT COUNT(*) FROM issues WHERE repo_id = r.id),
218 (SELECT COUNT(*) FROM patches WHERE repo_id = r.id)
219 FROM repositories r ORDER BY r.name`)
220 if err != nil {
221 return nil, err
222 }
223 defer rows.Close()
224 var out []RepoCounts
225 for rows.Next() {
226 var r RepoCounts
227 if err := rows.Scan(&r.ID, &r.Name, &r.Issues, &r.Patches); err != nil {
228 return nil, err
229 }
230 out = append(out, r)
231 }
232 return out, rows.Err()
233}
234