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 | // GetRepo looks a repo up by name and hides private repos from non-admins. |
| 50 | func (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. |
| 63 | func 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. |
| 71 | func 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. |
| 78 | func visibilityClause(isAdmin bool) string { |
| 79 | if isAdmin { |
| 80 | return `1 = 1` |
| 81 | } |
| 82 | return `is_private = 0` |
| 83 | } |
| 84 | |
| 85 | func (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". |
| 95 | func (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. |
| 121 | func (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 | |
| 138 | func (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 | |
| 143 | func (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. |
| 149 | func (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 | |
| 162 | func (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 | |
| 167 | func (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. |
| 173 | type RepoRef struct { |
| 174 | ID int64 |
| 175 | Name string |
| 176 | } |
| 177 | |
| 178 | func (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 | func (d *DB) DeleteRepos(ctx context.Context, ids []int64) error { |
| 196 | if len(ids) == 0 { |
| 197 | return nil |
| 198 | } |
| 199 | _, err := d.ExecContext(ctx, `DELETE FROM repositories WHERE id IN (`+placeholders(len(ids))+`)`, |
| 200 | int64Args(ids)...) |
| 201 | return err |
| 202 | } |
| 203 |