Commit efe33aeb0f
Verified · cmc
Layout: unified · split
internal/store/migrations/0071_saved_queries.down.sql added +1
| @@ -0,0 +1 @@ | |||
| 1 | DROP TABLE saved_queries; | ||
internal/store/migrations/0071_saved_queries.up.sql added +13
| @@ -0,0 +1,13 @@ | |||
| 1 | -- A user's named issue and merge request queries (#292). query is the | ||
| 2 | -- canonical text of the query; it is parsed again on every run, and @me | ||
| 3 | -- resolves to whoever runs it. A pinned query shows on the dashboard. | ||
| 4 | CREATE TABLE saved_queries ( | ||
| 5 | id INTEGER PRIMARY KEY, | ||
| 6 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | ||
| 7 | name TEXT NOT NULL, | ||
| 8 | query TEXT NOT NULL, | ||
| 9 | pinned INTEGER NOT NULL DEFAULT 0, | ||
| 10 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | ||
| 11 | updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | ||
| 12 | UNIQUE (user_id, name) | ||
| 13 | ); | ||
internal/store/savedqueries.go added +282
| @@ -0,0 +1,282 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "database/sql" | ||
| 5 | "errors" | ||
| 6 | "fmt" | ||
| 7 | "strings" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // SavedQuery is a user's named issue and merge request query (#292). | ||
| 11 | type SavedQuery struct { | ||
| 12 | Name string | ||
| 13 | Query string | ||
| 14 | Pinned bool | ||
| 15 | CreatedAt string | ||
| 16 | UpdatedAt string | ||
| 17 | } | ||
| 18 | |||
| 19 | // SaveQuery stores a query under name. replace allows overwriting one the | ||
| 20 | // user already has; without it an existing name is ErrExists. Replacing | ||
| 21 | // keeps the pin. | ||
| 22 | func (s *Store) SaveQuery(userID int64, name, query string, replace bool) error { | ||
| 23 | if replace { | ||
| 24 | _, err := s.DB.Exec(` | ||
| 25 | INSERT INTO saved_queries (user_id, name, query) VALUES (?, ?, ?) | ||
| 26 | ON CONFLICT (user_id, name) DO UPDATE SET query = excluded.query, | ||
| 27 | updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, userID, name, query) | ||
| 28 | return err | ||
| 29 | } | ||
| 30 | _, err := s.DB.Exec("INSERT INTO saved_queries (user_id, name, query) VALUES (?, ?, ?)", userID, name, query) | ||
| 31 | if isUniqueErr(err) { | ||
| 32 | return ErrExists | ||
| 33 | } | ||
| 34 | return err | ||
| 35 | } | ||
| 36 | |||
| 37 | func (s *Store) SavedQueryByName(userID int64, name string) (SavedQuery, error) { | ||
| 38 | var q SavedQuery | ||
| 39 | err := s.DB.QueryRow(`SELECT name, query, pinned, created_at, updated_at | ||
| 40 | FROM saved_queries WHERE user_id = ? AND name = ?`, userID, name). | ||
| 41 | Scan(&q.Name, &q.Query, &q.Pinned, &q.CreatedAt, &q.UpdatedAt) | ||
| 42 | if errors.Is(err, sql.ErrNoRows) { | ||
| 43 | return q, ErrNotFound | ||
| 44 | } | ||
| 45 | return q, err | ||
| 46 | } | ||
| 47 | |||
| 48 | // SavedQueries returns the user's queries by name; pinnedOnly narrows to | ||
| 49 | // the ones the dashboard shows. | ||
| 50 | func (s *Store) SavedQueries(userID int64, pinnedOnly bool) ([]SavedQuery, error) { | ||
| 51 | q := "SELECT name, query, pinned, created_at, updated_at FROM saved_queries WHERE user_id = ?" | ||
| 52 | if pinnedOnly { | ||
| 53 | q += " AND pinned = 1" | ||
| 54 | } | ||
| 55 | rows, err := s.DB.Query(q+" ORDER BY name", userID) | ||
| 56 | if err != nil { | ||
| 57 | return nil, err | ||
| 58 | } | ||
| 59 | defer rows.Close() | ||
| 60 | var out []SavedQuery | ||
| 61 | for rows.Next() { | ||
| 62 | var sq SavedQuery | ||
| 63 | if err := rows.Scan(&sq.Name, &sq.Query, &sq.Pinned, &sq.CreatedAt, &sq.UpdatedAt); err != nil { | ||
| 64 | return nil, err | ||
| 65 | } | ||
| 66 | out = append(out, sq) | ||
| 67 | } | ||
| 68 | return out, rows.Err() | ||
| 69 | } | ||
| 70 | |||
| 71 | func (s *Store) RemoveSavedQuery(userID int64, name string) error { | ||
| 72 | res, err := s.DB.Exec("DELETE FROM saved_queries WHERE user_id = ? AND name = ?", userID, name) | ||
| 73 | if err != nil { | ||
| 74 | return err | ||
| 75 | } | ||
| 76 | if n, _ := res.RowsAffected(); n == 0 { | ||
| 77 | return ErrNotFound | ||
| 78 | } | ||
| 79 | return nil | ||
| 80 | } | ||
| 81 | |||
| 82 | func (s *Store) PinSavedQuery(userID int64, name string, pinned bool) error { | ||
| 83 | res, err := s.DB.Exec("UPDATE saved_queries SET pinned = ? WHERE user_id = ? AND name = ?", pinned, userID, name) | ||
| 84 | if err != nil { | ||
| 85 | return err | ||
| 86 | } | ||
| 87 | if n, _ := res.RowsAffected(); n == 0 { | ||
| 88 | return ErrNotFound | ||
| 89 | } | ||
| 90 | return nil | ||
| 91 | } | ||
| 92 | |||
| 93 | // RepoScope is one repo:/owner: term. Name is a GLOB pattern; "" means | ||
| 94 | // every repository of Owner. | ||
| 95 | type RepoScope struct { | ||
| 96 | Owner string | ||
| 97 | Name string | ||
| 98 | } | ||
| 99 | |||
| 100 | // ItemFilter is a parsed query, ready to run. Usernames are resolved | ||
| 101 | // (@me is the caller's name by now). Empty fields match anything. | ||
| 102 | type ItemFilter struct { | ||
| 103 | Scopes []RepoScope // any of them; none means every repository | ||
| 104 | Issues, MRs bool // which tables to read | ||
| 105 | State string // open, closed, merged | ||
| 106 | Labels []string // all of them | ||
| 107 | NoLabel bool | ||
| 108 | Milestone string | ||
| 109 | NoMilestone bool | ||
| 110 | Assignee string // issues only | ||
| 111 | Author string | ||
| 112 | Text string // full-text over title and body | ||
| 113 | } | ||
| 114 | |||
| 115 | // ItemCursor is the sort key of the last row of a page: rows are newest | ||
| 116 | // first by creation, issues before merge requests at the same instant, | ||
| 117 | // then by id. | ||
| 118 | type ItemCursor struct { | ||
| 119 | CreatedAt string | ||
| 120 | Kind int // 0 issue, 1 merge request | ||
| 121 | ID int64 | ||
| 122 | } | ||
| 123 | |||
| 124 | // Item is one row of a cross-repository query. | ||
| 125 | type Item struct { | ||
| 126 | Kind string // issue or mr | ||
| 127 | ID int64 | ||
| 128 | RepoPath string | ||
| 129 | Number int64 | ||
| 130 | Title string | ||
| 131 | Author string | ||
| 132 | State string | ||
| 133 | Draft bool | ||
| 134 | Milestone string | ||
| 135 | CreatedAt string | ||
| 136 | UpdatedAt string | ||
| 137 | } | ||
| 138 | |||
| 139 | // Cursor is the key a page ending on this row continues from. | ||
| 140 | func (it Item) Cursor() ItemCursor { | ||
| 141 | k := 0 | ||
| 142 | if it.Kind == "mr" { | ||
| 143 | k = 1 | ||
| 144 | } | ||
| 145 | return ItemCursor{it.CreatedAt, k, it.ID} | ||
| 146 | } | ||
| 147 | |||
| 148 | // numbered collects arguments for SQL written with ?N placeholders, which | ||
| 149 | // visibleCond needs: it names the user as ?1 several times. | ||
| 150 | type numbered struct{ args []any } | ||
| 151 | |||
| 152 | func (n *numbered) add(v any) string { | ||
| 153 | n.args = append(n.args, v) | ||
| 154 | return fmt.Sprintf("?%d", len(n.args)) | ||
| 155 | } | ||
| 156 | |||
| 157 | // itemBranch is one table's half of the query: every row of it on a | ||
| 158 | // repository the user may read (visibleCond, public or reached) that f | ||
| 159 | // admits. userID is ?1 in a. | ||
| 160 | func itemBranch(kind string, f ItemFilter, a *numbered, after *ItemCursor) string { | ||
| 161 | table, kord, labels, draft := "issues", "0", "issue_labels il", "0" | ||
| 162 | onItem := "il.issue_id = x.id" | ||
| 163 | if kind == "mr" { | ||
| 164 | table, kord, labels, draft = "merge_requests", "1", "mr_labels il", "x.draft" | ||
| 165 | onItem = "il.mr_id = x.id" | ||
| 166 | } | ||
| 167 | var where []string | ||
| 168 | where = append(where, visibleCond) | ||
| 169 | if len(f.Scopes) > 0 { | ||
| 170 | var scopes []string | ||
| 171 | for _, sc := range f.Scopes { | ||
| 172 | cond := "COALESCE(u.username, o.name) = " + a.add(sc.Owner) | ||
| 173 | if sc.Name != "" { | ||
| 174 | cond += " AND r.name GLOB " + a.add(sc.Name) | ||
| 175 | } | ||
| 176 | scopes = append(scopes, "("+cond+")") | ||
| 177 | } | ||
| 178 | where = append(where, "("+strings.Join(scopes, " OR ")+")") | ||
| 179 | } | ||
| 180 | switch { | ||
| 181 | case f.State == "": | ||
| 182 | case f.State == "open" && kind == "mr": | ||
| 183 | where = append(where, "x.state IN ('open', 'source_gone')") | ||
| 184 | default: | ||
| 185 | where = append(where, "x.state = "+a.add(f.State)) | ||
| 186 | } | ||
| 187 | for _, l := range f.Labels { | ||
| 188 | where = append(where, "EXISTS (SELECT 1 FROM "+labels+" JOIN labels l ON l.id = il.label_id WHERE "+onItem+" AND l.name = "+a.add(l)+")") | ||
| 189 | } | ||
| 190 | if f.NoLabel { | ||
| 191 | where = append(where, "NOT EXISTS (SELECT 1 FROM "+labels+" WHERE "+onItem+")") | ||
| 192 | } | ||
| 193 | if f.NoMilestone { | ||
| 194 | where = append(where, "x.milestone_id IS NULL") | ||
| 195 | } else if f.Milestone != "" { | ||
| 196 | where = append(where, "ms.title = "+a.add(f.Milestone)) | ||
| 197 | } | ||
| 198 | if f.Assignee != "" { | ||
| 199 | where = append(where, `EXISTS (SELECT 1 FROM issue_assignees ia JOIN users iu ON iu.id = ia.user_id | ||
| 200 | WHERE ia.issue_id = x.id AND iu.username = `+a.add(f.Assignee)+")") | ||
| 201 | } | ||
| 202 | if f.Author != "" { | ||
| 203 | where = append(where, "au.username = "+a.add(f.Author)) | ||
| 204 | } | ||
| 205 | if f.Text != "" { | ||
| 206 | index := "issue_fts" | ||
| 207 | if kind == "mr" { | ||
| 208 | index = "mr_fts" | ||
| 209 | } | ||
| 210 | where = append(where, "x.id IN (SELECT rowid FROM "+index+" WHERE "+index+" MATCH "+a.add(FTSQuery(f.Text))+")") | ||
| 211 | } | ||
| 212 | if after != nil { | ||
| 213 | where = append(where, "(x.created_at, "+kord+", x.id) < ("+a.add(after.CreatedAt)+", "+a.add(after.Kind)+", "+a.add(after.ID)+")") | ||
| 214 | } | ||
| 215 | return `SELECT '` + kind + `' AS kind, ` + kord + ` AS kord, x.id AS id, | ||
| 216 | COALESCE(u.username, o.name) || '/' || r.name, x.number, x.title, au.username, | ||
| 217 | x.state, ` + draft + `, COALESCE(ms.title, ''), x.created_at AS created_at, x.updated_at | ||
| 218 | FROM ` + table + ` x | ||
| 219 | JOIN repos r ON r.id = x.repo_id | ||
| 220 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | ||
| 221 | LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id | ||
| 222 | JOIN users au ON au.id = x.author_id | ||
| 223 | LEFT JOIN milestones ms ON ms.id = x.milestone_id | ||
| 224 | WHERE ` + strings.Join(where, "\n\t AND ") | ||
| 225 | } | ||
| 226 | |||
| 227 | // itemUnion is the query over both tables f reads, or "" when it reads | ||
| 228 | // neither. | ||
| 229 | func itemUnion(userID int64, f ItemFilter, after *ItemCursor) (string, []any) { | ||
| 230 | a := &numbered{} | ||
| 231 | a.add(userID) | ||
| 232 | var parts []string | ||
| 233 | if f.Issues { | ||
| 234 | parts = append(parts, itemBranch("issue", f, a, after)) | ||
| 235 | } | ||
| 236 | if f.MRs { | ||
| 237 | parts = append(parts, itemBranch("mr", f, a, after)) | ||
| 238 | } | ||
| 239 | return strings.Join(parts, "\nUNION ALL\n"), a.args | ||
| 240 | } | ||
| 241 | |||
| 242 | // QueryItems runs f for the user across every repository they may read, | ||
| 243 | // newest first. after continues from a page's last row; limit 0 means | ||
| 244 | // every row. | ||
| 245 | func (s *Store) QueryItems(userID int64, f ItemFilter, after *ItemCursor, limit int) ([]Item, error) { | ||
| 246 | q, args := itemUnion(userID, f, after) | ||
| 247 | if q == "" { | ||
| 248 | return nil, nil | ||
| 249 | } | ||
| 250 | q = "SELECT * FROM (" + q + ") ORDER BY created_at DESC, kord DESC, id DESC" | ||
| 251 | if limit > 0 { | ||
| 252 | q += fmt.Sprintf(" LIMIT %d", limit) | ||
| 253 | } | ||
| 254 | rows, err := s.DB.Query(q, args...) | ||
| 255 | if err != nil { | ||
| 256 | return nil, err | ||
| 257 | } | ||
| 258 | defer rows.Close() | ||
| 259 | var out []Item | ||
| 260 | for rows.Next() { | ||
| 261 | var it Item | ||
| 262 | var kord int | ||
| 263 | if err := rows.Scan(&it.Kind, &kord, &it.ID, &it.RepoPath, &it.Number, &it.Title, &it.Author, | ||
| 264 | &it.State, &it.Draft, &it.Milestone, &it.CreatedAt, &it.UpdatedAt); err != nil { | ||
| 265 | return nil, err | ||
| 266 | } | ||
| 267 | out = append(out, it) | ||
| 268 | } | ||
| 269 | return out, rows.Err() | ||
| 270 | } | ||
| 271 | |||
| 272 | // CountItems is how many rows QueryItems would return without a limit, | ||
| 273 | // over the same readable repositories. | ||
| 274 | func (s *Store) CountItems(userID int64, f ItemFilter) (int, error) { | ||
| 275 | q, args := itemUnion(userID, f, nil) | ||
| 276 | if q == "" { | ||
| 277 | return 0, nil | ||
| 278 | } | ||
| 279 | var n int | ||
| 280 | err := s.DB.QueryRow("SELECT COUNT(*) FROM ("+q+")", args...).Scan(&n) | ||
| 281 | return n, err | ||
| 282 | } | ||
internal/store/savedqueries_test.go added +283
| @@ -0,0 +1,283 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "errors" | ||
| 5 | "fmt" | ||
| 6 | "testing" | ||
| 7 | ) | ||
| 8 | |||
| 9 | type queryFixture struct { | ||
| 10 | s *Store | ||
| 11 | alice, bob, carol int64 | ||
| 12 | pub, priv, bobPriv Repo | ||
| 13 | } | ||
| 14 | |||
| 15 | // newQueryFixture: alice owns a public and a private repository, bob a | ||
| 16 | // private one; carol owns nothing and is granted nothing. | ||
| 17 | func newQueryFixture(t *testing.T) queryFixture { | ||
| 18 | t.Helper() | ||
| 19 | s := open(t) | ||
| 20 | if err := s.MigrateUp(); err != nil { | ||
| 21 | t.Fatal(err) | ||
| 22 | } | ||
| 23 | var f queryFixture | ||
| 24 | f.s = s | ||
| 25 | must := func(id int64, err error) int64 { | ||
| 26 | t.Helper() | ||
| 27 | if err != nil { | ||
| 28 | t.Fatal(err) | ||
| 29 | } | ||
| 30 | return id | ||
| 31 | } | ||
| 32 | f.alice = must(s.CreateUser("alice", false)) | ||
| 33 | f.bob = must(s.CreateUser("bob", false)) | ||
| 34 | f.carol = must(s.CreateUser("carol", false)) | ||
| 35 | repo := func(owner int64, name, vis string) Repo { | ||
| 36 | r, err := s.RepoByID(must(s.CreateRepo("user", owner, name, vis))) | ||
| 37 | if err != nil { | ||
| 38 | t.Fatal(err) | ||
| 39 | } | ||
| 40 | return r | ||
| 41 | } | ||
| 42 | f.pub = repo(f.alice, "pub", "public") | ||
| 43 | f.priv = repo(f.alice, "priv", "private") | ||
| 44 | f.bobPriv = repo(f.bob, "secret", "private") | ||
| 45 | return f | ||
| 46 | } | ||
| 47 | |||
| 48 | func (f queryFixture) issue(t *testing.T, r Repo, author int64, title string) Issue { | ||
| 49 | t.Helper() | ||
| 50 | n, err := f.s.CreateIssue(r.ID, author, title, "", "md") | ||
| 51 | if err != nil { | ||
| 52 | t.Fatal(err) | ||
| 53 | } | ||
| 54 | i, err := f.s.IssueByNumber(r.ID, n) | ||
| 55 | if err != nil { | ||
| 56 | t.Fatal(err) | ||
| 57 | } | ||
| 58 | return i | ||
| 59 | } | ||
| 60 | |||
| 61 | func (f queryFixture) mr(t *testing.T, r Repo, author int64, title string) MR { | ||
| 62 | t.Helper() | ||
| 63 | n, err := f.s.CreateMR(r.ID, author, r.ID, "topic", "main", title, "", "", "md", false) | ||
| 64 | if err != nil { | ||
| 65 | t.Fatal(err) | ||
| 66 | } | ||
| 67 | m, err := f.s.MRByNumber(r.ID, n) | ||
| 68 | if err != nil { | ||
| 69 | t.Fatal(err) | ||
| 70 | } | ||
| 71 | return m | ||
| 72 | } | ||
| 73 | |||
| 74 | func titles(items []Item) []string { | ||
| 75 | var out []string | ||
| 76 | for _, it := range items { | ||
| 77 | out = append(out, it.Title) | ||
| 78 | } | ||
| 79 | return out | ||
| 80 | } | ||
| 81 | |||
| 82 | // A query reaches only what the caller may read: someone else's private | ||
| 83 | // repository is absent from the rows and from the count, even when the | ||
| 84 | // query names it. | ||
| 85 | func TestQueryItemsSkipsUnreadableRepositories(t *testing.T) { | ||
| 86 | f := newQueryFixture(t) | ||
| 87 | f.issue(t, f.pub, f.alice, "public bug") | ||
| 88 | f.issue(t, f.priv, f.alice, "alice private bug") | ||
| 89 | f.issue(t, f.bobPriv, f.bob, "bob private bug") | ||
| 90 | f.mr(t, f.bobPriv, f.bob, "bob private mr") | ||
| 91 | f.mr(t, f.pub, f.alice, "public mr") | ||
| 92 | |||
| 93 | all := ItemFilter{Issues: true, MRs: true} | ||
| 94 | for _, tc := range []struct { | ||
| 95 | name string | ||
| 96 | user int64 | ||
| 97 | f ItemFilter | ||
| 98 | want int | ||
| 99 | }{ | ||
| 100 | {"carol, everything", f.carol, all, 2}, | ||
| 101 | {"alice, everything", f.alice, all, 3}, | ||
| 102 | {"bob, everything", f.bob, all, 4}, | ||
| 103 | {"carol naming bob's repository", f.carol, ItemFilter{Issues: true, MRs: true, Scopes: []RepoScope{{Owner: "bob", Name: "secret"}}}, 0}, | ||
| 104 | {"carol naming bob", f.carol, ItemFilter{Issues: true, MRs: true, Scopes: []RepoScope{{Owner: "bob"}}}, 0}, | ||
| 105 | {"carol globbing alice", f.carol, ItemFilter{Issues: true, MRs: true, Scopes: []RepoScope{{Owner: "alice", Name: "p*"}}}, 2}, | ||
| 106 | {"alice globbing herself", f.alice, ItemFilter{Issues: true, MRs: true, Scopes: []RepoScope{{Owner: "alice", Name: "p*"}}}, 3}, | ||
| 107 | {"carol by bob's text", f.carol, ItemFilter{Issues: true, MRs: true, Text: "bob"}, 0}, | ||
| 108 | } { | ||
| 109 | items, err := f.s.QueryItems(tc.user, tc.f, nil, 0) | ||
| 110 | if err != nil { | ||
| 111 | t.Fatal(err) | ||
| 112 | } | ||
| 113 | n, err := f.s.CountItems(tc.user, tc.f) | ||
| 114 | if err != nil { | ||
| 115 | t.Fatal(err) | ||
| 116 | } | ||
| 117 | if len(items) != tc.want || n != tc.want { | ||
| 118 | t.Errorf("%s: %d rows %q, count %d; want %d", tc.name, len(items), titles(items), n, tc.want) | ||
| 119 | } | ||
| 120 | } | ||
| 121 | |||
| 122 | // A grant opens bob's repository to carol. | ||
| 123 | if err := f.s.GrantAccess(f.bobPriv.ID, f.carol, "read"); err != nil { | ||
| 124 | t.Fatal(err) | ||
| 125 | } | ||
| 126 | if n, _ := f.s.CountItems(f.carol, all); n != 4 { | ||
| 127 | t.Errorf("carol with a grant counts %d, want 4", n) | ||
| 128 | } | ||
| 129 | } | ||
| 130 | |||
| 131 | func TestQueryItemsFilters(t *testing.T) { | ||
| 132 | f := newQueryFixture(t) | ||
| 133 | bug := f.issue(t, f.pub, f.alice, "labelled bug") | ||
| 134 | if err := f.s.SetIssueLabel(f.pub, bug.ID, "bug", true); err != nil { | ||
| 135 | t.Fatal(err) | ||
| 136 | } | ||
| 137 | if err := f.s.SetIssueLabel(f.pub, bug.ID, "ui", true); err != nil { | ||
| 138 | t.Fatal(err) | ||
| 139 | } | ||
| 140 | both := f.issue(t, f.pub, f.bob, "assigned in milestone") | ||
| 141 | ms, err := f.s.CreateMilestone(f.pub, "v2", "", "") | ||
| 142 | if err != nil { | ||
| 143 | t.Fatal(err) | ||
| 144 | } | ||
| 145 | if err := f.s.SetIssueMilestone(both.ID, ms); err != nil { | ||
| 146 | t.Fatal(err) | ||
| 147 | } | ||
| 148 | if err := f.s.SetIssueAssignee(both.ID, f.carol, true); err != nil { | ||
| 149 | t.Fatal(err) | ||
| 150 | } | ||
| 151 | closed := f.issue(t, f.pub, f.alice, "closed one") | ||
| 152 | if err := f.s.SetIssueState(closed.ID, "closed"); err != nil { | ||
| 153 | t.Fatal(err) | ||
| 154 | } | ||
| 155 | merged := f.mr(t, f.pub, f.alice, "merged mr") | ||
| 156 | if err := f.s.MarkMerged(merged.ID, "", f.alice, ""); err != nil { | ||
| 157 | t.Fatal(err) | ||
| 158 | } | ||
| 159 | f.mr(t, f.pub, f.bob, "open mr") | ||
| 160 | |||
| 161 | for _, tc := range []struct { | ||
| 162 | f ItemFilter | ||
| 163 | want string | ||
| 164 | }{ | ||
| 165 | {ItemFilter{Issues: true, MRs: true, State: "open"}, "[open mr assigned in milestone labelled bug]"}, | ||
| 166 | {ItemFilter{Issues: true, MRs: true, State: "merged"}, "[merged mr]"}, | ||
| 167 | {ItemFilter{Issues: true, State: "closed"}, "[closed one]"}, | ||
| 168 | {ItemFilter{Issues: true, MRs: true, Labels: []string{"bug", "ui"}}, "[labelled bug]"}, | ||
| 169 | {ItemFilter{Issues: true, MRs: true, Labels: []string{"bug", "nope"}}, "[]"}, | ||
| 170 | {ItemFilter{Issues: true, NoLabel: true}, "[closed one assigned in milestone]"}, | ||
| 171 | {ItemFilter{Issues: true, Milestone: "v2"}, "[assigned in milestone]"}, | ||
| 172 | {ItemFilter{Issues: true, NoMilestone: true}, "[closed one labelled bug]"}, | ||
| 173 | {ItemFilter{Issues: true, Assignee: "carol"}, "[assigned in milestone]"}, | ||
| 174 | {ItemFilter{Issues: true, MRs: true, Author: "bob"}, "[open mr assigned in milestone]"}, | ||
| 175 | {ItemFilter{MRs: true}, "[open mr merged mr]"}, | ||
| 176 | {ItemFilter{}, "[]"}, | ||
| 177 | } { | ||
| 178 | items, err := f.s.QueryItems(f.alice, tc.f, nil, 0) | ||
| 179 | if err != nil { | ||
| 180 | t.Fatal(err) | ||
| 181 | } | ||
| 182 | if got := fmt.Sprint(titles(items)); got != tc.want && !(got == "[]" && tc.want == "[]") { | ||
| 183 | t.Errorf("%+v = %s, want %s", tc.f, got, tc.want) | ||
| 184 | } | ||
| 185 | } | ||
| 186 | } | ||
| 187 | |||
| 188 | // Paging walks every row once across repositories and both tables, even | ||
| 189 | // when rows share a creation time: the cursor carries the tie-breakers. | ||
| 190 | func TestQueryItemsPagesAcrossRepositories(t *testing.T) { | ||
| 191 | f := newQueryFixture(t) | ||
| 192 | var want []string | ||
| 193 | for i := 0; i < 5; i++ { | ||
| 194 | f.issue(t, f.pub, f.alice, fmt.Sprintf("pub issue %d", i)) | ||
| 195 | f.issue(t, f.priv, f.alice, fmt.Sprintf("priv issue %d", i)) | ||
| 196 | f.mr(t, f.pub, f.alice, fmt.Sprintf("pub mr %d", i)) | ||
| 197 | f.issue(t, f.bobPriv, f.bob, fmt.Sprintf("bob issue %d", i)) | ||
| 198 | } | ||
| 199 | // Half the rows at one instant, so ordering falls to kind and id. | ||
| 200 | if _, err := f.s.DB.Exec("UPDATE issues SET created_at = '2026-01-01T00:00:00.000Z' WHERE id % 2 = 0"); err != nil { | ||
| 201 | t.Fatal(err) | ||
| 202 | } | ||
| 203 | if _, err := f.s.DB.Exec("UPDATE merge_requests SET created_at = '2026-01-01T00:00:00.000Z' WHERE id % 2 = 1"); err != nil { | ||
| 204 | t.Fatal(err) | ||
| 205 | } | ||
| 206 | all := ItemFilter{Issues: true, MRs: true} | ||
| 207 | full, err := f.s.QueryItems(f.alice, all, nil, 0) | ||
| 208 | if err != nil { | ||
| 209 | t.Fatal(err) | ||
| 210 | } | ||
| 211 | if len(full) != 15 { | ||
| 212 | t.Fatalf("alice reads %d rows, want 15", len(full)) | ||
| 213 | } | ||
| 214 | for _, it := range full { | ||
| 215 | want = append(want, it.Kind+it.Title) | ||
| 216 | } | ||
| 217 | var got []string | ||
| 218 | var after *ItemCursor | ||
| 219 | for pages := 0; ; pages++ { | ||
| 220 | if pages > 20 { | ||
| 221 | t.Fatal("paging does not end") | ||
| 222 | } | ||
| 223 | page, err := f.s.QueryItems(f.alice, all, after, 4) | ||
| 224 | if err != nil { | ||
| 225 | t.Fatal(err) | ||
| 226 | } | ||
| 227 | for _, it := range page { | ||
| 228 | got = append(got, it.Kind+it.Title) | ||
| 229 | } | ||
| 230 | if len(page) < 4 { | ||
| 231 | break | ||
| 232 | } | ||
| 233 | c := page[len(page)-1].Cursor() | ||
| 234 | after = &c | ||
| 235 | } | ||
| 236 | if fmt.Sprint(got) != fmt.Sprint(want) { | ||
| 237 | t.Errorf("paged\n%v\nwant\n%v", got, want) | ||
| 238 | } | ||
| 239 | } | ||
| 240 | |||
| 241 | func TestSavedQueries(t *testing.T) { | ||
| 242 | f := newQueryFixture(t) | ||
| 243 | s := f.s | ||
| 244 | if err := s.SaveQuery(f.alice, "mine", "is:open", false); err != nil { | ||
| 245 | t.Fatal(err) | ||
| 246 | } | ||
| 247 | if err := s.SaveQuery(f.alice, "mine", "is:closed", false); !errors.Is(err, ErrExists) { | ||
| 248 | t.Fatalf("second save without replace: %v, want ErrExists", err) | ||
| 249 | } | ||
| 250 | if err := s.PinSavedQuery(f.alice, "mine", true); err != nil { | ||
| 251 | t.Fatal(err) | ||
| 252 | } | ||
| 253 | if err := s.SaveQuery(f.alice, "mine", "is:closed", true); err != nil { | ||
| 254 | t.Fatal(err) | ||
| 255 | } | ||
| 256 | q, err := s.SavedQueryByName(f.alice, "mine") | ||
| 257 | if err != nil || q.Query != "is:closed" || !q.Pinned { | ||
| 258 | t.Fatalf("after replace: %+v %v; want the new text, still pinned", q, err) | ||
| 259 | } | ||
| 260 | // Names are per user. | ||
| 261 | if err := s.SaveQuery(f.bob, "mine", "is:open", false); err != nil { | ||
| 262 | t.Fatal(err) | ||
| 263 | } | ||
| 264 | if _, err := s.SavedQueryByName(f.carol, "mine"); !errors.Is(err, ErrNotFound) { | ||
| 265 | t.Fatalf("carol sees alice's query: %v", err) | ||
| 266 | } | ||
| 267 | if err := s.SaveQuery(f.alice, "other", "is:open", false); err != nil { | ||
| 268 | t.Fatal(err) | ||
| 269 | } | ||
| 270 | pinned, err := s.SavedQueries(f.alice, true) | ||
| 271 | if err != nil || len(pinned) != 1 || pinned[0].Name != "mine" { | ||
| 272 | t.Fatalf("pinned = %+v %v", pinned, err) | ||
| 273 | } | ||
| 274 | if err := s.RemoveSavedQuery(f.alice, "mine"); err != nil { | ||
| 275 | t.Fatal(err) | ||
| 276 | } | ||
| 277 | if err := s.RemoveSavedQuery(f.alice, "mine"); !errors.Is(err, ErrNotFound) { | ||
| 278 | t.Fatalf("second remove: %v", err) | ||
| 279 | } | ||
| 280 | if err := s.PinSavedQuery(f.alice, "gone", true); !errors.Is(err, ErrNotFound) { | ||
| 281 | t.Fatalf("pin of a missing query: %v", err) | ||
| 282 | } | ||
| 283 | } | ||