Commit 7b688211e7

7b688211e75fdc0402bd3ddba6ab536adcaf4f79

parent: efe33aeb0f

Verified · cmc

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

control: query save|list|show|run|remove|pin|unpin, issue and mr list --query/--q, pinned queries on dashboard

Ref #292

Layout: unified · split

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.
677func anyFlag(args, flags []string) bool {
678 for _, f := range flags {
679 if hasFlag(args, f) {
680 return true
681 }
682 }
683 return false
684}
cmd/gitbay/main.go +14 −4
@@ -59,6 +59,15 @@ func newRoot() *cobra.Command {
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
161var rootSections = []rootSection{ 170var 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"
213type passOpts struct { 222type 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 {
632func issueCmd() *cobra.Command { 642func 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",
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
281func runIssueList(c *Ctx, args []string) int { 285func 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
621func runMRList(c *Ctx, args []string) int { 625func 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 +411
@@ -0,0 +1,411 @@
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "regexp"
8 "strconv"
9 "strings"
10
11 "gitbay.org/gitbay/internal/protocol"
12 "gitbay.org/gitbay/internal/store"
13)
14
15func init() {
16 register(Command{Path: []string{"query", "save"},
17 Summary: "save an issue and merge request query across repositories under a name",
18 Usage: "query save <name> <query>... [--force]",
19 Flags: []Flag{
20 {"--force", "", "replace a query of the same name", ""},
21 },
22 Examples: []string{
23 "query save mine is:open assignee:@me",
24 `query save triage "repo:krz/*" is:issue is:open no:label`,
25 `query save v2 owner:krz is:open label:bug label:"needs review" milestone:v2`,
26 "query save mine-merged is:merged author:@me --force",
27 },
28 Run: runQuerySave})
29 register(Command{Path: []string{"query", "list"},
30 Summary: "list your saved queries",
31 Usage: "query list",
32 Examples: []string{"query list"},
33 ReadOnly: true, Run: runQueryList})
34 register(Command{Path: []string{"query", "show"},
35 Summary: "show a saved query and how many rows it matches",
36 Usage: "query show <name>",
37 Examples: []string{"query show mine"},
38 ReadOnly: true, Run: runQueryShow})
39 register(Command{Path: []string{"query", "run"},
40 Summary: "list the issues and merge requests a saved query matches",
41 Usage: "query run <name> [--limit <n>] [--cursor <c>]",
42 Flags: []Flag{
43 {"--limit", "<n>", "rows per page", strconv.Itoa(queryDefaultLimit)},
44 {"--cursor", "<c>", "continue from the previous page", ""},
45 },
46 Examples: []string{"query run mine --limit 20"},
47 ReadOnly: true, Run: runQueryRun})
48 register(Command{Path: []string{"query", "remove"},
49 Summary: "delete a saved query",
50 Usage: "query remove <name>",
51 Examples: []string{"query remove mine"},
52 Run: runQueryRemove})
53 register(Command{Path: []string{"query", "pin"},
54 Summary: "show a saved query on your dashboard",
55 Usage: "query pin <name>",
56 Examples: []string{"query pin mine"},
57 Run: func(c *Ctx, args []string) int { return runQueryPin(c, args, true) }})
58 register(Command{Path: []string{"query", "unpin"},
59 Summary: "take a saved query off your dashboard",
60 Usage: "query unpin <name>",
61 Examples: []string{"query unpin mine"},
62 Run: func(c *Ctx, args []string) int { return runQueryPin(c, args, false) }})
63}
64
65// queryDefaultLimit is a page when --limit is not given. A query spans
66// every repository the caller reads, so its listing is always paged.
67const queryDefaultLimit = 50
68
69// dashboardQueryItems is how many rows of each pinned query the
70// dashboard carries.
71const dashboardQueryItems = 5
72
73var queryNamePat = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)
74
75// SavedQueryOut is one saved query. Count is filled by query show.
76type SavedQueryOut struct {
77 Name string `json:"name"`
78 Query string `json:"query"`
79 Pinned bool `json:"pinned"`
80 Count *int `json:"count,omitempty"`
81}
82
83// QueryItem is one row of a cross-repository listing, naming its
84// repository.
85type QueryItem struct {
86 Kind string `json:"kind"` // issue or mr
87 Repo string `json:"repo"`
88 Number int64 `json:"number"`
89 Title string `json:"title"`
90 State string `json:"state"`
91 Draft bool `json:"draft,omitempty"`
92 Author string `json:"author"`
93 Milestone string `json:"milestone,omitempty"`
94 CreatedAt string `json:"created_at"`
95 UpdatedAt string `json:"updated_at"`
96}
97
98// Ref is the item as a person writes it: owner/name#n or owner/name!n.
99func (it QueryItem) Ref() string {
100 return fmt.Sprintf("%s%s%d", it.Repo, SearchMarker(it.Kind), it.Number)
101}
102
103// DashboardQuery is a pinned query on the dashboard: its first rows and
104// how many it matches in all. Error is set, and the rest empty, when the
105// saved text no longer parses.
106type DashboardQuery struct {
107 Name string `json:"name"`
108 Query string `json:"query"`
109 Count int `json:"count"`
110 Items []QueryItem `json:"items"`
111 Error string `json:"error,omitempty"`
112}
113
114func queryItems(items []store.Item) []QueryItem {
115 out := make([]QueryItem, 0, len(items))
116 for _, it := range items {
117 out = append(out, QueryItem{Kind: it.Kind, Repo: it.RepoPath, Number: it.Number, Title: it.Title,
118 State: it.State, Draft: it.Draft, Author: it.Author, Milestone: it.Milestone,
119 CreatedAt: it.CreatedAt, UpdatedAt: it.UpdatedAt})
120 }
121 return out
122}
123
124// PinnedQueries runs each of user's pinned queries for the dashboard. It
125// is exported for the web dashboard, which reads the store directly.
126func PinnedQueries(st *store.Store, user store.User) ([]DashboardQuery, error) {
127 saved, err := st.SavedQueries(user.ID, true)
128 if err != nil {
129 return nil, err
130 }
131 out := []DashboardQuery{}
132 for _, sq := range saved {
133 d := DashboardQuery{Name: sq.Name, Query: sq.Query, Items: []QueryItem{}}
134 q, err := ParseItemQuery(sq.Query)
135 if err != nil {
136 d.Error = err.Error()
137 out = append(out, d)
138 continue
139 }
140 f := q.Filter(user.Username, true, true)
141 if d.Count, err = st.CountItems(user.ID, f); err != nil {
142 return nil, err
143 }
144 items, err := st.QueryItems(user.ID, f, nil, dashboardQueryItems)
145 if err != nil {
146 return nil, err
147 }
148 d.Items = queryItems(items)
149 out = append(out, d)
150 }
151 return out, nil
152}
153
154func encodeItemCursor(k store.ItemCursor) string {
155 return encodeCursor("query", fmt.Sprintf("%s|%d|%d", k.CreatedAt, k.Kind, k.ID))
156}
157
158func decodeItemCursor(key string) (*store.ItemCursor, error) {
159 parts := strings.Split(key, "|")
160 if len(parts) != 3 {
161 return nil, errors.New("bad cursor")
162 }
163 kind, err1 := strconv.Atoi(parts[1])
164 id, err2 := strconv.ParseInt(parts[2], 10, 64)
165 if err1 != nil || err2 != nil || (kind != 0 && kind != 1) || parts[0] == "" {
166 return nil, errors.New("bad cursor")
167 }
168 return &store.ItemCursor{CreatedAt: parts[0], Kind: kind, ID: id}, nil
169}
170
171// savedQuery loads one of the caller's queries and parses it.
172func savedQuery(c *Ctx, name string) (store.SavedQuery, ItemQuery, int) {
173 sq, err := c.Store.SavedQueryByName(c.User.ID, name)
174 if errors.Is(err, store.ErrNotFound) {
175 return sq, ItemQuery{}, c.fail(protocol.ExitNotFound, "no saved query %q; query list shows yours", name)
176 }
177 if err != nil {
178 return sq, ItemQuery{}, c.fail(protocol.ExitFailure, "%v", err)
179 }
180 q, err := ParseItemQuery(sq.Query)
181 if err != nil {
182 return sq, q, c.fail(protocol.ExitFailure, "saved query %s no longer parses (%v); save it again with --force", name, err)
183 }
184 return sq, q, -1
185}
186
187// runItemQuery lists what q matches in the tables issues and mrs allow,
188// one page at a time. The output is always the paged shape.
189func runItemQuery(c *Ctx, q ItemQuery, issues, mrs bool, p page) int {
190 var after *store.ItemCursor
191 if p.key != "" {
192 var err error
193 if after, err = decodeItemCursor(p.key); err != nil {
194 return c.fail(protocol.ExitUsage, "bad cursor")
195 }
196 }
197 if p.limit == 0 {
198 p.limit = queryDefaultLimit
199 }
200 p.active = true
201 items, err := c.Store.QueryItems(c.User.ID, q.Filter(c.User.Username, issues, mrs), after, p.queryLimit())
202 if err != nil {
203 return c.fail(protocol.ExitFailure, "%v", err)
204 }
205 next := ""
206 if len(items) > p.limit {
207 items = items[:p.limit]
208 next = encodeItemCursor(items[len(items)-1].Cursor())
209 }
210 ds := queryItems(items)
211 return c.emitPage(p, ds, next, func(w io.Writer) {
212 tb := c.table(w, "REF", "STATE", "TITLE", "AUTHOR")
213 for _, d := range ds {
214 state := d.State
215 if d.Draft {
216 state = "draft"
217 }
218 tb.row(cRef(d.Ref()), cState(state), cFlex(d.Title), cText(d.Author))
219 }
220 tb.flush()
221 })
222}
223
224// usesQuery reports whether issue list or mr list was given a query,
225// which changes the cursor they page with.
226func usesQuery(args []string) bool {
227 for _, a := range args {
228 if a == "--query" || a == "--q" {
229 return true
230 }
231 }
232 return false
233}
234
235// listByQuery is issue list and mr list given --query or --q: the query
236// spans repositories, so it takes none as an argument and no other
237// filter flag. kind is the command's noun.
238func listByQuery(c *Ctx, fl flags, kind string, p page) int {
239 if fl.Has("--query") && fl.Has("--q") {
240 return c.usageWith("--query and --q are two ways to give one query; pass one")
241 }
242 if len(fl.Pos) > 0 {
243 return c.usageWith("a query spans repositories; name them in it (repo:" + fl.Pos[0] + ") rather than as an argument")
244 }
245 for _, f := range []string{"--state", "--label", "--assignee", "--author", "--milestone", "--search"} {
246 if fl.Has(f) {
247 return c.usageWith(f + " does not combine with a query; put it in the query")
248 }
249 }
250 var q ItemQuery
251 if fl.Has("--query") {
252 var code int
253 if _, q, code = savedQuery(c, fl.Value("--query")); code >= 0 {
254 return code
255 }
256 } else {
257 var err error
258 if q, err = ParseItemQuery(fl.Value("--q")); err != nil {
259 return c.fail(protocol.ExitUsage, "%v", err)
260 }
261 }
262 issues, mrs := q.Selects()
263 if kind == "issue" && !issues {
264 return c.usageWith("that query matches only merge requests; use mr list or query run")
265 }
266 if kind == "mr" && !mrs {
267 return c.usageWith("that query matches only issues; use issue list or query run")
268 }
269 return runItemQuery(c, q, kind == "issue", kind == "mr", p)
270}
271
272func savedQueryOut(sq store.SavedQuery) SavedQueryOut {
273 return SavedQueryOut{Name: sq.Name, Query: sq.Query, Pinned: sq.Pinned}
274}
275
276func runQuerySave(c *Ctx, args []string) int {
277 fl, err := c.parseArgs(args, flagSpec{Bools: []string{"--force"}, MaxPos: -1, Usage: c.Cmd.Usage})
278 if err != nil {
279 return c.fail(protocol.ExitUsage, "%v", err)
280 }
281 if len(fl.Pos) < 2 {
282 return c.usage()
283 }
284 name := fl.Pos[0]
285 if !queryNamePat.MatchString(name) {
286 return c.fail(protocol.ExitUsage, "invalid query name %q: lowercase letters, digits, '.', '-', '_'; must start with a letter or digit; max 64 chars", name)
287 }
288 q, err := ParseItemQuery(fl.Pos[1:]...)
289 if err != nil {
290 return c.fail(protocol.ExitUsage, "%v", err)
291 }
292 err = c.Store.SaveQuery(c.User.ID, name, q.String(), fl.Has("--force"))
293 if errors.Is(err, store.ErrExists) {
294 return c.fail(protocol.ExitFailure, "you already have a query named %s; pass --force to replace it", name)
295 }
296 if err != nil {
297 return c.fail(protocol.ExitFailure, "%v", err)
298 }
299 sq, err := c.Store.SavedQueryByName(c.User.ID, name)
300 if err != nil {
301 return c.fail(protocol.ExitFailure, "%v", err)
302 }
303 return c.emit(savedQueryOut(sq), func(w io.Writer) {
304 fmt.Fprintf(w, "saved %s: %s\n", sq.Name, sq.Query)
305 })
306}
307
308func runQueryList(c *Ctx, args []string) int {
309 if len(args) != 0 {
310 return c.usage()
311 }
312 saved, err := c.Store.SavedQueries(c.User.ID, false)
313 if err != nil {
314 return c.fail(protocol.ExitFailure, "%v", err)
315 }
316 ds := []SavedQueryOut{}
317 for _, sq := range saved {
318 ds = append(ds, savedQueryOut(sq))
319 }
320 return c.emit(ds, func(w io.Writer) {
321 tb := c.table(w, "NAME", "PINNED", "QUERY")
322 for _, d := range ds {
323 pinned := ""
324 if d.Pinned {
325 pinned = "pinned"
326 }
327 tb.row(cRef(d.Name), cText(pinned), cFlex(d.Query))
328 }
329 tb.flush()
330 })
331}
332
333func runQueryShow(c *Ctx, args []string) int {
334 if len(args) != 1 {
335 return c.usage()
336 }
337 sq, q, code := savedQuery(c, args[0])
338 if code >= 0 {
339 return code
340 }
341 n, err := c.Store.CountItems(c.User.ID, q.Filter(c.User.Username, true, true))
342 if err != nil {
343 return c.fail(protocol.ExitFailure, "%v", err)
344 }
345 d := savedQueryOut(sq)
346 d.Count = &n
347 return c.emit(d, func(w io.Writer) {
348 v := c.view(w)
349 v.title(d.Name, "", "")
350 pinned := "no"
351 if d.Pinned {
352 pinned = "yes"
353 }
354 v.fields("query", d.Query, "matches", strconv.Itoa(n), "pinned", pinned)
355 })
356}
357
358func runQueryRun(c *Ctx, args []string) int {
359 rest, p, code := parsePageFlags(c, args, "query", false)
360 if code >= 0 {
361 return code
362 }
363 if len(rest) != 1 {
364 return c.usage()
365 }
366 _, q, code := savedQuery(c, rest[0])
367 if code >= 0 {
368 return code
369 }
370 return runItemQuery(c, q, true, true, p)
371}
372
373func runQueryRemove(c *Ctx, args []string) int {
374 if len(args) != 1 {
375 return c.usage()
376 }
377 err := c.Store.RemoveSavedQuery(c.User.ID, args[0])
378 if errors.Is(err, store.ErrNotFound) {
379 return c.fail(protocol.ExitNotFound, "no saved query %q; query list shows yours", args[0])
380 }
381 if err != nil {
382 return c.fail(protocol.ExitFailure, "%v", err)
383 }
384 return c.emit(map[string]string{"removed": args[0]}, func(w io.Writer) {
385 fmt.Fprintf(w, "removed %s\n", args[0])
386 })
387}
388
389func runQueryPin(c *Ctx, args []string, pinned bool) int {
390 if len(args) != 1 {
391 return c.usage()
392 }
393 err := c.Store.PinSavedQuery(c.User.ID, args[0], pinned)
394 if errors.Is(err, store.ErrNotFound) {
395 return c.fail(protocol.ExitNotFound, "no saved query %q; query list shows yours", args[0])
396 }
397 if err != nil {
398 return c.fail(protocol.ExitFailure, "%v", err)
399 }
400 sq, err := c.Store.SavedQueryByName(c.User.ID, args[0])
401 if err != nil {
402 return c.fail(protocol.ExitFailure, "%v", err)
403 }
404 return c.emit(savedQueryOut(sq), func(w io.Writer) {
405 verb := "unpinned"
406 if pinned {
407 verb = "pinned"
408 }
409 fmt.Fprintf(w, "%s %s\n", verb, sq.Name)
410 })
411}
internal/control/query_test.go added +222
@@ -0,0 +1,222 @@
1package control
2
3import (
4 "bytes"
5 "encoding/json"
6 "strings"
7 "testing"
8
9 "gitbay.org/gitbay/internal/config"
10 "gitbay.org/gitbay/internal/protocol"
11 "gitbay.org/gitbay/internal/store"
12)
13
14type queryEnv struct {
15 t *testing.T
16 st *store.Store
17 users map[string]store.User
18}
19
20func newQueryEnv(t *testing.T) queryEnv {
21 st, err := store.Open(":memory:")
22 if err != nil {
23 t.Fatal(err)
24 }
25 t.Cleanup(func() { st.Close() })
26 if err := st.MigrateUp(); err != nil {
27 t.Fatal(err)
28 }
29 e := queryEnv{t, st, map[string]store.User{}}
30 for _, name := range []string{"alice", "bob"} {
31 id, err := st.CreateUser(name, false)
32 if err != nil {
33 t.Fatal(err)
34 }
35 e.users[name] = store.User{ID: id, Username: name}
36 }
37 return e
38}
39
40// run dispatches argv as user with --json and returns the exit code,
41// the envelope's data and stderr.
42func (e queryEnv) run(user string, argv ...string) (int, json.RawMessage, string) {
43 var out, errOut bytes.Buffer
44 c := &Ctx{User: e.users[user], Scope: "full", Store: e.st, Cfg: config.Config{},
45 Stdin: strings.NewReader(""), Stdout: &out, Stderr: &errOut}
46 code := Dispatch(c, append(argv, "--json"))
47 var env struct {
48 Data json.RawMessage `json:"data"`
49 }
50 json.Unmarshal(out.Bytes(), &env)
51 return code, env.Data, errOut.String() + out.String()
52}
53
54func (e queryEnv) repo(owner, name, vis string) store.Repo {
55 id, err := e.st.CreateRepo("user", e.users[owner].ID, name, vis)
56 if err != nil {
57 e.t.Fatal(err)
58 }
59 r, err := e.st.RepoByID(id)
60 if err != nil {
61 e.t.Fatal(err)
62 }
63 return r
64}
65
66func TestQueryCommands(t *testing.T) {
67 e := newQueryEnv(t)
68 pub := e.repo("alice", "pub", "public")
69 secret := e.repo("bob", "secret", "private")
70 for i, r := range []store.Repo{pub, secret, pub} {
71 owner := e.users[r.OwnerName]
72 if _, err := e.st.CreateIssue(r.ID, owner.ID, "issue "+string(rune('a'+i)), "", "md"); err != nil {
73 t.Fatal(err)
74 }
75 }
76 if _, err := e.st.CreateMR(pub.ID, e.users["alice"].ID, pub.ID, "topic", "main", "an mr", "", "", "md", false); err != nil {
77 t.Fatal(err)
78 }
79
80 if code, _, msg := e.run("alice", "query", "save", "Bad Name", "is:open"); code != protocol.ExitUsage {
81 t.Fatalf("bad name: exit %d %s", code, msg)
82 }
83 if code, _, msg := e.run("alice", "query", "save", "open", "is:open", "bogus:x"); code != protocol.ExitUsage || !strings.Contains(msg, "bogus:x") {
84 t.Fatalf("bad term: exit %d %s; want 2 naming the term", code, msg)
85 }
86 code, data, msg := e.run("alice", "query", "save", "open", "is:open is:open", "label:needs review")
87 if code != 0 {
88 t.Fatalf("save: exit %d %s", code, msg)
89 }
90 var saved SavedQueryOut
91 json.Unmarshal(data, &saved)
92 if saved.Query != "is:open label:needs review" {
93 t.Errorf("stored %q: want the canonical text", saved.Query)
94 }
95 if code, _, msg := e.run("alice", "query", "save", "open", "is:open"); code != protocol.ExitFailure || !strings.Contains(msg, "--force") {
96 t.Fatalf("save over an existing name: exit %d %s", code, msg)
97 }
98 if code, _, msg := e.run("alice", "query", "save", "open", "is:open", "--force"); code != 0 {
99 t.Fatalf("save --force: exit %d %s", code, msg)
100 }
101
102 // bob's saved query sees his private repository; the same text run
103 // by alice does not, and her count does not include it.
104 for _, u := range []string{"alice", "bob"} {
105 if code, _, msg := e.run(u, "query", "save", "all", "is:open"); code != 0 {
106 t.Fatalf("save as %s: exit %d %s", u, code, msg)
107 }
108 }
109 count := func(user string) int {
110 code, data, msg := e.run(user, "query", "show", "all")
111 if code != 0 {
112 t.Fatalf("show as %s: exit %d %s", user, code, msg)
113 }
114 var d SavedQueryOut
115 json.Unmarshal(data, &d)
116 return *d.Count
117 }
118 if a, b := count("alice"), count("bob"); a != 3 || b != 4 {
119 t.Errorf("counts alice %d bob %d, want 3 and 4", a, b)
120 }
121
122 type pageOut struct {
123 Items []QueryItem `json:"items"`
124 Next string `json:"next"`
125 }
126 var seen []string
127 cursor := ""
128 for i := 0; i < 5; i++ {
129 argv := []string{"query", "run", "all", "--limit", "1"}
130 if cursor != "" {
131 argv = append(argv, "--cursor", cursor)
132 }
133 code, data, msg := e.run("alice", argv...)
134 if code != 0 {
135 t.Fatalf("run: exit %d %s", code, msg)
136 }
137 var p pageOut
138 if err := json.Unmarshal(data, &p); err != nil {
139 t.Fatal(err)
140 }
141 for _, it := range p.Items {
142 seen = append(seen, it.Ref())
143 }
144 if cursor = p.Next; cursor == "" {
145 break
146 }
147 }
148 if got := strings.Join(seen, " "); got != "alice/pub!1 alice/pub#2 alice/pub#1" {
149 t.Errorf("paged run = %s", got)
150 }
151
152 // issue list and mr list narrow to their own kind.
153 code, data, msg = e.run("alice", "issue", "list", "--query", "all")
154 var p pageOut
155 json.Unmarshal(data, &p)
156 if code != 0 || len(p.Items) != 2 || p.Items[0].Kind != "issue" {
157 t.Errorf("issue list --query: exit %d %s", code, msg)
158 }
159 code, data, msg = e.run("alice", "mr", "list", "--q", "repo:alice/* is:open")
160 p = pageOut{}
161 json.Unmarshal(data, &p)
162 if code != 0 || len(p.Items) != 1 || p.Items[0].Repo != "alice/pub" {
163 t.Errorf("mr list --q: exit %d %s", code, msg)
164 }
165 for _, argv := range [][]string{
166 {"issue", "list", "alice/pub", "--query", "all"},
167 {"issue", "list", "--query", "all", "--state", "closed"},
168 {"issue", "list", "--query", "all", "--q", "is:open"},
169 {"issue", "list", "--q", "is:mr"},
170 {"mr", "list", "--q", "assignee:@me"},
171 {"mr", "list", "--q", "nope:x"},
172 {"issue", "list", "--query", "all", "--cursor", "bad"},
173 {"issue", "list", "--query", "all", "--cursor", encodeCursor("issue", "3")},
174 } {
175 if code, _, msg := e.run("alice", argv...); code != protocol.ExitUsage {
176 t.Errorf("%q: exit %d %s, want usage", argv, code, msg)
177 }
178 }
179 if code, _, _ := e.run("alice", "issue", "list", "--query", "nosuch"); code != protocol.ExitNotFound {
180 t.Errorf("unknown saved query: exit %d, want not found", code)
181 }
182
183 // Pinned queries reach the dashboard with their count and first rows.
184 if code, _, msg := e.run("alice", "query", "pin", "all"); code != 0 {
185 t.Fatalf("pin: exit %d %s", code, msg)
186 }
187 code, data, msg = e.run("alice", "dashboard")
188 if code != 0 {
189 t.Fatalf("dashboard: exit %d %s", code, msg)
190 }
191 var d DashboardOut
192 json.Unmarshal(data, &d)
193 if len(d.Queries) != 1 || d.Queries[0].Name != "all" || d.Queries[0].Count != 3 || len(d.Queries[0].Items) != 3 {
194 t.Errorf("dashboard queries = %+v", d.Queries)
195 }
196 if code, _, _ := e.run("alice", "query", "unpin", "all"); code != 0 {
197 t.Fatal("unpin")
198 }
199 _, data, _ = e.run("alice", "dashboard")
200 d = DashboardOut{}
201 json.Unmarshal(data, &d)
202 if d.Queries == nil || len(d.Queries) != 0 {
203 t.Errorf("after unpin, dashboard queries = %#v, want []", d.Queries)
204 }
205
206 code, data, _ = e.run("alice", "query", "list")
207 var list []SavedQueryOut
208 json.Unmarshal(data, &list)
209 if code != 0 || len(list) != 2 || list[0].Name != "all" || list[1].Name != "open" {
210 t.Errorf("query list = %+v", list)
211 }
212 if code, _, _ := e.run("alice", "query", "remove", "all"); code != 0 {
213 t.Fatal("remove")
214 }
215 if code, _, _ := e.run("alice", "query", "show", "all"); code != protocol.ExitNotFound {
216 t.Errorf("show after remove: exit %d", code)
217 }
218 // bob's query of the same name is his own.
219 if code, _, _ := e.run("bob", "query", "show", "all"); code != 0 {
220 t.Errorf("bob lost his query when alice removed hers")
221 }
222}
internal/control/querylang.go added +300
@@ -0,0 +1,300 @@
1package control
2
3import (
4 "fmt"
5 "regexp"
6 "strings"
7
8 "gitbay.org/gitbay/internal/policy"
9 "gitbay.org/gitbay/internal/store"
10)
11
12// ItemQuery is a parsed issue and merge request query (#292): terms
13// separated by spaces, every term narrowing the result.
14//
15// repo:owner/name repo:owner/glob* repo:* owner:name
16// is:open|closed|merged is:issue|mr
17// label:x (repeatable, all must match) no:label
18// milestone:x no:milestone
19// assignee:user|@me author:user|@me
20// anything else without a colon: text matched against title and body
21//
22// Several repo: and owner: terms widen the scope to any of them. A value
23// with spaces is written in double quotes, label:"needs review".
24type ItemQuery struct {
25 Scopes []store.RepoScope
26 AnyRepo bool // repo:*
27 Kind string
28 State string
29 Labels []string
30 NoLabel bool
31 Milestone string
32 NoMilestone bool
33 Assignee string
34 Author string
35 Text []string
36}
37
38// QueryError is a query that does not parse. Token is the term at fault.
39type QueryError struct {
40 Token string
41 Msg string
42}
43
44func (e *QueryError) Error() string { return fmt.Sprintf("query term %q: %s", e.Token, e.Msg) }
45
46// repoGlob is a repository name that may carry * wildcards; names hold
47// none of GLOB's other metacharacters, so * is the only one that reaches
48// SQLite.
49var repoGlob = regexp.MustCompile(`^[a-z0-9._*-]{1,64}$`)
50
51// queryTokens splits s on whitespace, keeping double-quoted runs whole
52// and dropping the quotes. quoted marks a token that opened with a quote:
53// it is text even if it holds a colon.
54func queryTokens(s string) (toks []string, quoted []bool, err error) {
55 var cur strings.Builder
56 in, have, startQuoted := false, false, false
57 flush := func() {
58 if have {
59 toks = append(toks, cur.String())
60 quoted = append(quoted, startQuoted)
61 }
62 cur.Reset()
63 have, startQuoted = false, false
64 }
65 for _, r := range s {
66 switch {
67 case r == '"':
68 if !have {
69 startQuoted = true
70 }
71 in, have = !in, true
72 case !in && (r == ' ' || r == '\t' || r == '\n' || r == '\r'):
73 flush()
74 default:
75 cur.WriteRune(r)
76 have = true
77 }
78 }
79 if in {
80 return nil, nil, &QueryError{Token: s, Msg: "unterminated quote"}
81 }
82 flush()
83 return toks, quoted, nil
84}
85
86// ParseItemQuery parses the words of a query. Each word may itself hold
87// several terms, so a query arrives the same whether it was one quoted
88// argument or many.
89func ParseItemQuery(words ...string) (ItemQuery, error) {
90 var q ItemQuery
91 n := 0
92 for _, w := range words {
93 toks, quoted, err := queryTokens(w)
94 if err != nil {
95 return q, err
96 }
97 for i, t := range toks {
98 n++
99 if err := q.term(t, quoted[i]); err != nil {
100 return q, err
101 }
102 }
103 }
104 if n == 0 {
105 return q, &QueryError{Token: "", Msg: "the query is empty"}
106 }
107 if text := strings.Join(q.Text, " "); text != "" {
108 if err := validQuery(text); err != nil {
109 return q, &QueryError{Token: text, Msg: "text " + err.Error()}
110 }
111 }
112 return q, nil
113}
114
115func (q *ItemQuery) term(tok string, quoted bool) error {
116 key, val, ok := strings.Cut(tok, ":")
117 if quoted || !ok {
118 q.Text = append(q.Text, tok)
119 return nil
120 }
121 bad := func(msg string) error { return &QueryError{Token: tok, Msg: msg} }
122 if val == "" {
123 return bad("missing value")
124 }
125 user := func(dst *string, what string) error {
126 if val != "@me" && policy.ValidateName(val) != nil {
127 return bad("not a username")
128 }
129 if *dst != "" && *dst != val {
130 return bad("only one " + what)
131 }
132 *dst = val
133 return nil
134 }
135 switch key {
136 case "repo":
137 if val == "*" {
138 q.AnyRepo = true
139 return nil
140 }
141 owner, name, ok := strings.Cut(val, "/")
142 if !ok || policy.ValidateName(owner) != nil || !repoGlob.MatchString(name) {
143 return bad("want repo:owner/name, repo:owner/glob or repo:*")
144 }
145 q.Scopes = append(q.Scopes, store.RepoScope{Owner: owner, Name: name})
146 case "owner":
147 if policy.ValidateName(val) != nil {
148 return bad("not an owner name")
149 }
150 q.Scopes = append(q.Scopes, store.RepoScope{Owner: val})
151 case "is":
152 switch val {
153 case "issue", "mr":
154 if q.Kind != "" && q.Kind != val {
155 return bad("only one of is:issue and is:mr")
156 }
157 q.Kind = val
158 case "open", "closed", "merged":
159 if q.State != "" && q.State != val {
160 return bad("only one of is:open, is:closed and is:merged")
161 }
162 q.State = val
163 default:
164 return bad("is: takes open, closed, merged, issue or mr")
165 }
166 if q.State == "merged" && q.Kind == "issue" {
167 return bad("an issue is never merged")
168 }
169 if q.Kind == "mr" && q.Assignee != "" {
170 return bad("merge requests have no assignees")
171 }
172 case "label":
173 if !q.hasLabel(val) {
174 q.Labels = append(q.Labels, val)
175 }
176 if q.NoLabel {
177 return bad("label: and no:label never both match")
178 }
179 case "milestone":
180 if (q.Milestone != "" && q.Milestone != val) || q.NoMilestone {
181 return bad("only one milestone")
182 }
183 q.Milestone = val
184 case "no":
185 switch val {
186 case "label":
187 if len(q.Labels) > 0 {
188 return bad("label: and no:label never both match")
189 }
190 q.NoLabel = true
191 case "milestone":
192 if q.Milestone != "" {
193 return bad("only one milestone")
194 }
195 q.NoMilestone = true
196 default:
197 return bad("no: takes label or milestone")
198 }
199 case "assignee":
200 if q.Kind == "mr" {
201 return bad("merge requests have no assignees")
202 }
203 return user(&q.Assignee, "assignee")
204 case "author":
205 return user(&q.Author, "author")
206 default:
207 return bad("unknown qualifier; the qualifiers are repo:, owner:, is:, label:, no:, milestone:, assignee: and author:")
208 }
209 return nil
210}
211
212func (q ItemQuery) hasLabel(l string) bool {
213 for _, x := range q.Labels {
214 if x == l {
215 return true
216 }
217 }
218 return false
219}
220
221// Selects reports which tables the query can match rows in.
222func (q ItemQuery) Selects() (issues, mrs bool) {
223 issues = q.Kind != "mr" && q.State != "merged"
224 mrs = q.Kind != "issue" && q.Assignee == ""
225 return
226}
227
228// String is the canonical text, which is what a saved query stores.
229func (q ItemQuery) String() string {
230 var t []string
231 add := func(k, v string) {
232 if strings.ContainsAny(v, " \t\r\n") {
233 v = `"` + v + `"`
234 }
235 t = append(t, k+":"+v)
236 }
237 if q.AnyRepo {
238 t = append(t, "repo:*")
239 }
240 for _, s := range q.Scopes {
241 if s.Name == "" {
242 add("owner", s.Owner)
243 } else {
244 add("repo", s.Owner+"/"+s.Name)
245 }
246 }
247 if q.Kind != "" {
248 add("is", q.Kind)
249 }
250 if q.State != "" {
251 add("is", q.State)
252 }
253 for _, l := range q.Labels {
254 add("label", l)
255 }
256 if q.NoLabel {
257 add("no", "label")
258 }
259 if q.Milestone != "" {
260 add("milestone", q.Milestone)
261 }
262 if q.NoMilestone {
263 add("no", "milestone")
264 }
265 if q.Assignee != "" {
266 add("assignee", q.Assignee)
267 }
268 if q.Author != "" {
269 add("author", q.Author)
270 }
271 for _, w := range q.Text {
272 if strings.Contains(w, ":") || strings.ContainsAny(w, " \t\r\n") {
273 w = `"` + w + `"`
274 }
275 t = append(t, w)
276 }
277 return strings.Join(t, " ")
278}
279
280// Filter is the query as the store runs it for the user named me, over
281// the tables issues and mrs allow as well as the query's own kind.
282func (q ItemQuery) Filter(me string, issues, mrs bool) store.ItemFilter {
283 qi, qm := q.Selects()
284 f := store.ItemFilter{
285 Issues: issues && qi, MRs: mrs && qm,
286 State: q.State, Labels: q.Labels, NoLabel: q.NoLabel,
287 Milestone: q.Milestone, NoMilestone: q.NoMilestone,
288 Assignee: q.Assignee, Author: q.Author, Text: strings.Join(q.Text, " "),
289 }
290 if !q.AnyRepo {
291 f.Scopes = q.Scopes
292 }
293 if f.Assignee == "@me" {
294 f.Assignee = me
295 }
296 if f.Author == "@me" {
297 f.Author = me
298 }
299 return f
300}
internal/control/querylang_test.go added +110
@@ -0,0 +1,110 @@
1package control
2
3import (
4 "errors"
5 "reflect"
6 "testing"
7
8 "gitbay.org/gitbay/internal/store"
9)
10
11func TestParseItemQuery(t *testing.T) {
12 for _, tc := range []struct {
13 in []string
14 want ItemQuery
15 text string // canonical form
16 }{
17 {[]string{"repo:krz/gitbay"}, ItemQuery{Scopes: []store.RepoScope{{Owner: "krz", Name: "gitbay"}}}, "repo:krz/gitbay"},
18 {[]string{"repo:krz/git* owner:cmc"}, ItemQuery{Scopes: []store.RepoScope{{Owner: "krz", Name: "git*"}, {Owner: "cmc"}}}, "repo:krz/git* owner:cmc"},
19 {[]string{"repo:*", "is:open"}, ItemQuery{AnyRepo: true, State: "open"}, "repo:* is:open"},
20 {[]string{"is:merged"}, ItemQuery{State: "merged"}, "is:merged"},
21 {[]string{"is:open is:issue"}, ItemQuery{Kind: "issue", State: "open"}, "is:issue is:open"},
22 {[]string{"label:bug", "label:ui", "label:bug"}, ItemQuery{Labels: []string{"bug", "ui"}}, "label:bug label:ui"},
23 {[]string{`label:"needs review"`}, ItemQuery{Labels: []string{"needs review"}}, `label:"needs review"`},
24 {[]string{"label:needs review"}, ItemQuery{Labels: []string{"needs"}, Text: []string{"review"}}, "label:needs review"},
25 {[]string{"no:label no:milestone"}, ItemQuery{NoLabel: true, NoMilestone: true}, "no:label no:milestone"},
26 {[]string{"milestone:v2"}, ItemQuery{Milestone: "v2"}, "milestone:v2"},
27 {[]string{"assignee:@me author:cmc"}, ItemQuery{Assignee: "@me", Author: "cmc"}, "assignee:@me author:cmc"},
28 {[]string{"crash", `"on start"`, "is:open"}, ItemQuery{State: "open", Text: []string{"crash", "on start"}}, `is:open crash "on start"`},
29 {[]string{`"a:b"`}, ItemQuery{Text: []string{"a:b"}}, `"a:b"`},
30 } {
31 got, err := ParseItemQuery(tc.in...)
32 if err != nil {
33 t.Errorf("%q: %v", tc.in, err)
34 continue
35 }
36 if !reflect.DeepEqual(got, tc.want) {
37 t.Errorf("%q = %+v, want %+v", tc.in, got, tc.want)
38 }
39 if s := got.String(); s != tc.text {
40 t.Errorf("%q canonical = %q, want %q", tc.in, s, tc.text)
41 }
42 again, err := ParseItemQuery(got.String())
43 if err != nil || !reflect.DeepEqual(again, got) {
44 t.Errorf("%q does not survive its canonical form: %+v, %v", tc.in, again, err)
45 }
46 }
47}
48
49func TestParseItemQueryRefuses(t *testing.T) {
50 for _, tc := range []struct {
51 in, token string
52 }{
53 {"", ""},
54 {"foo:bar", "foo:bar"},
55 {"is:open is:bogus", "is:bogus"},
56 {"is:open is:closed", "is:closed"},
57 {"is:issue is:mr", "is:mr"},
58 {"is:issue is:merged", "is:merged"},
59 {"is:mr assignee:cmc", "assignee:cmc"},
60 {"assignee:cmc is:mr", "is:mr"},
61 {"assignee:cmc assignee:bob", "assignee:bob"},
62 {"author:Not_A_User", "author:Not_A_User"},
63 {"repo:krz", "repo:krz"},
64 {"repo:*/gitbay", "repo:*/gitbay"},
65 {"repo:krz/[ab]", "repo:krz/[ab]"},
66 {"owner:krz/x", "owner:krz/x"},
67 {"label:bug no:label", "no:label"},
68 {"no:milestone milestone:v1", "milestone:v1"},
69 {"no:author", "no:author"},
70 {"label:", "label:"},
71 {`label:"open`, `label:"open`},
72 {"x", "x"}, // text too short
73 } {
74 _, err := ParseItemQuery(tc.in)
75 var qe *QueryError
76 if !errors.As(err, &qe) {
77 t.Errorf("%q: err %v, want a QueryError", tc.in, err)
78 continue
79 }
80 if qe.Token != tc.token {
81 t.Errorf("%q: token %q, want %q (%v)", tc.in, qe.Token, tc.token, err)
82 }
83 }
84}
85
86func TestItemQuerySelects(t *testing.T) {
87 for _, tc := range []struct {
88 in string
89 issues, mrs bool
90 }{
91 {"is:open", true, true},
92 {"is:issue", true, false},
93 {"is:mr", false, true},
94 {"is:merged", false, true},
95 {"assignee:cmc", true, false},
96 } {
97 q, err := ParseItemQuery(tc.in)
98 if err != nil {
99 t.Fatal(err)
100 }
101 if i, m := q.Selects(); i != tc.issues || m != tc.mrs {
102 t.Errorf("%q selects issues=%v mrs=%v, want %v %v", tc.in, i, m, tc.issues, tc.mrs)
103 }
104 }
105 q, _ := ParseItemQuery("repo:* repo:krz/x assignee:@me author:@me")
106 f := q.Filter("alice", true, true)
107 if f.Scopes != nil || f.Assignee != "alice" || f.Author != "alice" {
108 t.Errorf("Filter = %+v: repo:* widens to every repository, @me is the caller", f)
109 }
110}