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// GetRepo looks a repo up by name and hides private repos from non-admins.
50func (d *DB) GetRepo(ctx context.Context, name string, isAdmin bool) (*Repo, error) {
51 repo, err := d.RepoByName(ctx, name)
52 if err != nil || repo == nil {
53 return nil, err
54 }
55 if repo.IsPrivate && !isAdmin {
56 return nil, nil
57 }
58 return repo, nil
59}
60
61// searchClause builds the optional name/description LIKE filter. The pattern
62// is already escaped by the caller.
63func searchClause(pattern string, args []any) (string, []any) {
64 if pattern == "" {
65 return "", args
66 }
67 return ` AND (name LIKE ? ESCAPE '\' OR description LIKE ? ESCAPE '\')`, append(args, pattern, pattern)
68}
69
70// EscapeLike turns a raw search term into a LIKE pattern.
71func EscapeLike(term string) string {
72 r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
73 return "%" + r.Replace(term) + "%"
74}
75
76// visibilityClause hides private repos from non-admins. An admin sees
77// everything, so the clause becomes a no-op.
78func visibilityClause(isAdmin bool) string {
79 if isAdmin {
80 return `1 = 1`
81 }
82 return `is_private = 0`
83}
84
85func (d *DB) CountRepos(ctx context.Context, isAdmin bool, searchPattern string) (int, error) {
86 where, args := searchClause(searchPattern, nil)
87 var n int
88 err := d.QueryRowContext(ctx,
89 `SELECT COUNT(*) FROM repositories WHERE `+visibilityClause(isAdmin)+where, args...).Scan(&n)
90 return n, err
91}
92
93// ListRepos returns one page of repos. Pinned repos always come first; sort is
94// "name" or "created".
95func (d *DB) ListRepos(ctx context.Context, isAdmin bool, searchPattern, sort string, limit, offset int) ([]Repo, error) {
96 where, args := searchClause(searchPattern, nil)
97 order := `is_pinned DESC, created_at DESC`
98 if sort == "name" {
99 order = `is_pinned DESC, name ASC`
100 }
101 args = append(args, limit, offset)
102 rows, err := d.QueryContext(ctx,
103 `SELECT `+repoColumns+` FROM repositories WHERE `+visibilityClause(isAdmin)+where+
104 ` ORDER BY `+order+` LIMIT ? OFFSET ?`, args...)
105 if err != nil {
106 return nil, err
107 }
108 defer rows.Close()
109 var out []Repo
110 for rows.Next() {
111 r, err := scanRepo(rows)
112 if err != nil {
113 return nil, err
114 }
115 out = append(out, *r)
116 }
117 return out, rows.Err()
118}
119
120// CreateRepo inserts a repo and returns the stored row.
121func (d *DB) CreateRepo(ctx context.Context, name string, description *string, isPrivate bool,
122 defaultBranch, createdAt string,
123) (*Repo, error) {
124 res, err := d.ExecContext(ctx,
125 `INSERT INTO repositories (name, description, is_private, default_branch, created_at)
126 VALUES (?, ?, ?, ?, ?)`,
127 name, description, isPrivate, defaultBranch, createdAt)
128 if err != nil {
129 return nil, err
130 }
131 id, err := res.LastInsertId()
132 if err != nil {
133 return nil, err
134 }
135 return scanRepo(d.QueryRowContext(ctx, `SELECT `+repoColumns+` FROM repositories WHERE id = ?`, id))
136}
137
138func (d *DB) DeleteRepo(ctx context.Context, id int64) error {
139 _, err := d.ExecContext(ctx, `DELETE FROM repositories WHERE id = ?`, id)
140 return err
141}
142
143func (d *DB) DeleteRepoByName(ctx context.Context, name string) error {
144 _, err := d.ExecContext(ctx, `DELETE FROM repositories WHERE name = ?`, name)
145 return err
146}
147
148// UpdateRepoSettings writes the repo settings form in one statement.
149func (d *DB) UpdateRepoSettings(ctx context.Context, id int64, description *string,
150 isPrivate, isPinned, allowUserLabels bool, defaultBranch string,
151 issueTemplate, patchTemplate *string,
152) error {
153 _, err := d.ExecContext(ctx,
154 `UPDATE repositories SET description = ?, is_private = ?, is_pinned = ?,
155 allow_user_labels = ?, default_branch = ?, issue_template = ?, patch_template = ?
156 WHERE id = ?`,
157 description, isPrivate, isPinned, allowUserLabels, defaultBranch,
158 issueTemplate, patchTemplate, id)
159 return err
160}
161
162func (d *DB) RenameRepo(ctx context.Context, id int64, newName string) error {
163 _, err := d.ExecContext(ctx, `UPDATE repositories SET name = ? WHERE id = ?`, newName, id)
164 return err
165}
166
167func (d *DB) SetDefaultBranch(ctx context.Context, id int64, branch string) error {
168 _, err := d.ExecContext(ctx, `UPDATE repositories SET default_branch = ? WHERE id = ?`, branch, id)
169 return err
170}
171
172// RepoRef is the id/name pair used by the disk sync job.
173type RepoRef struct {
174 ID int64
175 Name string
176}
177
178func (d *DB) AllRepoRefs(ctx context.Context) ([]RepoRef, error) {
179 rows, err := d.QueryContext(ctx, `SELECT id, name FROM repositories`)
180 if err != nil {
181 return nil, err
182 }
183 defer rows.Close()
184 var out []RepoRef
185 for rows.Next() {
186 var r RepoRef
187 if err := rows.Scan(&r.ID, &r.Name); err != nil {
188 return nil, err
189 }
190 out = append(out, r)
191 }
192 return out, rows.Err()
193}
194
195// RepoCounts is a repo row with its issue and patch counts. A delete also
196// cascades to releases and CI runs, which are not counted here.
197type RepoCounts struct {
198 ID int64
199 Name string
200 Issues int
201 Patches int
202}
203
204// AllRepoCounts lists every repo with its issue and patch counts.
205func (d *DB) AllRepoCounts(ctx context.Context) ([]RepoCounts, error) {
206 rows, err := d.QueryContext(ctx, `
207 SELECT r.id, r.name,
208 (SELECT COUNT(*) FROM issues WHERE repo_id = r.id),
209 (SELECT COUNT(*) FROM patches WHERE repo_id = r.id)
210 FROM repositories r ORDER BY r.name`)
211 if err != nil {
212 return nil, err
213 }
214 defer rows.Close()
215 var out []RepoCounts
216 for rows.Next() {
217 var r RepoCounts
218 if err := rows.Scan(&r.ID, &r.Name, &r.Issues, &r.Patches); err != nil {
219 return nil, err
220 }
221 out = append(out, r)
222 }
223 return out, rows.Err()
224}
225