repos.go
| 1 | package db |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "database/sql" |
| 6 | "errors" |
| 7 | "strings" |
| 8 | ) |
| 9 | |
| 10 | type 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 | |
| 25 | const 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 | |
| 28 | type rowScanner interface { |
| 29 | Scan(dest ...any) error |
| 30 | } |
| 31 | |
| 32 | func 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 | |
| 45 | func (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. |
| 52 | func (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. |
| 59 | func (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. |
| 72 | func 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. |
| 80 | func 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. |
| 87 | func visibilityClause(isAdmin bool) string { |
| 88 | if isAdmin { |
| 89 | return `1 = 1` |
| 90 | } |
| 91 | return `is_private = 0` |
| 92 | } |
| 93 | |
| 94 | func (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". |
| 104 | func (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. |
| 130 | func (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 | |
| 147 | func (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 | |
| 152 | func (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. |
| 158 | func (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 | |
| 171 | func (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 | |
| 176 | func (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. |
| 182 | type RepoRef struct { |
| 183 | ID int64 |
| 184 | Name string |
| 185 | } |
| 186 | |
| 187 | func (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. |
| 206 | type 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. |
| 214 | func (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 |