Commit efe33aeb0f

efe33aeb0f95e8c4806226e54184b3ed8e18e715

parent: 728f109ae5

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 05:30 UTC

store: saved queries and a cross-repository issue and MR query

Ref #292

Layout: unified · split

internal/store/migrations/0071_saved_queries.down.sql added +1
@@ -0,0 +1 @@
1DROP 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.
4CREATE 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 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6 "fmt"
7 "strings"
8)
9
10// SavedQuery is a user's named issue and merge request query (#292).
11type 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.
22func (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
37func (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.
50func (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
71func (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
82func (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.
95type 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.
102type 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.
118type 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.
125type 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.
140func (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.
150type numbered struct{ args []any }
151
152func (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.
160func 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.
229func 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.
245func (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.
274func (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 @@
1package store
2
3import (
4 "errors"
5 "fmt"
6 "testing"
7)
8
9type 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.
17func 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
48func (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
61func (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
74func 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.
85func 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
131func 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.
190func 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
241func 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}