Saved queries for issues and merge requests across repositories !531

merged merged by cmc on 2026-09-29 06:29 UTC · krz/gitbay:saved-queries-292 into main

29 files changed, +2280 −10

Layout: unified · split

.gitbay/wiki/Parity.org +18
@@ -309,6 +309,9 @@ now render the way the reference does. go-org is not yet on the corpus.
309309|----------------------------------+-----+-----+-----|
310310| search repositories | yes | yes | yes |
311311| search issues and merge requests | yes | yes | yes |
312| saved query save, remove, pin | yes | no | no |
313| saved query list and results | yes | yes | no |
314| pinned queries on the dashboard | yes | yes | no |
312315| browse all public repositories | yes | yes | yes |
313316| profile page | yes | yes | yes |
314317| profile sections as tabs | n/a | yes | n/a |
@@ -327,6 +330,21 @@ operator — =c++=, =AND=, a lone quote — is a word to match and never a
327330syntax error. =repo grep= remains the per-repository file-contents
328331search.
329332
333A saved query is a named query over issues and merge requests across
334every repository the caller can read (#292): =query save <name>
335<query>= (=--force= replaces), =query list=, =query show= with its
336count, =query run= for the rows, =query remove=, =query pin= and
337=unpin=. =issue list --query <name>= and =mr list --query <name>= run
338one narrowed to their kind, and =--q '<query>'= takes one written out.
339Results are newest first, always paged with ={items, next}= and fifty
340rows by default, each naming its repository. Readability is the
341search rule, decided in the SQL, so a private repository someone else
342owns is neither a row nor part of a count. =dashboard= carries each
343pinned query's count and first five rows under =queries=; the web
344shows them on the dashboard and serves =/<owner>/-/queries= and
345=/<owner>/-/queries/<name>=, which are a 404 under anyone else's name.
346Saving, removing and pinning have no web form yet.
347
330348The about text is =profile/README.{md,org,markdown}= on the default
331349branch of =<owner>/.gitbay=, resolved in that order, so the extension
332350picks the renderer rather than a stored format. =profile show= reports
.gitbay/wiki/Users.org +35
@@ -455,6 +455,41 @@ Lists narrow the same way on every surface: =issue list --label bug
455455merge request lists take the same names as query parameters, and each
456456active filter shows with a link that drops it.
457457
458A query spans repositories, and a saved one keeps it under a name:
459
460#+begin_src sh
461gitbay query save mine is:open assignee:@me
462gitbay query save triage 'repo:krz/*' is:issue is:open no:label
463gitbay query save v2 owner:krz label:bug 'label:needs review' milestone:v2
464gitbay query list / show mine / remove mine
465gitbay query run mine [--limit 20] [--cursor <c>] # issues and MRs
466gitbay issue list --query mine # only the issues
467gitbay mr list --q 'owner:krz is:open author:@me' # a query written out
468gitbay query pin mine # on the dashboard; unpin
469#+end_src
470
471The terms: =repo:owner/name=, =repo:owner/glob*= (=*= in the name
472only), =repo:*=, =owner:name=; =is:open=, =is:closed=, =is:merged=,
473=is:issue=, =is:mr=; =label:x= (repeat it: every label must be there),
474=no:label=; =milestone:x=, =no:milestone=; =assignee:user=,
475=author:user=, either as =@me= for whoever runs the query. Anything
476without a colon is text matched against title and body, as =search=
477does. Every term narrows, except that several =repo:= and =owner:=
478terms widen the scope to any of them; with none the query covers every
479repository you can read, and only those — someone else's private
480repository is neither a row nor part of a count. Merge requests have no
481assignees, so =assignee:= means issues. A term that does not parse
482exits 2 and names itself. =@me= is resolved when the query runs, and
483the saved text is the query in a canonical order.
484
485Rows come newest first, each naming its repository, paged as ={items,
486next}= with fifty to a page unless =--limit= says otherwise.
487An account keeps at most 50 saved queries, 10 of them pinned; past
488either, =query save= or =query pin= exits 2 until one is removed or
489unpinned. =dashboard --json= carries each pinned query's count and first five
490rows under =queries=; the web dashboard shows them, and
491=/you/-/queries= lists your saved queries with a page per query.
492
458493Labels take a colour: =gitbay label set bug --color cf222e=; =label
459494list= shows each with its colour and how many issues and merge requests
460495carry it, and =label remove= takes one off all of them. =issue label
CHANGELOG.org +8
@@ -39,6 +39,14 @@ anything beyond "replace the binary and restart" is needed.
3939- Reactions on issues, merge requests and their comments: =issue react=
4040 and =mr react= with a fixed set of eight, counts and your own in
4141 =show=, comment ids in =show=, and buttons on the web pages (#291).
42- Saved queries: =query save|list|show|run|remove|pin|unpin= keep a
43 named query over issues and merge requests across every repository
44 the caller can read (=repo:=, =owner:=, =is:=, =label:=, =no:=,
45 =milestone:=, =assignee:=, =author:=, free text). =issue list --query=
46 and =mr list --query= run one, =--q= takes one written out, both paged
47 with ={items, next}=. =dashboard= gains =queries= for pinned ones; the
48 web shows them on the dashboard and at =/<you>/-/queries=. An account
49 keeps at most 50 saved queries, 10 pinned (#292).
4250
4351* v1.38.0 — 2026-09-29
4452
cmd/gitbay/local.go +10
@@ -672,3 +672,13 @@ func gitRaw(env, stdin string, args ...string) (string, int) {
672672 }
673673 return string(out), 0
674674}
675
676// anyFlag reports whether any of flags is in args.
677func anyFlag(args, flags []string) bool {
678 for _, f := range flags {
679 if hasFlag(args, f) {
680 return true
681 }
682 }
683 return false
684}
cmd/gitbay/main.go +14 −4
@@ -59,6 +59,15 @@ func newRoot() *cobra.Command {
5959 pass("feed", passOpts{server: []string{"feed"}}),
6060 withShort(pass("explore", passOpts{server: []string{"explore"}}), "public repositories on this instance"),
6161 withShort(pass("search", passOpts{server: []string{"search"}}), "find repositories, issues and merge requests"),
62 group("query", "saved issue and merge request queries across repositories",
63 pass("save", passOpts{server: []string{"query", "save"}}),
64 pass("list", passOpts{server: []string{"query", "list"}}),
65 pass("show", passOpts{server: []string{"query", "show"}}),
66 pass("run", passOpts{server: []string{"query", "run"}}),
67 pass("remove", passOpts{server: []string{"query", "remove"}}),
68 pass("pin", passOpts{server: []string{"query", "pin"}}),
69 pass("unpin", passOpts{server: []string{"query", "unpin"}}),
70 ),
6271 group("notifications", "your notification inbox",
6372 pass("list", passOpts{server: []string{"notifications", "list"}}),
6473 pass("read", passOpts{server: []string{"notifications", "read"}}),
@@ -159,7 +168,7 @@ type rootSection struct {
159168}
160169
161170var rootSections = []rootSection{
162 {"WORK", []string{"issue", "mr", "build", "release", "milestone", "label", "search"}},
171 {"WORK", []string{"issue", "mr", "build", "release", "milestone", "label", "search", "query"}},
163172 {"REPOSITORIES", []string{"repo", "wiki", "status", "webhook", "init"}},
164173 {"YOU", []string{"dashboard", "feed", "notifications", "auth", "profile", "snippet", "web"}},
165174 {"INSTANCE", []string{"org", "explore", "register", "migrate", "remote", "admin", "audit", "man"}},
@@ -213,6 +222,7 @@ const stdinWhat = "gitbay.stdin_what"
213222type passOpts struct {
214223 server []string // server-side command path
215224 needsRepo bool // prepend inferred owner/name unless given
225 crossRepo []string // flags that span repositories: with one, no repository is inferred
216226 stdinOK bool // wire local stdin through when --file - asks for it
217227 alwaysStdin bool // stdin is the payload, named by no flag: a bare redirect
218228 // stdinWhat names the payload for the prompt shown when stdin is a
@@ -294,7 +304,7 @@ func runPass(o passOpts, cliPath string, args []string) int {
294304 return protocol.ExitFailure
295305 }
296306 explicitRepo := len(args) > 0 && !strings.HasPrefix(args[0], "-") && strings.Contains(args[0], "/")
297 if o.needsRepo {
307 if o.needsRepo && !anyFlag(args, o.crossRepo) {
298308 args, err = withRepo(t, args)
299309 if err != nil {
300310 fmt.Fprintln(os.Stderr, "gitbay:", err)
@@ -632,7 +642,7 @@ func repoCmd() *cobra.Command {
632642func issueCmd() *cobra.Command {
633643 return group("issue", "issues",
634644 pass("create", passOpts{server: []string{"issue", "create"}, needsRepo: true, stdinOK: true, editor: "issue"}),
635 pass("list", passOpts{server: []string{"issue", "list"}, needsRepo: true}),
645 pass("list", passOpts{server: []string{"issue", "list"}, needsRepo: true, crossRepo: []string{"--query", "--q"}}),
636646 pass("show", passOpts{server: []string{"issue", "show"}, needsRepo: true}),
637647 pass("comment", passOpts{server: []string{"issue", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
638648 pass("close", passOpts{server: []string{"issue", "close"}, needsRepo: true}),
@@ -675,7 +685,7 @@ func mrCmd() *cobra.Command {
675685 review.AddCommand(pass("request", passOpts{server: []string{"mr", "review", "request"}, needsRepo: true}))
676686 return group("mr", "merge requests",
677687 pass("create", passOpts{server: []string{"mr", "create"}, needsRepo: true, stdinOK: true, editor: "merge request", inferSource: true}),
678 pass("list", passOpts{server: []string{"mr", "list"}, needsRepo: true}),
688 pass("list", passOpts{server: []string{"mr", "list"}, needsRepo: true, crossRepo: []string{"--query", "--q"}}),
679689 pass("show", passOpts{server: []string{"mr", "show"}, needsRepo: true}),
680690 pass("diff", passOpts{server: []string{"mr", "diff"}, needsRepo: true}),
681691 local("checkout", "fetch and check out the MR head locally: gitbay mr checkout <n>", cmdMRCheckout),
cmd/gitbay/summaries_gen.go +7
@@ -127,6 +127,13 @@ var summaries = map[string]string{
127127 "pgp remove": "remove an OpenPGP key by fingerprint",
128128 "profile set": "set your profile",
129129 "profile show": "show a user's or org's profile",
130 "query list": "list your saved queries",
131 "query pin": "show a saved query on your dashboard",
132 "query remove": "delete a saved query",
133 "query run": "list the issues and merge requests a saved query matches",
134 "query save": "save an issue and merge request query across repositories under a name",
135 "query show": "show a saved query and how many rows it matches",
136 "query unpin": "take a saved query off your dashboard",
130137 "register": "create an account (only meaningful for unregistered keys)",
131138 "release asset add": "upload an asset from stdin",
132139 "release asset get": "write an asset to stdout",
e2e/readonly_test.go +4
@@ -80,6 +80,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
8080 must("", "org", "create", "theorg")
8181 must("", "org", "team", "create", "theorg", "core")
8282 must("", "token", "create", "--name", "t")
83 must("", "query", "save", "q", "is:open")
8384 must("", "web", "login")
8485 pub, _ := os.ReadFile(deployKey + ".pub")
8586 must(string(pub), "repo", "deploy-key", "add", "alice/app")
@@ -163,6 +164,9 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
163164 "notifications device list": nil,
164165 "repo bookmarks": nil,
165166 "search": {"app"},
167 "query list": {},
168 "query show": {"q"},
169 "query run": {"q"},
166170 "mr revisions": {"alice/app", "1"},
167171 "mr range-diff": {"alice/app", "1"},
168172 "webhook list": {"alice/app"},
e2e/savedqueries_test.go added +156
@@ -0,0 +1,156 @@
1package e2e
2
3import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11// Saved queries over stock ssh: a query spans the repositories the
12// caller reads, never another user's private one, pages with the
13// cursor, and a pinned one reaches the dashboard and its web page (#292).
14func TestSavedQueries(t *testing.T) {
15 t.Parallel()
16 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
17 aliceKey := inst.newKey(t, "alice")
18 bobKey := inst.newKey(t, "bob")
19 inst.admin(t, "admin", "user", "create", "alice",
20 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
21 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
22 ssh := func(key string, want int, args ...string) string {
23 t.Helper()
24 out, errOut, code := inst.ssh(t, key, "", args...)
25 if code != want {
26 t.Fatalf("%v: exit %d, want %d\n%s%s", args, code, want, out, errOut)
27 }
28 return out + errOut
29 }
30
31 ssh(aliceKey, 0, "repo", "create", "alice/app")
32 ssh(aliceKey, 0, "repo", "create", "alice/lib")
33 ssh(bobKey, 0, "repo", "create", "bob/secret", "--private")
34 work := t.TempDir()
35 env := inst.gitEnv(aliceKey)
36 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
37 dir := filepath.Join(work, "w")
38 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
39 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
40 mustGit(t, dir, env, "add", ".")
41 mustGit(t, dir, env, "commit", "-q", "-m", "base")
42 mustGit(t, dir, env, "push", "-q", "origin", "main")
43 mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
44 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\nb\n"), 0o644)
45 mustGit(t, dir, env, "commit", "-q", "-am", "feat")
46 mustGit(t, dir, env, "push", "-q", "origin", "feat")
47 ssh(aliceKey, 0, "mr", "create", "alice/app", "--source", "feat", "--target", "main", "--title", "feature")
48 ssh(aliceKey, 0, "issue", "create", "alice/app", "--title", "app-bug", "--label", "bug")
49 ssh(aliceKey, 0, "issue", "create", "alice/lib", "--title", "lib-bug", "--label", "bug")
50 ssh(bobKey, 0, "issue", "create", "bob/secret", "--title", "hidden-bug", "--label", "bug")
51
52 if out := ssh(aliceKey, 2, "query", "save", "bad", "is:open", "bogus:x"); !strings.Contains(out, "bogus:x") {
53 t.Errorf("a bad term is not named: %s", out)
54 }
55 ssh(aliceKey, 0, "query", "save", "bugs", "'repo:alice/*'", "label:bug", "is:open")
56 ssh(aliceKey, 1, "query", "save", "bugs", "is:open")
57 ssh(aliceKey, 0, "query", "save", "all", "is:open")
58 ssh(bobKey, 0, "query", "save", "all", "is:open")
59
60 type page struct {
61 Data struct {
62 Items []struct {
63 Kind string `json:"kind"`
64 Repo string `json:"repo"`
65 Title string `json:"title"`
66 } `json:"items"`
67 Next string `json:"next"`
68 } `json:"data"`
69 }
70 run := func(key string, args ...string) page {
71 t.Helper()
72 var p page
73 if err := json.Unmarshal([]byte(ssh(key, 0, append(args, "--json")...)), &p); err != nil {
74 t.Fatal(err)
75 }
76 return p
77 }
78 titles := func(p page) string {
79 var out []string
80 for _, it := range p.Data.Items {
81 out = append(out, it.Repo+":"+it.Title)
82 }
83 return strings.Join(out, " ")
84 }
85
86 if got := titles(run(aliceKey, "issue", "list", "--query", "bugs")); got != "alice/lib:lib-bug alice/app:app-bug" {
87 t.Errorf("issue list --query bugs = %s", got)
88 }
89 if got := titles(run(aliceKey, "mr", "list", "--query", "all")); got != "alice/app:feature" {
90 t.Errorf("mr list --query all = %s", got)
91 }
92 if got := titles(run(bobKey, "query", "run", "all")); !strings.Contains(got, "hidden-bug") {
93 t.Errorf("bob's own private issue is missing from his query: %s", got)
94 }
95
96 // alice pages through everything she reads, one row at a time.
97 var seen []string
98 cursor := ""
99 for i := 0; i < 6; i++ {
100 args := []string{"query", "run", "all", "--limit", "1"}
101 if cursor != "" {
102 args = append(args, "--cursor", cursor)
103 }
104 p := run(aliceKey, args...)
105 seen = append(seen, titles(p))
106 if cursor = p.Data.Next; cursor == "" {
107 break
108 }
109 }
110 if got := strings.Join(seen, " "); got != "alice/lib:lib-bug alice/app:app-bug alice/app:feature" {
111 t.Errorf("paged run = %s", got)
112 }
113
114 ssh(aliceKey, 0, "query", "pin", "all")
115 var dash struct {
116 Data struct {
117 Queries []struct {
118 Name string `json:"name"`
119 Count int `json:"count"`
120 } `json:"queries"`
121 } `json:"data"`
122 }
123 json.Unmarshal([]byte(ssh(aliceKey, 0, "dashboard", "--json")), &dash)
124 if len(dash.Data.Queries) != 1 || dash.Data.Queries[0].Name != "all" || dash.Data.Queries[0].Count != 3 {
125 t.Errorf("dashboard queries = %+v; want all with 3, bob's private issue uncounted", dash.Data.Queries)
126 }
127
128 out := ssh(aliceKey, 0, "web", "login", "--json")
129 var login struct {
130 Data struct {
131 URL string `json:"url"`
132 } `json:"data"`
133 }
134 json.Unmarshal([]byte(out), &login)
135 browser := newBrowser(t)
136 if status, _ := browserGet(t, browser, inst.base()+login.Data.URL[strings.Index(login.Data.URL, "/login"):]); status != 200 {
137 t.Fatalf("login: %d", status)
138 }
139 _, body := browserGet(t, browser, inst.base()+"/")
140 if !strings.Contains(body, `<a href="/alice/-/queries/all">all</a> <span class="count">3</span>`) {
141 t.Errorf("dashboard lacks the pinned query:\n%s", body)
142 }
143 status, body := browserGet(t, browser, inst.base()+"/alice/-/queries/bugs")
144 if status != 200 || !strings.Contains(body, "lib-bug") || strings.Contains(body, "hidden-bug") {
145 t.Errorf("/alice/-/queries/bugs: %d\n%s", status, body)
146 }
147 if status, _ := browserGet(t, browser, inst.base()+"/alice/-/queries/nosuch"); status != 404 {
148 t.Errorf("/alice/-/queries/nosuch: %d, want 404", status)
149 }
150 if status, _ := browserGet(t, browser, inst.base()+"/bob/-/queries/all"); status != 404 {
151 t.Errorf("alice reached bob's query page: %d", status)
152 }
153 if _, body := inst.get(t, "/alice/-/queries/bugs"); strings.Contains(body, "lib-bug") {
154 t.Error("an anonymous visitor reached a saved query page")
155 }
156}
internal/control/dashboard.go +16
@@ -82,6 +82,8 @@ type DashboardOut struct {
8282 Pinned []PinnedOut `json:"pinned"`
8383 Activity []FeedOut `json:"recent_activity"`
8484 Builds []DashboardBuild `json:"builds"`
85 // Queries is each pinned saved query with its first rows.
86 Queries []DashboardQuery `json:"queries"`
8587 // Unread is the notification inbox badge, so a client showing one
8688 // does not need a second read to fill it.
8789 Unread int `json:"unread"`
@@ -161,6 +163,9 @@ func runDashboard(c *Ctx, args []string) int {
161163 for _, b := range builds {
162164 d.Builds = append(d.Builds, DashboardBuild{b.RepoPath, b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt})
163165 }
166 if d.Queries, err = PinnedQueries(c.Store, c.User); err != nil {
167 return c.fail(protocol.ExitFailure, "%v", err)
168 }
164169 d.Unread = c.Store.UnreadNotices(c.User.ID)
165170 if c.User.IsAdmin {
166171 d.Server = &ServerOut{Commit: buildinfo.String()}
@@ -217,6 +222,17 @@ func runDashboard(c *Ctx, args []string) int {
217222 section("assigned to you:", itemHeader, itemRows(d.Assigned, "#"))
218223 section("open merge requests:", itemHeader, itemRows(d.MRs, "!"))
219224 section("open issues:", itemHeader, itemRows(d.Issues, "#"))
225 for _, q := range d.Queries {
226 rows := make([][]cell, 0, len(q.Items))
227 for _, it := range q.Items {
228 rows = append(rows, []cell{cRef(it.Ref()), cFlex(it.Title), cText(it.Author)})
229 }
230 title := fmt.Sprintf("query %s (%d):", q.Name, q.Count)
231 if q.Error != "" {
232 title = fmt.Sprintf("query %s: %s", q.Name, q.Error)
233 }
234 section(title, itemHeader, rows)
235 }
220236
221237 pinnedRows := make([][]cell, len(d.Pinned))
222238 for i, p := range d.Pinned {
internal/control/help.go +1
@@ -49,6 +49,7 @@ var nounSummaries = map[string]string{
4949 "repo": "create and manage repositories",
5050 "runner": "the claim/report loop CI runners use",
5151 "search": "find repositories, issues and merge requests",
52 "query": "saved issue and merge request queries across repositories",
5253 "snippet": "shared text files, outside any repository",
5354 "status": "commit statuses (CI)",
5455 "token": "API tokens (minted over SSH, used with the JSON API)",
internal/control/issue.go +16 −1
@@ -35,7 +35,7 @@ func init() {
3535 ReadsStdin: true, Run: runIssueCreate})
3636 register(Command{Path: []string{"issue", "list"},
3737 Summary: "list issues",
38 Usage: "issue list <owner/name> [--state open|closed|all] [--label <l>] [--assignee <user>] [--author <user>] [--milestone <title>|none] [--search <text>] [--limit <n>] [--cursor <c>]",
38 Usage: "issue list <owner/name> [--state open|closed|all] [--label <l>] [--assignee <user>] [--author <user>] [--milestone <title>|none] [--search <text>] [--limit <n>] [--cursor <c>] | issue list --query <name> | --q <query> [--limit <n>] [--cursor <c>]",
3939 Flags: []Flag{
4040 {"--state", "open|closed|all", "which issues", "open"},
4141 {"--label", "<l>", "only issues carrying this label", ""},
@@ -43,12 +43,16 @@ func init() {
4343 {"--author", "<user>", "only issues opened by this user", ""},
4444 {"--milestone", "<title>|none", "only issues in this milestone, or in none", ""},
4545 {"--search", "<text>", "match title and body", ""},
46 {"--query", "<name>", "a saved query, across repositories, in place of a repository and filters", ""},
47 {"--q", "<query>", "a query written out, as query save takes it", ""},
4648 {"--limit", "<n>", "rows per page", ""},
4749 {"--cursor", "<c>", "continue from the previous page", ""},
4850 },
4951 Examples: []string{
5052 "issue list krz/gitbay --label bug --state all",
5153 "issue list krz/gitbay --assignee cmc",
54 "issue list --query mine",
55 `issue list --q "owner:krz is:open assignee:@me"`,
5256 },
5357 ReadOnly: true, Run: runIssueList})
5458 register(Command{Path: []string{"issue", "show"},
@@ -279,6 +283,17 @@ func runIssueCreate(c *Ctx, args []string) int {
279283}
280284
281285func runIssueList(c *Ctx, args []string) int {
286 if usesQuery(args) {
287 args, p, code := parsePageFlags(c, args, "query", false)
288 if code >= 0 {
289 return code
290 }
291 fl, err := c.parseArgs(args, flagSpec{Values: []string{"--query", "--q", "--state", "--label", "--assignee", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: c.Cmd.Usage})
292 if err != nil {
293 return c.fail(protocol.ExitUsage, "%v", err)
294 }
295 return listByQuery(c, fl, "issue", p)
296 }
282297 args, p, code := parsePageFlags(c, args, "issue", true)
283298 if code >= 0 {
284299 return code
internal/control/mr.go +16 −1
@@ -105,19 +105,23 @@ func init() {
105105 Run: runMRReady})
106106 register(Command{Path: []string{"mr", "list"},
107107 Summary: "list merge requests",
108 Usage: "mr list <owner/name> [--state open|merged|closed|source_gone|all] [--label <l>] [--author <user>] [--milestone <title>|none] [--search <text>] [--limit <n>] [--cursor <c>]",
108 Usage: "mr list <owner/name> [--state open|merged|closed|source_gone|all] [--label <l>] [--author <user>] [--milestone <title>|none] [--search <text>] [--limit <n>] [--cursor <c>] | mr list --query <name> | --q <query> [--limit <n>] [--cursor <c>]",
109109 Flags: []Flag{
110110 {"--state", "open|merged|closed|source_gone|all", "which merge requests", "open"},
111111 {"--label", "<l>", "only MRs carrying this label", ""},
112112 {"--author", "<user>", "only MRs opened by this user", ""},
113113 {"--milestone", "<title>|none", "only MRs in this milestone, or in none", ""},
114114 {"--search", "<text>", "match title and body", ""},
115 {"--query", "<name>", "a saved query, across repositories, in place of a repository and filters", ""},
116 {"--q", "<query>", "a query written out, as query save takes it", ""},
115117 {"--limit", "<n>", "rows per page", ""},
116118 {"--cursor", "<c>", "continue from the previous page", ""},
117119 },
118120 Examples: []string{
119121 "mr list krz/gitbay --state open",
120122 "mr list krz/gitbay --author cmc --state all",
123 "mr list --query reviews",
124 `mr list --q "repo:krz/* is:open author:@me"`,
121125 },
122126 ReadOnly: true, Run: runMRList})
123127 register(Command{Path: []string{"mr", "show"},
@@ -619,6 +623,17 @@ func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut {
619623}
620624
621625func runMRList(c *Ctx, args []string) int {
626 if usesQuery(args) {
627 args, p, code := parsePageFlags(c, args, "query", false)
628 if code >= 0 {
629 return code
630 }
631 fl, err := c.parseArgs(args, flagSpec{Values: []string{"--query", "--q", "--state", "--label", "--assignee", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: c.Cmd.Usage})
632 if err != nil {
633 return c.fail(protocol.ExitUsage, "%v", err)
634 }
635 return listByQuery(c, fl, "mr", p)
636 }
622637 args, p, code := parsePageFlags(c, args, "mr", true)
623638 if code >= 0 {
624639 return code
internal/control/query.go added +444
@@ -0,0 +1,444 @@
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "regexp"
8 "strconv"
9 "strings"
10
11 "gitbay.org/gitbay/internal/protocol"
12 "gitbay.org/gitbay/internal/store"
13)
14
15func init() {
16 register(Command{Path: []string{"query", "save"},
17 Summary: "save an issue and merge request query across repositories under a name",
18 Usage: "query save <name> <query>... [--force]",
19 Flags: []Flag{
20 {"--force", "", "replace a query of the same name", ""},
21 },
22 Examples: []string{
23 "query save mine is:open assignee:@me",
24 `query save triage "repo:krz/*" is:issue is:open no:label`,
25 `query save v2 owner:krz is:open label:bug label:"needs review" milestone:v2`,
26 "query save mine-merged is:merged author:@me --force",
27 },
28 Run: runQuerySave})
29 register(Command{Path: []string{"query", "list"},
30 Summary: "list your saved queries",
31 Usage: "query list",
32 Examples: []string{"query list"},
33 ReadOnly: true, Run: runQueryList})
34 register(Command{Path: []string{"query", "show"},
35 Summary: "show a saved query and how many rows it matches",
36 Usage: "query show <name>",
37 Examples: []string{"query show mine"},
38 ReadOnly: true, Run: runQueryShow})
39 register(Command{Path: []string{"query", "run"},
40 Summary: "list the issues and merge requests a saved query matches",
41 Usage: "query run <name> [--limit <n>] [--cursor <c>]",
42 Flags: []Flag{
43 {"--limit", "<n>", "rows per page", strconv.Itoa(queryDefaultLimit)},
44 {"--cursor", "<c>", "continue from the previous page", ""},
45 },
46 Examples: []string{"query run mine --limit 20"},
47 ReadOnly: true, Run: runQueryRun})
48 register(Command{Path: []string{"query", "remove"},
49 Summary: "delete a saved query",
50 Usage: "query remove <name>",
51 Examples: []string{"query remove mine"},
52 Run: runQueryRemove})
53 register(Command{Path: []string{"query", "pin"},
54 Summary: "show a saved query on your dashboard",
55 Usage: "query pin <name>",
56 Examples: []string{"query pin mine"},
57 Run: func(c *Ctx, args []string) int { return runQueryPin(c, args, true) }})
58 register(Command{Path: []string{"query", "unpin"},
59 Summary: "take a saved query off your dashboard",
60 Usage: "query unpin <name>",
61 Examples: []string{"query unpin mine"},
62 Run: func(c *Ctx, args []string) int { return runQueryPin(c, args, false) }})
63}
64
65// queryDefaultLimit is a page when --limit is not given. A query spans
66// every repository the caller reads, so its listing is always paged.
67const queryDefaultLimit = 50
68
69// dashboardQueryItems is how many rows of each pinned query the
70// dashboard carries.
71const dashboardQueryItems = 5
72
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
80var queryNamePat = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)
81
82// SavedQueryOut is one saved query. Count is filled by query show.
83type SavedQueryOut struct {
84 Name string `json:"name"`
85 Query string `json:"query"`
86 Pinned bool `json:"pinned"`
87 Count *int `json:"count,omitempty"`
88}
89
90// QueryItem is one row of a cross-repository listing, naming its
91// repository.
92type QueryItem struct {
93 Kind string `json:"kind"` // issue or mr
94 Repo string `json:"repo"`
95 Number int64 `json:"number"`
96 Title string `json:"title"`
97 State string `json:"state"`
98 Draft bool `json:"draft,omitempty"`
99 Author string `json:"author"`
100 Milestone string `json:"milestone,omitempty"`
101 CreatedAt string `json:"created_at"`
102 UpdatedAt string `json:"updated_at"`
103}
104
105// Ref is the item as a person writes it: owner/name#n or owner/name!n.
106func (it QueryItem) Ref() string {
107 return fmt.Sprintf("%s%s%d", it.Repo, SearchMarker(it.Kind), it.Number)
108}
109
110// DashboardQuery is a pinned query on the dashboard: its first rows and
111// how many it matches in all. Error is set, and the rest empty, when the
112// saved text no longer parses.
113type DashboardQuery struct {
114 Name string `json:"name"`
115 Query string `json:"query"`
116 Count int `json:"count"`
117 Items []QueryItem `json:"items"`
118 Error string `json:"error,omitempty"`
119}
120
121func queryItems(items []store.Item) []QueryItem {
122 out := make([]QueryItem, 0, len(items))
123 for _, it := range items {
124 out = append(out, QueryItem{Kind: it.Kind, Repo: it.RepoPath, Number: it.Number, Title: it.Title,
125 State: it.State, Draft: it.Draft, Author: it.Author, Milestone: it.Milestone,
126 CreatedAt: it.CreatedAt, UpdatedAt: it.UpdatedAt})
127 }
128 return out
129}
130
131// PinnedQueries runs each of user's pinned queries for the dashboard. It
132// is exported for the web dashboard, which reads the store directly.
133func PinnedQueries(st *store.Store, user store.User) ([]DashboardQuery, error) {
134 saved, err := st.SavedQueries(user.ID, true)
135 if err != nil {
136 return nil, err
137 }
138 out := []DashboardQuery{}
139 for _, sq := range saved {
140 d := DashboardQuery{Name: sq.Name, Query: sq.Query, Items: []QueryItem{}}
141 q, err := ParseItemQuery(sq.Query)
142 if err != nil {
143 d.Error = err.Error()
144 out = append(out, d)
145 continue
146 }
147 f := q.Filter(user.Username, true, true)
148 if d.Count, err = st.CountItems(user.ID, f); err != nil {
149 return nil, err
150 }
151 items, err := st.QueryItems(user.ID, f, nil, dashboardQueryItems)
152 if err != nil {
153 return nil, err
154 }
155 d.Items = queryItems(items)
156 out = append(out, d)
157 }
158 return out, nil
159}
160
161func encodeItemCursor(k store.ItemCursor) string {
162 return encodeCursor("query", fmt.Sprintf("%s|%d|%d", k.CreatedAt, k.Kind, k.ID))
163}
164
165func decodeItemCursor(key string) (*store.ItemCursor, error) {
166 parts := strings.Split(key, "|")
167 if len(parts) != 3 {
168 return nil, errors.New("bad cursor")
169 }
170 kind, err1 := strconv.Atoi(parts[1])
171 id, err2 := strconv.ParseInt(parts[2], 10, 64)
172 if err1 != nil || err2 != nil || (kind != 0 && kind != 1) || parts[0] == "" {
173 return nil, errors.New("bad cursor")
174 }
175 return &store.ItemCursor{CreatedAt: parts[0], Kind: kind, ID: id}, nil
176}
177
178// savedQuery loads one of the caller's queries and parses it.
179func savedQuery(c *Ctx, name string) (store.SavedQuery, ItemQuery, int) {
180 sq, err := c.Store.SavedQueryByName(c.User.ID, name)
181 if errors.Is(err, store.ErrNotFound) {
182 return sq, ItemQuery{}, c.fail(protocol.ExitNotFound, "no saved query %q; query list shows yours", name)
183 }
184 if err != nil {
185 return sq, ItemQuery{}, c.fail(protocol.ExitFailure, "%v", err)
186 }
187 q, err := ParseItemQuery(sq.Query)
188 if err != nil {
189 return sq, q, c.fail(protocol.ExitFailure, "saved query %s no longer parses (%v); save it again with --force", name, err)
190 }
191 return sq, q, -1
192}
193
194// runItemQuery lists what q matches in the tables issues and mrs allow,
195// one page at a time. The output is always the paged shape.
196func runItemQuery(c *Ctx, q ItemQuery, issues, mrs bool, p page) int {
197 var after *store.ItemCursor
198 if p.key != "" {
199 var err error
200 if after, err = decodeItemCursor(p.key); err != nil {
201 return c.fail(protocol.ExitUsage, "bad cursor")
202 }
203 }
204 if p.limit == 0 {
205 p.limit = queryDefaultLimit
206 }
207 p.active = true
208 items, err := c.Store.QueryItems(c.User.ID, q.Filter(c.User.Username, issues, mrs), after, p.queryLimit())
209 if err != nil {
210 return c.fail(protocol.ExitFailure, "%v", err)
211 }
212 next := ""
213 if len(items) > p.limit {
214 items = items[:p.limit]
215 next = encodeItemCursor(items[len(items)-1].Cursor())
216 }
217 ds := queryItems(items)
218 return c.emitPage(p, ds, next, func(w io.Writer) {
219 tb := c.table(w, "REF", "STATE", "TITLE", "AUTHOR")
220 for _, d := range ds {
221 state := d.State
222 if d.Draft {
223 state = "draft"
224 }
225 tb.row(cRef(d.Ref()), cState(state), cFlex(d.Title), cText(d.Author))
226 }
227 tb.flush()
228 })
229}
230
231// usesQuery reports whether issue list or mr list was given a query,
232// which changes the cursor they page with.
233func usesQuery(args []string) bool {
234 for _, a := range args {
235 if a == "--query" || a == "--q" {
236 return true
237 }
238 }
239 return false
240}
241
242// listByQuery is issue list and mr list given --query or --q: the query
243// spans repositories, so it takes none as an argument and no other
244// filter flag. kind is the command's noun.
245func listByQuery(c *Ctx, fl flags, kind string, p page) int {
246 if fl.Has("--query") && fl.Has("--q") {
247 return c.usageWith("--query and --q are two ways to give one query; pass one")
248 }
249 if len(fl.Pos) > 0 {
250 return c.usageWith("a query spans repositories; name them in it (repo:" + fl.Pos[0] + ") rather than as an argument")
251 }
252 for _, f := range []string{"--state", "--label", "--assignee", "--author", "--milestone", "--search"} {
253 if fl.Has(f) {
254 return c.usageWith(f + " does not combine with a query; put it in the query")
255 }
256 }
257 var q ItemQuery
258 if fl.Has("--query") {
259 var code int
260 if _, q, code = savedQuery(c, fl.Value("--query")); code >= 0 {
261 return code
262 }
263 } else {
264 var err error
265 if q, err = ParseItemQuery(fl.Value("--q")); err != nil {
266 return c.fail(protocol.ExitUsage, "%v", err)
267 }
268 }
269 issues, mrs := q.Selects()
270 if kind == "issue" && !issues {
271 return c.usageWith("that query matches only merge requests; use mr list or query run")
272 }
273 if kind == "mr" && !mrs {
274 return c.usageWith("that query matches only issues; use issue list or query run")
275 }
276 return runItemQuery(c, q, kind == "issue", kind == "mr", p)
277}
278
279func savedQueryOut(sq store.SavedQuery) SavedQueryOut {
280 return SavedQueryOut{Name: sq.Name, Query: sq.Query, Pinned: sq.Pinned}
281}
282
283func runQuerySave(c *Ctx, args []string) int {
284 fl, err := c.parseArgs(args, flagSpec{Bools: []string{"--force"}, MaxPos: -1, Usage: c.Cmd.Usage})
285 if err != nil {
286 return c.fail(protocol.ExitUsage, "%v", err)
287 }
288 if len(fl.Pos) < 2 {
289 return c.usage()
290 }
291 name := fl.Pos[0]
292 if !queryNamePat.MatchString(name) {
293 return c.fail(protocol.ExitUsage, "invalid query name %q: lowercase letters, digits, '.', '-', '_'; must start with a letter or digit; max 64 chars", name)
294 }
295 q, err := ParseItemQuery(fl.Pos[1:]...)
296 if err != nil {
297 return c.fail(protocol.ExitUsage, "%v", err)
298 }
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 }
310 err = c.Store.SaveQuery(c.User.ID, name, q.String(), fl.Has("--force"))
311 if errors.Is(err, store.ErrExists) {
312 return c.fail(protocol.ExitFailure, "you already have a query named %s; pass --force to replace it", name)
313 }
314 if err != nil {
315 return c.fail(protocol.ExitFailure, "%v", err)
316 }
317 sq, err := c.Store.SavedQueryByName(c.User.ID, name)
318 if err != nil {
319 return c.fail(protocol.ExitFailure, "%v", err)
320 }
321 return c.emit(savedQueryOut(sq), func(w io.Writer) {
322 fmt.Fprintf(w, "saved %s: %s\n", sq.Name, sq.Query)
323 })
324}
325
326func runQueryList(c *Ctx, args []string) int {
327 if len(args) != 0 {
328 return c.usage()
329 }
330 saved, err := c.Store.SavedQueries(c.User.ID, false)
331 if err != nil {
332 return c.fail(protocol.ExitFailure, "%v", err)
333 }
334 ds := []SavedQueryOut{}
335 for _, sq := range saved {
336 ds = append(ds, savedQueryOut(sq))
337 }
338 return c.emit(ds, func(w io.Writer) {
339 tb := c.table(w, "NAME", "PINNED", "QUERY")
340 for _, d := range ds {
341 pinned := ""
342 if d.Pinned {
343 pinned = "pinned"
344 }
345 tb.row(cRef(d.Name), cText(pinned), cFlex(d.Query))
346 }
347 tb.flush()
348 })
349}
350
351func runQueryShow(c *Ctx, args []string) int {
352 if len(args) != 1 {
353 return c.usage()
354 }
355 sq, q, code := savedQuery(c, args[0])
356 if code >= 0 {
357 return code
358 }
359 n, err := c.Store.CountItems(c.User.ID, q.Filter(c.User.Username, true, true))
360 if err != nil {
361 return c.fail(protocol.ExitFailure, "%v", err)
362 }
363 d := savedQueryOut(sq)
364 d.Count = &n
365 return c.emit(d, func(w io.Writer) {
366 v := c.view(w)
367 v.title(d.Name, "", "")
368 pinned := "no"
369 if d.Pinned {
370 pinned = "yes"
371 }
372 v.fields("query", d.Query, "matches", strconv.Itoa(n), "pinned", pinned)
373 })
374}
375
376func runQueryRun(c *Ctx, args []string) int {
377 rest, p, code := parsePageFlags(c, args, "query", false)
378 if code >= 0 {
379 return code
380 }
381 if len(rest) != 1 {
382 return c.usage()
383 }
384 _, q, code := savedQuery(c, rest[0])
385 if code >= 0 {
386 return code
387 }
388 return runItemQuery(c, q, true, true, p)
389}
390
391func runQueryRemove(c *Ctx, args []string) int {
392 if len(args) != 1 {
393 return c.usage()
394 }
395 err := c.Store.RemoveSavedQuery(c.User.ID, args[0])
396 if errors.Is(err, store.ErrNotFound) {
397 return c.fail(protocol.ExitNotFound, "no saved query %q; query list shows yours", args[0])
398 }
399 if err != nil {
400 return c.fail(protocol.ExitFailure, "%v", err)
401 }
402 return c.emit(map[string]string{"removed": args[0]}, func(w io.Writer) {
403 fmt.Fprintf(w, "removed %s\n", args[0])
404 })
405}
406
407func runQueryPin(c *Ctx, args []string, pinned bool) int {
408 if len(args) != 1 {
409 return c.usage()
410 }
411 sq, err := c.Store.SavedQueryByName(c.User.ID, args[0])
412 if errors.Is(err, store.ErrNotFound) {
413 return c.fail(protocol.ExitNotFound, "no saved query %q; query list shows yours", args[0])
414 }
415 if err != nil {
416 return c.fail(protocol.ExitFailure, "%v", err)
417 }
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 }
431 if err != nil {
432 return c.fail(protocol.ExitFailure, "%v", err)
433 }
434 if sq, err = c.Store.SavedQueryByName(c.User.ID, args[0]); err != nil {
435 return c.fail(protocol.ExitFailure, "%v", err)
436 }
437 return c.emit(savedQueryOut(sq), func(w io.Writer) {
438 verb := "unpinned"
439 if pinned {
440 verb = "pinned"
441 }
442 fmt.Fprintf(w, "%s %s\n", verb, sq.Name)
443 })
444}
internal/control/query_test.go added +258
@@ -0,0 +1,258 @@
1package control
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "strings"
8 "testing"
9
10 "gitbay.org/gitbay/internal/config"
11 "gitbay.org/gitbay/internal/protocol"
12 "gitbay.org/gitbay/internal/store"
13)
14
15type queryEnv struct {
16 t *testing.T
17 st *store.Store
18 users map[string]store.User
19}
20
21func newQueryEnv(t *testing.T) queryEnv {
22 st, err := store.Open(":memory:")
23 if err != nil {
24 t.Fatal(err)
25 }
26 t.Cleanup(func() { st.Close() })
27 if err := st.MigrateUp(); err != nil {
28 t.Fatal(err)
29 }
30 e := queryEnv{t, st, map[string]store.User{}}
31 for _, name := range []string{"alice", "bob"} {
32 id, err := st.CreateUser(name, false)
33 if err != nil {
34 t.Fatal(err)
35 }
36 e.users[name] = store.User{ID: id, Username: name}
37 }
38 return e
39}
40
41// run dispatches argv as user with --json and returns the exit code,
42// the envelope's data and stderr.
43func (e queryEnv) run(user string, argv ...string) (int, json.RawMessage, string) {
44 var out, errOut bytes.Buffer
45 c := &Ctx{User: e.users[user], Scope: "full", Store: e.st, Cfg: config.Config{Limits: config.Limits{WriteRate: -1}},
46 Stdin: strings.NewReader(""), Stdout: &out, Stderr: &errOut}
47 code := Dispatch(c, append(argv, "--json"))
48 var env struct {
49 Data json.RawMessage `json:"data"`
50 }
51 json.Unmarshal(out.Bytes(), &env)
52 return code, env.Data, errOut.String() + out.String()
53}
54
55func (e queryEnv) repo(owner, name, vis string) store.Repo {
56 id, err := e.st.CreateRepo("user", e.users[owner].ID, name, vis)
57 if err != nil {
58 e.t.Fatal(err)
59 }
60 r, err := e.st.RepoByID(id)
61 if err != nil {
62 e.t.Fatal(err)
63 }
64 return r
65}
66
67func TestQueryCommands(t *testing.T) {
68 e := newQueryEnv(t)
69 pub := e.repo("alice", "pub", "public")
70 secret := e.repo("bob", "secret", "private")
71 for i, r := range []store.Repo{pub, secret, pub} {
72 owner := e.users[r.OwnerName]
73 if _, err := e.st.CreateIssue(r.ID, owner.ID, "issue "+string(rune('a'+i)), "", "md"); err != nil {
74 t.Fatal(err)
75 }
76 }
77 if _, err := e.st.CreateMR(pub.ID, e.users["alice"].ID, pub.ID, "topic", "main", "an mr", "", "", "md", false); err != nil {
78 t.Fatal(err)
79 }
80
81 if code, _, msg := e.run("alice", "query", "save", "Bad Name", "is:open"); code != protocol.ExitUsage {
82 t.Fatalf("bad name: exit %d %s", code, msg)
83 }
84 if code, _, msg := e.run("alice", "query", "save", "open", "is:open", "bogus:x"); code != protocol.ExitUsage || !strings.Contains(msg, "bogus:x") {
85 t.Fatalf("bad term: exit %d %s; want 2 naming the term", code, msg)
86 }
87 code, data, msg := e.run("alice", "query", "save", "open", "is:open is:open", "label:needs review")
88 if code != 0 {
89 t.Fatalf("save: exit %d %s", code, msg)
90 }
91 var saved SavedQueryOut
92 json.Unmarshal(data, &saved)
93 if saved.Query != "is:open label:needs review" {
94 t.Errorf("stored %q: want the canonical text", saved.Query)
95 }
96 if code, _, msg := e.run("alice", "query", "save", "open", "is:open"); code != protocol.ExitFailure || !strings.Contains(msg, "--force") {
97 t.Fatalf("save over an existing name: exit %d %s", code, msg)
98 }
99 if code, _, msg := e.run("alice", "query", "save", "open", "is:open", "--force"); code != 0 {
100 t.Fatalf("save --force: exit %d %s", code, msg)
101 }
102
103 // bob's saved query sees his private repository; the same text run
104 // by alice does not, and her count does not include it.
105 for _, u := range []string{"alice", "bob"} {
106 if code, _, msg := e.run(u, "query", "save", "all", "is:open"); code != 0 {
107 t.Fatalf("save as %s: exit %d %s", u, code, msg)
108 }
109 }
110 count := func(user string) int {
111 code, data, msg := e.run(user, "query", "show", "all")
112 if code != 0 {
113 t.Fatalf("show as %s: exit %d %s", user, code, msg)
114 }
115 var d SavedQueryOut
116 json.Unmarshal(data, &d)
117 return *d.Count
118 }
119 if a, b := count("alice"), count("bob"); a != 3 || b != 4 {
120 t.Errorf("counts alice %d bob %d, want 3 and 4", a, b)
121 }
122
123 type pageOut struct {
124 Items []QueryItem `json:"items"`
125 Next string `json:"next"`
126 }
127 var seen []string
128 cursor := ""
129 for i := 0; i < 5; i++ {
130 argv := []string{"query", "run", "all", "--limit", "1"}
131 if cursor != "" {
132 argv = append(argv, "--cursor", cursor)
133 }
134 code, data, msg := e.run("alice", argv...)
135 if code != 0 {
136 t.Fatalf("run: exit %d %s", code, msg)
137 }
138 var p pageOut
139 if err := json.Unmarshal(data, &p); err != nil {
140 t.Fatal(err)
141 }
142 for _, it := range p.Items {
143 seen = append(seen, it.Ref())
144 }
145 if cursor = p.Next; cursor == "" {
146 break
147 }
148 }
149 if got := strings.Join(seen, " "); got != "alice/pub!1 alice/pub#2 alice/pub#1" {
150 t.Errorf("paged run = %s", got)
151 }
152
153 // issue list and mr list narrow to their own kind.
154 code, data, msg = e.run("alice", "issue", "list", "--query", "all")
155 var p pageOut
156 json.Unmarshal(data, &p)
157 if code != 0 || len(p.Items) != 2 || p.Items[0].Kind != "issue" {
158 t.Errorf("issue list --query: exit %d %s", code, msg)
159 }
160 code, data, msg = e.run("alice", "mr", "list", "--q", "repo:alice/* is:open")
161 p = pageOut{}
162 json.Unmarshal(data, &p)
163 if code != 0 || len(p.Items) != 1 || p.Items[0].Repo != "alice/pub" {
164 t.Errorf("mr list --q: exit %d %s", code, msg)
165 }
166 for _, argv := range [][]string{
167 {"issue", "list", "alice/pub", "--query", "all"},
168 {"issue", "list", "--query", "all", "--state", "closed"},
169 {"issue", "list", "--query", "all", "--q", "is:open"},
170 {"issue", "list", "--q", "is:mr"},
171 {"mr", "list", "--q", "assignee:@me"},
172 {"mr", "list", "--q", "nope:x"},
173 {"issue", "list", "--query", "all", "--cursor", "bad"},
174 {"issue", "list", "--query", "all", "--cursor", encodeCursor("issue", "3")},
175 } {
176 if code, _, msg := e.run("alice", argv...); code != protocol.ExitUsage {
177 t.Errorf("%q: exit %d %s, want usage", argv, code, msg)
178 }
179 }
180 if code, _, _ := e.run("alice", "issue", "list", "--query", "nosuch"); code != protocol.ExitNotFound {
181 t.Errorf("unknown saved query: exit %d, want not found", code)
182 }
183
184 // Pinned queries reach the dashboard with their count and first rows.
185 if code, _, msg := e.run("alice", "query", "pin", "all"); code != 0 {
186 t.Fatalf("pin: exit %d %s", code, msg)
187 }
188 code, data, msg = e.run("alice", "dashboard")
189 if code != 0 {
190 t.Fatalf("dashboard: exit %d %s", code, msg)
191 }
192 var d DashboardOut
193 json.Unmarshal(data, &d)
194 if len(d.Queries) != 1 || d.Queries[0].Name != "all" || d.Queries[0].Count != 3 || len(d.Queries[0].Items) != 3 {
195 t.Errorf("dashboard queries = %+v", d.Queries)
196 }
197 if code, _, _ := e.run("alice", "query", "unpin", "all"); code != 0 {
198 t.Fatal("unpin")
199 }
200 _, data, _ = e.run("alice", "dashboard")
201 d = DashboardOut{}
202 json.Unmarshal(data, &d)
203 if d.Queries == nil || len(d.Queries) != 0 {
204 t.Errorf("after unpin, dashboard queries = %#v, want []", d.Queries)
205 }
206
207 code, data, _ = e.run("alice", "query", "list")
208 var list []SavedQueryOut
209 json.Unmarshal(data, &list)
210 if code != 0 || len(list) != 2 || list[0].Name != "all" || list[1].Name != "open" {
211 t.Errorf("query list = %+v", list)
212 }
213 if code, _, _ := e.run("alice", "query", "remove", "all"); code != 0 {
214 t.Fatal("remove")
215 }
216 if code, _, _ := e.run("alice", "query", "show", "all"); code != protocol.ExitNotFound {
217 t.Errorf("show after remove: exit %d", code)
218 }
219 // bob's query of the same name is his own.
220 if code, _, _ := e.run("bob", "query", "show", "all"); code != 0 {
221 t.Errorf("bob lost his query when alice removed hers")
222 }
223}
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 added +305
@@ -0,0 +1,305 @@
1package control
2
3import (
4 "fmt"
5 "regexp"
6 "strings"
7
8 "gitbay.org/gitbay/internal/policy"
9 "gitbay.org/gitbay/internal/store"
10)
11
12// ItemQuery is a parsed issue and merge request query (#292): terms
13// separated by spaces, every term narrowing the result.
14//
15// repo:owner/name repo:owner/glob* repo:* owner:name
16// is:open|closed|merged is:issue|mr
17// label:x (repeatable, all must match) no:label
18// milestone:x no:milestone
19// assignee:user|@me author:user|@me
20// anything else without a colon: text matched against title and body
21//
22// Several repo: and owner: terms widen the scope to any of them. A value
23// with spaces is written in double quotes, label:"needs review".
24type ItemQuery struct {
25 Scopes []store.RepoScope
26 AnyRepo bool // repo:*
27 Kind string
28 State string
29 Labels []string
30 NoLabel bool
31 Milestone string
32 NoMilestone bool
33 Assignee string
34 Author string
35 Text []string
36}
37
38// QueryError is a query that does not parse. Token is the term at fault.
39type QueryError struct {
40 Token string
41 Msg string
42}
43
44func (e *QueryError) Error() string { return fmt.Sprintf("query term %q: %s", e.Token, e.Msg) }
45
46// repoGlob is a repository name that may carry * wildcards; names hold
47// none of GLOB's other metacharacters, so * is the only one that reaches
48// SQLite.
49var repoGlob = regexp.MustCompile(`^[a-z0-9._*-]{1,64}$`)
50
51// queryTokens splits s on whitespace, keeping double-quoted runs whole
52// and dropping the quotes. quoted marks a token that opened with a quote:
53// it is text even if it holds a colon.
54func queryTokens(s string) (toks []string, quoted []bool, err error) {
55 var cur strings.Builder
56 in, have, startQuoted := false, false, false
57 flush := func() {
58 if have {
59 toks = append(toks, cur.String())
60 quoted = append(quoted, startQuoted)
61 }
62 cur.Reset()
63 have, startQuoted = false, false
64 }
65 for _, r := range s {
66 switch {
67 case r == '"':
68 if !have {
69 startQuoted = true
70 }
71 in, have = !in, true
72 case !in && (r == ' ' || r == '\t' || r == '\n' || r == '\r'):
73 flush()
74 default:
75 cur.WriteRune(r)
76 have = true
77 }
78 }
79 if in {
80 return nil, nil, &QueryError{Token: s, Msg: "unterminated quote"}
81 }
82 flush()
83 return toks, quoted, nil
84}
85
86// ParseItemQuery parses the words of a query. Each word may itself hold
87// several terms, so a query arrives the same whether it was one quoted
88// argument or many.
89func ParseItemQuery(words ...string) (ItemQuery, error) {
90 var q ItemQuery
91 n := 0
92 for _, w := range words {
93 toks, quoted, err := queryTokens(w)
94 if err != nil {
95 return q, err
96 }
97 for i, t := range toks {
98 n++
99 if err := q.term(t, quoted[i]); err != nil {
100 return q, err
101 }
102 }
103 }
104 if n == 0 {
105 return q, &QueryError{Token: "", Msg: "the query is empty"}
106 }
107 if text := strings.Join(q.Text, " "); text != "" {
108 if err := validQuery(text); err != nil {
109 return q, &QueryError{Token: text, Msg: "text " + err.Error()}
110 }
111 }
112 return q, nil
113}
114
115func (q *ItemQuery) term(tok string, quoted bool) error {
116 key, val, ok := strings.Cut(tok, ":")
117 if quoted || !ok {
118 q.Text = append(q.Text, tok)
119 return nil
120 }
121 bad := func(msg string) error { return &QueryError{Token: tok, Msg: msg} }
122 if val == "" {
123 return bad("missing value")
124 }
125 user := func(dst *string, what string) error {
126 if val != "@me" && policy.ValidateName(val) != nil {
127 return bad("not a username")
128 }
129 if *dst != "" && *dst != val {
130 return bad("only one " + what)
131 }
132 *dst = val
133 return nil
134 }
135 switch key {
136 case "repo":
137 if val == "*" {
138 q.AnyRepo = true
139 return nil
140 }
141 owner, name, ok := strings.Cut(val, "/")
142 if !ok || policy.ValidateName(owner) != nil || !repoGlob.MatchString(name) {
143 return bad("want repo:owner/name, repo:owner/glob or repo:*")
144 }
145 q.Scopes = append(q.Scopes, store.RepoScope{Owner: owner, Name: name})
146 case "owner":
147 if policy.ValidateName(val) != nil {
148 return bad("not an owner name")
149 }
150 q.Scopes = append(q.Scopes, store.RepoScope{Owner: val})
151 case "is":
152 switch val {
153 case "issue", "mr":
154 if q.Kind != "" && q.Kind != val {
155 return bad("only one of is:issue and is:mr")
156 }
157 q.Kind = val
158 case "open", "closed", "merged":
159 if q.State != "" && q.State != val {
160 return bad("only one of is:open, is:closed and is:merged")
161 }
162 q.State = val
163 default:
164 return bad("is: takes open, closed, merged, issue or mr")
165 }
166 if q.State == "merged" && q.Kind == "issue" {
167 return bad("an issue is never merged")
168 }
169 if q.Kind == "mr" && q.Assignee != "" {
170 return bad("merge requests have no assignees")
171 }
172 case "label":
173 if !q.hasLabel(val) {
174 q.Labels = append(q.Labels, val)
175 }
176 if q.NoLabel {
177 return bad("label: and no:label never both match")
178 }
179 case "milestone":
180 if (q.Milestone != "" && q.Milestone != val) || q.NoMilestone {
181 return bad("only one milestone")
182 }
183 q.Milestone = val
184 case "no":
185 switch val {
186 case "label":
187 if len(q.Labels) > 0 {
188 return bad("label: and no:label never both match")
189 }
190 q.NoLabel = true
191 case "milestone":
192 if q.Milestone != "" {
193 return bad("only one milestone")
194 }
195 q.NoMilestone = true
196 default:
197 return bad("no: takes label or milestone")
198 }
199 case "assignee":
200 if q.Kind == "mr" {
201 return bad("merge requests have no assignees")
202 }
203 if err := user(&q.Assignee, "assignee"); err != nil {
204 return err
205 }
206 case "author":
207 return user(&q.Author, "author")
208 default:
209 return bad("unknown qualifier; the qualifiers are repo:, owner:, is:, label:, no:, milestone:, assignee: and author:")
210 }
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 }
214 return nil
215}
216
217func (q ItemQuery) hasLabel(l string) bool {
218 for _, x := range q.Labels {
219 if x == l {
220 return true
221 }
222 }
223 return false
224}
225
226// Selects reports which tables the query can match rows in.
227func (q ItemQuery) Selects() (issues, mrs bool) {
228 issues = q.Kind != "mr" && q.State != "merged"
229 mrs = q.Kind != "issue" && q.Assignee == ""
230 return
231}
232
233// String is the canonical text, which is what a saved query stores.
234func (q ItemQuery) String() string {
235 var t []string
236 add := func(k, v string) {
237 if strings.ContainsAny(v, " \t\r\n") {
238 v = `"` + v + `"`
239 }
240 t = append(t, k+":"+v)
241 }
242 if q.AnyRepo {
243 t = append(t, "repo:*")
244 }
245 for _, s := range q.Scopes {
246 if s.Name == "" {
247 add("owner", s.Owner)
248 } else {
249 add("repo", s.Owner+"/"+s.Name)
250 }
251 }
252 if q.Kind != "" {
253 add("is", q.Kind)
254 }
255 if q.State != "" {
256 add("is", q.State)
257 }
258 for _, l := range q.Labels {
259 add("label", l)
260 }
261 if q.NoLabel {
262 add("no", "label")
263 }
264 if q.Milestone != "" {
265 add("milestone", q.Milestone)
266 }
267 if q.NoMilestone {
268 add("no", "milestone")
269 }
270 if q.Assignee != "" {
271 add("assignee", q.Assignee)
272 }
273 if q.Author != "" {
274 add("author", q.Author)
275 }
276 for _, w := range q.Text {
277 if strings.Contains(w, ":") || strings.ContainsAny(w, " \t\r\n") {
278 w = `"` + w + `"`
279 }
280 t = append(t, w)
281 }
282 return strings.Join(t, " ")
283}
284
285// Filter is the query as the store runs it for the user named me, over
286// the tables issues and mrs allow as well as the query's own kind.
287func (q ItemQuery) Filter(me string, issues, mrs bool) store.ItemFilter {
288 qi, qm := q.Selects()
289 f := store.ItemFilter{
290 Issues: issues && qi, MRs: mrs && qm,
291 State: q.State, Labels: q.Labels, NoLabel: q.NoLabel,
292 Milestone: q.Milestone, NoMilestone: q.NoMilestone,
293 Assignee: q.Assignee, Author: q.Author, Text: strings.Join(q.Text, " "),
294 }
295 if !q.AnyRepo {
296 f.Scopes = q.Scopes
297 }
298 if f.Assignee == "@me" {
299 f.Assignee = me
300 }
301 if f.Author == "@me" {
302 f.Author = me
303 }
304 return f
305}
internal/control/querylang_test.go added +112
@@ -0,0 +1,112 @@
1package control
2
3import (
4 "errors"
5 "reflect"
6 "testing"
7
8 "gitbay.org/gitbay/internal/store"
9)
10
11func TestParseItemQuery(t *testing.T) {
12 for _, tc := range []struct {
13 in []string
14 want ItemQuery
15 text string // canonical form
16 }{
17 {[]string{"repo:krz/gitbay"}, ItemQuery{Scopes: []store.RepoScope{{Owner: "krz", Name: "gitbay"}}}, "repo:krz/gitbay"},
18 {[]string{"repo:krz/git* owner:cmc"}, ItemQuery{Scopes: []store.RepoScope{{Owner: "krz", Name: "git*"}, {Owner: "cmc"}}}, "repo:krz/git* owner:cmc"},
19 {[]string{"repo:*", "is:open"}, ItemQuery{AnyRepo: true, State: "open"}, "repo:* is:open"},
20 {[]string{"is:merged"}, ItemQuery{State: "merged"}, "is:merged"},
21 {[]string{"is:open is:issue"}, ItemQuery{Kind: "issue", State: "open"}, "is:issue is:open"},
22 {[]string{"label:bug", "label:ui", "label:bug"}, ItemQuery{Labels: []string{"bug", "ui"}}, "label:bug label:ui"},
23 {[]string{`label:"needs review"`}, ItemQuery{Labels: []string{"needs review"}}, `label:"needs review"`},
24 {[]string{"label:needs review"}, ItemQuery{Labels: []string{"needs"}, Text: []string{"review"}}, "label:needs review"},
25 {[]string{"no:label no:milestone"}, ItemQuery{NoLabel: true, NoMilestone: true}, "no:label no:milestone"},
26 {[]string{"milestone:v2"}, ItemQuery{Milestone: "v2"}, "milestone:v2"},
27 {[]string{"assignee:@me author:cmc"}, ItemQuery{Assignee: "@me", Author: "cmc"}, "assignee:@me author:cmc"},
28 {[]string{"crash", `"on start"`, "is:open"}, ItemQuery{State: "open", Text: []string{"crash", "on start"}}, `is:open crash "on start"`},
29 {[]string{`"a:b"`}, ItemQuery{Text: []string{"a:b"}}, `"a:b"`},
30 } {
31 got, err := ParseItemQuery(tc.in...)
32 if err != nil {
33 t.Errorf("%q: %v", tc.in, err)
34 continue
35 }
36 if !reflect.DeepEqual(got, tc.want) {
37 t.Errorf("%q = %+v, want %+v", tc.in, got, tc.want)
38 }
39 if s := got.String(); s != tc.text {
40 t.Errorf("%q canonical = %q, want %q", tc.in, s, tc.text)
41 }
42 again, err := ParseItemQuery(got.String())
43 if err != nil || !reflect.DeepEqual(again, got) {
44 t.Errorf("%q does not survive its canonical form: %+v, %v", tc.in, again, err)
45 }
46 }
47}
48
49func TestParseItemQueryRefuses(t *testing.T) {
50 for _, tc := range []struct {
51 in, token string
52 }{
53 {"", ""},
54 {"foo:bar", "foo:bar"},
55 {"is:open is:bogus", "is:bogus"},
56 {"is:open is:closed", "is:closed"},
57 {"is:issue is:mr", "is:mr"},
58 {"is:issue is:merged", "is:merged"},
59 {"is:mr assignee:cmc", "assignee:cmc"},
60 {"assignee:cmc is:mr", "is:mr"},
61 {"assignee:cmc assignee:bob", "assignee:bob"},
62 {"is:merged assignee:cmc", "assignee:cmc"},
63 {"assignee:@me is:merged", "is:merged"},
64 {"author:Not_A_User", "author:Not_A_User"},
65 {"repo:krz", "repo:krz"},
66 {"repo:*/gitbay", "repo:*/gitbay"},
67 {"repo:krz/[ab]", "repo:krz/[ab]"},
68 {"owner:krz/x", "owner:krz/x"},
69 {"label:bug no:label", "no:label"},
70 {"no:milestone milestone:v1", "milestone:v1"},
71 {"no:author", "no:author"},
72 {"label:", "label:"},
73 {`label:"open`, `label:"open`},
74 {"x", "x"}, // text too short
75 } {
76 _, err := ParseItemQuery(tc.in)
77 var qe *QueryError
78 if !errors.As(err, &qe) {
79 t.Errorf("%q: err %v, want a QueryError", tc.in, err)
80 continue
81 }
82 if qe.Token != tc.token {
83 t.Errorf("%q: token %q, want %q (%v)", tc.in, qe.Token, tc.token, err)
84 }
85 }
86}
87
88func TestItemQuerySelects(t *testing.T) {
89 for _, tc := range []struct {
90 in string
91 issues, mrs bool
92 }{
93 {"is:open", true, true},
94 {"is:issue", true, false},
95 {"is:mr", false, true},
96 {"is:merged", false, true},
97 {"assignee:cmc", true, false},
98 } {
99 q, err := ParseItemQuery(tc.in)
100 if err != nil {
101 t.Fatal(err)
102 }
103 if i, m := q.Selects(); i != tc.issues || m != tc.mrs {
104 t.Errorf("%q selects issues=%v mrs=%v, want %v %v", tc.in, i, m, tc.issues, tc.mrs)
105 }
106 }
107 q, _ := ParseItemQuery("repo:* repo:krz/x assignee:@me author:@me")
108 f := q.Filter("alice", true, true)
109 if f.Scopes != nil || f.Assignee != "alice" || f.Author != "alice" {
110 t.Errorf("Filter = %+v: repo:* widens to every repository, @me is the caller", f)
111 }
112}
internal/httpd/anchors_test.go +2 −1
@@ -59,8 +59,9 @@ func TestStylesheetURLCarriesTheBuildHash(t *testing.T) {
5959 Assigned []store.DashboardItem
6060 MRs []store.DashboardItem
6161 Issues []store.DashboardItem
62 Queries []control.DashboardQuery
6263 Feed []control.FeedLine
63 }{base, "dashboard", nil, nil, nil, nil, nil, nil})
64 }{base, "dashboard", nil, nil, nil, nil, nil, nil, nil})
6465 if err != nil {
6566 t.Fatal(err)
6667 }
internal/httpd/dashpins_test.go +2 −1
@@ -24,12 +24,13 @@ func TestDashboardTilesAndPins(t *testing.T) {
2424 Assigned []store.DashboardItem
2525 MRs []store.DashboardItem
2626 Issues []store.DashboardItem
27 Queries []control.DashboardQuery
2728 Feed []control.FeedLine
2829 }{base, "dashboard", []pinnedRow{
2930 {Owner: "krz", Name: "gitbay", Issues: 3, MRs: 0, Build: "success"},
3031 {Owner: "krz", Name: "old", Build: "cancelled"},
3132 }, nil, nil, nil,
32 []store.DashboardItem{{RepoPath: "krz/gitbay", Number: 1, Title: "one", Author: "alice", State: "open"}}, nil})
33 []store.DashboardItem{{RepoPath: "krz/gitbay", Number: 1, Title: "one", Author: "alice", State: "open"}}, nil, nil})
3334 if err != nil {
3435 t.Fatal(err)
3536 }
internal/httpd/queries.go added +78
@@ -0,0 +1,78 @@
1package httpd
2
3import (
4 "net/http"
5 "net/url"
6 "strconv"
7
8 "gitbay.org/gitbay/internal/control"
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13// queryPerPage is how many rows a saved query's page shows before
14// offering the next, through query run's own cursor.
15const queryPerPage = 50
16
17// queriesPage lists the viewer's saved queries (/{owner}/-/queries), or
18// runs one (/{owner}/-/queries/{name}) through query show and query run,
19// the reads the CLI makes. Saved queries are private: under anyone else's
20// name the page is not found.
21func (s *Server) queriesPage(w http.ResponseWriter, r *http.Request, viewer store.User) {
22 if r.PathValue("owner") != viewer.Username {
23 s.notFound(w, r)
24 return
25 }
26 name := r.PathValue("name")
27 data := struct {
28 basePage
29 Tab string
30 Saved []control.SavedQueryOut
31 Query control.SavedQueryOut
32 Count int
33 Items []control.QueryItem
34 Next string
35 }{basePage: s.baseFor(viewer), Tab: "dashboard"}
36 if name == "" {
37 if msg, ok := s.runControlInto(viewer, []string{"query", "list"}, &data.Saved); !ok {
38 http.Error(w, msg, http.StatusInternalServerError)
39 return
40 }
41 s.render(w, "queries.html", data)
42 return
43 }
44 switch code, msg := s.runControlIntoCode(viewer, []string{"query", "show", name}, &data.Query); code {
45 case protocol.ExitOK:
46 case protocol.ExitNotFound:
47 s.notFound(w, r)
48 return
49 default:
50 http.Error(w, msg, http.StatusInternalServerError)
51 return
52 }
53 if data.Query.Count != nil {
54 data.Count = *data.Query.Count
55 }
56 argv := []string{"query", "run", name, "--limit", strconv.Itoa(queryPerPage)}
57 if cursor := r.URL.Query().Get("cursor"); cursor != "" {
58 argv = append(argv, "--cursor", cursor)
59 }
60 var page struct {
61 Items []control.QueryItem `json:"items"`
62 Next string `json:"next"`
63 }
64 switch code, msg := s.runControlIntoCode(viewer, argv, &page); code {
65 case protocol.ExitOK:
66 case protocol.ExitUsage:
67 http.Error(w, msg, http.StatusBadRequest)
68 return
69 default:
70 http.Error(w, msg, http.StatusInternalServerError)
71 return
72 }
73 data.Items = page.Items
74 if page.Next != "" {
75 data.Next = "?" + url.Values{"cursor": {page.Next}}.Encode()
76 }
77 s.render(w, "queries.html", data)
78}
internal/httpd/queries_test.go added +117
@@ -0,0 +1,117 @@
1package httpd
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "strings"
7 "testing"
8
9 "gitbay.org/gitbay/internal/config"
10 "gitbay.org/gitbay/internal/store"
11)
12
13// A pinned query shows on the dashboard and its page lists what it
14// matches, one page at a time, never another user's private rows.
15func TestSavedQueryPages(t *testing.T) {
16 st, err := store.Open(":memory:")
17 if err != nil {
18 t.Fatal(err)
19 }
20 defer st.Close()
21 if err := st.MigrateUp(); err != nil {
22 t.Fatal(err)
23 }
24 alice, err := st.CreateUser("alice", false)
25 if err != nil {
26 t.Fatal(err)
27 }
28 bob, err := st.CreateUser("bob", false)
29 if err != nil {
30 t.Fatal(err)
31 }
32 pub, err := st.CreateRepo("user", alice, "pub", "public")
33 if err != nil {
34 t.Fatal(err)
35 }
36 secret, err := st.CreateRepo("user", bob, "secret", "private")
37 if err != nil {
38 t.Fatal(err)
39 }
40 for i := 0; i < queryPerPage+1; i++ {
41 if _, err := st.CreateIssue(pub, alice, "public issue", "", "md"); err != nil {
42 t.Fatal(err)
43 }
44 }
45 if _, err := st.CreateIssue(secret, bob, "bob's secret", "", "md"); err != nil {
46 t.Fatal(err)
47 }
48 if err := st.SaveQuery(alice, "open", "is:open", false); err != nil {
49 t.Fatal(err)
50 }
51 if err := st.PinSavedQuery(alice, "open", true); err != nil {
52 t.Fatal(err)
53 }
54 cfg := config.Default()
55 cfg.Web.Mode = "accounts"
56 s := New(cfg, st, nil)
57 viewer := store.User{ID: alice, Username: "alice"}
58
59 get := func(path string) *httptest.ResponseRecorder {
60 rr := httptest.NewRecorder()
61 req := httptest.NewRequest("GET", path, nil)
62 if owner, rest, ok := strings.Cut(strings.TrimPrefix(req.URL.Path, "/"), "/-/queries"); ok {
63 req.SetPathValue("owner", owner)
64 req.SetPathValue("name", strings.TrimPrefix(rest, "/"))
65 }
66 if req.URL.Path == "/" {
67 s.dashboard(rr, req, viewer)
68 } else {
69 s.queriesPage(rr, req, viewer)
70 }
71 return rr
72 }
73
74 dash := get("/").Body.String()
75 for _, want := range []string{
76 `<a href="/alice/-/queries/open">open</a> <span class="count">51</span>`,
77 `<code>is:open</code>`,
78 `<a href="/alice/-/queries/open">all 51 →</a>`,
79 } {
80 if !strings.Contains(dash, want) {
81 t.Errorf("dashboard lacks %q", want)
82 }
83 }
84
85 first := get("/alice/-/queries/open")
86 if first.Code != http.StatusOK {
87 t.Fatalf("query page: %d %s", first.Code, first.Body.String())
88 }
89 body := first.Body.String()
90 if n := strings.Count(body, `<span class="repo">alice/pub#`); n != queryPerPage {
91 t.Errorf("first page lists %d rows, want %d", n, queryPerPage)
92 }
93 if strings.Contains(body, "secret") || strings.Contains(dash, "secret") {
94 t.Error("a private repository of another user reached the page")
95 }
96 i := strings.Index(body, `<p class="pager"><a href="?cursor=`)
97 if i < 0 {
98 t.Fatalf("no next link:\n%s", body)
99 }
100 href := body[i+len(`<p class="pager"><a href="`):]
101 href = strings.ReplaceAll(href[:strings.Index(href, `"`)], "&amp;", "&")
102 second := get("/alice/-/queries/open" + href).Body.String()
103 if n := strings.Count(second, `<span class="repo">alice/pub#`); n != 1 || !strings.Contains(second, `alice/pub#1<`) {
104 t.Errorf("second page lists %d rows, want the oldest one", n)
105 }
106
107 if rr := get("/alice/-/queries/nosuch"); rr.Code != http.StatusNotFound {
108 t.Errorf("unknown query: %d, want 404", rr.Code)
109 }
110 if rr := get("/bob/-/queries/open"); rr.Code != http.StatusNotFound {
111 t.Errorf("alice under bob's name: %d, want 404", rr.Code)
112 }
113 list := get("/alice/-/queries").Body.String()
114 if !strings.Contains(list, `<a href="/alice/-/queries/open">open</a> <span class="chip">pinned</span>`) {
115 t.Errorf("query list lacks the pinned query:\n%s", list)
116 }
117}
internal/httpd/routes.go +2
@@ -184,6 +184,8 @@ func (s *Server) Routes() []Route {
184184 Route{Method: "POST", Pattern: "/{owner}/-/milestones", Mutating: true,
185185 Handler: s.checkOrigin(s.requireUser(s.orgMilestoneSubmit))},
186186 Route{Method: "GET", Pattern: "/bookmarks", Handler: s.requireUser(s.bookmarksPage)},
187 Route{Method: "GET", Pattern: "/{owner}/-/queries", Handler: s.requireUser(s.queriesPage)},
188 Route{Method: "GET", Pattern: "/{owner}/-/queries/{name}", Handler: s.requireUser(s.queriesPage)},
187189 Route{Method: "GET", Pattern: "/{owner}/-/snippets/new", Handler: s.requireUser(s.snippetNewForm)},
188190 Route{Method: "POST", Pattern: "/{owner}/-/snippets/new", Mutating: true,
189191 Handler: s.checkOrigin(s.requireUser(s.snippetNewSubmit))},
internal/httpd/web.go +3 −1
@@ -209,6 +209,7 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.
209209 reviews, _ := s.st.ReviewQueue(viewer.ID)
210210 assigned, _ := s.st.AssignedIssues(viewer.ID)
211211 events, _ := s.st.RecentEvents(viewer.ID, 20, 0)
212 queries, _ := control.PinnedQueries(s.st, viewer)
212213 s.render(w, "dashboard.html", struct {
213214 basePage
214215 Tab string
@@ -217,8 +218,9 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.
217218 Assigned []store.DashboardItem
218219 MRs []store.DashboardItem
219220 Issues []store.DashboardItem
221 Queries []control.DashboardQuery
220222 Feed []control.FeedLine
221 }{s.baseFor(viewer), "dashboard", s.pinnedRows(viewer), reviews, assigned, mrs, issues, control.FeedLines(events)})
223 }{s.baseFor(viewer), "dashboard", s.pinnedRows(viewer), reviews, assigned, mrs, issues, queries, control.FeedLines(events)})
222224}
223225
224226func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
internal/store/migrations/0071_saved_queries.down.sql added +3
@@ -0,0 +1,3 @@
1DROP INDEX merge_requests_repo_created;
2DROP INDEX issues_repo_created;
3DROP TABLE saved_queries;
internal/store/migrations/0071_saved_queries.up.sql added +17
@@ -0,0 +1,17 @@
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);
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 added +301
@@ -0,0 +1,301 @@
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
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
79func (s *Store) RemoveSavedQuery(userID int64, name string) error {
80 res, err := s.DB.Exec("DELETE FROM saved_queries WHERE user_id = ? AND name = ?", userID, name)
81 if err != nil {
82 return err
83 }
84 if n, _ := res.RowsAffected(); n == 0 {
85 return ErrNotFound
86 }
87 return nil
88}
89
90func (s *Store) PinSavedQuery(userID int64, name string, pinned bool) error {
91 res, err := s.DB.Exec("UPDATE saved_queries SET pinned = ? WHERE user_id = ? AND name = ?", pinned, userID, name)
92 if err != nil {
93 return err
94 }
95 if n, _ := res.RowsAffected(); n == 0 {
96 return ErrNotFound
97 }
98 return nil
99}
100
101// RepoScope is one repo:/owner: term. Name is a GLOB pattern; "" means
102// every repository of Owner.
103type RepoScope struct {
104 Owner string
105 Name string
106}
107
108// ItemFilter is a parsed query, ready to run. Usernames are resolved
109// (@me is the caller's name by now). Empty fields match anything.
110type ItemFilter struct {
111 Scopes []RepoScope // any of them; none means every repository
112 Issues, MRs bool // which tables to read
113 State string // open, closed, merged
114 Labels []string // all of them
115 NoLabel bool
116 Milestone string
117 NoMilestone bool
118 Assignee string // issues only
119 Author string
120 Text string // full-text over title and body
121}
122
123// ItemCursor is the sort key of the last row of a page: rows are newest
124// first by creation, merge requests before issues at the same instant,
125// then by id, highest first.
126type ItemCursor struct {
127 CreatedAt string
128 Kind int // 0 issue, 1 merge request
129 ID int64
130}
131
132// Item is one row of a cross-repository query.
133type Item struct {
134 Kind string // issue or mr
135 ID int64
136 RepoPath string
137 Number int64
138 Title string
139 Author string
140 State string
141 Draft bool
142 Milestone string
143 CreatedAt string
144 UpdatedAt string
145}
146
147// Cursor is the key a page ending on this row continues from.
148func (it Item) Cursor() ItemCursor {
149 k := 0
150 if it.Kind == "mr" {
151 k = 1
152 }
153 return ItemCursor{it.CreatedAt, k, it.ID}
154}
155
156// numbered collects arguments for SQL written with ?N placeholders, which
157// visibleCond needs: it names the user as ?1 several times.
158type numbered struct{ args []any }
159
160func (n *numbered) add(v any) string {
161 n.args = append(n.args, v)
162 return fmt.Sprintf("?%d", len(n.args))
163}
164
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}
171 if len(f.Scopes) > 0 {
172 var scopes []string
173 for _, sc := range f.Scopes {
174 cond := "COALESCE(u.username, o.name) = " + a.add(sc.Owner)
175 if sc.Name != "" {
176 cond += " AND r.name GLOB " + a.add(sc.Name)
177 }
178 scopes = append(scopes, "("+cond+")")
179 }
180 where = append(where, "("+strings.Join(scopes, " OR ")+")")
181 }
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 + ")"}
198 switch {
199 case f.State == "":
200 case f.State == "open" && kind == "mr":
201 where = append(where, "x.state IN ('open', 'source_gone')")
202 default:
203 where = append(where, "x.state = "+a.add(f.State))
204 }
205 for _, l := range f.Labels {
206 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)+")")
207 }
208 if f.NoLabel {
209 where = append(where, "NOT EXISTS (SELECT 1 FROM "+labels+" WHERE "+onItem+")")
210 }
211 if f.NoMilestone {
212 where = append(where, "x.milestone_id IS NULL")
213 } else if f.Milestone != "" {
214 where = append(where, "ms.title = "+a.add(f.Milestone))
215 }
216 if f.Assignee != "" {
217 where = append(where, `EXISTS (SELECT 1 FROM issue_assignees ia JOIN users iu ON iu.id = ia.user_id
218 WHERE ia.issue_id = x.id AND iu.username = `+a.add(f.Assignee)+")")
219 }
220 if f.Author != "" {
221 where = append(where, "au.username = "+a.add(f.Author))
222 }
223 if f.Text != "" {
224 index := "issue_fts"
225 if kind == "mr" {
226 index = "mr_fts"
227 }
228 where = append(where, "x.id IN (SELECT rowid FROM "+index+" WHERE "+index+" MATCH "+a.add(FTSQuery(f.Text))+")")
229 }
230 if after != nil {
231 where = append(where, "(x.created_at, "+kord+", x.id) < ("+a.add(after.CreatedAt)+", "+a.add(after.Kind)+", "+a.add(after.ID)+")")
232 }
233 return `SELECT '` + kind + `' AS kind, ` + kord + ` AS kord, x.id AS id,
234 COALESCE(u.username, o.name) || '/' || r.name, x.number, x.title, au.username,
235 x.state, ` + draft + `, COALESCE(ms.title, ''), x.created_at AS created_at, x.updated_at
236 FROM ` + table + ` x
237 JOIN repos r ON r.id = x.repo_id
238 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
239 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
240 JOIN users au ON au.id = x.author_id
241 LEFT JOIN milestones ms ON ms.id = x.milestone_id
242 WHERE ` + strings.Join(where, "\n\t AND ")
243}
244
245// itemUnion is the query over both tables f reads, or "" when it reads
246// neither.
247func itemUnion(userID int64, f ItemFilter, after *ItemCursor) (string, []any) {
248 a := &numbered{}
249 a.add(userID)
250 repos := itemRepos(f, a)
251 var parts []string
252 if f.Issues {
253 parts = append(parts, itemBranch("issue", f, a, repos, after))
254 }
255 if f.MRs {
256 parts = append(parts, itemBranch("mr", f, a, repos, after))
257 }
258 return strings.Join(parts, "\nUNION ALL\n"), a.args
259}
260
261// QueryItems runs f for the user across every repository they may read,
262// newest first. after continues from a page's last row; limit 0 means
263// every row.
264func (s *Store) QueryItems(userID int64, f ItemFilter, after *ItemCursor, limit int) ([]Item, error) {
265 q, args := itemUnion(userID, f, after)
266 if q == "" {
267 return nil, nil
268 }
269 q = "SELECT * FROM (" + q + ") ORDER BY created_at DESC, kord DESC, id DESC"
270 if limit > 0 {
271 q += fmt.Sprintf(" LIMIT %d", limit)
272 }
273 rows, err := s.DB.Query(q, args...)
274 if err != nil {
275 return nil, err
276 }
277 defer rows.Close()
278 var out []Item
279 for rows.Next() {
280 var it Item
281 var kord int
282 if err := rows.Scan(&it.Kind, &kord, &it.ID, &it.RepoPath, &it.Number, &it.Title, &it.Author,
283 &it.State, &it.Draft, &it.Milestone, &it.CreatedAt, &it.UpdatedAt); err != nil {
284 return nil, err
285 }
286 out = append(out, it)
287 }
288 return out, rows.Err()
289}
290
291// CountItems is how many rows QueryItems would return without a limit,
292// over the same readable repositories.
293func (s *Store) CountItems(userID int64, f ItemFilter) (int, error) {
294 q, args := itemUnion(userID, f, nil)
295 if q == "" {
296 return 0, nil
297 }
298 var n int
299 err := s.DB.QueryRow("SELECT COUNT(*) FROM ("+q+")", args...).Scan(&n)
300 return n, err
301}
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}
internal/web/templates/dashboard.html +16
@@ -42,6 +42,22 @@
4242{{if .MRs}}{{template "queue" dict "ID" "mrs" "Title" "Open merge requests" "Items" .MRs "Kind" "mrs" "Empty" "No open merge requests" "Hint" "Your MRs across all repos."}}{{end}}
4343{{if .Issues}}{{template "queue" dict "ID" "issues" "Title" "Open issues" "Items" .Issues "Kind" "issues" "Empty" "No open issues" "Hint" "Your issues across all repos."}}{{end}}
4444{{if not (or .Reviews .Assigned .MRs .Issues)}}<p class="none">Nothing open.</p>{{end}}
45{{range .Queries}}
46<h2 id="query-{{.Name}}"><a href="/{{$.Viewer}}/-/queries/{{.Name}}">{{.Name}}</a> <span class="count">{{.Count}}</span></h2>
47<p class="hint"><code>{{.Query}}</code></p>
48{{if .Error}}<p class="none">{{.Error}}</p>{{else}}
49<ul class="issuelist rows">
50{{range .Items}}<li>
51 <div class="issuemain">
52 <p class="title"><a href="/{{.Repo}}/{{if eq .Kind "mr"}}mrs{{else}}issues{{end}}/{{.Number}}">{{.Title}}</a></p>
53 <p class="meta"><span class="repo">{{.Ref}}</span> · <a href="/{{.Author}}">{{.Author}}</a> · {{when .UpdatedAt}}</p>
54 </div>
55</li>
56{{else}}<li class="empty">Nothing matches</li>{{end}}
57</ul>
58{{if gt .Count (len .Items)}}<p class="pager"><a href="/{{$.Viewer}}/-/queries/{{.Name}}">all {{.Count}} →</a></p>{{end}}
59{{end}}
60{{end}}
4561</section>
4662
4763<aside class="feedcol" aria-label="Recent activity">
internal/web/templates/queries.html added +35
@@ -0,0 +1,35 @@
1{{define "width"}}wide{{end}}
2{{define "title"}}{{if .Query.Name}}{{.Query.Name}} · {{end}}queries · {{.Site}}{{end}}
3{{define "content"}}
4{{if .Query.Name}}
5<div class="listhead">
6 <h1>{{.Query.Name}} <span class="count">{{.Count}}</span></h1>
7 <span class="spacer"></span>
8 <p class="meta"><a href="/{{.Viewer}}/-/queries">saved queries</a></p>
9</div>
10<p class="meta"><code>{{.Query.Query}}</code> · the same read as <code>gitbay query run {{.Query.Name}}</code></p>
11<ul class="issuelist rows">
12{{range .Items}}<li>
13 <div class="issuemain">
14 <p class="title"><a href="/{{.Repo}}/{{if eq .Kind "mr"}}mrs{{else}}issues{{end}}/{{.Number}}">{{.Title}}</a></p>
15 <p class="meta"><span class="repo">{{.Ref}}</span> · <a href="/{{.Author}}">{{.Author}}</a>{{if .Milestone}} · in {{.Milestone}}{{end}} · {{when .CreatedAt}}</p>
16 </div>
17 <span class="chip {{if eq .State "open"}}chip-open{{else if eq .State "merged"}}chip-done{{else}}chip-closed{{end}}">{{if .Draft}}draft{{else}}{{.State}}{{end}}</span>
18</li>
19{{else}}<li class="empty">nothing matches</li>{{end}}
20</ul>
21{{if .Next}}<p class="pager"><a href="{{.Next}}">older →</a></p>{{end}}
22{{else}}
23<h1>Saved queries</h1>
24<p class="meta">Saved with <code>gitbay query save &lt;name&gt; &lt;query&gt;</code>; <code>gitbay query pin &lt;name&gt;</code> puts one on the dashboard.</p>
25<ul class="issuelist rows">
26{{range .Saved}}<li>
27 <div class="issuemain">
28 <p class="title"><a href="/{{$.Viewer}}/-/queries/{{.Name}}">{{.Name}}</a>{{if .Pinned}} <span class="chip">pinned</span>{{end}}</p>
29 <p class="meta"><code>{{.Query}}</code></p>
30 </div>
31</li>
32{{else}}<li class="empty">no saved queries</li>{{end}}
33</ul>
34{{end}}
35{{end}}
internal/web/web_test.go +1 −1
@@ -89,7 +89,7 @@ func TestWhenNamesTheZone(t *testing.T) {
8989// none. The merge request page picks wide for its diff view, so it gets
9090// a per-view define instead of a fixed one.
9191func TestMainWidthClass(t *testing.T) {
92 wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "globalsearch.html": true, "edit.html": true, "dashboard.html": true, "issues.html": true, "mrs.html": true, "mrrangediff.html": true, "explore.html": true, "notifications.html": true, "settings.html": true, "account.html": true, "admin.html": true, "adminusers.html": true}
92 wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "globalsearch.html": true, "edit.html": true, "dashboard.html": true, "issues.html": true, "mrs.html": true, "mrrangediff.html": true, "explore.html": true, "notifications.html": true, "settings.html": true, "account.html": true, "admin.html": true, "adminusers.html": true, "queries.html": true}
9393 bounded := map[string]bool{"landing.html": true, "fork.html": true, "login.html": true, "logout.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true}
9494 perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`}
9595 for _, name := range Pages() {