Commit 7b688211e7
Verified · cmc
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. | ||
| 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", |
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 +411
| @@ -0,0 +1,411 @@ | |||
| 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 | var queryNamePat = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`) | ||
| 74 | |||
| 75 | // SavedQueryOut is one saved query. Count is filled by query show. | ||
| 76 | type 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. | ||
| 85 | type 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. | ||
| 99 | func (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. | ||
| 106 | type 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 | |||
| 114 | func 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. | ||
| 126 | func 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 | |||
| 154 | func encodeItemCursor(k store.ItemCursor) string { | ||
| 155 | return encodeCursor("query", fmt.Sprintf("%s|%d|%d", k.CreatedAt, k.Kind, k.ID)) | ||
| 156 | } | ||
| 157 | |||
| 158 | func 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. | ||
| 172 | func 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. | ||
| 189 | func 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. | ||
| 226 | func 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. | ||
| 238 | func 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 | |||
| 272 | func savedQueryOut(sq store.SavedQuery) SavedQueryOut { | ||
| 273 | return SavedQueryOut{Name: sq.Name, Query: sq.Query, Pinned: sq.Pinned} | ||
| 274 | } | ||
| 275 | |||
| 276 | func 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 | |||
| 308 | func 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 | |||
| 333 | func 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 | |||
| 358 | func 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 | |||
| 373 | func 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 | |||
| 389 | func 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 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 14 | type queryEnv struct { | ||
| 15 | t *testing.T | ||
| 16 | st *store.Store | ||
| 17 | users map[string]store.User | ||
| 18 | } | ||
| 19 | |||
| 20 | func 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. | ||
| 42 | func (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 | |||
| 54 | func (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 | |||
| 66 | func 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 @@ | |||
| 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 | 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 | |||
| 212 | func (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. | ||
| 222 | func (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. | ||
| 229 | func (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. | ||
| 282 | func (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 @@ | |||
| 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 | {"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 | |||
| 86 | func 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 | } | ||