Commit d5176c7dfc

d5176c7dfc50b6a7eb62b6dea27e1797429bc168

parent: 04879dbcf6

Verified · cmc

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

saved queries: cap saved at 50 and pinned at 10, readability per repository, repo_id/created_at indexes, refuse is:merged assignee:

Ref #292

Layout: unified · split

internal/control/query.go +35 −2
@@ -70,6 +70,13 @@ const queryDefaultLimit = 50
7070// dashboard carries.
7171const dashboardQueryItems = 5
7272
73// Every pinned query is run on each dashboard read, so an account keeps
74// a bounded number of each.
75const (
76 maxSavedQueries = 50
77 maxPinnedQueries = 10
78)
79
7380var queryNamePat = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)
7481
7582// SavedQueryOut is one saved query. Count is filled by query show.
@@ -289,6 +296,17 @@ func runQuerySave(c *Ctx, args []string) int {
289296 if err != nil {
290297 return c.fail(protocol.ExitUsage, "%v", err)
291298 }
299 if _, err := c.Store.SavedQueryByName(c.User.ID, name); errors.Is(err, store.ErrNotFound) {
300 saved, _, err := c.Store.CountSavedQueries(c.User.ID)
301 if err != nil {
302 return c.fail(protocol.ExitFailure, "%v", err)
303 }
304 if saved >= maxSavedQueries {
305 return c.fail(protocol.ExitUsage, "saved query limit reached (%d); remove one first", maxSavedQueries)
306 }
307 } else if err != nil {
308 return c.fail(protocol.ExitFailure, "%v", err)
309 }
292310 err = c.Store.SaveQuery(c.User.ID, name, q.String(), fl.Has("--force"))
293311 if errors.Is(err, store.ErrExists) {
294312 return c.fail(protocol.ExitFailure, "you already have a query named %s; pass --force to replace it", name)
@@ -390,17 +408,32 @@ func runQueryPin(c *Ctx, args []string, pinned bool) int {
390408 if len(args) != 1 {
391409 return c.usage()
392410 }
393 err := c.Store.PinSavedQuery(c.User.ID, args[0], pinned)
411 sq, err := c.Store.SavedQueryByName(c.User.ID, args[0])
394412 if errors.Is(err, store.ErrNotFound) {
395413 return c.fail(protocol.ExitNotFound, "no saved query %q; query list shows yours", args[0])
396414 }
397415 if err != nil {
398416 return c.fail(protocol.ExitFailure, "%v", err)
399417 }
400 sq, err := c.Store.SavedQueryByName(c.User.ID, args[0])
418 if pinned && !sq.Pinned {
419 _, n, err := c.Store.CountSavedQueries(c.User.ID)
420 if err != nil {
421 return c.fail(protocol.ExitFailure, "%v", err)
422 }
423 if n >= maxPinnedQueries {
424 return c.fail(protocol.ExitUsage, "pinned query limit reached (%d); unpin one first", maxPinnedQueries)
425 }
426 }
427 err = c.Store.PinSavedQuery(c.User.ID, args[0], pinned)
428 if errors.Is(err, store.ErrNotFound) {
429 return c.fail(protocol.ExitNotFound, "no saved query %q; query list shows yours", args[0])
430 }
401431 if err != nil {
402432 return c.fail(protocol.ExitFailure, "%v", err)
403433 }
434 if sq, err = c.Store.SavedQueryByName(c.User.ID, args[0]); err != nil {
435 return c.fail(protocol.ExitFailure, "%v", err)
436 }
404437 return c.emit(savedQueryOut(sq), func(w io.Writer) {
405438 verb := "unpinned"
406439 if pinned {
internal/control/query_test.go +37 −1
@@ -3,6 +3,7 @@ package control
33import (
44 "bytes"
55 "encoding/json"
6 "fmt"
67 "strings"
78 "testing"
89
@@ -41,7 +42,7 @@ func newQueryEnv(t *testing.T) queryEnv {
4142// the envelope's data and stderr.
4243func (e queryEnv) run(user string, argv ...string) (int, json.RawMessage, string) {
4344 var out, errOut bytes.Buffer
44 c := &Ctx{User: e.users[user], Scope: "full", Store: e.st, Cfg: config.Config{},
45 c := &Ctx{User: e.users[user], Scope: "full", Store: e.st, Cfg: config.Config{Limits: config.Limits{WriteRate: -1}},
4546 Stdin: strings.NewReader(""), Stdout: &out, Stderr: &errOut}
4647 code := Dispatch(c, append(argv, "--json"))
4748 var env struct {
@@ -220,3 +221,38 @@ func TestQueryCommands(t *testing.T) {
220221 t.Errorf("bob lost his query when alice removed hers")
221222 }
222223}
224
225// An account keeps at most maxSavedQueries, maxPinnedQueries of them
226// pinned; replacing or re-pinning one it has is not a new one.
227func TestQueryCaps(t *testing.T) {
228 e := newQueryEnv(t)
229 for i := 0; i < maxSavedQueries; i++ {
230 if code, _, msg := e.run("alice", "query", "save", fmt.Sprintf("q%d", i), "is:open"); code != 0 {
231 t.Fatalf("save %d: exit %d %s", i, code, msg)
232 }
233 }
234 if code, _, msg := e.run("alice", "query", "save", "one-more", "is:open"); code != protocol.ExitUsage || !strings.Contains(msg, "limit") {
235 t.Fatalf("save past the cap: exit %d %s", code, msg)
236 }
237 if code, _, msg := e.run("alice", "query", "save", "q0", "is:closed", "--force"); code != 0 {
238 t.Fatalf("replace at the cap: exit %d %s", code, msg)
239 }
240 if code, _, _ := e.run("bob", "query", "save", "mine", "is:open"); code != 0 {
241 t.Fatal("the cap is per account")
242 }
243 for i := 0; i < maxPinnedQueries; i++ {
244 if code, _, msg := e.run("alice", "query", "pin", fmt.Sprintf("q%d", i)); code != 0 {
245 t.Fatalf("pin %d: exit %d %s", i, code, msg)
246 }
247 }
248 if code, _, msg := e.run("alice", "query", "pin", "q0"); code != 0 {
249 t.Fatalf("re-pin at the cap: exit %d %s", code, msg)
250 }
251 if code, _, msg := e.run("alice", "query", "pin", fmt.Sprintf("q%d", maxPinnedQueries)); code != protocol.ExitUsage || !strings.Contains(msg, "limit") {
252 t.Fatalf("pin past the cap: exit %d %s", code, msg)
253 }
254 e.run("alice", "query", "unpin", "q0")
255 if code, _, msg := e.run("alice", "query", "pin", fmt.Sprintf("q%d", maxPinnedQueries)); code != 0 {
256 t.Fatalf("pin after an unpin: exit %d %s", code, msg)
257 }
258}
internal/control/querylang.go +6 −1
@@ -200,12 +200,17 @@ func (q *ItemQuery) term(tok string, quoted bool) error {
200200 if q.Kind == "mr" {
201201 return bad("merge requests have no assignees")
202202 }
203 return user(&q.Assignee, "assignee")
203 if err := user(&q.Assignee, "assignee"); err != nil {
204 return err
205 }
204206 case "author":
205207 return user(&q.Author, "author")
206208 default:
207209 return bad("unknown qualifier; the qualifiers are repo:, owner:, is:, label:, no:, milestone:, assignee: and author:")
208210 }
211 if issues, mrs := q.Selects(); !issues && !mrs {
212 return bad("is:merged and assignee: never both match: only merge requests merge, and they have no assignees")
213 }
209214 return nil
210215}
211216
internal/control/querylang_test.go +2
@@ -59,6 +59,8 @@ func TestParseItemQueryRefuses(t *testing.T) {
5959 {"is:mr assignee:cmc", "assignee:cmc"},
6060 {"assignee:cmc is:mr", "is:mr"},
6161 {"assignee:cmc assignee:bob", "assignee:bob"},
62 {"is:merged assignee:cmc", "assignee:cmc"},
63 {"assignee:@me is:merged", "is:merged"},
6264 {"author:Not_A_User", "author:Not_A_User"},
6365 {"repo:krz", "repo:krz"},
6466 {"repo:*/gitbay", "repo:*/gitbay"},
internal/store/migrations/0071_saved_queries.down.sql +2
@@ -1 +1,3 @@
1DROP INDEX merge_requests_repo_created;
2DROP INDEX issues_repo_created;
13DROP TABLE saved_queries;
internal/store/migrations/0071_saved_queries.up.sql +4
@@ -11,3 +11,7 @@ CREATE TABLE saved_queries (
1111 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
1212 UNIQUE (user_id, name)
1313);
14
15-- A query reads each table by the repositories it may see, newest first.
16CREATE INDEX issues_repo_created ON issues(repo_id, created_at);
17CREATE INDEX merge_requests_repo_created ON merge_requests(repo_id, created_at);
internal/store/savedqueries.go +35 −16
@@ -68,6 +68,14 @@ func (s *Store) SavedQueries(userID int64, pinnedOnly bool) ([]SavedQuery, error
6868 return out, rows.Err()
6969}
7070
71// CountSavedQueries is how many queries the user has saved, and how many
72// of them are pinned.
73func (s *Store) CountSavedQueries(userID int64) (saved, pinned int, err error) {
74 err = s.DB.QueryRow("SELECT COUNT(*), COALESCE(SUM(pinned), 0) FROM saved_queries WHERE user_id = ?", userID).
75 Scan(&saved, &pinned)
76 return
77}
78
7179func (s *Store) RemoveSavedQuery(userID int64, name string) error {
7280 res, err := s.DB.Exec("DELETE FROM saved_queries WHERE user_id = ? AND name = ?", userID, name)
7381 if err != nil {
@@ -113,8 +121,8 @@ type ItemFilter struct {
113121}
114122
115123// 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.
124// first by creation, merge requests before issues at the same instant,
125// then by id, highest first.
118126type ItemCursor struct {
119127 CreatedAt string
120128 Kind int // 0 issue, 1 merge request
@@ -154,18 +162,12 @@ func (n *numbered) add(v any) string {
154162 return fmt.Sprintf("?%d", len(n.args))
155163}
156164
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)
165// itemRepos is the subquery naming the repositories a query reads: those
166// the user may read (visibleCond, public or reached) within f's scopes.
167// Deciding readability per repository rather than per row lets each
168// table be read through its repo_id index. userID is ?1 in a.
169func itemRepos(f ItemFilter, a *numbered) string {
170 where := []string{visibleCond}
169171 if len(f.Scopes) > 0 {
170172 var scopes []string
171173 for _, sc := range f.Scopes {
@@ -177,6 +179,22 @@ func itemBranch(kind string, f ItemFilter, a *numbered, after *ItemCursor) strin
177179 }
178180 where = append(where, "("+strings.Join(scopes, " OR ")+")")
179181 }
182 return `SELECT r.id FROM repos r
183 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
184 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
185 WHERE ` + strings.Join(where, " AND ")
186}
187
188// itemBranch is one table's half of the query: every row of it in one of
189// the repositories repos names that f admits.
190func itemBranch(kind string, f ItemFilter, a *numbered, repos string, after *ItemCursor) string {
191 table, kord, labels, draft := "issues", "0", "issue_labels il", "0"
192 onItem := "il.issue_id = x.id"
193 if kind == "mr" {
194 table, kord, labels, draft = "merge_requests", "1", "mr_labels il", "x.draft"
195 onItem = "il.mr_id = x.id"
196 }
197 where := []string{"x.repo_id IN (" + repos + ")"}
180198 switch {
181199 case f.State == "":
182200 case f.State == "open" && kind == "mr":
@@ -229,12 +247,13 @@ func itemBranch(kind string, f ItemFilter, a *numbered, after *ItemCursor) strin
229247func itemUnion(userID int64, f ItemFilter, after *ItemCursor) (string, []any) {
230248 a := &numbered{}
231249 a.add(userID)
250 repos := itemRepos(f, a)
232251 var parts []string
233252 if f.Issues {
234 parts = append(parts, itemBranch("issue", f, a, after))
253 parts = append(parts, itemBranch("issue", f, a, repos, after))
235254 }
236255 if f.MRs {
237 parts = append(parts, itemBranch("mr", f, a, after))
256 parts = append(parts, itemBranch("mr", f, a, repos, after))
238257 }
239258 return strings.Join(parts, "\nUNION ALL\n"), a.args
240259}