Saved queries for issues and merge requests across repositories !531
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. | |||
| 309 | |----------------------------------+-----+-----+-----| | 309 | |----------------------------------+-----+-----+-----| |
| 310 | | search repositories | yes | yes | yes | | 310 | | search repositories | yes | yes | yes | |
| 311 | | search issues and merge requests | yes | yes | yes | | 311 | | 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 | | ||
| 312 | | browse all public repositories | yes | yes | yes | | 315 | | browse all public repositories | yes | yes | yes | |
| 313 | | profile page | yes | yes | yes | | 316 | | profile page | yes | yes | yes | |
| 314 | | profile sections as tabs | n/a | yes | n/a | | 317 | | 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 | |||
| 327 | syntax error. =repo grep= remains the per-repository file-contents | 330 | syntax error. =repo grep= remains the per-repository file-contents |
| 328 | search. | 331 | search. |
| 329 | 332 | ||
| 333 | A saved query is a named query over issues and merge requests across | ||
| 334 | every repository the caller can read (#292): =query save <name> | ||
| 335 | <query>= (=--force= replaces), =query list=, =query show= with its | ||
| 336 | count, =query run= for the rows, =query remove=, =query pin= and | ||
| 337 | =unpin=. =issue list --query <name>= and =mr list --query <name>= run | ||
| 338 | one narrowed to their kind, and =--q '<query>'= takes one written out. | ||
| 339 | Results are newest first, always paged with ={items, next}= and fifty | ||
| 340 | rows by default, each naming its repository. Readability is the | ||
| 341 | search rule, decided in the SQL, so a private repository someone else | ||
| 342 | owns is neither a row nor part of a count. =dashboard= carries each | ||
| 343 | pinned query's count and first five rows under =queries=; the web | ||
| 344 | shows them on the dashboard and serves =/<owner>/-/queries= and | ||
| 345 | =/<owner>/-/queries/<name>=, which are a 404 under anyone else's name. | ||
| 346 | Saving, removing and pinning have no web form yet. | ||
| 347 | |||
| 330 | The about text is =profile/README.{md,org,markdown}= on the default | 348 | The about text is =profile/README.{md,org,markdown}= on the default |
| 331 | branch of =<owner>/.gitbay=, resolved in that order, so the extension | 349 | branch of =<owner>/.gitbay=, resolved in that order, so the extension |
| 332 | picks the renderer rather than a stored format. =profile show= reports | 350 | picks 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 | |||
| 455 | merge request lists take the same names as query parameters, and each | 455 | merge request lists take the same names as query parameters, and each |
| 456 | active filter shows with a link that drops it. | 456 | active filter shows with a link that drops it. |
| 457 | 457 | ||
| 458 | A query spans repositories, and a saved one keeps it under a name: | ||
| 459 | |||
| 460 | #+begin_src sh | ||
| 461 | gitbay query save mine is:open assignee:@me | ||
| 462 | gitbay query save triage 'repo:krz/*' is:issue is:open no:label | ||
| 463 | gitbay query save v2 owner:krz label:bug 'label:needs review' milestone:v2 | ||
| 464 | gitbay query list / show mine / remove mine | ||
| 465 | gitbay query run mine [--limit 20] [--cursor <c>] # issues and MRs | ||
| 466 | gitbay issue list --query mine # only the issues | ||
| 467 | gitbay mr list --q 'owner:krz is:open author:@me' # a query written out | ||
| 468 | gitbay query pin mine # on the dashboard; unpin | ||
| 469 | #+end_src | ||
| 470 | |||
| 471 | The terms: =repo:owner/name=, =repo:owner/glob*= (=*= in the name | ||
| 472 | only), =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 | ||
| 476 | without a colon is text matched against title and body, as =search= | ||
| 477 | does. Every term narrows, except that several =repo:= and =owner:= | ||
| 478 | terms widen the scope to any of them; with none the query covers every | ||
| 479 | repository you can read, and only those — someone else's private | ||
| 480 | repository is neither a row nor part of a count. Merge requests have no | ||
| 481 | assignees, so =assignee:= means issues. A term that does not parse | ||
| 482 | exits 2 and names itself. =@me= is resolved when the query runs, and | ||
| 483 | the saved text is the query in a canonical order. | ||
| 484 | |||
| 485 | Rows come newest first, each naming its repository, paged as ={items, | ||
| 486 | next}= with fifty to a page unless =--limit= says otherwise. | ||
| 487 | An account keeps at most 50 saved queries, 10 of them pinned; past | ||
| 488 | either, =query save= or =query pin= exits 2 until one is removed or | ||
| 489 | unpinned. =dashboard --json= carries each pinned query's count and first five | ||
| 490 | rows under =queries=; the web dashboard shows them, and | ||
| 491 | =/you/-/queries= lists your saved queries with a page per query. | ||
| 492 | |||
| 458 | Labels take a colour: =gitbay label set bug --color cf222e=; =label | 493 | Labels take a colour: =gitbay label set bug --color cf222e=; =label |
| 459 | list= shows each with its colour and how many issues and merge requests | 494 | list= shows each with its colour and how many issues and merge requests |
| 460 | carry it, and =label remove= takes one off all of them. =issue label | 495 | carry 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. | |||
| 39 | - Reactions on issues, merge requests and their comments: =issue react= | 39 | - Reactions on issues, merge requests and their comments: =issue react= |
| 40 | and =mr react= with a fixed set of eight, counts and your own in | 40 | and =mr react= with a fixed set of eight, counts and your own in |
| 41 | =show=, comment ids in =show=, and buttons on the web pages (#291). | 41 | =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). | ||
| 42 | 50 | ||
| 43 | * v1.38.0 — 2026-09-29 | 51 | * v1.38.0 — 2026-09-29 |
| 44 | 52 | ||
cmd/gitbay/local.go +10
| @@ -672,3 +672,13 @@ func gitRaw(env, stdin string, args ...string) (string, int) { | |||
| 672 | } | 672 | } |
| 673 | return string(out), 0 | 673 | return string(out), 0 |
| 674 | } | 674 | } |
| 675 | |||
| 676 | // anyFlag reports whether any of flags is in args. | ||
| 677 | func 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 { | |||
| 59 | pass("feed", passOpts{server: []string{"feed"}}), | 59 | pass("feed", passOpts{server: []string{"feed"}}), |
| 60 | withShort(pass("explore", passOpts{server: []string{"explore"}}), "public repositories on this instance"), | 60 | withShort(pass("explore", passOpts{server: []string{"explore"}}), "public repositories on this instance"), |
| 61 | withShort(pass("search", passOpts{server: []string{"search"}}), "find repositories, issues and merge requests"), | 61 | 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 | ), | ||
| 62 | group("notifications", "your notification inbox", | 71 | group("notifications", "your notification inbox", |
| 63 | pass("list", passOpts{server: []string{"notifications", "list"}}), | 72 | pass("list", passOpts{server: []string{"notifications", "list"}}), |
| 64 | pass("read", passOpts{server: []string{"notifications", "read"}}), | 73 | pass("read", passOpts{server: []string{"notifications", "read"}}), |
| @@ -159,7 +168,7 @@ type rootSection struct { | |||
| 159 | } | 168 | } |
| 160 | 169 | ||
| 161 | var rootSections = []rootSection{ | 170 | var rootSections = []rootSection{ |
| 162 | {"WORK", []string{"issue", "mr", "build", "release", "milestone", "label", "search"}}, | 171 | {"WORK", []string{"issue", "mr", "build", "release", "milestone", "label", "search", "query"}}, |
| 163 | {"REPOSITORIES", []string{"repo", "wiki", "status", "webhook", "init"}}, | 172 | {"REPOSITORIES", []string{"repo", "wiki", "status", "webhook", "init"}}, |
| 164 | {"YOU", []string{"dashboard", "feed", "notifications", "auth", "profile", "snippet", "web"}}, | 173 | {"YOU", []string{"dashboard", "feed", "notifications", "auth", "profile", "snippet", "web"}}, |
| 165 | {"INSTANCE", []string{"org", "explore", "register", "migrate", "remote", "admin", "audit", "man"}}, | 174 | {"INSTANCE", []string{"org", "explore", "register", "migrate", "remote", "admin", "audit", "man"}}, |
| @@ -213,6 +222,7 @@ const stdinWhat = "gitbay.stdin_what" | |||
| 213 | type passOpts struct { | 222 | type passOpts struct { |
| 214 | server []string // server-side command path | 223 | server []string // server-side command path |
| 215 | needsRepo bool // prepend inferred owner/name unless given | 224 | needsRepo bool // prepend inferred owner/name unless given |
| 225 | crossRepo []string // flags that span repositories: with one, no repository is inferred | ||
| 216 | stdinOK bool // wire local stdin through when --file - asks for it | 226 | stdinOK bool // wire local stdin through when --file - asks for it |
| 217 | alwaysStdin bool // stdin is the payload, named by no flag: a bare redirect | 227 | alwaysStdin bool // stdin is the payload, named by no flag: a bare redirect |
| 218 | // stdinWhat names the payload for the prompt shown when stdin is a | 228 | // 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 { | |||
| 294 | return protocol.ExitFailure | 304 | return protocol.ExitFailure |
| 295 | } | 305 | } |
| 296 | explicitRepo := len(args) > 0 && !strings.HasPrefix(args[0], "-") && strings.Contains(args[0], "/") | 306 | explicitRepo := len(args) > 0 && !strings.HasPrefix(args[0], "-") && strings.Contains(args[0], "/") |
| 297 | if o.needsRepo { | 307 | if o.needsRepo && !anyFlag(args, o.crossRepo) { |
| 298 | args, err = withRepo(t, args) | 308 | args, err = withRepo(t, args) |
| 299 | if err != nil { | 309 | if err != nil { |
| 300 | fmt.Fprintln(os.Stderr, "gitbay:", err) | 310 | fmt.Fprintln(os.Stderr, "gitbay:", err) |
| @@ -632,7 +642,7 @@ func repoCmd() *cobra.Command { | |||
| 632 | func issueCmd() *cobra.Command { | 642 | func issueCmd() *cobra.Command { |
| 633 | return group("issue", "issues", | 643 | return group("issue", "issues", |
| 634 | pass("create", passOpts{server: []string{"issue", "create"}, needsRepo: true, stdinOK: true, editor: "issue"}), | 644 | 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"}}), |
| 636 | pass("show", passOpts{server: []string{"issue", "show"}, needsRepo: true}), | 646 | pass("show", passOpts{server: []string{"issue", "show"}, needsRepo: true}), |
| 637 | pass("comment", passOpts{server: []string{"issue", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}), | 647 | pass("comment", passOpts{server: []string{"issue", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}), |
| 638 | pass("close", passOpts{server: []string{"issue", "close"}, needsRepo: true}), | 648 | pass("close", passOpts{server: []string{"issue", "close"}, needsRepo: true}), |
| @@ -675,7 +685,7 @@ func mrCmd() *cobra.Command { | |||
| 675 | review.AddCommand(pass("request", passOpts{server: []string{"mr", "review", "request"}, needsRepo: true})) | 685 | review.AddCommand(pass("request", passOpts{server: []string{"mr", "review", "request"}, needsRepo: true})) |
| 676 | return group("mr", "merge requests", | 686 | return group("mr", "merge requests", |
| 677 | pass("create", passOpts{server: []string{"mr", "create"}, needsRepo: true, stdinOK: true, editor: "merge request", inferSource: true}), | 687 | 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"}}), |
| 679 | pass("show", passOpts{server: []string{"mr", "show"}, needsRepo: true}), | 689 | pass("show", passOpts{server: []string{"mr", "show"}, needsRepo: true}), |
| 680 | pass("diff", passOpts{server: []string{"mr", "diff"}, needsRepo: true}), | 690 | pass("diff", passOpts{server: []string{"mr", "diff"}, needsRepo: true}), |
| 681 | local("checkout", "fetch and check out the MR head locally: gitbay mr checkout <n>", cmdMRCheckout), | 691 | 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{ | |||
| 127 | "pgp remove": "remove an OpenPGP key by fingerprint", | 127 | "pgp remove": "remove an OpenPGP key by fingerprint", |
| 128 | "profile set": "set your profile", | 128 | "profile set": "set your profile", |
| 129 | "profile show": "show a user's or org's profile", | 129 | "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", | ||
| 130 | "register": "create an account (only meaningful for unregistered keys)", | 137 | "register": "create an account (only meaningful for unregistered keys)", |
| 131 | "release asset add": "upload an asset from stdin", | 138 | "release asset add": "upload an asset from stdin", |
| 132 | "release asset get": "write an asset to stdout", | 139 | "release asset get": "write an asset to stdout", |
e2e/readonly_test.go +4
| @@ -80,6 +80,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) { | |||
| 80 | must("", "org", "create", "theorg") | 80 | must("", "org", "create", "theorg") |
| 81 | must("", "org", "team", "create", "theorg", "core") | 81 | must("", "org", "team", "create", "theorg", "core") |
| 82 | must("", "token", "create", "--name", "t") | 82 | must("", "token", "create", "--name", "t") |
| 83 | must("", "query", "save", "q", "is:open") | ||
| 83 | must("", "web", "login") | 84 | must("", "web", "login") |
| 84 | pub, _ := os.ReadFile(deployKey + ".pub") | 85 | pub, _ := os.ReadFile(deployKey + ".pub") |
| 85 | must(string(pub), "repo", "deploy-key", "add", "alice/app") | 86 | must(string(pub), "repo", "deploy-key", "add", "alice/app") |
| @@ -163,6 +164,9 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) { | |||
| 163 | "notifications device list": nil, | 164 | "notifications device list": nil, |
| 164 | "repo bookmarks": nil, | 165 | "repo bookmarks": nil, |
| 165 | "search": {"app"}, | 166 | "search": {"app"}, |
| 167 | "query list": {}, | ||
| 168 | "query show": {"q"}, | ||
| 169 | "query run": {"q"}, | ||
| 166 | "mr revisions": {"alice/app", "1"}, | 170 | "mr revisions": {"alice/app", "1"}, |
| 167 | "mr range-diff": {"alice/app", "1"}, | 171 | "mr range-diff": {"alice/app", "1"}, |
| 168 | "webhook list": {"alice/app"}, | 172 | "webhook list": {"alice/app"}, |
e2e/savedqueries_test.go added +156
| @@ -0,0 +1,156 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 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). | ||
| 14 | func 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 { | |||
| 82 | Pinned []PinnedOut `json:"pinned"` | 82 | Pinned []PinnedOut `json:"pinned"` |
| 83 | Activity []FeedOut `json:"recent_activity"` | 83 | Activity []FeedOut `json:"recent_activity"` |
| 84 | Builds []DashboardBuild `json:"builds"` | 84 | Builds []DashboardBuild `json:"builds"` |
| 85 | // Queries is each pinned saved query with its first rows. | ||
| 86 | Queries []DashboardQuery `json:"queries"` | ||
| 85 | // Unread is the notification inbox badge, so a client showing one | 87 | // Unread is the notification inbox badge, so a client showing one |
| 86 | // does not need a second read to fill it. | 88 | // does not need a second read to fill it. |
| 87 | Unread int `json:"unread"` | 89 | Unread int `json:"unread"` |
| @@ -161,6 +163,9 @@ func runDashboard(c *Ctx, args []string) int { | |||
| 161 | for _, b := range builds { | 163 | for _, b := range builds { |
| 162 | d.Builds = append(d.Builds, DashboardBuild{b.RepoPath, b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt}) | 164 | d.Builds = append(d.Builds, DashboardBuild{b.RepoPath, b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt}) |
| 163 | } | 165 | } |
| 166 | if d.Queries, err = PinnedQueries(c.Store, c.User); err != nil { | ||
| 167 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 168 | } | ||
| 164 | d.Unread = c.Store.UnreadNotices(c.User.ID) | 169 | d.Unread = c.Store.UnreadNotices(c.User.ID) |
| 165 | if c.User.IsAdmin { | 170 | if c.User.IsAdmin { |
| 166 | d.Server = &ServerOut{Commit: buildinfo.String()} | 171 | d.Server = &ServerOut{Commit: buildinfo.String()} |
| @@ -217,6 +222,17 @@ func runDashboard(c *Ctx, args []string) int { | |||
| 217 | section("assigned to you:", itemHeader, itemRows(d.Assigned, "#")) | 222 | section("assigned to you:", itemHeader, itemRows(d.Assigned, "#")) |
| 218 | section("open merge requests:", itemHeader, itemRows(d.MRs, "!")) | 223 | section("open merge requests:", itemHeader, itemRows(d.MRs, "!")) |
| 219 | section("open issues:", itemHeader, itemRows(d.Issues, "#")) | 224 | 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 | } | ||
| 220 | 236 | ||
| 221 | pinnedRows := make([][]cell, len(d.Pinned)) | 237 | pinnedRows := make([][]cell, len(d.Pinned)) |
| 222 | for i, p := range d.Pinned { | 238 | for i, p := range d.Pinned { |
internal/control/help.go +1
| @@ -49,6 +49,7 @@ var nounSummaries = map[string]string{ | |||
| 49 | "repo": "create and manage repositories", | 49 | "repo": "create and manage repositories", |
| 50 | "runner": "the claim/report loop CI runners use", | 50 | "runner": "the claim/report loop CI runners use", |
| 51 | "search": "find repositories, issues and merge requests", | 51 | "search": "find repositories, issues and merge requests", |
| 52 | "query": "saved issue and merge request queries across repositories", | ||
| 52 | "snippet": "shared text files, outside any repository", | 53 | "snippet": "shared text files, outside any repository", |
| 53 | "status": "commit statuses (CI)", | 54 | "status": "commit statuses (CI)", |
| 54 | "token": "API tokens (minted over SSH, used with the JSON API)", | 55 | "token": "API tokens (minted over SSH, used with the JSON API)", |
internal/control/issue.go +16 −1
| @@ -35,7 +35,7 @@ func init() { | |||
| 35 | ReadsStdin: true, Run: runIssueCreate}) | 35 | ReadsStdin: true, Run: runIssueCreate}) |
| 36 | register(Command{Path: []string{"issue", "list"}, | 36 | register(Command{Path: []string{"issue", "list"}, |
| 37 | Summary: "list issues", | 37 | 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>]", |
| 39 | Flags: []Flag{ | 39 | Flags: []Flag{ |
| 40 | {"--state", "open|closed|all", "which issues", "open"}, | 40 | {"--state", "open|closed|all", "which issues", "open"}, |
| 41 | {"--label", "<l>", "only issues carrying this label", ""}, | 41 | {"--label", "<l>", "only issues carrying this label", ""}, |
| @@ -43,12 +43,16 @@ func init() { | |||
| 43 | {"--author", "<user>", "only issues opened by this user", ""}, | 43 | {"--author", "<user>", "only issues opened by this user", ""}, |
| 44 | {"--milestone", "<title>|none", "only issues in this milestone, or in none", ""}, | 44 | {"--milestone", "<title>|none", "only issues in this milestone, or in none", ""}, |
| 45 | {"--search", "<text>", "match title and body", ""}, | 45 | {"--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", ""}, | ||
| 46 | {"--limit", "<n>", "rows per page", ""}, | 48 | {"--limit", "<n>", "rows per page", ""}, |
| 47 | {"--cursor", "<c>", "continue from the previous page", ""}, | 49 | {"--cursor", "<c>", "continue from the previous page", ""}, |
| 48 | }, | 50 | }, |
| 49 | Examples: []string{ | 51 | Examples: []string{ |
| 50 | "issue list krz/gitbay --label bug --state all", | 52 | "issue list krz/gitbay --label bug --state all", |
| 51 | "issue list krz/gitbay --assignee cmc", | 53 | "issue list krz/gitbay --assignee cmc", |
| 54 | "issue list --query mine", | ||
| 55 | `issue list --q "owner:krz is:open assignee:@me"`, | ||
| 52 | }, | 56 | }, |
| 53 | ReadOnly: true, Run: runIssueList}) | 57 | ReadOnly: true, Run: runIssueList}) |
| 54 | register(Command{Path: []string{"issue", "show"}, | 58 | register(Command{Path: []string{"issue", "show"}, |
| @@ -279,6 +283,17 @@ func runIssueCreate(c *Ctx, args []string) int { | |||
| 279 | } | 283 | } |
| 280 | 284 | ||
| 281 | func runIssueList(c *Ctx, args []string) int { | 285 | func 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 | } | ||
| 282 | args, p, code := parsePageFlags(c, args, "issue", true) | 297 | args, p, code := parsePageFlags(c, args, "issue", true) |
| 283 | if code >= 0 { | 298 | if code >= 0 { |
| 284 | return code | 299 | return code |
internal/control/mr.go +16 −1
| @@ -105,19 +105,23 @@ func init() { | |||
| 105 | Run: runMRReady}) | 105 | Run: runMRReady}) |
| 106 | register(Command{Path: []string{"mr", "list"}, | 106 | register(Command{Path: []string{"mr", "list"}, |
| 107 | Summary: "list merge requests", | 107 | 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>]", |
| 109 | Flags: []Flag{ | 109 | Flags: []Flag{ |
| 110 | {"--state", "open|merged|closed|source_gone|all", "which merge requests", "open"}, | 110 | {"--state", "open|merged|closed|source_gone|all", "which merge requests", "open"}, |
| 111 | {"--label", "<l>", "only MRs carrying this label", ""}, | 111 | {"--label", "<l>", "only MRs carrying this label", ""}, |
| 112 | {"--author", "<user>", "only MRs opened by this user", ""}, | 112 | {"--author", "<user>", "only MRs opened by this user", ""}, |
| 113 | {"--milestone", "<title>|none", "only MRs in this milestone, or in none", ""}, | 113 | {"--milestone", "<title>|none", "only MRs in this milestone, or in none", ""}, |
| 114 | {"--search", "<text>", "match title and body", ""}, | 114 | {"--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", ""}, | ||
| 115 | {"--limit", "<n>", "rows per page", ""}, | 117 | {"--limit", "<n>", "rows per page", ""}, |
| 116 | {"--cursor", "<c>", "continue from the previous page", ""}, | 118 | {"--cursor", "<c>", "continue from the previous page", ""}, |
| 117 | }, | 119 | }, |
| 118 | Examples: []string{ | 120 | Examples: []string{ |
| 119 | "mr list krz/gitbay --state open", | 121 | "mr list krz/gitbay --state open", |
| 120 | "mr list krz/gitbay --author cmc --state all", | 122 | "mr list krz/gitbay --author cmc --state all", |
| 123 | "mr list --query reviews", | ||
| 124 | `mr list --q "repo:krz/* is:open author:@me"`, | ||
| 121 | }, | 125 | }, |
| 122 | ReadOnly: true, Run: runMRList}) | 126 | ReadOnly: true, Run: runMRList}) |
| 123 | register(Command{Path: []string{"mr", "show"}, | 127 | register(Command{Path: []string{"mr", "show"}, |
| @@ -619,6 +623,17 @@ func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut { | |||
| 619 | } | 623 | } |
| 620 | 624 | ||
| 621 | func runMRList(c *Ctx, args []string) int { | 625 | func 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 | } | ||
| 622 | args, p, code := parsePageFlags(c, args, "mr", true) | 637 | args, p, code := parsePageFlags(c, args, "mr", true) |
| 623 | if code >= 0 { | 638 | if code >= 0 { |
| 624 | return code | 639 | return code |
internal/control/query.go added +444
| @@ -0,0 +1,444 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 15 | func 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. | ||
| 67 | const queryDefaultLimit = 50 | ||
| 68 | |||
| 69 | // dashboardQueryItems is how many rows of each pinned query the | ||
| 70 | // dashboard carries. | ||
| 71 | const dashboardQueryItems = 5 | ||
| 72 | |||
| 73 | // Every pinned query is run on each dashboard read, so an account keeps | ||
| 74 | // a bounded number of each. | ||
| 75 | const ( | ||
| 76 | maxSavedQueries = 50 | ||
| 77 | maxPinnedQueries = 10 | ||
| 78 | ) | ||
| 79 | |||
| 80 | var queryNamePat = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`) | ||
| 81 | |||
| 82 | // SavedQueryOut is one saved query. Count is filled by query show. | ||
| 83 | type 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. | ||
| 92 | type 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. | ||
| 106 | func (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. | ||
| 113 | type 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 | |||
| 121 | func 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. | ||
| 133 | func 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 | |||
| 161 | func encodeItemCursor(k store.ItemCursor) string { | ||
| 162 | return encodeCursor("query", fmt.Sprintf("%s|%d|%d", k.CreatedAt, k.Kind, k.ID)) | ||
| 163 | } | ||
| 164 | |||
| 165 | func 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. | ||
| 179 | func 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. | ||
| 196 | func 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. | ||
| 233 | func 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. | ||
| 245 | func 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 | |||
| 279 | func savedQueryOut(sq store.SavedQuery) SavedQueryOut { | ||
| 280 | return SavedQueryOut{Name: sq.Name, Query: sq.Query, Pinned: sq.Pinned} | ||
| 281 | } | ||
| 282 | |||
| 283 | func 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 | |||
| 326 | func 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 | |||
| 351 | func 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 | |||
| 376 | func 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 | |||
| 391 | func 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 | |||
| 407 | func 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 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 15 | type queryEnv struct { | ||
| 16 | t *testing.T | ||
| 17 | st *store.Store | ||
| 18 | users map[string]store.User | ||
| 19 | } | ||
| 20 | |||
| 21 | func 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. | ||
| 43 | func (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 | |||
| 55 | func (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 | |||
| 67 | func 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. | ||
| 227 | func 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 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 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". | ||
| 24 | type 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. | ||
| 39 | type QueryError struct { | ||
| 40 | Token string | ||
| 41 | Msg string | ||
| 42 | } | ||
| 43 | |||
| 44 | func (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. | ||
| 49 | var 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. | ||
| 54 | func 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. | ||
| 89 | func 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 | |||
| 115 | func (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 | |||
| 217 | func (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. | ||
| 227 | func (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. | ||
| 234 | func (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. | ||
| 287 | func (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 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "errors" | ||
| 5 | "reflect" | ||
| 6 | "testing" | ||
| 7 | |||
| 8 | "gitbay.org/gitbay/internal/store" | ||
| 9 | ) | ||
| 10 | |||
| 11 | func 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 | |||
| 49 | func 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 | |||
| 88 | func 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) { | |||
| 59 | Assigned []store.DashboardItem | 59 | Assigned []store.DashboardItem |
| 60 | MRs []store.DashboardItem | 60 | MRs []store.DashboardItem |
| 61 | Issues []store.DashboardItem | 61 | Issues []store.DashboardItem |
| 62 | Queries []control.DashboardQuery | ||
| 62 | Feed []control.FeedLine | 63 | Feed []control.FeedLine |
| 63 | }{base, "dashboard", nil, nil, nil, nil, nil, nil}) | 64 | }{base, "dashboard", nil, nil, nil, nil, nil, nil, nil}) |
| 64 | if err != nil { | 65 | if err != nil { |
| 65 | t.Fatal(err) | 66 | t.Fatal(err) |
| 66 | } | 67 | } |
internal/httpd/dashpins_test.go +2 −1
| @@ -24,12 +24,13 @@ func TestDashboardTilesAndPins(t *testing.T) { | |||
| 24 | Assigned []store.DashboardItem | 24 | Assigned []store.DashboardItem |
| 25 | MRs []store.DashboardItem | 25 | MRs []store.DashboardItem |
| 26 | Issues []store.DashboardItem | 26 | Issues []store.DashboardItem |
| 27 | Queries []control.DashboardQuery | ||
| 27 | Feed []control.FeedLine | 28 | Feed []control.FeedLine |
| 28 | }{base, "dashboard", []pinnedRow{ | 29 | }{base, "dashboard", []pinnedRow{ |
| 29 | {Owner: "krz", Name: "gitbay", Issues: 3, MRs: 0, Build: "success"}, | 30 | {Owner: "krz", Name: "gitbay", Issues: 3, MRs: 0, Build: "success"}, |
| 30 | {Owner: "krz", Name: "old", Build: "cancelled"}, | 31 | {Owner: "krz", Name: "old", Build: "cancelled"}, |
| 31 | }, nil, nil, nil, | 32 | }, 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}) |
| 33 | if err != nil { | 34 | if err != nil { |
| 34 | t.Fatal(err) | 35 | t.Fatal(err) |
| 35 | } | 36 | } |
internal/httpd/queries.go added +78
| @@ -0,0 +1,78 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 15 | const 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. | ||
| 21 | func (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 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 15 | func 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, `"`)], "&", "&") | ||
| 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 { | |||
| 184 | Route{Method: "POST", Pattern: "/{owner}/-/milestones", Mutating: true, | 184 | Route{Method: "POST", Pattern: "/{owner}/-/milestones", Mutating: true, |
| 185 | Handler: s.checkOrigin(s.requireUser(s.orgMilestoneSubmit))}, | 185 | Handler: s.checkOrigin(s.requireUser(s.orgMilestoneSubmit))}, |
| 186 | Route{Method: "GET", Pattern: "/bookmarks", Handler: s.requireUser(s.bookmarksPage)}, | 186 | 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)}, | ||
| 187 | Route{Method: "GET", Pattern: "/{owner}/-/snippets/new", Handler: s.requireUser(s.snippetNewForm)}, | 189 | Route{Method: "GET", Pattern: "/{owner}/-/snippets/new", Handler: s.requireUser(s.snippetNewForm)}, |
| 188 | Route{Method: "POST", Pattern: "/{owner}/-/snippets/new", Mutating: true, | 190 | Route{Method: "POST", Pattern: "/{owner}/-/snippets/new", Mutating: true, |
| 189 | Handler: s.checkOrigin(s.requireUser(s.snippetNewSubmit))}, | 191 | 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. | |||
| 209 | reviews, _ := s.st.ReviewQueue(viewer.ID) | 209 | reviews, _ := s.st.ReviewQueue(viewer.ID) |
| 210 | assigned, _ := s.st.AssignedIssues(viewer.ID) | 210 | assigned, _ := s.st.AssignedIssues(viewer.ID) |
| 211 | events, _ := s.st.RecentEvents(viewer.ID, 20, 0) | 211 | events, _ := s.st.RecentEvents(viewer.ID, 20, 0) |
| 212 | queries, _ := control.PinnedQueries(s.st, viewer) | ||
| 212 | s.render(w, "dashboard.html", struct { | 213 | s.render(w, "dashboard.html", struct { |
| 213 | basePage | 214 | basePage |
| 214 | Tab string | 215 | Tab string |
| @@ -217,8 +218,9 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store. | |||
| 217 | Assigned []store.DashboardItem | 218 | Assigned []store.DashboardItem |
| 218 | MRs []store.DashboardItem | 219 | MRs []store.DashboardItem |
| 219 | Issues []store.DashboardItem | 220 | Issues []store.DashboardItem |
| 221 | Queries []control.DashboardQuery | ||
| 220 | Feed []control.FeedLine | 222 | 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)}) |
| 222 | } | 224 | } |
| 223 | 225 | ||
| 224 | func (s *Server) explore(w http.ResponseWriter, r *http.Request) { | 226 | func (s *Server) explore(w http.ResponseWriter, r *http.Request) { |
internal/store/migrations/0071_saved_queries.down.sql added +3
| @@ -0,0 +1,3 @@ | |||
| 1 | DROP INDEX merge_requests_repo_created; | ||
| 2 | DROP INDEX issues_repo_created; | ||
| 3 | DROP 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. | ||
| 4 | CREATE TABLE saved_queries ( | ||
| 5 | id INTEGER PRIMARY KEY, | ||
| 6 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | ||
| 7 | name TEXT NOT NULL, | ||
| 8 | query TEXT NOT NULL, | ||
| 9 | pinned INTEGER NOT NULL DEFAULT 0, | ||
| 10 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | ||
| 11 | updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | ||
| 12 | UNIQUE (user_id, name) | ||
| 13 | ); | ||
| 14 | |||
| 15 | -- A query reads each table by the repositories it may see, newest first. | ||
| 16 | CREATE INDEX issues_repo_created ON issues(repo_id, created_at); | ||
| 17 | CREATE INDEX merge_requests_repo_created ON merge_requests(repo_id, created_at); | ||
internal/store/savedqueries.go added +301
| @@ -0,0 +1,301 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "database/sql" | ||
| 5 | "errors" | ||
| 6 | "fmt" | ||
| 7 | "strings" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // SavedQuery is a user's named issue and merge request query (#292). | ||
| 11 | type SavedQuery struct { | ||
| 12 | Name string | ||
| 13 | Query string | ||
| 14 | Pinned bool | ||
| 15 | CreatedAt string | ||
| 16 | UpdatedAt string | ||
| 17 | } | ||
| 18 | |||
| 19 | // SaveQuery stores a query under name. replace allows overwriting one the | ||
| 20 | // user already has; without it an existing name is ErrExists. Replacing | ||
| 21 | // keeps the pin. | ||
| 22 | func (s *Store) SaveQuery(userID int64, name, query string, replace bool) error { | ||
| 23 | if replace { | ||
| 24 | _, err := s.DB.Exec(` | ||
| 25 | INSERT INTO saved_queries (user_id, name, query) VALUES (?, ?, ?) | ||
| 26 | ON CONFLICT (user_id, name) DO UPDATE SET query = excluded.query, | ||
| 27 | updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, userID, name, query) | ||
| 28 | return err | ||
| 29 | } | ||
| 30 | _, err := s.DB.Exec("INSERT INTO saved_queries (user_id, name, query) VALUES (?, ?, ?)", userID, name, query) | ||
| 31 | if isUniqueErr(err) { | ||
| 32 | return ErrExists | ||
| 33 | } | ||
| 34 | return err | ||
| 35 | } | ||
| 36 | |||
| 37 | func (s *Store) SavedQueryByName(userID int64, name string) (SavedQuery, error) { | ||
| 38 | var q SavedQuery | ||
| 39 | err := s.DB.QueryRow(`SELECT name, query, pinned, created_at, updated_at | ||
| 40 | FROM saved_queries WHERE user_id = ? AND name = ?`, userID, name). | ||
| 41 | Scan(&q.Name, &q.Query, &q.Pinned, &q.CreatedAt, &q.UpdatedAt) | ||
| 42 | if errors.Is(err, sql.ErrNoRows) { | ||
| 43 | return q, ErrNotFound | ||
| 44 | } | ||
| 45 | return q, err | ||
| 46 | } | ||
| 47 | |||
| 48 | // SavedQueries returns the user's queries by name; pinnedOnly narrows to | ||
| 49 | // the ones the dashboard shows. | ||
| 50 | func (s *Store) SavedQueries(userID int64, pinnedOnly bool) ([]SavedQuery, error) { | ||
| 51 | q := "SELECT name, query, pinned, created_at, updated_at FROM saved_queries WHERE user_id = ?" | ||
| 52 | if pinnedOnly { | ||
| 53 | q += " AND pinned = 1" | ||
| 54 | } | ||
| 55 | rows, err := s.DB.Query(q+" ORDER BY name", userID) | ||
| 56 | if err != nil { | ||
| 57 | return nil, err | ||
| 58 | } | ||
| 59 | defer rows.Close() | ||
| 60 | var out []SavedQuery | ||
| 61 | for rows.Next() { | ||
| 62 | var sq SavedQuery | ||
| 63 | if err := rows.Scan(&sq.Name, &sq.Query, &sq.Pinned, &sq.CreatedAt, &sq.UpdatedAt); err != nil { | ||
| 64 | return nil, err | ||
| 65 | } | ||
| 66 | out = append(out, sq) | ||
| 67 | } | ||
| 68 | return out, rows.Err() | ||
| 69 | } | ||
| 70 | |||
| 71 | // CountSavedQueries is how many queries the user has saved, and how many | ||
| 72 | // of them are pinned. | ||
| 73 | func (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 | |||
| 79 | func (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 | |||
| 90 | func (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. | ||
| 103 | type 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. | ||
| 110 | type 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. | ||
| 126 | type 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. | ||
| 133 | type 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. | ||
| 148 | func (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. | ||
| 158 | type numbered struct{ args []any } | ||
| 159 | |||
| 160 | func (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. | ||
| 169 | func 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. | ||
| 190 | func 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. | ||
| 247 | func 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. | ||
| 264 | func (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. | ||
| 293 | func (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 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "errors" | ||
| 5 | "fmt" | ||
| 6 | "testing" | ||
| 7 | ) | ||
| 8 | |||
| 9 | type queryFixture struct { | ||
| 10 | s *Store | ||
| 11 | alice, bob, carol int64 | ||
| 12 | pub, priv, bobPriv Repo | ||
| 13 | } | ||
| 14 | |||
| 15 | // newQueryFixture: alice owns a public and a private repository, bob a | ||
| 16 | // private one; carol owns nothing and is granted nothing. | ||
| 17 | func newQueryFixture(t *testing.T) queryFixture { | ||
| 18 | t.Helper() | ||
| 19 | s := open(t) | ||
| 20 | if err := s.MigrateUp(); err != nil { | ||
| 21 | t.Fatal(err) | ||
| 22 | } | ||
| 23 | var f queryFixture | ||
| 24 | f.s = s | ||
| 25 | must := func(id int64, err error) int64 { | ||
| 26 | t.Helper() | ||
| 27 | if err != nil { | ||
| 28 | t.Fatal(err) | ||
| 29 | } | ||
| 30 | return id | ||
| 31 | } | ||
| 32 | f.alice = must(s.CreateUser("alice", false)) | ||
| 33 | f.bob = must(s.CreateUser("bob", false)) | ||
| 34 | f.carol = must(s.CreateUser("carol", false)) | ||
| 35 | repo := func(owner int64, name, vis string) Repo { | ||
| 36 | r, err := s.RepoByID(must(s.CreateRepo("user", owner, name, vis))) | ||
| 37 | if err != nil { | ||
| 38 | t.Fatal(err) | ||
| 39 | } | ||
| 40 | return r | ||
| 41 | } | ||
| 42 | f.pub = repo(f.alice, "pub", "public") | ||
| 43 | f.priv = repo(f.alice, "priv", "private") | ||
| 44 | f.bobPriv = repo(f.bob, "secret", "private") | ||
| 45 | return f | ||
| 46 | } | ||
| 47 | |||
| 48 | func (f queryFixture) issue(t *testing.T, r Repo, author int64, title string) Issue { | ||
| 49 | t.Helper() | ||
| 50 | n, err := f.s.CreateIssue(r.ID, author, title, "", "md") | ||
| 51 | if err != nil { | ||
| 52 | t.Fatal(err) | ||
| 53 | } | ||
| 54 | i, err := f.s.IssueByNumber(r.ID, n) | ||
| 55 | if err != nil { | ||
| 56 | t.Fatal(err) | ||
| 57 | } | ||
| 58 | return i | ||
| 59 | } | ||
| 60 | |||
| 61 | func (f queryFixture) mr(t *testing.T, r Repo, author int64, title string) MR { | ||
| 62 | t.Helper() | ||
| 63 | n, err := f.s.CreateMR(r.ID, author, r.ID, "topic", "main", title, "", "", "md", false) | ||
| 64 | if err != nil { | ||
| 65 | t.Fatal(err) | ||
| 66 | } | ||
| 67 | m, err := f.s.MRByNumber(r.ID, n) | ||
| 68 | if err != nil { | ||
| 69 | t.Fatal(err) | ||
| 70 | } | ||
| 71 | return m | ||
| 72 | } | ||
| 73 | |||
| 74 | func titles(items []Item) []string { | ||
| 75 | var out []string | ||
| 76 | for _, it := range items { | ||
| 77 | out = append(out, it.Title) | ||
| 78 | } | ||
| 79 | return out | ||
| 80 | } | ||
| 81 | |||
| 82 | // A query reaches only what the caller may read: someone else's private | ||
| 83 | // repository is absent from the rows and from the count, even when the | ||
| 84 | // query names it. | ||
| 85 | func TestQueryItemsSkipsUnreadableRepositories(t *testing.T) { | ||
| 86 | f := newQueryFixture(t) | ||
| 87 | f.issue(t, f.pub, f.alice, "public bug") | ||
| 88 | f.issue(t, f.priv, f.alice, "alice private bug") | ||
| 89 | f.issue(t, f.bobPriv, f.bob, "bob private bug") | ||
| 90 | f.mr(t, f.bobPriv, f.bob, "bob private mr") | ||
| 91 | f.mr(t, f.pub, f.alice, "public mr") | ||
| 92 | |||
| 93 | all := ItemFilter{Issues: true, MRs: true} | ||
| 94 | for _, tc := range []struct { | ||
| 95 | name string | ||
| 96 | user int64 | ||
| 97 | f ItemFilter | ||
| 98 | want int | ||
| 99 | }{ | ||
| 100 | {"carol, everything", f.carol, all, 2}, | ||
| 101 | {"alice, everything", f.alice, all, 3}, | ||
| 102 | {"bob, everything", f.bob, all, 4}, | ||
| 103 | {"carol naming bob's repository", f.carol, ItemFilter{Issues: true, MRs: true, Scopes: []RepoScope{{Owner: "bob", Name: "secret"}}}, 0}, | ||
| 104 | {"carol naming bob", f.carol, ItemFilter{Issues: true, MRs: true, Scopes: []RepoScope{{Owner: "bob"}}}, 0}, | ||
| 105 | {"carol globbing alice", f.carol, ItemFilter{Issues: true, MRs: true, Scopes: []RepoScope{{Owner: "alice", Name: "p*"}}}, 2}, | ||
| 106 | {"alice globbing herself", f.alice, ItemFilter{Issues: true, MRs: true, Scopes: []RepoScope{{Owner: "alice", Name: "p*"}}}, 3}, | ||
| 107 | {"carol by bob's text", f.carol, ItemFilter{Issues: true, MRs: true, Text: "bob"}, 0}, | ||
| 108 | } { | ||
| 109 | items, err := f.s.QueryItems(tc.user, tc.f, nil, 0) | ||
| 110 | if err != nil { | ||
| 111 | t.Fatal(err) | ||
| 112 | } | ||
| 113 | n, err := f.s.CountItems(tc.user, tc.f) | ||
| 114 | if err != nil { | ||
| 115 | t.Fatal(err) | ||
| 116 | } | ||
| 117 | if len(items) != tc.want || n != tc.want { | ||
| 118 | t.Errorf("%s: %d rows %q, count %d; want %d", tc.name, len(items), titles(items), n, tc.want) | ||
| 119 | } | ||
| 120 | } | ||
| 121 | |||
| 122 | // A grant opens bob's repository to carol. | ||
| 123 | if err := f.s.GrantAccess(f.bobPriv.ID, f.carol, "read"); err != nil { | ||
| 124 | t.Fatal(err) | ||
| 125 | } | ||
| 126 | if n, _ := f.s.CountItems(f.carol, all); n != 4 { | ||
| 127 | t.Errorf("carol with a grant counts %d, want 4", n) | ||
| 128 | } | ||
| 129 | } | ||
| 130 | |||
| 131 | func TestQueryItemsFilters(t *testing.T) { | ||
| 132 | f := newQueryFixture(t) | ||
| 133 | bug := f.issue(t, f.pub, f.alice, "labelled bug") | ||
| 134 | if err := f.s.SetIssueLabel(f.pub, bug.ID, "bug", true); err != nil { | ||
| 135 | t.Fatal(err) | ||
| 136 | } | ||
| 137 | if err := f.s.SetIssueLabel(f.pub, bug.ID, "ui", true); err != nil { | ||
| 138 | t.Fatal(err) | ||
| 139 | } | ||
| 140 | both := f.issue(t, f.pub, f.bob, "assigned in milestone") | ||
| 141 | ms, err := f.s.CreateMilestone(f.pub, "v2", "", "") | ||
| 142 | if err != nil { | ||
| 143 | t.Fatal(err) | ||
| 144 | } | ||
| 145 | if err := f.s.SetIssueMilestone(both.ID, ms); err != nil { | ||
| 146 | t.Fatal(err) | ||
| 147 | } | ||
| 148 | if err := f.s.SetIssueAssignee(both.ID, f.carol, true); err != nil { | ||
| 149 | t.Fatal(err) | ||
| 150 | } | ||
| 151 | closed := f.issue(t, f.pub, f.alice, "closed one") | ||
| 152 | if err := f.s.SetIssueState(closed.ID, "closed"); err != nil { | ||
| 153 | t.Fatal(err) | ||
| 154 | } | ||
| 155 | merged := f.mr(t, f.pub, f.alice, "merged mr") | ||
| 156 | if err := f.s.MarkMerged(merged.ID, "", f.alice, ""); err != nil { | ||
| 157 | t.Fatal(err) | ||
| 158 | } | ||
| 159 | f.mr(t, f.pub, f.bob, "open mr") | ||
| 160 | |||
| 161 | for _, tc := range []struct { | ||
| 162 | f ItemFilter | ||
| 163 | want string | ||
| 164 | }{ | ||
| 165 | {ItemFilter{Issues: true, MRs: true, State: "open"}, "[open mr assigned in milestone labelled bug]"}, | ||
| 166 | {ItemFilter{Issues: true, MRs: true, State: "merged"}, "[merged mr]"}, | ||
| 167 | {ItemFilter{Issues: true, State: "closed"}, "[closed one]"}, | ||
| 168 | {ItemFilter{Issues: true, MRs: true, Labels: []string{"bug", "ui"}}, "[labelled bug]"}, | ||
| 169 | {ItemFilter{Issues: true, MRs: true, Labels: []string{"bug", "nope"}}, "[]"}, | ||
| 170 | {ItemFilter{Issues: true, NoLabel: true}, "[closed one assigned in milestone]"}, | ||
| 171 | {ItemFilter{Issues: true, Milestone: "v2"}, "[assigned in milestone]"}, | ||
| 172 | {ItemFilter{Issues: true, NoMilestone: true}, "[closed one labelled bug]"}, | ||
| 173 | {ItemFilter{Issues: true, Assignee: "carol"}, "[assigned in milestone]"}, | ||
| 174 | {ItemFilter{Issues: true, MRs: true, Author: "bob"}, "[open mr assigned in milestone]"}, | ||
| 175 | {ItemFilter{MRs: true}, "[open mr merged mr]"}, | ||
| 176 | {ItemFilter{}, "[]"}, | ||
| 177 | } { | ||
| 178 | items, err := f.s.QueryItems(f.alice, tc.f, nil, 0) | ||
| 179 | if err != nil { | ||
| 180 | t.Fatal(err) | ||
| 181 | } | ||
| 182 | if got := fmt.Sprint(titles(items)); got != tc.want && !(got == "[]" && tc.want == "[]") { | ||
| 183 | t.Errorf("%+v = %s, want %s", tc.f, got, tc.want) | ||
| 184 | } | ||
| 185 | } | ||
| 186 | } | ||
| 187 | |||
| 188 | // Paging walks every row once across repositories and both tables, even | ||
| 189 | // when rows share a creation time: the cursor carries the tie-breakers. | ||
| 190 | func TestQueryItemsPagesAcrossRepositories(t *testing.T) { | ||
| 191 | f := newQueryFixture(t) | ||
| 192 | var want []string | ||
| 193 | for i := 0; i < 5; i++ { | ||
| 194 | f.issue(t, f.pub, f.alice, fmt.Sprintf("pub issue %d", i)) | ||
| 195 | f.issue(t, f.priv, f.alice, fmt.Sprintf("priv issue %d", i)) | ||
| 196 | f.mr(t, f.pub, f.alice, fmt.Sprintf("pub mr %d", i)) | ||
| 197 | f.issue(t, f.bobPriv, f.bob, fmt.Sprintf("bob issue %d", i)) | ||
| 198 | } | ||
| 199 | // Half the rows at one instant, so ordering falls to kind and id. | ||
| 200 | if _, err := f.s.DB.Exec("UPDATE issues SET created_at = '2026-01-01T00:00:00.000Z' WHERE id % 2 = 0"); err != nil { | ||
| 201 | t.Fatal(err) | ||
| 202 | } | ||
| 203 | if _, err := f.s.DB.Exec("UPDATE merge_requests SET created_at = '2026-01-01T00:00:00.000Z' WHERE id % 2 = 1"); err != nil { | ||
| 204 | t.Fatal(err) | ||
| 205 | } | ||
| 206 | all := ItemFilter{Issues: true, MRs: true} | ||
| 207 | full, err := f.s.QueryItems(f.alice, all, nil, 0) | ||
| 208 | if err != nil { | ||
| 209 | t.Fatal(err) | ||
| 210 | } | ||
| 211 | if len(full) != 15 { | ||
| 212 | t.Fatalf("alice reads %d rows, want 15", len(full)) | ||
| 213 | } | ||
| 214 | for _, it := range full { | ||
| 215 | want = append(want, it.Kind+it.Title) | ||
| 216 | } | ||
| 217 | var got []string | ||
| 218 | var after *ItemCursor | ||
| 219 | for pages := 0; ; pages++ { | ||
| 220 | if pages > 20 { | ||
| 221 | t.Fatal("paging does not end") | ||
| 222 | } | ||
| 223 | page, err := f.s.QueryItems(f.alice, all, after, 4) | ||
| 224 | if err != nil { | ||
| 225 | t.Fatal(err) | ||
| 226 | } | ||
| 227 | for _, it := range page { | ||
| 228 | got = append(got, it.Kind+it.Title) | ||
| 229 | } | ||
| 230 | if len(page) < 4 { | ||
| 231 | break | ||
| 232 | } | ||
| 233 | c := page[len(page)-1].Cursor() | ||
| 234 | after = &c | ||
| 235 | } | ||
| 236 | if fmt.Sprint(got) != fmt.Sprint(want) { | ||
| 237 | t.Errorf("paged\n%v\nwant\n%v", got, want) | ||
| 238 | } | ||
| 239 | } | ||
| 240 | |||
| 241 | func TestSavedQueries(t *testing.T) { | ||
| 242 | f := newQueryFixture(t) | ||
| 243 | s := f.s | ||
| 244 | if err := s.SaveQuery(f.alice, "mine", "is:open", false); err != nil { | ||
| 245 | t.Fatal(err) | ||
| 246 | } | ||
| 247 | if err := s.SaveQuery(f.alice, "mine", "is:closed", false); !errors.Is(err, ErrExists) { | ||
| 248 | t.Fatalf("second save without replace: %v, want ErrExists", err) | ||
| 249 | } | ||
| 250 | if err := s.PinSavedQuery(f.alice, "mine", true); err != nil { | ||
| 251 | t.Fatal(err) | ||
| 252 | } | ||
| 253 | if err := s.SaveQuery(f.alice, "mine", "is:closed", true); err != nil { | ||
| 254 | t.Fatal(err) | ||
| 255 | } | ||
| 256 | q, err := s.SavedQueryByName(f.alice, "mine") | ||
| 257 | if err != nil || q.Query != "is:closed" || !q.Pinned { | ||
| 258 | t.Fatalf("after replace: %+v %v; want the new text, still pinned", q, err) | ||
| 259 | } | ||
| 260 | // Names are per user. | ||
| 261 | if err := s.SaveQuery(f.bob, "mine", "is:open", false); err != nil { | ||
| 262 | t.Fatal(err) | ||
| 263 | } | ||
| 264 | if _, err := s.SavedQueryByName(f.carol, "mine"); !errors.Is(err, ErrNotFound) { | ||
| 265 | t.Fatalf("carol sees alice's query: %v", err) | ||
| 266 | } | ||
| 267 | if err := s.SaveQuery(f.alice, "other", "is:open", false); err != nil { | ||
| 268 | t.Fatal(err) | ||
| 269 | } | ||
| 270 | pinned, err := s.SavedQueries(f.alice, true) | ||
| 271 | if err != nil || len(pinned) != 1 || pinned[0].Name != "mine" { | ||
| 272 | t.Fatalf("pinned = %+v %v", pinned, err) | ||
| 273 | } | ||
| 274 | if err := s.RemoveSavedQuery(f.alice, "mine"); err != nil { | ||
| 275 | t.Fatal(err) | ||
| 276 | } | ||
| 277 | if err := s.RemoveSavedQuery(f.alice, "mine"); !errors.Is(err, ErrNotFound) { | ||
| 278 | t.Fatalf("second remove: %v", err) | ||
| 279 | } | ||
| 280 | if err := s.PinSavedQuery(f.alice, "gone", true); !errors.Is(err, ErrNotFound) { | ||
| 281 | t.Fatalf("pin of a missing query: %v", err) | ||
| 282 | } | ||
| 283 | } | ||
internal/web/templates/dashboard.html +16
| @@ -42,6 +42,22 @@ | |||
| 42 | {{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}} | 42 | {{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}} |
| 43 | {{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}} | 43 | {{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}} |
| 44 | {{if not (or .Reviews .Assigned .MRs .Issues)}}<p class="none">Nothing open.</p>{{end}} | 44 | {{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}} | ||
| 45 | </section> | 61 | </section> |
| 46 | 62 | ||
| 47 | <aside class="feedcol" aria-label="Recent activity"> | 63 | <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 <name> <query></code>; <code>gitbay query pin <name></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) { | |||
| 89 | // none. The merge request page picks wide for its diff view, so it gets | 89 | // none. The merge request page picks wide for its diff view, so it gets |
| 90 | // a per-view define instead of a fixed one. | 90 | // a per-view define instead of a fixed one. |
| 91 | func TestMainWidthClass(t *testing.T) { | 91 | func 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} |
| 93 | 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} | 93 | 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} |
| 94 | perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`} | 94 | perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`} |
| 95 | for _, name := range Pages() { | 95 | for _, name := range Pages() { |